diff --git a/.changeset/23429-sys-metadata-family-organization-column-retired.md b/.changeset/23429-sys-metadata-family-organization-column-retired.md new file mode 100644 index 00000000000..23bc7a96f4d --- /dev/null +++ b/.changeset/23429-sys-metadata-family-organization-column-retired.md @@ -0,0 +1,60 @@ +--- +'@objectstack/metadata-core': minor +'@objectstack/metadata-protocol': minor +'@objectstack/metadata': minor +'@objectstack/rest': minor +'@objectstack/spec': minor +'@objectstack/objectql': minor +'@objectstack/plugin-security': minor +'@objectstack/service-datasource': minor +'@objectstack/service-automation': minor +'@objectstack/platform-objects': minor +'@objectstack/driver-sql': minor +'@objectstack/driver-turso': minor +'@objectstack/cli': patch +--- + +feat(metadata-core,metadata-protocol)!: the `sys_metadata` family declares no organization column — `sys_metadata`, `sys_metadata_audit`, `sys_metadata_commit` and `sys_metadata_history` (ADR-0131 D6/D7) + +Clause-②: yes (narrowing) + + + +**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + +Metadata is the environment's (ADR-0131 D6). The four tables of the metadata family no longer declare `organization_id`, and each sets `systemFields.tenant: false`, so the engine injects none back. The engine refuses an undeclared field, so nothing reads or writes the column any more. + +FROM → TO: + +- An authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on one of the four tables → delete it; no organization owns a row. The field resolver answers it as an unknown field. +- `new SysMetadataRepository({ engine, organizationId, orgLabel })` → `new SysMetadataRepository({ engine, orgLabel })`. The `organizationId` option is gone: every repository reads and writes the environment's rows. +- `DatabaseLoader`'s `organizationId` option (and so `MetadataManager.setDatabaseDriver` / `setDataEngine`'s `organizationId` argument) → accepted and ignored, like `environmentId`: no write stamps it and no read filters on it. Drop the argument. +- `@objectstack/spec`: the published `ApiError.code` / `ErrorCode` enum widens by `LEGACY_ORGANIZATION_ROWS_UNREADABLE`, registered under `@objectstack/metadata-core`. +- `isLegacyOrganizationRow(row)` (`@objectstack/metadata-core`) → `readLegacyOrganizationRows(driver, table?)`. The row-value test read a key the engine no longer returns, so it is removed. The new read answers from the physical column, through the table's driver (`engine.getDriverForObject`): id → organization, empty on a migrated or fresh database. A seam that cannot answer is refused (`LegacyOrganizationRowsUnreadable`), never read as "none". +- An existing database → run `os migrate organization-ownership` (review its plan), then `os migrate organization-ownership --apply --plan-file FILE` with that plan file. The ceremony promotes or reports the legacy organization rows, then drops the physical column. Until it has run, the v18 runtime refuses to start against the database. + +The indexes are re-keyed with the column: + +- The overlay unique key is (`type`, `name`, `package_id`, `bundle_member`): the declared `idx_sys_metadata_overlay_active` and the runtime partial indexes of the same names. Removing the organization TIGHTENS uniqueness. On a database the ceremony has not cleared, two legacy organizations' rows, or an organization's row and the environment's, can share a key. There the runtime index migration keeps the previous index and names the duplicates (ADR-0120 D4); it never drops a row. `os migrate duplicates` and the boot report group by the same key. +- `sys_metadata_history` is unique on (`event_seq`) and on (`type`, `name`, `version`). Its other indexes, and those of `sys_metadata_audit` and `sys_metadata_commit`, lose their organization prefix. +- `idx_sys_metadata_org_type` is gone. + +What else changes: + +- The boot line `[metadata_org_scoped_unserved]` is gone with the column it read. A database still holding legacy organization rows is refused at boot instead, until the ceremony has run. +- The package uninstall no longer removes a legacy organization's row of the package's name: no row carries an organization. +- Who reads the family through the generic data door (`/data/sys_metadata_*`), before → after: + - `sys_metadata_audit`, `sys_metadata_history`, `sys_metadata_commit`, **under a wall** (`isolated` / `group`): before, the tenant wall filtered each caller to its own organization's rows, and an `organization_admin` read its organization's. With the column gone the wall has nothing to filter on, so without a gate that admin would read every organization's trail, history bodies and commit ranges. After: each requires `studio.access` (`requiredPermissions`, the #22107 precedent). The capability is platform-scoped, so an `organization_admin` holding it by role does not exist, and that admin is refused. + - The same three, **under `single`**: before, any caller whose grant covered the table (a read wildcard) read it. After: `studio.access` is required there too. A Studio author (the platform admin among them) reads as before; a plain member is refused. + - Every `/meta` door serving these rows admits `studio.access`: `/meta/:type/:name/history` and `/audit` admit `studio.access`, `setup.access` or `manage_metadata`; `/packages/:id/commits` admits `studio.access` or `setup.access`. `requiredPermissions` is an AND-gate and cannot name an any-of set, and `setup.access` is held by organization admins, so it could not be the gate. A caller holding only `setup.access` or only `manage_metadata` therefore still reads these rows through `/meta`, but not through the data door. + - `sys_metadata` stays ungated, before and after: its rows are what `/meta` already serves to every organization. +- On a database the ceremony has not migrated (which `os serve` refuses, but every `os migrate` boot opens, the ceremony's own among them), the legacy organization rows are still told apart, off the physical column: + - every read the metadata protocol makes of the family goes through one seam that leaves them out. That covers boot hydration; the `/meta` list, by-name, layered and draft-preview reads; the item lock (`_lock`) the reads report and the save and delete gates enforce; the package-less write and read address; and the package, commit, audit, history and diff reads. Two reads keep them on purpose and read the column themselves: `os migrate meta --stored` reports such a row, and the commit revert refuses a legacy commit by name. The authored hook and action binders leave them out too; + - the metadata repository's write never resolves to one: before, a save of an address that only a legacy organization's row held rewrote that row in place (measured on the ceremony's template promotion). Now the write targets the environment's row, and with none it creates one, which the re-keyed unique index refuses beside the organization's row until the ceremony settles it; + - `os migrate meta --stored` reports such a row `skipped` and never re-saves it; + - `os migrate security-catalog-overlays` neither lists nor deletes it; + - a commit recorded in a legacy organization's layer is still refused by the revert; + - the runtime-datasource reads, the packaged permission-set overlay reading, the kernel:ready security-catalog echo, the stored flow-credential move and a `DatabaseLoader` (engine- or driver-backed) leave them out. +- `SqlDriver.listPhysicalColumns(object, options?)` (new, read-only): a table's physical column names, from the catalog, joining `options.transaction`. `TursoDriver`'s remote face answers it from the remote catalog. Every boot asks whether the family still carries the retired column, and it asks this way, not through `execute()`. So `os migrate plan` keeps its "a plan writes nothing" line on a migrated or fresh database. The legacy rows themselves are read with a raw statement only where the column exists. +- `GET /forms/:slug` and `POST /forms/:slug/submit` no longer read the Default Organization's legacy layer. Every metadata read is now environment → code. The doors judge each candidate against the environment's view list, which holds one item per package of a view name. So a withdrawal in one package still closes that name's form in every package. On a deployment with no organization this closes more than before, which is the fail-closed direction. A withdrawal saved only at the organization layer is carried onto the environment by the ceremony's `metadata-promotion` step, which the runtime requires before it starts. +- The protocol's `getMetaItems` drops its undocumented `legacyFormOrganizationId` request key. It was never in the spec's `GetMetaItemsRequest`, and only those doors passed it. A caller that still passes it now gets the environment's list. diff --git a/.changeset/23429-v18-boot-refusal.md b/.changeset/23429-v18-boot-refusal.md new file mode 100644 index 00000000000..a3ef2ad320f --- /dev/null +++ b/.changeset/23429-v18-boot-refusal.md @@ -0,0 +1,29 @@ +--- +'@objectstack/runtime': minor +'@objectstack/cli': minor +'@objectstack/spec': minor +--- + +feat(runtime,cli)!: `os serve` refuses to start against a database still in the v17 organization-ownership shape, and `os migrate apply` refuses to drop its `organization_id` columns, until `os migrate organization-ownership` has verified it (ADR-0131 D10 item 5) + +Clause-②: yes (narrowing) + + + +**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + +A v18 runtime no longer serves a database it was never migrated for. At `kernel:ready`, `os serve` (and so `os dev`) checks every table whose object declares no organization column (`systemFields.tenant: false`). If one still carries a physical `organization_id`, the boot is refused unless the `sys_migration` row `adr-0131-organization-ownership` is verified. The refusal names the tables and the command. It is thrown before `kernel:listening`, so no request is served. Its code is `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED`. + +FROM → TO: + +- A 17.x database started by an 18.x `os serve` → run `os migrate organization-ownership`, review its plan, then `os migrate organization-ownership --apply --plan-file FILE` with that plan file, and start again. To keep 17.x semantics on an unmigrated database, run a 17.x runtime. +- `os migrate apply --allow-destructive` on such a database (`@objectstack/cli`, BREAKING `minor`: the command narrows) → it now refuses to drop `organization_id` from any table in the ceremony's inventory (the D7 tables and the `sys_metadata` family) while the ceremony's marker is not verified. Before, the drift report offered each such column as an unmapped destructive column, and the apply dropped it. Dropped ahead of the ceremony, that column takes the ceremony's promotion and report steps with it, turns every legacy organization row into an environment row, and leaves the boot refusal no column to find. The refusal names `os migrate organization-ownership`, exits 1, and under `--json` answers `message: 'refused_organization_ownership_unmigrated'` with the `tables`, as the `sys_account.issuer` (#17440) and `sys_comment.reactions` (#23281) refusals do. The refusal holds the WHOLE `--allow-destructive` run, as those two do: nothing in it is created, applied or dropped, every other destructive drop and the additive work included. A run without `--allow-destructive` is unchanged. The ceremony drops these columns itself, in its column-drops step, before it records the run verified. +- There is no escape hatch: no `OS_ALLOW_*` or `OS_SKIP_*` variable and no flag skips either check. + +What does not change: + +- A fresh database boots: no table it creates carries a retired column, so no marker is needed. +- A database whose ceremony verified boots, whatever its columns. +- The `os migrate` commands boot as before, the ceremony's own among them: the boot guard is composed by `os serve` only, never by the shared standalone stack (`createStandaloneStack`, which `bootSchemaStack` and every other CLI data and schema boot build on). Only an `os migrate apply --allow-destructive` run that would drop an inventory table's `organization_id` is refused, as above. +- A driver with no physical columns to read (memory, MongoDB) is not checked. +- A host that composes its own kernel can add the guard itself: `OrganizationOwnershipBootGuardPlugin`, or `assertOrganizationOwnershipMigrated(engine)` from `@objectstack/runtime`. `ORGANIZATION_OWNERSHIP_MIGRATION_ID` is now exported from `@objectstack/spec/system`. diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index bab5796bc51..a87c60922b4 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -106,7 +106,7 @@ later ships. See [ADR-0070](https://github.com/objectstack-ai/objectstack/blob/m **Most metadata types must not be overlaid by an environment** — overlaying a packaged item would break the physical schema or the behaviour its package ships. The whitelist lives in **one** place: `MetadataTypeRegistryEntry.allowEnvironmentOverlay` in `packages/spec/src/kernel/metadata-plugin.zod.ts` (spelled `allowOrgOverride` before protocol 18; `GET /api/v1/meta/types` serves it per entry as `allowEnvironmentOverlay`). -> **ADR-0131 D6 — the per-organization overlay axis is retired.** The `/meta` doors (REST and the runtime dispatcher) carry no organization into a metadata write or read: a Studio or `PUT /api/v1/meta/...` write of the five ✅ types below lands **environment-wide** (`organization_id` NULL) and is served to every organization, and an organization admin holding only the retired `manage_org_presentation` capability is refused (`403`). The protocol itself refuses every organization-scoped write, and every read — item, list, layers, cached, history, diff, audit, drafts and the commit timeline — is environment → code, whatever organization a caller names. An overlay row an earlier release stored under an organization — under the `single` posture, every Studio save of these types, filed under the Default Organization — is served by no read: boot names each such row, per type (`[metadata_org_scoped_unserved]`), and the v18 migration ceremony (ADR-0131 C7, `os migrate`) carries it to the environment layer; nothing deletes or rewrites it before then. Re-save the item in Studio to make the edit live now. Public forms are the exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay for the form's body, while a withdrawal in either layer closes the form, fail-closed. So a legacy organization overlay of a public form keeps serving its body there: a Studio re-save of that body (an environment row) does not change the body the public form serves, and a Studio withdrawal (an environment row) still closes it. The table still records which types take an overlay of a shipped item. +> **ADR-0131 D6 — the per-organization overlay axis is retired.** The `/meta` doors (REST and the runtime dispatcher) carry no organization into a metadata write or read: a Studio or `PUT /api/v1/meta/...` write of the five ✅ types below lands **environment-wide** (since ADR-0131 D7 the `sys_metadata` family declares no organization column) and is served to every organization, and an organization admin holding only the retired `manage_org_presentation` capability is refused (`403`). The protocol itself refuses every organization-scoped write, and every read — item, list, layers, cached, history, diff, audit, drafts and the commit timeline — is environment → code, whatever organization a caller names. An overlay row an earlier release stored under an organization — under the `single` posture, every Studio save of these types, filed under the Default Organization — is served by no read, and an 18.x `os serve` refuses to start on a database still holding one until the v18 migration ceremony (ADR-0131 C7, `os migrate organization-ownership --apply`) has carried it to the environment layer and dropped the column; nothing deletes or rewrites it before then. The anonymous form doors read the environment alone too: the ceremony carries every organization-layer public-form withdrawal onto the environment, fail-closed. The table still records which types take an overlay of a shipped item. | Type | `allowEnvironmentOverlay` | Rationale | | :--- | :---: | :--- | @@ -234,7 +234,7 @@ The hash is `sha256:` + 64-hex of a canonical JSON serialization of the body, wi |:---|:---|:---| | `defineView(...)` / `defineFlow(...)` / any source file → compiled into `dist/objectstack.json` | ❌ Never. Loaded into the in-memory registry on boot; refreshed via HMR in dev. | ❌ The artifact's own version history *is* Git. The metadata layer does not duplicate it. | | Editing a `.json` under `//.json` (FS overlay, e.g. `/view/case_grid.json`) | ❌ FS layer is independent of DB. | ✅ Appended to the change log at `/.objectstack/.log/main.jsonl` by `FileSystemRepository`. | -| Studio inline edit, or `PUT /api/v1/meta/...` (REST) on an `allowEnvironmentOverlay: true` type | ✅ Written by `SysMetadataRepository.put()` as an **overlay row**, environment-wide (`organization_id` NULL) since ADR-0131 D6. | ✅ Appended to `sys_metadata_history` in the **same transaction** as the `sys_metadata` write. No-op puts (identical hash) skip the history row entirely. | +| Studio inline edit, or `PUT /api/v1/meta/...` (REST) on an `allowEnvironmentOverlay: true` type | ✅ Written by `SysMetadataRepository.put()` as an **overlay row**, environment-wide since ADR-0131 D6 (the table declares no organization column since D7). | ✅ Appended to `sys_metadata_history` in the **same transaction** as the `sys_metadata` write. No-op puts (identical hash) skip the history row entirely. | | Deploying a new build (new `dist/objectstack.json`) | ❌ The artifact is loaded into memory, not synced into `sys_metadata`. | ❌ Use Git tags / your deployment platform's release log; that's where artifact "version history" lives. | ### Why artifact never enters the database diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 9f1b1b6f69b..a85386649b4 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -989,6 +989,24 @@ occupancy on its own. | `needs_confirm` | non-narrowing type change, rebuild a non-unique index whose columns changed | `os migrate apply` — except `manual_column_type_change` (only `os migrate multi-value-columns --apply` runs it), and `manual_widen_varchar_to_text` and `unbuildable_index`, which nothing applies | | `destructive` | drop an orphaned column or index, tighten `NOT NULL`, narrow a type, rebuild an index as `UNIQUE`, create a `UNIQUE` index existing rows already violate | `os migrate apply --allow-destructive` | +`--allow-destructive` refuses some drops until the data they would lose is +safe. No flag skips these refusals: + +- `sys_account.issuer` is dropped only when its retirement pre-flight reads the + table clean (`os migrate account-issuer` reports the colliding rows). +- `sys_comment.reactions` waits for a verified `os migrate comment-reactions --apply` run. +- `organization_id` on any table the organization-ownership ceremony covers + waits for a verified `os migrate organization-ownership --apply --plan-file FILE` + run. These tables are the ADR-0131 D7 tables and the `sys_metadata` family. + The ceremony drops those columns itself, in its column-drops step, before it + records the run verified. Dropping one first would turn every legacy + organization row into an environment row. Under `--json` the refusal answers + `message: 'refused_organization_ownership_unmigrated'` and lists the `tables`. + +Each refusal holds the whole `--allow-destructive` run: nothing in it is +created, applied or dropped, the run's other changes included. A run without +`--allow-destructive` is unchanged. + #### Index drift `plan` covers indexes as well as columns: diff --git a/content/docs/kernel/contracts/metadata-service.mdx b/content/docs/kernel/contracts/metadata-service.mdx index 1b45fc0f27f..3489e9ab65b 100644 --- a/content/docs/kernel/contracts/metadata-service.mdx +++ b/content/docs/kernel/contracts/metadata-service.mdx @@ -408,18 +408,15 @@ View customization rides ADR-0005's metadata overlay (opt-in per type via `/meta` doors stores a `sys_metadata` row, and the layered read returns `code` / `overlay` / `effective` for it. Since ADR-0131 D6 the per-organization overlay axis is retired: the doors carry no organization, so the overlay is -**environment-wide** (`organization_id` NULL) and served to every organization +**environment-wide** (the `sys_metadata` family declares no organization +column, ADR-0131 D7) and served to every organization of the deployment, whatever the author's active organization. Every protocol read is environment → code, whatever organization a caller names. An overlay -row an earlier release stored under an organization is served by no read: -boot names it, and the v18 migration ceremony (ADR-0131 C7) carries it to the -environment layer. Public forms are the exception: until that ceremony the anonymous form doors -read a form `view` in the Default Organization and prefer its overlay for the -form's body, while a withdrawal in either layer closes the form, fail-closed. -So a legacy organization overlay of a public form keeps serving its body there: -a Studio re-save of that body (an environment row) does not change the body the -public form serves, and a Studio withdrawal (an environment row) still closes -it. The per-user, +row an earlier release stored under an organization is served by no read, and +an 18.x `os serve` refuses to start on a database still holding one until the +v18 migration ceremony (ADR-0131 C7) has carried it to the environment layer. The anonymous form doors read the environment alone too: the ceremony carries +every organization-layer public-form withdrawal onto the environment, +fail-closed. The per-user, per-field patch overlay a previous revision of this page taught here was removed in #13135 — it was never served by any route. diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 8ac3e96f3d7..555afd86bd5 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +331 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +333 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -225,6 +225,7 @@ const result = ApiErrorSchema.parse(data); * `IP_NOT_ALLOWED` * `ITEM_LOCKED` * `LAST_LOCAL_CREDENTIAL` +* `LEGACY_ORGANIZATION_ROWS_UNREADABLE` * `MANIFEST_CONFLICT` * `MAPPING_FORMAT_MISMATCH` * `MAPPING_FORMAT_UNSUPPORTED` @@ -265,6 +266,7 @@ const result = ApiErrorSchema.parse(data); * `OBJECT_OWNERSHIP_CONFLICT` * `OBJECT_PACKAGE_DISABLED` * `OPENAPI_UNAVAILABLE` +* `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED` * `OS_PROTOCOL_INCOMPATIBLE` * `PACKAGE_DELETE_FAILED` * `PACKAGE_DELETE_PARTIAL` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 81184b6a4cf..fdca0fb0c27 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -392,6 +392,7 @@ const result = ErrorCode.parse(data); * `IP_NOT_ALLOWED` * `ITEM_LOCKED` * `LAST_LOCAL_CREDENTIAL` +* `LEGACY_ORGANIZATION_ROWS_UNREADABLE` * `MANIFEST_CONFLICT` * `MAPPING_FORMAT_MISMATCH` * `MAPPING_FORMAT_UNSUPPORTED` @@ -432,6 +433,7 @@ const result = ErrorCode.parse(data); * `OBJECT_OWNERSHIP_CONFLICT` * `OBJECT_PACKAGE_DISABLED` * `OPENAPI_UNAVAILABLE` +* `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED` * `OS_PROTOCOL_INCOMPATIBLE` * `PACKAGE_DELETE_FAILED` * `PACKAGE_DELETE_PARTIAL` diff --git a/packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts b/packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts new file mode 100644 index 00000000000..8a2e7ef0bc6 --- /dev/null +++ b/packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts @@ -0,0 +1,199 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23429 E1 · ADR-0131 D10 item 5] `os migrate apply --allow-destructive` + * refuses to drop `organization_id` from a table in the organization-ownership + * ceremony's inventory until the ceremony's marker is verified — the + * #17440 / #23281 shape, `--json` refusal included, no flag that stands it + * down. Pinned on a v17-shape `sys_platform_setting` (an inventory table v18 declares + * no organization column on, so the drift report offers its physical one for + * the destructive apply): + * + * 1. the drop is refused, naming the ceremony, and nothing is dropped — the + * whole `--allow-destructive` run is held, an unrelated drop in it included; + * 2. a control: an unrelated destructive drop on the same table is not gated; + * 3. once the ceremony's marker is verified, the drop proceeds. + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Database from 'better-sqlite3'; +import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system'; +import { isExitSignal } from '../../utils/format.js'; +import MigrateApply from './apply.js'; + +// Pay the first transform of the dist-resolved workspace deps the command +// reaches through dynamic `import()`s at MODULE LOAD, not inside a case. +import '@objectstack/runtime'; +import '@objectstack/objectql'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI_ROOT = resolve(HERE, '..', '..', '..'); +const CASE_TIMEOUT_MS = 240_000; + +const OVERRIDING_ENV = [ + 'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME', + 'OS_TELEMETRY_DB', 'OS_AUTH_SECRET', 'AUTH_SECRET', 'BETTER_AUTH_SECRET', 'OS_ARTIFACT_PATH', +] as const; + +async function runJson(argv: string[]): Promise<{ payload: any; exitCode: number }> { + const savedExit = process.exitCode; + const swallow = ((_chunk: unknown, ...rest: unknown[]) => { + const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined; + if (cb) cb(); + return true; + }) as typeof process.stdout.write; + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(swallow); + vi.spyOn(process.stderr, 'write').mockImplementation(swallow); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + let processExit: number | undefined; + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + processExit = code ?? 0; + throw new Error(`__PROCESS_EXIT__:${processExit}`); + }) as never); + let thrownExit: number | undefined; + try { + try { + await MigrateApply.run(argv, { root: CLI_ROOT }); + } catch (error) { + const trapped = error instanceof Error && error.message.startsWith('__PROCESS_EXIT__'); + if (!trapped && !isExitSignal(error)) throw error; + if (!trapped) thrownExit = (error as { oclif?: { exit?: number } }).oclif?.exit; + } + const out = stdout.mock.calls.map((c) => String(c[0])).join('').trim(); + let payload: any; + try { payload = JSON.parse(out); } catch { payload = { unparsed: out.slice(-400) }; } + return { payload, exitCode: processExit ?? thrownExit ?? (process.exitCode as number | undefined) ?? 0 }; + } finally { + process.exitCode = savedExit; + vi.restoreAllMocks(); + } +} + +let dir: string; +let v18Db: string; +const savedEnv: Record = {}; +const savedCwd = process.cwd(); + +function withDb(file: string, fn: (db: Database.Database) => T): T { + const db = new Database(file); + try { + return fn(db); + } finally { + db.close(); + } +} + +const columnsOf = (file: string, table: string): string[] => + withDb(file, (db) => (db.prepare(`PRAGMA table_info('${table}')`).all() as Array<{ name: string }>).map((c) => c.name)); + +/** A copy of the v18 schema with `columns` added raw to `sys_platform_setting`. */ +let seq = 0; +function caseDb(columns: readonly string[]): string { + seq += 1; + const file = join(dir, `case-${seq}.db`); + copyFileSync(v18Db, file); + withDb(file, (db) => { for (const c of columns) db.exec(`ALTER TABLE sys_platform_setting ADD COLUMN ${c} TEXT`); }); + return file; +} + +const dropArgv = (file: string) => ['--database-url', `file:${file}`, '--allow-destructive', '--yes', '--json']; + +beforeAll(async () => { + for (const key of [...OVERRIDING_ENV, 'NODE_ENV'] as const) savedEnv[key] = process.env[key]; + for (const key of OVERRIDING_ENV) delete process.env[key]; + dir = mkdtempSync(join(tmpdir(), 'os-23429-drop-gate-')); + mkdirSync(join(dir, 'dist'), { recursive: true }); + writeFileSync( + join(dir, 'objectstack.config.ts'), + "export default { manifest: { id: 'com.example.os23429gate', name: 'gate', version: '0.0.0', type: 'app' } };\n", + ); + process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json'); + process.env.NODE_ENV = 'production'; + process.env.OS_TELEMETRY_DB = '0'; + v18Db = join(dir, 'v18.db'); + process.chdir(dir); + try { + const created = await runJson(['--database-url', `file:${v18Db}`, '--yes', '--json']); + expect(created.exitCode, JSON.stringify(created.payload).slice(0, 400)).toBe(0); + } finally { + process.chdir(savedCwd); + } + expect(columnsOf(v18Db, 'sys_platform_setting'), 'fixture: v18 declares no organization column here').not.toContain('organization_id'); +}, CASE_TIMEOUT_MS); + +afterAll(() => { + process.chdir(savedCwd); + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ } +}); + +beforeEach(() => { process.chdir(dir); }); +afterEach(() => { process.chdir(savedCwd); }); + +describe('os migrate apply --allow-destructive waits for the organization-ownership ceremony (#23429 E1)', () => { + it('1. a v17-shape table: the organization_id drop is refused, naming the ceremony, and nothing is dropped', async () => { + const file = caseDb(['organization_id']); + const { payload, exitCode } = await runJson(dropArgv(file)); + expect(exitCode).toBe(1); + expect(payload).toMatchObject({ + message: 'refused_organization_ownership_unmigrated', + tables: ['sys_platform_setting'], + applied: [], + created: [], + marker: null, + }); + expect(payload.detail).toMatch(/os migrate organization-ownership --apply/); + expect(columnsOf(file, 'sys_platform_setting')).toContain('organization_id'); + }, CASE_TIMEOUT_MS); + + it('1b. an unverified marker refuses too', async () => { + const file = caseDb(['organization_id']); + withDb(file, (db) => db.prepare( + 'INSERT INTO sys_migration (id, last_run_at, verified_at, blocking) VALUES (?, ?, NULL, 2)', + ).run(ORGANIZATION_OWNERSHIP_MIGRATION_ID, new Date().toISOString())); + const { payload, exitCode } = await runJson(dropArgv(file)); + expect(exitCode).toBe(1); + expect(payload).toMatchObject({ message: 'refused_organization_ownership_unmigrated', tables: ['sys_platform_setting'] }); + expect(payload.detail).toMatch(/is not verified \(2 blocking/); + expect(columnsOf(file, 'sys_platform_setting')).toContain('organization_id'); + }, CASE_TIMEOUT_MS); + + it('1c. the refusal holds the WHOLE run: a gated drop beside an unrelated one, and neither is applied', async () => { + const file = caseDb(['organization_id', 'legacy_23429_note']); + const { payload, exitCode } = await runJson(dropArgv(file)); + expect(exitCode).toBe(1); + expect(payload).toMatchObject({ + message: 'refused_organization_ownership_unmigrated', tables: ['sys_platform_setting'], applied: [], created: [], + }); + expect(columnsOf(file, 'sys_platform_setting')).toEqual(expect.arrayContaining(['organization_id', 'legacy_23429_note'])); + }, CASE_TIMEOUT_MS); + + it('2. control: an unrelated destructive drop on the same table is not gated', async () => { + const file = caseDb(['legacy_23429_note']); + const { payload, exitCode } = await runJson(dropArgv(file)); + expect(exitCode, JSON.stringify(payload).slice(0, 600)).toBe(0); + expect(payload.message).not.toBe('refused_organization_ownership_unmigrated'); + expect(columnsOf(file, 'sys_platform_setting')).not.toContain('legacy_23429_note'); + }, CASE_TIMEOUT_MS); + + it('3. once the ceremony\'s marker is verified, the drop proceeds', async () => { + const file = caseDb(['organization_id']); + const now = new Date().toISOString(); + withDb(file, (db) => db.prepare( + 'INSERT INTO sys_migration (id, last_run_at, verified_at, blocking) VALUES (?, ?, ?, 0)', + ).run(ORGANIZATION_OWNERSHIP_MIGRATION_ID, now, now)); + const { payload, exitCode } = await runJson(dropArgv(file)); + expect(exitCode, JSON.stringify(payload).slice(0, 600)).toBe(0); + expect(payload.message).not.toBe('refused_organization_ownership_unmigrated'); + expect(columnsOf(file, 'sys_platform_setting')).not.toContain('organization_id'); + }, CASE_TIMEOUT_MS); +}); diff --git a/packages/cli/src/commands/migrate/apply.ts b/packages/cli/src/commands/migrate/apply.ts index 4fe54313fc9..2368c4506b9 100644 --- a/packages/cli/src/commands/migrate/apply.ts +++ b/packages/cli/src/commands/migrate/apply.ts @@ -38,6 +38,7 @@ import { REACTIONS_COLUMN, readCommentReactionsConversion, } from '../../utils/comment-reactions-conversion.js'; +import { ceremonyOrganizationColumnDrops, readOrganizationOwnershipMarker } from '../../utils/organization-ownership-drop-gate.js'; async function confirm(question: string): Promise { if (!process.stdin.isTTY) return false; // non-interactive → require --yes @@ -480,6 +481,55 @@ export default class MigrateApply extends Command { } } + // ── [#23429 E1, ADR-0131 D10 item 5] REFUSE AN organization_id DROP AHEAD OF THE CEREMONY ── + // + // The third sibling, placed by the same rule: below the report, above the + // confirmation prompt, above both writes. A v18 runtime declares no + // organization column on the ceremony's column-drop tables, so the drift + // report offers each physical one for `--allow-destructive`. Dropped here + // first, the ceremony never promotes or reports the legacy organization + // rows, they become environment rows, and the v18 boot refusal finds no + // column left to refuse over — the escape hatch D10 item 5 forbids. So + // the drop waits for the ceremony's verified marker; no flag stands it + // down (`utils/organization-ownership-drop-gate.ts`). + const organizationDrops = ceremonyOrganizationColumnDrops(intended); + if (organizationDrops.length > 0) { + const engine = (stack.kernel as { getService?: (n: string) => unknown }).getService?.call( + stack.kernel, + 'objectql', + ); + const marker = await readOrganizationOwnershipMarker(engine as never); + if (!marker.verified) { + const detail = marker.flag + ? `The last run of "os migrate organization-ownership --apply --plan-file FILE" (${marker.flag.last_run_at}) is not verified ` + + `(${marker.flag.blocking} blocking item(s)).` + : 'No run of "os migrate organization-ownership --apply --plan-file FILE" is recorded on this database.'; + if (flags.json) { + await emitJson({ + ...target, created: [], applied: [], skipped: drift, pending, + message: 'refused_organization_ownership_unmigrated', tables: organizationDrops, detail, marker: marker.flag, + }, 1, { compact: true }); + return; + } + printError( + `Refusing this --allow-destructive run: it would drop organization_id from ${organizationDrops.join(', ')}, which the organization-ownership ceremony ` + + `owns that drop, and it has not verified on this database. ${detail} Dropping the column first would turn ` + + 'every legacy organization row into an environment row without the ceremony\'s promotion or report. Run ' + + '"os migrate organization-ownership --out PLAN" to write and review its plan, then ' + + '"os migrate organization-ownership --apply --plan-file PLAN"; it drops these columns itself, in its ' + + 'column-drops step, before it records the run verified. Nothing in this run was applied.', + ); + this.exit(1); + return; + } + if (!flags.json) { + printSuccess( + `organization_id on ${organizationDrops.join(', ')}: the organization-ownership ceremony verified ` + + `(${marker.flag?.verified_at}) — the drop may proceed.`, + ); + } + } + const totalIntended = intended.length + pending.length; if (totalIntended === 0) { if (flags.json) { await emitJson({ ...target, applied: [], skipped: deferred, created: [], message: 'nothing_safe_to_apply' }, 0, { compact: true }); return; } diff --git a/packages/cli/src/commands/migrate/duplicates.integration.test.ts b/packages/cli/src/commands/migrate/duplicates.integration.test.ts index d8854ceb40e..f06ae6e7f82 100644 --- a/packages/cli/src/commands/migrate/duplicates.integration.test.ts +++ b/packages/cli/src/commands/migrate/duplicates.integration.test.ts @@ -295,7 +295,9 @@ describe('#8928 os migrate duplicates — against a really booted stack', () => status: 'blocked', groups: [ { - key: { type: 'view', name: 'crm_case.board', organization_id: 'org_x', package_id_key: '' }, + // [ADR-0131 C7b] The overlay key is (type, name, package, bundle member): + // the organization left it, so the legacy rows group without it. + key: { type: 'view', name: 'crm_case.board', package_id_key: '' }, rowCount: 2, }, ], diff --git a/packages/cli/src/commands/migrate/meta-stored-legacy-organization.integration.test.ts b/packages/cli/src/commands/migrate/meta-stored-legacy-organization.integration.test.ts new file mode 100644 index 00000000000..07bf970a61c --- /dev/null +++ b/packages/cli/src/commands/migrate/meta-stored-legacy-organization.integration.test.ts @@ -0,0 +1,343 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D6/D7] The `sys_metadata` family declares no `organization_id`, + * and the real engine shapes every read to the declared columns. On a + * database the organization-ownership ceremony has not migrated the column is + * still on disk, and the `os migrate` boots (unlike `os serve`, which refuses + * such a database) reach it. Two paths are pinned here on a REAL engine over a + * v17-shape SQLite file — the physical column added raw, outside the declared + * object, and the legacy rows written into it the way a 17.x runtime stored + * them: + * + * 1. `os migrate meta --stored` still tells a legacy organization's row apart + * (off the physical column) and reports it `skipped`, never re-saving it, + * while the environment's row beside it is rewritten. + * 2. The ceremony's template promotion saves through `protocol.saveMetaItem` + * (`organization-ownership.ts`, the `door.save` port). MEASURED before the + * fix: with a legacy organization's `email_template` row and no environment + * row, the repository's single-row read resolved the organization's row and + * the save rewrote it IN PLACE — the organization's body gone, the row still + * the organization's. Now the write resolves the environment's row only: + * with none, it creates one, and the re-keyed unique index (no organization + * in the key) refuses it beside the organization's row — nothing written, + * the organization's row untouched, and the ceremony reports the refusal. + * Once the ceremony's promotion has made that row the environment's (S1, + * an in-place `organization_id = NULL`), the same save updates it. + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createSecurityCatalogReader } from '@objectstack/core'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { isExitSignal } from '../../utils/format.js'; +import { bootSchemaStack } from '../../utils/schema-migrate.js'; +import MigrateMeta from './meta.js'; + +// [#10126] Pay the first transform of these dist-resolved workspace deps at +// MODULE LOAD: the command reaches them through dynamic `import()`s inside +// clocked hooks (`scripts/check-test-source-alias.mjs`). +import '@objectstack/runtime'; +import '@objectstack/metadata-protocol'; +import '@objectstack/platform-objects/plugin'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI_ROOT = resolve(HERE, '..', '..', '..'); +const CASE_TIMEOUT_MS = 180_000; + +const ARTIFACT = { manifest: { id: 'com.example.m23429', name: 'm23429', version: '1.0.0', type: 'app' } }; + +/** A pre-17 flow: `delete_record` carrying `config.filters`, which the stored pass renames to `filter`. */ +const legacyFlow = (name: string) => ({ + name, + label: name, + type: 'autolaunched', + status: 'active', + nodes: [ + { id: 'n0', type: 'start', label: 'Start' }, + { id: 'n1', type: 'delete_record', label: 'Purge', config: { objectName: 'sys_user', filters: { name: 'stale' } } }, + ], + edges: [{ id: 'e1', source: 'n0', target: 'n1' }], +}); + +const TEMPLATE = 'm23429_welcome'; +const template = (subject: string) => ({ name: TEMPLATE, label: 'Welcome', locale: 'en-US', subject, bodyHtml: `

${subject}

` }); + +const PKG = 'com.example.m23429'; +const PKG_TEMPLATE = 'm23429_pkg_tpl'; +const LOCKED_TEMPLATE = 'm23429_locked_tpl'; +const tpl = (name: string, subject: string, extra: Record = {}) => + ({ name, label: name, locale: 'en-US', subject, bodyHtml: `

${subject}

`, ...extra }); +const permission = (name: string, label: string) => ({ name, label, objects: {} }); + +interface SeedRow { id: string; type: string; name: string; organization_id: string | null; package_id?: string | null; body: unknown } +const SEED: SeedRow[] = [ + { id: 'env_flow', type: 'flow', name: 'm23429_env_purge', organization_id: null, body: legacyFlow('m23429_env_purge') }, + { id: 'org_flow', type: 'flow', name: 'm23429_org_purge', organization_id: 'org_a', body: legacyFlow('m23429_org_purge') }, + { id: 'org_template', type: 'email_template', name: TEMPLATE, organization_id: 'org_a', body: template('Org A welcome') }, + // An organization row BOUND TO A PACKAGE: its binding must never become the + // package-less write address (packagelessAddress) or the served row. + { id: 'org_pkg_template', type: 'email_template', name: PKG_TEMPLATE, organization_id: 'org_a', package_id: PKG, body: tpl(PKG_TEMPLATE, 'Org A packaged') }, + // An organization row CARRYING `_lock`: it must never lock the environment's item. + { id: 'org_locked_template', type: 'email_template', name: LOCKED_TEMPLATE, organization_id: 'org_a', body: tpl(LOCKED_TEMPLATE, 'Org A locked', { _lock: 'full' }) }, + // The kernel:ready security-catalog echo: an organization's permission + // overlay, beside the environment's control. + { id: 'org_permission', type: 'permission', name: 'm23429_org_perm', organization_id: 'org_a', body: permission('m23429_org_perm', 'Org A') }, + { id: 'env_permission', type: 'permission', name: 'm23429_env_perm', organization_id: null, body: permission('m23429_env_perm', 'Environment') }, +]; + +let dir: string; +let templateDb: string; +const savedCwd = process.cwd(); +const ENV_KEYS = ['OS_ARTIFACT_PATH', 'OS_DATABASE_URL', 'DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME', 'NODE_ENV'] as const; +const savedEnv: Record = {}; + +let caseSeq = 0; +function caseDb(): string { + caseSeq += 1; + const file = join(dir, 'cases', `${caseSeq}.db`); + mkdirSync(dirname(file), { recursive: true }); + copyFileSync(templateDb, file); + return file; +} + +/** Every `sys_metadata` row as stored, the physical organization column included. */ +async function rawRows(dbFile: string): Promise> { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true }); + try { + const rows = await (driver as any).knex('sys_metadata').select('id', 'type', 'name', 'organization_id', 'state', 'metadata'); + return rows.map((r: any) => ({ ...r, metadata: typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata })); + } finally { + await driver.disconnect(); + } +} + +async function runJson(argv: string[]): Promise<{ payload: any; exitCode: number }> { + const savedExit = process.exitCode; + const swallow = ((_chunk: unknown, ...rest: unknown[]) => { + const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined; + if (cb) cb(); + return true; + }) as typeof process.stdout.write; + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(swallow); + vi.spyOn(process.stderr, 'write').mockImplementation(swallow); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + let thrownExit: number | undefined; + try { + try { + await MigrateMeta.run([...argv, '--json'], { root: CLI_ROOT }); + } catch (error) { + if (!isExitSignal(error)) throw error; + thrownExit = (error as { oclif?: { exit?: number } }).oclif?.exit; + } + const out = stdout.mock.calls.map((c) => String(c[0])).join('').trim(); + let payload: any; + try { payload = JSON.parse(out); } catch { payload = { unparsed: out.slice(-400) }; } + return { payload, exitCode: thrownExit ?? (process.exitCode as number | undefined) ?? 0 }; + } finally { + process.exitCode = savedExit; + vi.restoreAllMocks(); + } +} + +beforeAll(async () => { + for (const key of ENV_KEYS) savedEnv[key] = process.env[key]; + for (const key of ENV_KEYS) delete process.env[key]; + process.env.NODE_ENV = 'production'; + dir = mkdtempSync(join(tmpdir(), 'os-23429-')); + mkdirSync(join(dir, 'dist'), { recursive: true }); + process.env.OS_HOME = join(dir, 'home'); + process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json'); + writeFileSync(process.env.OS_ARTIFACT_PATH, JSON.stringify(ARTIFACT)); + + // The tables as this release provisions them, on a boot that hydrates nothing. + templateDb = join(dir, 'template.db'); + const seed = await bootSchemaStack({ + jsonOutput: false, databaseUrl: `file:${templateDb}`, projectRoot: dir, hydrateMetadata: false, + }); + await seed.shutdown(); + // The v17 shape, at the driver: the physical column a 17.x runtime created, + // outside the declared object, and the rows written into it. + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: templateDb }, useNullAsDefault: true }); + try { + const k = (driver as any).knex; + await k.raw('ALTER TABLE sys_metadata ADD COLUMN organization_id TEXT'); + const now = new Date().toISOString(); + for (const row of SEED) { + await k('sys_metadata').insert({ + id: row.id, type: row.type, name: row.name, organization_id: row.organization_id, package_id: row.package_id ?? null, + state: 'active', version: 1, checksum: `sha256:seed_${row.id}`, created_at: now, updated_at: now, + metadata: JSON.stringify(row.body), + }); + } + } finally { + await driver.disconnect(); + } +}, CASE_TIMEOUT_MS); + +afterAll(() => { + process.chdir(savedCwd); + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } +}); + +beforeEach(() => { process.chdir(dir); }); +afterEach(() => { process.chdir(savedCwd); }); + +describe('os migrate meta --stored on a v17-shape database — the legacy organization row is told apart by its physical column', () => { + it('preview: the environment row is pending, the organization row is skipped with its organization', async () => { + const db = caseDb(); + const before = await rawRows(db); + const { payload } = await runJson(['--stored', '--database-url', `file:${db}`]); + expect(payload.error, JSON.stringify(payload).slice(0, 600)).toBeUndefined(); + const byName = new Map((payload.rows ?? []).map((r: any) => [r.name, r])); + expect(byName.get('m23429_env_purge')).toMatchObject({ outcome: 'pending', organizationId: null }); + expect(byName.get('m23429_org_purge')).toMatchObject({ outcome: 'skipped', organizationId: 'org_a' }); + expect((byName.get('m23429_org_purge') as any).reason).toContain('promotion ceremony (ADR-0131 C7)'); + expect(await rawRows(db)).toEqual(before); + }, CASE_TIMEOUT_MS); + + it('--apply: the environment row is rewritten, the organization row is left byte-for-byte as stored', async () => { + const db = caseDb(); + const { payload } = await runJson(['--stored', '--apply', '--yes', '--database-url', `file:${db}`]); + expect(payload.error, JSON.stringify(payload).slice(0, 600)).toBeUndefined(); + const byName = new Map((payload.rows ?? []).map((r: any) => [r.name, r])); + expect(byName.get('m23429_env_purge')).toMatchObject({ outcome: 'rewritten' }); + expect(byName.get('m23429_org_purge')).toMatchObject({ outcome: 'skipped', organizationId: 'org_a' }); + + const after = await rawRows(db); + const env = after.filter((r) => r.name === 'm23429_env_purge'); + expect(env).toHaveLength(1); + expect(env[0]!.metadata.nodes[1].config).toMatchObject({ filter: { name: 'stale' } }); + const org = after.find((r) => r.id === 'org_flow')!; + expect(org.organization_id).toBe('org_a'); + expect(org.metadata).toEqual(legacyFlow('m23429_org_purge')); + }, CASE_TIMEOUT_MS); +}); + +/** The ceremony's `--apply` boot (`organization-ownership.ts`), with `fn` run against its kernel. */ +async function onCeremonyBoot(db: string, fn: (kernel: { getService(name: string): any }) => Promise): Promise { + const stack = await bootSchemaStack({ + jsonOutput: false, + databaseUrl: `file:${db}`, + projectRoot: dir, + deferSchemaDdl: true, + composeHostStack: true, + composeServedPlatform: true, + composeAuthGatedSecurity: true, + }); + try { + return await fn(stack.kernel as { getService(name: string): any }); + } finally { + await stack.shutdown(); + } +} + +/** The ceremony's `door.save` (`protocol.saveMetaItem`, no `source`): `null`, or what it threw. */ +const doorSave = (kernel: { getService(name: string): any }, type: string, name: string, item: unknown) => + kernel.getService('protocol').saveMetaItem({ type, name, item, actor: 'os migrate organization-ownership' }) + .then(() => null, (error: unknown) => error); + +async function saveTemplateOnCeremonyBoot(db: string, subject: string): Promise { + return onCeremonyBoot(db, (kernel) => doorSave(kernel, 'email_template', TEMPLATE, template(subject))); +} + +describe('the ceremony\'s template-promotion save on a v17-shape database', () => { + it('a legacy organization template and no environment row: the save never rewrites the organization\'s row — it is refused, nothing written', async () => { + const db = caseDb(); + const before = (await rawRows(db)).filter((r) => r.type === 'email_template'); + const error = await saveTemplateOnCeremonyBoot(db, 'Environment welcome'); + expect(String((error as Error | null)?.message ?? error)).toMatch(/Duplicate record refused on 'sys_metadata'/); + expect((await rawRows(db)).filter((r) => r.type === 'email_template')).toEqual(before); + }, CASE_TIMEOUT_MS); + + it('once the promotion has made that row the environment\'s (in place), the same save updates it', async () => { + const db = caseDb(); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: db }, useNullAsDefault: true }); + try { + await (driver as any).knex('sys_metadata').where({ id: 'org_template' }).update({ organization_id: null }); + } finally { + await driver.disconnect(); + } + expect(await saveTemplateOnCeremonyBoot(db, 'Environment welcome')).toBeNull(); + const rows = (await rawRows(db)).filter((r) => r.type === 'email_template' && r.name === TEMPLATE && r.state === 'active'); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ id: 'org_template', organization_id: null }); + expect(rows[0]!.metadata).toMatchObject({ subject: 'Environment welcome' }); + }, CASE_TIMEOUT_MS); +}); + +describe('[ADR-0131 D6/D7] the ceremony boot\'s readers ignore a legacy organization row (re-review 6181366782)', () => { + it('door.save of a package-less item an organization holds BOUND TO A PACKAGE: the address is the environment\'s, never the organization row\'s package', async () => { + const db = caseDb(); + const error = await onCeremonyBoot(db, (kernel) => doorSave(kernel, 'email_template', PKG_TEMPLATE, tpl(PKG_TEMPLATE, 'Environment'))); + expect(error, String((error as Error | null)?.message ?? error)).toBeNull(); + const rows = (await rawRows(db)).filter((r) => r.name === PKG_TEMPLATE); + const org = rows.find((r) => r.id === 'org_pkg_template')!; + expect(org).toMatchObject({ organization_id: 'org_a' }); + expect(org.metadata).toEqual(tpl(PKG_TEMPLATE, 'Org A packaged')); + const env = rows.filter((r) => r.organization_id === null); + expect(env).toHaveLength(1); + expect(env[0]!.metadata).toMatchObject({ subject: 'Environment' }); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: db }, useNullAsDefault: true }); + try { + const pkg = await (driver as any).knex('sys_metadata').where({ id: env[0]!.id }).first('package_id'); + expect(pkg.package_id ?? null, 'the organization row\'s binding is not the environment row\'s address').toBeNull(); + } finally { + await driver.disconnect(); + } + }, CASE_TIMEOUT_MS); + + it('lock.verdict and the _lock gate: an organization row carrying _lock: full neither reads as the item\'s lock nor is the gate\'s refusal', async () => { + const db = caseDb(); + const { read, error } = await onCeremonyBoot(db, async (kernel) => { + const protocol = kernel.getService('protocol'); + // The ceremony's `lock.verdict` port reads `getMetaItem`. + const read = await protocol.getMetaItem({ type: 'email_template', name: LOCKED_TEMPLATE }).catch(() => null); + const error = await doorSave(kernel, 'email_template', LOCKED_TEMPLATE, tpl(LOCKED_TEMPLATE, 'Environment')); + return { read, error }; + }); + expect((read as { lock?: unknown } | null)?.lock ?? 'none').not.toBe('full'); + expect((read as { editable?: unknown } | null)?.editable).not.toBe(false); + // The `_lock` gate runs BEFORE the write. It did not refuse over the + // organization's lock: the save reached the store, where the re-keyed + // unique index refuses an environment row beside the organization's row of + // the same key — the refusal the template pin above measures. + expect(String((error as Error | null)?.message ?? error)).toMatch(/Duplicate record refused on 'sys_metadata'/); + expect(String((error as Error | null)?.message ?? error)).not.toMatch(/lock/i); + const org = (await rawRows(db)).find((r) => r.id === 'org_locked_template')!; + expect(org.metadata).toEqual(tpl(LOCKED_TEMPLATE, 'Org A locked', { _lock: 'full' })); + }, CASE_TIMEOUT_MS); + + it('the kernel:ready security-catalog echo registers the environment\'s permission overlay and never an organization\'s', async () => { + const db = caseDb(); + const saved = process.env.OS_AUTH_SECRET; + process.env.OS_AUTH_SECRET = 'm23429-secret-'.padEnd(40, 'x'); + try { + const { orgEntry, envEntry } = await onCeremonyBoot(db, async (kernel) => { + const catalog = createSecurityCatalogReader({ + registry: kernel.getService('objectql').registry, + metadata: kernel.getService('metadata'), + }); + return { + orgEntry: await catalog.resolve('permission', 'm23429_org_perm'), + envEntry: await catalog.resolve('permission', 'm23429_env_perm'), + }; + }); + expect(envEntry, 'control: the environment\'s overlay is in the catalog').toBeDefined(); + expect(orgEntry, 'an organization\'s overlay is not the environment\'s').toBeUndefined(); + } finally { + if (saved === undefined) delete process.env.OS_AUTH_SECRET; + else process.env.OS_AUTH_SECRET = saved; + } + }, CASE_TIMEOUT_MS); +}); + diff --git a/packages/cli/src/commands/migrate/organization-ownership.ts b/packages/cli/src/commands/migrate/organization-ownership.ts index 1863bc0eb70..2c04768259d 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.ts @@ -25,6 +25,9 @@ import { resolvePlannedDriverExec } from '../../utils/unmanaged-tables.js'; import { buildOrganizationOwnershipPlan, OrganizationOwnershipPlanRefusal, + columnListSql, + columnNameOf, + dialectOf, type OrganizationOwnershipPlan, } from '../../utils/organization-ownership-plan.js'; import { @@ -610,8 +613,11 @@ export default class MigrateOrganizationOwnership extends Command { // The environment's own row first (it is what a promotion must never // overwrite), then what the registry serves for the bundle member. find: async (name, locale) => { + // Once column-drops has run, every row is the environment's (C7b). + const scoped = (await executor.query(columnListSql(dialectOf(executor.client) ?? 'sqlite'), ['sys_metadata'])) + .some((row) => columnNameOf(row) === 'organization_id'); const stored = await executor.query( - 'SELECT metadata FROM sys_metadata WHERE type = ? AND name = ? AND state = ? AND organization_id IS NULL ' + + `SELECT metadata FROM sys_metadata WHERE type = ? AND name = ? AND state = ?${scoped ? ' AND organization_id IS NULL' : ''} ` + 'AND (bundle_member = ? OR (bundle_member IS NULL AND ? = ?))', ['email_template', name, 'active', locale, locale, 'en-US'], ); diff --git a/packages/cli/src/commands/migrate/security-catalog-overlays.integration.test.ts b/packages/cli/src/commands/migrate/security-catalog-overlays.integration.test.ts index 648955c5dfc..9033fdf2eb0 100644 --- a/packages/cli/src/commands/migrate/security-catalog-overlays.integration.test.ts +++ b/packages/cli/src/commands/migrate/security-catalog-overlays.integration.test.ts @@ -44,7 +44,6 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { SqlDriver } from '@objectstack/driver-sql'; -import type { IObjectQLEngine } from '@objectstack/spec/contracts'; import { NAMESPACE_CONFLICT_CODE } from '@objectstack/objectql'; import { SECURITY_PLUGIN_ID } from '@objectstack/plugin-security'; import { isExitSignal } from '../../utils/format.js'; @@ -61,7 +60,6 @@ import '@objectstack/platform-objects/plugin'; const HERE = dirname(fileURLToPath(import.meta.url)); const CLI_ROOT = resolve(HERE, '..', '..', '..'); const CASE_TIMEOUT_MS = 180_000; -const SYSTEM = { context: { isSystem: true } } as const; const PKG = 'com.example.m22371'; const ARTIFACT = { @@ -139,11 +137,13 @@ async function trail(dbFile: string): Promise<{ history: string[]; audit: string const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true }); try { const k = (driver as any).knex; - const history = await k('sys_metadata_history').select('type', 'name', 'operation_type', 'recorded_by', 'organization_id'); + // [ADR-0131 D7] The history table carries no organization column: every + // tombstone is the environment's. + const history = await k('sys_metadata_history').select('type', 'name', 'operation_type', 'recorded_by'); const audit = await k('sys_metadata_audit').select('type', 'name', 'operation', 'outcome', 'actor'); return { history: history.filter((r: any) => r.operation_type === 'delete') - .map((r: any) => `${r.type}/${r.name} ${r.operation_type} by ${r.recorded_by} org=${r.organization_id ?? 'env'}`).sort(), + .map((r: any) => `${r.type}/${r.name} ${r.operation_type} by ${r.recorded_by}`).sort(), audit: audit.filter((r: any) => r.operation === 'delete') .map((r: any) => `${r.type}/${r.name} ${r.operation} ${r.outcome} by ${r.actor}`).sort(), }; @@ -240,16 +240,27 @@ beforeAll(async () => { const seed = await bootSchemaStack({ jsonOutput: false, databaseUrl: `file:${templateDb}`, projectRoot: dir, hydrateMetadata: false, }); - try { - const ql = seed.kernel.getService('objectql') as IObjectQLEngine; - const now = new Date().toISOString(); - for (const row of SEED) { - await ql.insert('sys_metadata', { - ...row, version: 1, checksum: null, created_at: now, updated_at: now, metadata: JSON.stringify(bodyOf(row)), - }, SYSTEM); + await seed.shutdown(); + // [ADR-0131 D7] The family declares no organization column, so the engine + // cannot write a legacy organization row. The v17 shape is seeded at the + // driver: the physical column a 17.x runtime created, outside the declared + // object, and the rows written into it the way that release stored them. + { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: templateDb }, useNullAsDefault: true }); + try { + const k = (driver as any).knex; + await k.raw('ALTER TABLE sys_metadata ADD COLUMN organization_id TEXT'); + const now = new Date().toISOString(); + let n = 0; + for (const row of SEED) { + n += 1; + await k('sys_metadata').insert({ + id: `seed_${n}`, ...row, version: 1, checksum: null, created_at: now, updated_at: now, metadata: JSON.stringify(bodyOf(row)), + }); + } + } finally { + await driver.disconnect(); } - } finally { - await seed.shutdown(); } }, CASE_TIMEOUT_MS); @@ -345,10 +356,10 @@ describe('os migrate security-catalog-overlays --apply — deletes exactly the l // row for each one the protocol's door deleted. const { history, audit } = await trail(db); expect(history).toEqual([ - 'permission/m22371_rep delete by os migrate security-catalog-overlays org=env', - 'permissions/member_default delete by os migrate security-catalog-overlays org=env', - 'position/m22371_lead delete by os migrate security-catalog-overlays org=env', - 'positions/m22371_old_lead delete by os migrate security-catalog-overlays org=env', + 'permission/m22371_rep delete by os migrate security-catalog-overlays', + 'permissions/member_default delete by os migrate security-catalog-overlays', + 'position/m22371_lead delete by os migrate security-catalog-overlays', + 'positions/m22371_old_lead delete by os migrate security-catalog-overlays', ]); expect(audit).toEqual([ 'permission/m22371_rep delete allowed by os migrate security-catalog-overlays', @@ -356,7 +367,9 @@ describe('os migrate security-catalog-overlays --apply — deletes exactly the l ]); // The cold boot of the same database and configuration that was refused now - // comes up, and a second preview lists nothing (exit 0). + // comes up, and a second preview lists nothing (exit 0). [ADR-0131 D7] The + // legacy organization control stays stored and is never hydrated: the boot + // leaves it out off the physical column, as the step does. expect(await coldBootConflicts(db)).toEqual([]); const again = await runJson(['--database-url', `file:${db}`, '--json']); expect(again.payload.listed).toBe(0); @@ -370,6 +383,7 @@ describe('os migrate security-catalog-overlays --apply — deletes exactly the l expect(exitCode).toBe(0); expect(payload.deleted).toBe(LISTED_AUTH_OFF.length); expect(await storedRows(db)).toContain('permissions/member_default/env/active'); + expect(await storedRows(db)).toContain('permission/m22371_rep/org_m22371/active'); expect(await coldBootConflicts(db)).toEqual([]); }, CASE_TIMEOUT_MS * 2); }); diff --git a/packages/cli/src/commands/migrate/security-catalog-overlays.ts b/packages/cli/src/commands/migrate/security-catalog-overlays.ts index c400cfbaf46..cb9e84efd4c 100644 --- a/packages/cli/src/commands/migrate/security-catalog-overlays.ts +++ b/packages/cli/src/commands/migrate/security-catalog-overlays.ts @@ -290,7 +290,7 @@ export default class MigrateSecurityCatalogOverlays extends Command { // The preview's read-only boot measured whether `sys_metadata` exists; a // table that does not exist stores no row (the `meta --stored` reading). const reads = absentTableReads(stack); - const rows = reads.absent('sys_metadata') ? [] : await listSecurityCatalogOverlayRows(stack.kernel); + const rows = reads.absent('sys_metadata') ? [] : await listSecurityCatalogOverlayRows(stack.kernel, stack.driver); const outcomes = apply && rows.length > 0 ? await deleteSecurityCatalogOverlayRows(stack.kernel, rows) : []; const deleted = outcomes.filter((o) => o.outcome === 'deleted').length; const failed = outcomes.filter((o) => o.outcome === 'failed').length; diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index e06e62ff1b2..da096a85202 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -3863,6 +3863,25 @@ export default class Serve extends Command { } } + // 5c-ter. [ADR-0131 D10 item 5] The v18 boot refusal, beside the + // recovery scan and in its shape: at `kernel:ready` it refuses to start + // a database still in the v17 organization-ownership shape whose + // ceremony marker is not verified, naming `os migrate + // organization-ownership`. Composed HERE, on served kernels only — + // ⛔ never in `createStandaloneStack`, which every CLI data and schema + // boot shares, the ceremony's own boot among them. A host that composes + // its own instance keeps it; a guard that cannot be composed fails the + // boot rather than leave the database unjudged (no escape hatch, D10). + const hasOrganizationOwnershipGuard = plugins.some( + (p: any) => p?.name === 'com.objectstack.organization-ownership-boot-guard' + || p?.constructor?.name === 'OrganizationOwnershipBootGuardPlugin' + ); + if (!hasOrganizationOwnershipGuard) { + const { OrganizationOwnershipBootGuardPlugin } = await import('@objectstack/runtime'); + await kernel.use(new OrganizationOwnershipBootGuardPlugin()); + trackPlugin('OrganizationOwnershipBootGuard'); + } + // 5d. Auto-register AuthPlugin (and paired Security/Audit) when the // 'auth' tier is enabled and no auth plugin is already configured. // The Console expects /api/v1/auth/* to be served by better-auth via diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index d3dd0eda148..ce105cf69b6 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -1,5 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system'; import { convertPositionBindingRows, permissionSetDefinitionFromRow, @@ -160,8 +161,8 @@ import { describeSecurityPluginComposition, type SchemaMigrationComposition } fr * Organization. ⛔ Run at boot: the command is the only caller. */ -/** The `sys_migration` row the ceremony checkpoints into and completes. */ -export const ORGANIZATION_OWNERSHIP_MIGRATION_ID = 'adr-0131-organization-ownership'; +/** The `sys_migration` row the ceremony checkpoints into and completes; the v18 boot guard reads it. */ +export { ORGANIZATION_OWNERSHIP_MIGRATION_ID }; export const CEREMONY_STEPS = [ 'attribution', diff --git a/packages/cli/src/utils/organization-ownership-drop-gate.ts b/packages/cli/src/utils/organization-ownership-drop-gate.ts new file mode 100644 index 00000000000..cc8fbd92ef3 --- /dev/null +++ b/packages/cli/src/utils/organization-ownership-drop-gate.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D10 item 5 · #23429 E1] The question `os migrate apply` asks + * before it drops an `organization_id` column: has the organization-ownership + * ceremony verified on this database? + * + * A v18 runtime declares no organization column on the ceremony's + * column-drop tables (the D7 tables and the `sys_metadata` family), so the + * drift report lists each physical one as an unmapped, destructive column and + * names `os migrate apply --allow-destructive` as its remedy. Applied ahead of + * the ceremony, that drop skips its promotion and report steps, turns every + * legacy organization row into an environment row, and leaves no physical + * column for the v18 boot refusal to find — so the server starts with the + * marker never written. D10 item 5 forbids exactly that escape hatch. So the + * drop of `organization_id` on any table in the ceremony's inventory waits for + * the ceremony's verified marker, as `sys_account.issuer` (#17440) and + * `sys_comment.reactions` (#23281) wait for theirs. ⛔ No flag stands it down. + */ + +import type { MigrationFlagEngine } from '@objectstack/platform-objects/system'; +import type { DataMigrationFlag } from '@objectstack/spec/system'; +import { ORGANIZATION_OWNERSHIP_INVENTORY } from './organization-ownership-inventory.js'; + +/** The column the ceremony owns the drop of. */ +export const ORGANIZATION_COLUMN = 'organization_id'; + +/** Every table the ceremony's inventory names. */ +const CEREMONY_TABLES: ReadonlySet = new Set(ORGANIZATION_OWNERSHIP_INVENTORY.map((entry) => entry.object)); + +/** The tables whose `organization_id` drop is among `intended`, sorted. */ +export function ceremonyOrganizationColumnDrops( + intended: ReadonlyArray<{ table: string; op?: { type?: string; column?: string } }>, +): string[] { + const tables = new Set(); + for (const d of intended) { + if (d.op?.type === 'drop_column' && d.op.column === ORGANIZATION_COLUMN && CEREMONY_TABLES.has(d.table)) tables.add(d.table); + } + return [...tables].sort(); +} + +/** + * Is the ceremony's run verified on this database? Fails toward retention: no + * `sys_migration` object, no row, an unreadable row or an unverified one all + * answer `verified: false`. + */ +export async function readOrganizationOwnershipMarker( + engine: MigrationFlagEngine, +): Promise<{ verified: boolean; flag: DataMigrationFlag | null }> { + const { readDataMigrationFlag } = await import('@objectstack/platform-objects/system'); + const { isDataMigrationFlagVerified, ORGANIZATION_OWNERSHIP_MIGRATION_ID } = await import('@objectstack/spec/system'); + const flag = await readDataMigrationFlag(engine, ORGANIZATION_OWNERSHIP_MIGRATION_ID); + return { verified: isDataMigrationFlagVerified(flag), flag }; +} diff --git a/packages/cli/src/utils/security-catalog-overlays.ts b/packages/cli/src/utils/security-catalog-overlays.ts index cf852a3fef4..43d46d8c427 100644 --- a/packages/cli/src/utils/security-catalog-overlays.ts +++ b/packages/cli/src/utils/security-catalog-overlays.ts @@ -7,8 +7,10 @@ * * ## The population * - * Every active, environment-wide `sys_metadata` row (`organization_id IS NULL`, - * `state = 'active'`) of type `permission` or `position` — the legacy plurals + * Every active environment `sys_metadata` row (`state = 'active'`; on a + * database the organization-ownership ceremony has not migrated, a row whose + * physical `organization_id` holds a value is a legacy organization's and is + * left out, ADR-0131 D6/D7) of type `permission` or `position` — the legacy plurals * `permissions` / `positions` included, which the boot's load folds to the same * types — whose name a configured package holds. That is exactly what the cold * boot's catalog check refuses (ADR-0048 N.3): the check meets the package-held @@ -89,6 +91,7 @@ const SYSTEM = { context: { isSystem: true } } as const; interface EngineLike { registry: unknown; find(object: string, query: Record, options?: Record): Promise; + getDriverForObject?(objectName: string): unknown; } function engineOf(kernel: { getService(name: string): unknown }): EngineLike { @@ -102,11 +105,11 @@ function engineOf(kernel: { getService(name: string): unknown }): EngineLike { return engine; } -/** Every active environment-wide row of one stored spelling, ordered by name then id. */ +/** Every active row of one stored spelling, ordered by name then id. */ async function storedRows(engine: EngineLike, storedType: string): Promise { const rows = await engine.find( 'sys_metadata', - { where: { type: storedType, organization_id: null, state: 'active' } }, + { where: { type: storedType, state: 'active' } }, SYSTEM, ); return [...rows].sort((a, b) => @@ -119,9 +122,12 @@ async function storedRows(engine: EngineLike, storedType: string): Promise { const engine = engineOf(kernel); const { findPackageHeldSecurityCatalogNames } = await import('@objectstack/objectql'); @@ -131,9 +137,15 @@ export async function listSecurityCatalogOverlayRows( } if (held.size === 0) return []; + // [ADR-0131 D6/D7] On a database the ceremony has not migrated, a legacy + // organization's rows are told apart by the physical column (the engine can + // no longer name it); an unanswered read refuses the step. + const { readLegacyOrganizationRows } = await import('@objectstack/metadata-core'); + const legacy = await readLegacyOrganizationRows(engine.getDriverForObject?.('sys_metadata') ?? driver); const listed: SecurityCatalogOverlayRow[] = []; for (const [storedType, catalogType] of STORED_TYPE_SPELLINGS) { for (const row of await storedRows(engine, storedType)) { + if (legacy.has(String(row.id))) continue; const name = String(row.name); const heldBy = held.get(`${catalogType}|${name}`); if (!heldBy) continue; @@ -160,7 +172,7 @@ export async function listSecurityCatalogOverlayRows( async function stillStored(engine: EngineLike, storedType: string, name: string, ids: readonly string[]): Promise> { const rows = await engine.find( 'sys_metadata', - { where: { type: storedType, name, organization_id: null, state: 'active' } }, + { where: { type: storedType, name, state: 'active' } }, SYSTEM, ); const present = new Set(rows.map((r: any) => String(r.id))); @@ -236,7 +248,7 @@ export async function deleteSecurityCatalogOverlayRows( const plural = rows.filter((row) => row.storedType !== row.catalogType); if (plural.length > 0) { const { SysMetadataRepository } = await import('@objectstack/metadata-protocol'); - const repository = new SysMetadataRepository({ engine: engine as never, organizationId: null, orgLabel: 'env' }); + const repository = new SysMetadataRepository({ engine: engine as never, orgLabel: 'env' }); for (const row of plural) { try { await repository.delete( diff --git a/packages/cli/test/permission-set-zero-row.integration.test.ts b/packages/cli/test/permission-set-zero-row.integration.test.ts index 7cd292ed4da..d764c0f51ab 100644 --- a/packages/cli/test/permission-set-zero-row.integration.test.ts +++ b/packages/cli/test/permission-set-zero-row.integration.test.ts @@ -88,7 +88,7 @@ async function storedPermissionNames(dbFile: string): Promise { const probe = new SqlDriver({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true }); const knex = (probe as any).knex; try { - const rows = await knex('sys_metadata').select('name').where({ type: 'permission', state: 'active' }).whereNull('organization_id'); + const rows = await knex('sys_metadata').select('name').where({ type: 'permission', state: 'active' }); // every row is the environment's (ADR-0131 D7) return rows.map((r: any) => String(r.name)).sort(); } finally { await knex.destroy(); diff --git a/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts b/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts index b676b038756..b373a962578 100644 --- a/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts +++ b/packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts @@ -249,7 +249,7 @@ describe('environment kernel: a metadata-door permission set or position is in t // The overlays are stored rows now, read through a connection of our own. const probe = new SqlDriver({ client: 'better-sqlite3', connection: { filename: s.dbFile }, useNullAsDefault: true }); try { - const rows = await (probe as any).knex('sys_metadata').select('type', 'name').where({ state: 'active' }).whereNull('organization_id'); + const rows = await (probe as any).knex('sys_metadata').select('type', 'name').where({ state: 'active' }); // every row is the environment's (ADR-0131 D7) const stored = rows.map((r: any) => `${r.type}/${r.name}`); expect(stored).toEqual(expect.arrayContaining([`permission/${PKG_SET}`, `permission/${LOOSE_SET}`, `position/${POSITION}`])); } finally { diff --git a/packages/drivers/driver-sql/src/sql-driver-list-physical-columns.test.ts b/packages/drivers/driver-sql/src/sql-driver-list-physical-columns.test.ts new file mode 100644 index 00000000000..e75490124c8 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-list-physical-columns.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D7 · #23429] `listPhysicalColumns` reads the table's catalog: the + * declared columns and any a past release left behind — which the metadata + * family's legacy-organization read (`@objectstack/metadata-core`'s + * `readLegacyOrganizationRows`) asks on every boot, without `execute()`. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { Knex } from 'knex'; +import type { DriverOptions } from '@objectstack/spec/data'; +import { SqlDriver } from './sql-driver.js'; + +const BYPASS: DriverOptions = { bypassTenantAudit: true }; +const TABLE = 'lpc_family'; + +describe('SqlDriver.listPhysicalColumns', () => { + let driver: SqlDriver; + const knex = () => (driver as unknown as { knex: Knex }).knex; + + beforeAll(async () => { + driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await driver.initObjects([{ name: TABLE, fields: { name: { type: 'string' } } }]); + await driver.create(TABLE, { id: 'env_1', name: 'environment' }, BYPASS); + }); + + afterAll(async () => { + await driver.disconnect(); + }); + + it('answers the declared columns', async () => { + const columns = await driver.listPhysicalColumns(TABLE); + expect(columns).toEqual(expect.arrayContaining(['id', 'name'])); + expect(columns).not.toContain('organization_id'); + }); + + it('answers [] for a table that does not exist', async () => { + expect(await driver.listPhysicalColumns('lpc_no_such_table')).toEqual([]); + }); + + it('answers a column a past release left behind', async () => { + await knex().raw(`ALTER TABLE ${TABLE} ADD COLUMN organization_id TEXT`); + expect(await driver.listPhysicalColumns(TABLE)).toContain('organization_id'); + }); + + it('joins the caller\'s transaction (a single-connection pool would otherwise wait on it)', async () => { + const columns = await knex().transaction(async (trx) => driver.listPhysicalColumns(TABLE, { transaction: trx } as DriverOptions)); + expect(columns).toContain('organization_id'); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 64886ac39d7..ad156001e04 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -15344,6 +15344,33 @@ export class SqlDriver implements IDataDriver { // Schema Introspection // =================================== + /** + * [ADR-0131 D7 · #23429] The PHYSICAL column names of `object`'s table, as + * the catalog holds them — the declared ones and any a past release left + * behind. Read-only, one table, through the builder every read uses (so it + * resolves a federated object's remote table, and joins + * `options.transaction` like any read: a single-connection pool would + * otherwise wait on the connection the caller's transaction holds). + * + * A table that does not exist answers `[]`. It is not `execute()`, so a + * declaration boot's write guard has nothing to count: a question every + * boot asks (does a retired column still physically exist?) must not cost a + * plan its "writes nothing" claim. + * + * Built on Knex directly, not {@link getBuilder}: it reads the CATALOG and + * returns column names, never a row, so the tenant scope a row read must + * carry (`check:tenant-chokepoint`) has nothing to scope. The table is + * resolved as `getBuilder` resolves it. + */ + async listPhysicalColumns(object: string, options?: DriverOptions): Promise { + let catalog = this.knex(this.physicalTableByObject[object] ?? object); + const remoteSchema = this.physicalSchemaByObject[object]; + if (remoteSchema) catalog = catalog.withSchema(remoteSchema); + if (options?.transaction) catalog = catalog.transacting(options.transaction as Knex.Transaction); + const info = (await catalog.columnInfo()) as Record; + return Object.keys(info ?? {}); + } + async introspectSchema(): Promise { const tables: Record = {}; // Stamped BEFORE the reads, not after: a consumer asking "has the remote diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index 0f732b98aa8..fca85eaa8ad 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -898,6 +898,9 @@ export const REMOTE_FACE_ANSWERS = { // Calls `this.analyzeQuery`, which refuses. explain: 'inherited', introspectSchema: 'refused', + // [#23429] The table's physical column names, from the remote catalog + // (`pragma_table_info`), which is what the inherited Knex read cannot reach. + listPhysicalColumns: 'remote', getKnex: 'refused', // Schema doors. syncSchema: 'remote', @@ -3880,6 +3883,32 @@ export class TursoDriver extends SqlDriver { return super.analyzeQuery(object, query, options); } + /** + * [ADR-0131 D7 · #23429] The physical column names of `object`'s table. The + * remote arm asks the remote catalog (`pragma_table_info`): the inherited + * read goes through Knex, which a remote face has no connection for. A table + * that does not exist answers `[]`, as the local arm does. + */ + override async listPhysicalColumns(object: string, options?: DriverOptions): Promise { + if (this.isRemote) { + this.assertRemoteTransactionUnsupported(options, 'listPhysicalColumns'); + const table = this.physicalTableByObject[object] ?? object; + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) { + throw new Error(`listPhysicalColumns: "${table}" is not a table name the remote catalog read can quote.`); + } + const sql = `SELECT name FROM pragma_table_info('${table}')`; + let rows: unknown; + try { + rows = await this.remoteTransport!.execute(sql); + } catch (error) { + // One envelope for both transports, as `execute()` declares it. + throw this.rawStatementFault(sql, error); + } + return (Array.isArray(rows) ? rows : []).map((row) => String((row as { name?: unknown }).name ?? '')); + } + return super.listPhysicalColumns(object, options); + } + /** * Schema introspection — refused on the REMOTE face. The inherited member * reads every table's columns, foreign keys, primary keys and unique diff --git a/packages/metadata-core/src/legacy-organization-row.test.ts b/packages/metadata-core/src/legacy-organization-row.test.ts index 020754baa47..99ab2b31764 100644 --- a/packages/metadata-core/src/legacy-organization-row.test.ts +++ b/packages/metadata-core/src/legacy-organization-row.test.ts @@ -1,16 +1,153 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect } from 'vitest'; -import { isLegacyOrganizationRow } from './legacy-organization-row.js'; +/** + * [ADR-0131 D6/D7] `readLegacyOrganizationRows` answers from the physical + * column, and refuses — never reads as "none" — every seam that did not answer. + * The real-engine pins (a v17-shape SQLite file) live in `@objectstack/cli` + * (`meta-stored-legacy-organization.integration.test.ts`), which carries the + * SQL stack; these pin the seam verdicts. + */ -describe('[ADR-0131 D6] isLegacyOrganizationRow — the one row-value test', () => { - it('a row whose organization_id names an organization is a legacy organization row', () => { - expect(isLegacyOrganizationRow({ organization_id: 'org_a' })).toBe(true); +import { describe, expect, it } from 'vitest'; +import { LegacyOrganizationRowsUnreadable, readLegacyOrganizationRows } from './legacy-organization-row.js'; + +/** A SQL driver double: the column list and the legacy select, by statement. */ +function sqlDriver(opts: { + dialectName?: unknown; + client?: unknown; + columns?: unknown; + rows?: unknown; +}) { + const seen: string[] = []; + return { + seen, + ...(opts.dialectName !== undefined ? { dialectName: opts.dialectName } : {}), + ...(opts.client !== undefined ? { config: { client: opts.client } } : {}), + async execute(sql: string) { + seen.push(sql); + return /column_name/.test(sql) ? opts.columns : opts.rows; + }, + }; +} + +const V17_COLUMNS = [{ column_name: 'id' }, { column_name: 'type' }, { column_name: 'organization_id' }]; +const V18_COLUMNS = [{ column_name: 'id' }, { column_name: 'type' }]; + +describe('readLegacyOrganizationRows — the answers', () => { + it('a v17-shape table: every row with an organization value, by id', async () => { + const driver = sqlDriver({ + dialectName: 'sqlite', + columns: V17_COLUMNS, + rows: [{ id: 'r1', organization_id: 'org_a' }, { id: 'r2', organization_id: 'org_b' }], + }); + const legacy = await readLegacyOrganizationRows(driver); + expect([...legacy]).toEqual([['r1', 'org_a'], ['r2', 'org_b']]); + expect(driver.seen[1]).toMatch(/organization_id IS NOT NULL AND organization_id <> ''/); + }); + + it('a migrated or fresh table (no column): none, and the rows are never read', async () => { + const driver = sqlDriver({ dialectName: 'postgres', columns: { rows: V18_COLUMNS } }); + expect((await readLegacyOrganizationRows(driver)).size).toBe(0); + expect(driver.seen).toHaveLength(1); + }); + + it('a table answered "no column" is not asked again on the same driver (the column only ever leaves)', async () => { + const driver = sqlDriver({ dialectName: 'sqlite', columns: V18_COLUMNS }); + await readLegacyOrganizationRows(driver); + await readLegacyOrganizationRows(driver); + expect(driver.seen).toHaveLength(1); + }); + + it('a table that HAS the column is read afresh every time — the values move during the ceremony', async () => { + const driver = sqlDriver({ dialectName: 'sqlite', columns: V17_COLUMNS, rows: [] }); + await readLegacyOrganizationRows(driver); + await readLegacyOrganizationRows(driver); + expect(driver.seen).toHaveLength(4); + }); + + it('mysql2\'s [rows, fields] and the upper-case COLUMN_NAME both read', async () => { + const driver = sqlDriver({ + client: 'mysql2', + columns: [[{ COLUMN_NAME: 'id' }, { COLUMN_NAME: 'organization_id' }], []], + rows: [[{ id: 'r1', organization_id: 'org_a' }], []], + }); + expect([...(await readLegacyOrganizationRows(driver))]).toEqual([['r1', 'org_a']]); + }); + + it('a driver that is not a SQL driver (no dialectName, no config.client — memory, MongoDB): none', async () => { + expect((await readLegacyOrganizationRows({ async execute() { return null; } })).size).toBe(0); + expect((await readLegacyOrganizationRows(undefined)).size).toBe(0); + }); +}); + +describe('readLegacyOrganizationRows — fails closed', () => { + it.each([ + ['null', null], + ['undefined', undefined], + ['the statement echoed back as a string', 'SELECT …'], + ['a bare object with no rows', { rowCount: 0 }], + ])('a seam that returns no result set (%s) is refused, never read as "no column"', async (_label, answer) => { + const driver = sqlDriver({ dialectName: 'sqlite', columns: answer }); + await expect(readLegacyOrganizationRows(driver)).rejects.toBeInstanceOf(LegacyOrganizationRowsUnreadable); + }); + + it('a legacy select that returns no result set is refused', async () => { + const driver = sqlDriver({ dialectName: 'sqlite', columns: V17_COLUMNS, rows: null }); + await expect(readLegacyOrganizationRows(driver)).rejects.toBeInstanceOf(LegacyOrganizationRowsUnreadable); + }); + + it('an EMPTY column list for a table that exists is refused', async () => { + const driver = sqlDriver({ dialectName: 'sqlite', columns: [] }); + await expect(readLegacyOrganizationRows(driver)).rejects.toThrow(/column list came back empty/); + }); + + it.each([ + ['dialectName unknown', { dialectName: 'unknown' }], + ['an unrecognised client string', { client: 'oracledb' }], + ['a client class with no dialectName', { client: class Client_Unknown {} }], + ])('a SQL driver whose dialect resolves to none (%s) is refused, never given the SQLite statement', async (_label, shape) => { + const driver = sqlDriver({ ...shape, columns: V17_COLUMNS, rows: [] }); + await expect(readLegacyOrganizationRows(driver)).rejects.toThrow(/no dialect/); + expect(driver.seen).toHaveLength(0); + }); + + it('a SQL driver with no raw seam is refused', async () => { + await expect(readLegacyOrganizationRows({ dialectName: 'sqlite' })).rejects.toThrow(/no raw-SQL seam/); + }); + + it('a table outside the family is refused before anything runs', async () => { + const driver = sqlDriver({ dialectName: 'sqlite', columns: V17_COLUMNS, rows: [] }); + await expect(readLegacyOrganizationRows(driver, 'sys_user')).rejects.toThrow(/not a sys_metadata-family table/); + expect(driver.seen).toHaveLength(0); + }); +}); + +describe('readLegacyOrganizationRows — the presence check is the driver\'s catalog read, not execute()', () => { + /** A `SqlDriver`-shaped double: `listPhysicalColumns`, and an `execute` that answers the legacy select only. */ + function catalogDriver(columns: unknown, rows: unknown = []) { + const calls: string[] = []; + return { + calls, + dialectName: 'sqlite', + async listPhysicalColumns(object: string) { calls.push(`columns:${object}`); return columns; }, + async execute(sql: string) { calls.push(/column_name/.test(sql) ? 'execute:catalog' : 'execute:legacy'); return rows; }, + }; + } + + it('a migrated or fresh table: one catalog read, and execute() is never called', async () => { + const driver = catalogDriver(['id', 'type']); + expect((await readLegacyOrganizationRows(driver)).size).toBe(0); + expect(driver.calls).toEqual(['columns:sys_metadata']); + }); + + it('a v17-shape table: the catalog read, then the legacy select', async () => { + const driver = catalogDriver(['id', 'organization_id'], [{ id: 'r1', organization_id: 'org_a' }]); + expect([...(await readLegacyOrganizationRows(driver))]).toEqual([['r1', 'org_a']]); + expect(driver.calls).toEqual(['columns:sys_metadata', 'execute:legacy']); }); - it('an absent, null or empty organization_id is the environment\'s row', () => { - for (const row of [{}, { organization_id: undefined }, { organization_id: null }, { organization_id: '' }, null, undefined]) { - expect(isLegacyOrganizationRow(row), JSON.stringify(row)).toBe(false); - } + it('a column list that is not a list, or is empty, is refused', async () => { + await expect(readLegacyOrganizationRows(catalogDriver(null))).rejects.toThrow(/not a list/); + await expect(readLegacyOrganizationRows(catalogDriver([]))).rejects.toThrow(/column list came back empty/); }); }); diff --git a/packages/metadata-core/src/legacy-organization-row.ts b/packages/metadata-core/src/legacy-organization-row.ts index 1429f694a23..3034a6f17b7 100644 --- a/packages/metadata-core/src/legacy-organization-row.ts +++ b/packages/metadata-core/src/legacy-organization-row.ts @@ -1,19 +1,216 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [ADR-0131 D6] A `sys_metadata` row stored in a legacy organization's layer: - * its `organization_id` names an organization (neither absent, `null` nor the - * empty string). No metadata read serves one and no boot loads one; the boot - * report names them, and nothing deletes or rewrites them. + * [ADR-0131 D6/D7] The legacy organization rows of a `sys_metadata`-family + * table, read off the PHYSICAL column. * - * Judged off the row in hand — `sys_metadata` is a platform table, never a - * federated object. Sunk here because its readers outside the metadata door — - * `@objectstack/objectql`'s authored hook and action binders, and - * `@objectstack/service-datasource`'s runtime-datasource reads — share no other - * common home, and a second spelling would let them disagree on which rows are - * the environment's. + * The family declares no `organization_id` any more, and the engine shapes + * every read to the declared columns, so a row an engine read returns never + * carries the key. On a database the organization-ownership ceremony has not + * migrated, the column is still on disk, and a row with a value in it is an + * organization's, not the environment's. `os serve` refuses such a database + * (the v18 boot refusal), but the `os migrate` boots do not, so a path they + * reach that must tell the two apart reads the column here, through the + * driver's raw-SQL seam. + * + * It replaces the row-value test this module used to export + * (`isLegacyOrganizationRow`), which read the key off the engine row — a key + * the engine no longer returns. Sunk here because its readers span the + * metadata door, `@objectstack/objectql`'s authored hook and action binders, + * `@objectstack/service-datasource` and the `os migrate` commands, which share + * no other common home. + * + * ## The answers, and the refusals + * + * - A driver that is not a SQL driver (no `dialectName` and no + * `config.client`: memory, MongoDB) has no physical column, so no row of it + * is a legacy organization's: the empty map. A v17 MongoDB deployment is + * outside the v18 upgrade (maintainer, 6171271939). + * - The column is absent (a fresh or migrated database): the empty map. + * - The column is present: every row whose value is not NULL and not `''`, + * by id, mapped to its organization — the definition the retired boot report + * used. + * - ⛔ Everything else THROWS, never reads as "none". A seam that returns no + * result set, a SQL driver whose dialect resolves to none, a SQL driver with + * no raw seam, or a column list that comes back EMPTY for a table the caller + * has established exists: each one is a question nobody answered, and + * "nothing to leave out" is exactly the answer that lists and deletes an + * organization's rows as the environment's. + */ + +/** Is this what a raw SELECT returns on one of the three dialects? */ +function isResultSet(result: unknown): boolean { + if (Array.isArray(result)) return true; + return typeof result === 'object' && result !== null && Array.isArray((result as { rows?: unknown }).rows); +} + +/** The rows of a raw SELECT: a bare array (SQLite), `{ rows }` (pg) or `[rows, fields]` (mysql2). */ +function normalizeRows(result: unknown): Record[] { + if (!result) return []; + if (Array.isArray(result)) { + if (result.length > 0 && Array.isArray(result[0])) return result[0] as Record[]; + return result as Record[]; + } + const rows = (result as { rows?: unknown }).rows; + return Array.isArray(rows) ? (rows as Record[]) : []; +} + +/** The `metadata-protocol` read probes' wording for the same refusal. */ +const SEAM_NO_ANSWER_DETAIL = + 'the raw-SQL seam returned no result set — a seam that cannot answer is not a seam that answered "no rows"'; + +type Dialect = 'sqlite' | 'postgres' | 'mysql'; + +/** The family tables this read may name; the name is interpolated as a literal. */ +const FAMILY_TABLES: ReadonlySet = new Set([ + 'sys_metadata', + 'sys_metadata_audit', + 'sys_metadata_commit', + 'sys_metadata_history', +]); + +/** Why the read refused, with the table it was asked about. */ +export class LegacyOrganizationRowsUnreadable extends Error { + readonly code = 'LEGACY_ORGANIZATION_ROWS_UNREADABLE'; + constructor(readonly table: string, detail: string) { + super( + `Could not tell ${table}'s legacy organization rows from the environment's: ${detail}. ` + + 'Nothing was listed or written. Run `os migrate organization-ownership` on this database first.', + ); + this.name = 'LegacyOrganizationRowsUnreadable'; + } +} + +interface RawDriver { + dialectName?: unknown; + config?: { client?: unknown }; + /** `SqlDriver`'s read-only catalog read — preferred: it is not `execute()`. */ + listPhysicalColumns?: (object: string, options?: { transaction?: unknown }) => Promise; + execute?: (sql: string, params?: unknown[], options?: { transaction?: unknown }) => Promise; + raw?: (sql: string) => Promise; +} + +function dialectOfName(name: string): Dialect | null { + const c = name.toLowerCase(); + if (c === 'sqlite' || c === 'sqlite3' || c === 'better-sqlite3') return 'sqlite'; + if (c === 'postgres' || c === 'pg' || c === 'postgresql' || c === 'pgnative') return 'postgres'; + if (c === 'mysql' || c === 'mysql2') return 'mysql'; + return null; +} + +/** + * The driver's dialect, `'none'` when it is not a SQL driver at all, or + * `'unresolved'` when it is one whose dialect this read cannot name. + * `dialectName` first: `SqliteWasmDriver`'s `config.client` is a class. + */ +function dialectOfDriver(driver: RawDriver | null | undefined): Dialect | 'none' | 'unresolved' { + if (!driver) return 'none'; + if (typeof driver.dialectName === 'string') return dialectOfName(driver.dialectName) ?? 'unresolved'; + const client = driver.config?.client; + if (client === undefined || client === null) return 'none'; + return typeof client === 'string' ? dialectOfName(client) ?? 'unresolved' : 'unresolved'; +} + +function columnListSql(dialect: Dialect, table: string): string { + if (dialect === 'sqlite') return `SELECT name AS column_name FROM pragma_table_info('${table}')`; + if (dialect === 'postgres') { + return ( + 'SELECT column_name FROM information_schema.columns ' + + `WHERE table_schema = ANY (current_schemas(false)) AND table_name = '${table}'` + ); + } + return `SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = '${table}'`; +} + +function columnNameOf(row: Record): string | null { + for (const key of ['column_name', 'COLUMN_NAME', 'name']) { + const value = row[key]; + if (typeof value === 'string' && value) return value; + } + return null; +} + +/** + * The tables each driver has ANSWERED "no organization column" for. The + * column only ever leaves (the ceremony's column-drops; a v18 runtime never + * declares it, and its schema sync only adds declared columns), so that answer + * cannot go stale in a process — which is what lets the request-time metadata + * reads ask on every call and pay one catalog read per process on a migrated + * database. Nothing else is cached: the organization VALUES move during the + * ceremony (its promotion clears them in place). */ -export function isLegacyOrganizationRow(row: { organization_id?: unknown } | null | undefined): boolean { - const organizationId = row?.organization_id; - return organizationId !== null && organizationId !== undefined && organizationId !== ''; +const COLUMN_ABSENT = new WeakMap>(); + +/** Remember `table` has no organization column on this driver; answer "none". */ +function rememberAbsent(driver: object, table: string): ReadonlyMap { + const absent = COLUMN_ABSENT.get(driver) ?? new Set(); + absent.add(table); + COLUMN_ABSENT.set(driver, absent); + return new Map(); +} + +/** + * Every legacy organization row of `table`, id → organization (module header). + * + * @param driver the driver `table` is bound to (`engine.getDriverForObject`). + * @param table one of the four family tables; the caller has established it + * exists, so an empty column list is refused, not read as "no column". + * @param opts.transaction the caller's open driver transaction, when it reads + * inside one: the statements join it (a single-connection pool would + * otherwise wait on the connection the transaction holds). + */ +export async function readLegacyOrganizationRows( + driver: unknown, + table: string = 'sys_metadata', + opts: { transaction?: unknown } = {}, +): Promise> { + if (!FAMILY_TABLES.has(table)) { + throw new LegacyOrganizationRowsUnreadable(table, 'it is not a sys_metadata-family table'); + } + const d = driver as RawDriver | null | undefined; + const dialect = dialectOfDriver(d); + if (dialect === 'none') return new Map(); + if (COLUMN_ABSENT.get(d as object)?.has(table)) return new Map(); + if (dialect === 'unresolved') { + throw new LegacyOrganizationRowsUnreadable(table, 'the SQL driver names no dialect this read can compile a column list for'); + } + const exec = typeof d!.execute === 'function' + ? (sql: string) => d!.execute!(sql, [], opts.transaction !== undefined ? { transaction: opts.transaction } : undefined) + : typeof d!.raw === 'function' + ? (sql: string) => d!.raw!(sql) + : null; + if (!exec) throw new LegacyOrganizationRowsUnreadable(table, 'the SQL driver has no raw-SQL seam'); + + // Is the column there? Asked on EVERY database, so through the driver's own + // catalog read when it has one (`SqlDriver.listPhysicalColumns`): not + // `execute()`, so a declaration boot's write guard — which counts every raw + // statement, since it cannot tell a read from a write — has nothing to count + // and a plan keeps its "writes nothing" claim. The catalog statement is the + // fallback for a driver without it. + let columns: Array; + if (typeof d!.listPhysicalColumns === 'function') { + const listed = await d!.listPhysicalColumns(table, opts.transaction !== undefined ? { transaction: opts.transaction } : undefined); + if (!Array.isArray(listed)) throw new LegacyOrganizationRowsUnreadable(table, 'the driver\'s column list is not a list'); + columns = listed.map((c) => (typeof c === 'string' && c ? c : null)); + } else { + const columnResult = await exec(columnListSql(dialect, table)); + if (!isResultSet(columnResult)) throw new LegacyOrganizationRowsUnreadable(table, SEAM_NO_ANSWER_DETAIL); + columns = normalizeRows(columnResult).map(columnNameOf); + } + if (columns.length === 0) { + throw new LegacyOrganizationRowsUnreadable(table, 'its column list came back empty for a table that exists'); + } + if (!columns.includes('organization_id')) return rememberAbsent(d as object, table); + + // The column is there: a database the ceremony has not migrated. The rows + // are read with a raw statement, which every SQL face runs (a driver's + // `find` compiles against the DECLARED columns on some faces). + + const rowResult = await exec( + `SELECT id, organization_id FROM ${table} WHERE organization_id IS NOT NULL AND organization_id <> ''`, + ); + if (!isResultSet(rowResult)) throw new LegacyOrganizationRowsUnreadable(table, SEAM_NO_ANSWER_DETAIL); + const legacy = new Map(); + for (const row of normalizeRows(rowResult)) legacy.set(String(row.id), String(row.organization_id)); + return legacy; } diff --git a/packages/metadata-core/src/objects/sys-metadata-audit.object.ts b/packages/metadata-core/src/objects/sys-metadata-audit.object.ts index 505f025848f..708d5c51897 100644 --- a/packages/metadata-core/src/objects/sys-metadata-audit.object.ts +++ b/packages/metadata-core/src/objects/sys-metadata-audit.object.ts @@ -26,8 +26,8 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * 3. What outcome? → outcome + code * 4. Was an override involved? → lock_overridden + lock_state * - * Indexed on `(organization_id, occurred_at)` for the per-org timeline - * query and `(type, name, occurred_at)` for the per-item history tab + * Indexed on `(occurred_at)` for the environment timeline query and + * `(type, name, occurred_at)` for the per-item history tab * Studio surfaces on the editor page. */ export const SysMetadataAuditObject = ObjectSchema.create({ @@ -109,13 +109,6 @@ export const SysMetadataAuditObject = ObjectSchema.create({ maxLength: 255, }), - /** Organization for multi-tenant filtering. NULL for env-wide writes. */ - organization_id: Field.lookup('sys_organization', { - label: 'Organization', - required: false, - readonly: true, - }), - /** Operation kind. */ operation: Field.select(['save', 'publish', 'rollback', 'delete', 'reset'], { label: 'Operation', @@ -222,8 +215,29 @@ export const SysMetadataAuditObject = ObjectSchema.create({ }), }, + // [ADR-0131 D6/D7, C7b] Tenant-less, like the overlays it records: the + // declared organization column is retired (ADR-0087 semantic entry + // 18.sys-metadata-audit-organization-column-retired), and `systemFields.tenant: false` + // keeps the engine from injecting one back. The v18 ceremony + // (`os migrate organization-ownership --apply`) drops the physical column. + systemFields: { tenant: false }, + // [ADR-0131 D7, the #22107 precedent] With no organization column there is + // no tenant wall, and a walled deployment's `organization_admin` holds a + // `'*'` grant — so without an object-level gate the generic data door would + // serve every organization's rows of this table (the metadata-protection trail: actors, refused-write notes). D7 governs it by + // object permission: `studio.access`, platform-scoped (no tenant + // administrator holds it by role) and admitted by every `/meta` door that + // serves this table (`/meta/:type/:name/history` and `/audit` admit it among + // `studio.access` / `setup.access` / `manage_metadata`; `/packages/:id/commits` + // among `studio.access` / `setup.access`). `requiredPermissions` is an + // AND-gate, so it names one capability all three doors admit — and not + // `setup.access`, which organization admins DO hold (plugin-security's + // `PLATFORM_ADMIN_ONLY_CAPABILITIES` excludes it on purpose). Platform code + // reads under the system context, which the gate does not stop. + requiredPermissions: ['studio.access'], + indexes: [ - { fields: ['organization_id', 'occurred_at'] }, + { fields: ['occurred_at'] }, { fields: ['type', 'name', 'occurred_at'] }, { fields: ['actor', 'occurred_at'] }, { fields: ['outcome'] }, diff --git a/packages/metadata-core/src/objects/sys-metadata-commit.object.ts b/packages/metadata-core/src/objects/sys-metadata-commit.object.ts index 993a4cdf84a..3622abfbecb 100644 --- a/packages/metadata-core/src/objects/sys-metadata-commit.object.ts +++ b/packages/metadata-core/src/objects/sys-metadata-commit.object.ts @@ -125,14 +125,6 @@ export const SysMetadataCommitObject = ObjectSchema.create({ readonly: true, }), - /** Organization ID for multi-tenant isolation. */ - organization_id: Field.lookup('sys_organization', { - label: 'Organization', - required: false, - readonly: true, - description: 'Organization for multi-tenant isolation.', - }), - /** When the commit was recorded. */ created_at: Field.datetime({ label: 'Created At', @@ -141,11 +133,32 @@ export const SysMetadataCommitObject = ObjectSchema.create({ }), }, + // [ADR-0131 D6/D7, C7b] Tenant-less, like the overlays it records: the + // declared organization column is retired (ADR-0087 semantic entry + // 18.sys-metadata-commit-organization-column-retired), and `systemFields.tenant: false` + // keeps the engine from injecting one back. The v18 ceremony + // (`os migrate organization-ownership --apply`) drops the physical column. + systemFields: { tenant: false }, + // [ADR-0131 D7, the #22107 precedent] With no organization column there is + // no tenant wall, and a walled deployment's `organization_admin` holds a + // `'*'` grant — so without an object-level gate the generic data door would + // serve every organization's rows of this table (package commit ranges and their items). D7 governs it by + // object permission: `studio.access`, platform-scoped (no tenant + // administrator holds it by role) and admitted by every `/meta` door that + // serves this table (`/meta/:type/:name/history` and `/audit` admit it among + // `studio.access` / `setup.access` / `manage_metadata`; `/packages/:id/commits` + // among `studio.access` / `setup.access`). `requiredPermissions` is an + // AND-gate, so it names one capability all three doors admit — and not + // `setup.access`, which organization admins DO hold (plugin-security's + // `PLATFORM_ADMIN_ONLY_CAPABILITIES` excludes it on purpose). Platform code + // reads under the system context, which the gate does not stop. + requiredPermissions: ['studio.access'], + indexes: [ // List a package's history newest-first (the timeline read pattern). - { fields: ['organization_id', 'package_id', 'created_at'] }, - // Org-wide commit replay / audit. - { fields: ['organization_id', 'created_at'] }, + { fields: ['package_id', 'created_at'] }, + // Environment-wide commit replay / audit. + { fields: ['created_at'] }, { fields: ['parent_commit_id'] }, ], diff --git a/packages/metadata-core/src/objects/sys-metadata-history.object.ts b/packages/metadata-core/src/objects/sys-metadata-history.object.ts index 4a83d767cf0..eb9ab5a4d58 100644 --- a/packages/metadata-core/src/objects/sys-metadata-history.object.ts +++ b/packages/metadata-core/src/objects/sys-metadata-history.object.ts @@ -7,27 +7,27 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * * Append-only durable log of every overlay change made through * `SysMetadataRepository.put` / `delete` (ADR-0008 §10 M1). Each row is a - * single event in the per-organisation event log; rows are NEVER + * single event in the environment's event log; rows are NEVER * mutated after insertion. The legacy `DatabaseLoader` writes the same * shape from its own put/restore code paths. * * ───────────────────────────────────────────────────────────────────── * Key design points (ADR-0008 §0 amendment + M1): * - * • Keyed by `(organization_id, type, name)` only — `environment_id` was - * removed in the branch/project-removal amendment. The original - * `metadata_id` column (a downgraded plain-text version of the old - * `sys_metadata.id` FK) was removed in the M1 follow-up — joins go - * through `(organization_id, type, name, version)` exclusively. + * • Keyed by `(type, name)` only — `environment_id` was removed in the + * branch/project-removal amendment, and `organization_id` with ADR-0131 + * D6 (the overlay axis is the environment's; C7b retired the column). + * The original `metadata_id` column (a downgraded plain-text version of + * the old `sys_metadata.id` FK) was removed in the M1 follow-up — joins + * go through `(type, name, version)` exclusively. * - * • `event_seq` is the per-org monotonic event-log cursor. Producers - * compute `MAX(event_seq) + 1 WHERE organization_id = X` inside the - * same transaction as the parent `sys_metadata` write. + * • `event_seq` is the environment's monotonic event-log cursor. + * Producers compute `MAX(event_seq) + 1` inside the same transaction as + * the parent `sys_metadata` write. * - * • `version` is the per-(org,type,name) lineage counter. Producers - * compute `MAX(version) + 1 WHERE organization_id = X AND type = T - * AND name = N` so delete + recreate continues incrementing instead - * of restarting at 1. + * • `version` is the per-(type,name) lineage counter. Producers compute + * `MAX(version) + 1 WHERE type = T AND name = N` so delete + recreate + * continues incrementing instead of restarting at 1. * * • `metadata` / `checksum` are nullable — DELETE rows have no body or * hash. Readers must tolerate null on both columns. @@ -36,8 +36,8 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * 'studio', …) and feeds MetadataEvent.source on history() reads. * * Indexes are purpose-built for the two dominant read patterns: - * 1. per-item history view → `(organization_id, type, name, version)` - * 2. org-wide event replay → `(organization_id, event_seq)` + * 1. per-item history view → `(type, name, version)` + * 2. environment event replay → `(event_seq)` * ───────────────────────────────────────────────────────────────────── */ export const SysMetadataHistoryObject = ObjectSchema.create({ @@ -47,7 +47,7 @@ export const SysMetadataHistoryObject = ObjectSchema.create({ icon: 'history', isSystem: true, managedBy: 'engine-owned', - description: 'Durable event log of metadata overlay changes (per-org, append-only)', + description: 'Durable event log of metadata overlay changes (environment-wide, append-only)', fields: { /** Primary Key (UUID) */ @@ -144,14 +144,6 @@ export const SysMetadataHistoryObject = ObjectSchema.create({ maxLength: 64, }), - /** Organization ID for multi-tenant isolation */ - organization_id: Field.lookup('sys_organization', { - label: 'Organization', - required: false, - readonly: true, - description: 'Organization for multi-tenant isolation.', - }), - /** * User who made this change (= MetadataEvent.actor). * @@ -179,13 +171,39 @@ export const SysMetadataHistoryObject = ObjectSchema.create({ }), }, + // [ADR-0131 D6/D7, C7b] Tenant-less, like the overlays it records: the + // declared organization column is retired (ADR-0087 semantic entry + // 18.sys-metadata-history-organization-column-retired), and `systemFields.tenant: false` + // keeps the engine from injecting one back. The v18 ceremony + // (`os migrate organization-ownership --apply`) drops the physical column. + systemFields: { tenant: false }, + // [ADR-0131 D7, the #22107 precedent] With no organization column there is + // no tenant wall, and a walled deployment's `organization_admin` holds a + // `'*'` grant — so without an object-level gate the generic data door would + // serve every organization's rows of this table (every item version body). D7 governs it by + // object permission: `studio.access`, platform-scoped (no tenant + // administrator holds it by role) and admitted by every `/meta` door that + // serves this table (`/meta/:type/:name/history` and `/audit` admit it among + // `studio.access` / `setup.access` / `manage_metadata`; `/packages/:id/commits` + // among `studio.access` / `setup.access`). `requiredPermissions` is an + // AND-gate, so it names one capability all three doors admit — and not + // `setup.access`, which organization admins DO hold (plugin-security's + // `PLATFORM_ADMIN_ONLY_CAPABILITIES` excludes it on purpose). Platform code + // reads under the system context, which the gate does not stop. + requiredPermissions: ['studio.access'], + indexes: [ - { fields: ['organization_id', 'event_seq'], unique: 'global' }, - { fields: ['organization_id', 'type', 'name', 'version'], unique: 'global' }, - { fields: ['organization_id', 'type', 'name', 'recorded_at'] }, + // The environment's event cursor and each item's lineage are unique + // environment-wide. Organization-layer history kept its own per-org + // sequences, so a legacy database holds duplicates on both keys, and a + // boot cannot build either index until the v18 ceremony has made them + // unique (a unique index that meets duplicates is logged and skipped). + { fields: ['event_seq'], unique: 'global' }, + { fields: ['type', 'name', 'version'], unique: 'global' }, + { fields: ['type', 'name', 'recorded_at'] }, // ADR-0009: getByHash() lookup — execution-pinned types resolve a // historical body by content hash via this index. - { fields: ['organization_id', 'type', 'name', 'checksum'] }, + { fields: ['type', 'name', 'checksum'] }, { fields: ['type', 'name'] }, { fields: ['recorded_at'] }, { fields: ['operation_type'] }, diff --git a/packages/metadata-core/src/objects/sys-metadata.object.ts b/packages/metadata-core/src/objects/sys-metadata.object.ts index 83812b2ab4d..8fcb75b41a5 100644 --- a/packages/metadata-core/src/objects/sys-metadata.object.ts +++ b/packages/metadata-core/src/objects/sys-metadata.object.ts @@ -149,13 +149,6 @@ export const SysMetadataObject = ObjectSchema.create({ defaultValue: 'active', }), - /** Organization ID for multi-tenant isolation */ - organization_id: Field.lookup('sys_organization', { - label: 'Organization', - required: false, - description: 'Organization for multi-tenant isolation.', - }), - /** * @deprecated ADR-0005 (revised 2026-05): per-env DBs replace per-project * isolation. `environment_id` is no longer written by saveMetaItem and not @@ -165,7 +158,7 @@ export const SysMetadataObject = ObjectSchema.create({ environment_id: Field.lookup('sys_environment', { label: 'Environment (deprecated)', required: false, - description: 'DEPRECATED. Use organization_id for tenant isolation.', + description: 'DEPRECATED. Overlays are environment rows (ADR-0131 D6); no tenant column.', }), /** Version number for optimistic concurrency */ @@ -219,9 +212,18 @@ export const SysMetadataObject = ObjectSchema.create({ }), }, + // [ADR-0131 D6/D7, C7b] Tenant-less: an overlay is the environment's. The + // declared organization column is retired (ADR-0087 semantic entry + // 18.sys-metadata-organization-column-retired), and `systemFields.tenant: false` + // keeps the engine from injecting one back; the v18 ceremony + // (`os migrate organization-ownership --apply`) promotes or reports the + // legacy organization rows first, then drops the physical column. + systemFields: { tenant: false }, + indexes: [ - // ADR-0005 (revised 2026-05) + ADR-0048: overlay uniqueness is scoped by - // (type, name, organization_id, package_id, bundle_member). `package_id` is + // ADR-0005 (revised 2026-05) + ADR-0048 + ADR-0131 D6: overlay uniqueness + // is scoped by (type, name, package_id, bundle_member) — the environment's + // key; the organization left it with the column (C7b). `package_id` is // part of the discriminator so two installed packages shipping the same // `type`/`name` each get their OWN customization row (a package-less / // global overlay uses NULL). `bundle_member` is the bundled type's member @@ -239,7 +241,7 @@ export const SysMetadataObject = ObjectSchema.create({ // // What actually delivers active-row scoping is the runtime layer: // `protocol.ts ensureOverlayIndex` issues a DROP-then-CREATE migration for - // `CREATE UNIQUE INDEX … ON sys_metadata (type, name, organization_id, + // `CREATE UNIQUE INDEX … ON sys_metadata (type, name, // COALESCE(package_id,''), COALESCE(bundle_member,'')) WHERE state = // 'active'` — which additionally fixes NULL-distinctness for package-less // rows and canonical members, something this declaration could not @@ -249,10 +251,9 @@ export const SysMetadataObject = ObjectSchema.create({ // with archived/reset rows. { name: 'idx_sys_metadata_overlay_active', - fields: ['type', 'name', 'organization_id', 'package_id', 'bundle_member'], + fields: ['type', 'name', 'package_id', 'bundle_member'], unique: 'global', }, - { name: 'idx_sys_metadata_org_type', fields: ['organization_id', 'type'] }, { fields: ['type', 'scope'] }, { fields: ['package_version_id'] }, { fields: ['state'] }, diff --git a/packages/metadata-protocol/src/meta-overlay-cache.test.ts b/packages/metadata-protocol/src/meta-overlay-cache.test.ts index b9da8787201..dbbc3388799 100644 --- a/packages/metadata-protocol/src/meta-overlay-cache.test.ts +++ b/packages/metadata-protocol/src/meta-overlay-cache.test.ts @@ -95,7 +95,6 @@ interface StoredRow { id: string; type: string; name: string; - organization_id: string | null; package_id: string | null; state: string; metadata: string; @@ -109,7 +108,6 @@ const storedRow = ( id: `r_${type}_${name}`, type, name, - organization_id: null, package_id: null, state: 'active', metadata: JSON.stringify({ name, label: `Label for ${name}` }), @@ -578,29 +576,22 @@ describe('[#11967] §7 distinct reads never share an entry', () => { expect((scoped.items as any[]).map((i) => i.name)).toEqual(['beta']); }); - // [ADR-0131 D6] The cache holds the environment's rows only. The one read - // that still reaches a legacy organization's rows — the anonymous form - // doors' `view` read, `legacyFormOrganizationId` — reads them uncached, - // on top of the environment's cached set, so it never answers from the - // environment-wide entry alone; and any other read naming an organization - // is the environment read. - it('the form doors\' legacy organization read does not answer from the env-wide entry', async () => { - const h = makeHarness([ - storedRow('view', 'alpha'), - storedRow('view', 'org_only', { organization_id: 'org_1', id: 'r_view_org' }), - ]); + // [ADR-0131 D6, C7] The cache holds the environment's rows only, and every + // read is the environment read: one naming an organization, by any key — + // the anonymous form doors' deleted `legacyFormOrganizationId` included — + // answers from the environment's entry. + it('a read naming an organization, by any key, answers from the env-wide entry', async () => { + const h = makeHarness([storedRow('view', 'alpha')]); const envWide = await h.protocol.getMetaItems({ type: 'view' }); const findsAfterEnvWide = h.finds.length; - - const legacy = await h.protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_1' }); - expect(h.finds.length).toBeGreaterThan(findsAfterEnvWide); - expect((envWide.items as any[]).map((i) => i.name)).toEqual(['alpha']); - expect((legacy.items as any[]).map((i) => i.name).sort()).toEqual(['alpha', 'org_only']); - const named = await h.protocol.getMetaItems({ type: 'view', organizationId: 'org_1' } as { type: string }); - expect((named.items as any[]).map((i) => i.name)).toEqual(['alpha']); + for (const named of [{ legacyFormOrganizationId: 'org_1' }, { organizationId: 'org_1' }]) { + const read = await h.protocol.getMetaItems({ type: 'view', ...named } as { type: string }); + expect((read.items as any[]).map((i) => i.name)).toEqual(['alpha']); + } + expect(h.finds.length).toBe(findsAfterEnvWide); }); diff --git a/packages/metadata-protocol/src/migrations/overlay-index.test.ts b/packages/metadata-protocol/src/migrations/overlay-index.test.ts index 69101db3471..7c0444f7540 100644 --- a/packages/metadata-protocol/src/migrations/overlay-index.test.ts +++ b/packages/metadata-protocol/src/migrations/overlay-index.test.ts @@ -187,13 +187,16 @@ describe('sys_metadata overlay uniqueness (#6418)', () => { expect(insert('g4', 'view', 'lead.g', 'org1', 'pkg1', 'active').ok).toBe(true); }); - it('keeps distinct types, names and organizations independent', async () => { + it('keeps distinct types and names independent; a legacy organization is no key part (ADR-0131 C7b)', async () => { await ensureMetadataOverlayIndexes(exec); expect(insert('i1', 'view', 'lead.x', 'org1', null, 'active').ok).toBe(true); expect(insert('i2', 'flow', 'lead.x', 'org1', null, 'active').ok).toBe(true); expect(insert('i3', 'view', 'lead.y', 'org1', null, 'active').ok).toBe(true); - expect(insert('i4', 'view', 'lead.x', 'org2', null, 'active').ok).toBe(true); + // The TIGHTENING: a second organization's row of the same key, or the + // environment's, collides — an overlay is the environment's. + expect(insert('i4', 'view', 'lead.x', 'org2', null, 'active').ok).toBe(false); + expect(insert('i5', 'view', 'lead.x', null, null, 'active').ok).toBe(false); }); // ── Idempotence ─────────────────────────────────────────────────────── @@ -523,28 +526,28 @@ describe('sys_metadata overlay uniqueness (#6418)', () => { /** * The key spelling is pinned as a literal, so a later "while we are in * here" re-keying cannot ride in unnoticed: re-partitioning a live unique - * index needs its own ADR-0120 D4 ceremony. The member part is the one - * re-keying since #6418 (#22877, ruled), and both NULL-folded parts use the - * spelling that shipped for `package_id`. + * index needs its own ADR-0120 D4 ceremony. The member part (#22877, + * ruled) and the organization's departure (ADR-0131 C7b) are the two + * re-keyings since #6418, and both NULL-folded parts use the spelling that + * shipped for `package_id`. */ it('pins the key spelling — the member part folded the way package_id is', () => { - expect(OVERLAY_INDEX_COLUMNS).toEqual(['type', 'name', 'organization_id', 'package_id', 'bundle_member']); + expect(OVERLAY_INDEX_COLUMNS).toEqual(['type', 'name', 'package_id', 'bundle_member']); expect(OVERLAY_NULL_SENTINELS).toEqual({ package_id: '', bundle_member: '' }); expect(overlayIndexKeyParts()).toEqual([ 'type', 'name', - 'organization_id', "COALESCE(package_id, '')", "COALESCE(bundle_member, '')", ]); expect(buildOverlayIndexSql(OVERLAY_INDEX_NAMES.active, 'active')).toEqual( 'CREATE UNIQUE INDEX IF NOT EXISTS idx_sys_metadata_overlay_active ' - + "ON sys_metadata (type, name, organization_id, COALESCE(package_id, ''), COALESCE(bundle_member, '')) " + + "ON sys_metadata (type, name, COALESCE(package_id, ''), COALESCE(bundle_member, '')) " + "WHERE state = 'active'", ); expect(buildOverlayIndexSql(OVERLAY_INDEX_NAMES.draft, 'draft')).toEqual( 'CREATE UNIQUE INDEX IF NOT EXISTS idx_sys_metadata_overlay_draft ' - + "ON sys_metadata (type, name, organization_id, COALESCE(package_id, ''), COALESCE(bundle_member, '')) " + + "ON sys_metadata (type, name, COALESCE(package_id, ''), COALESCE(bundle_member, '')) " + "WHERE state = 'draft'", ); }); @@ -557,7 +560,7 @@ describe('sys_metadata overlay uniqueness (#6418)', () => { const sql = buildOverlayFallbackIndexSql(OVERLAY_INDEX_NAMES.active); expect(sql).toEqual( 'CREATE INDEX IF NOT EXISTS idx_sys_metadata_overlay_active ' - + 'ON sys_metadata (type, name, organization_id, package_id, bundle_member)', + + 'ON sys_metadata (type, name, package_id, bundle_member)', ); expect(sql.toUpperCase()).not.toContain('UNIQUE'); expect(sql).toContain('IF NOT EXISTS'); @@ -572,11 +575,11 @@ describe('sys_metadata overlay uniqueness (#6418)', () => { it('the duplicate-listing query is groupable on PostgreSQL, not only SQLite', () => { const sql = buildOverlayDuplicateProbeSql('active'); expect(sql).toEqual( - 'SELECT type, name, organization_id, ' + 'SELECT type, name, ' + "COALESCE(package_id, '') AS package_id_key, " + "COALESCE(bundle_member, '') AS bundle_member_key, COUNT(*) AS duplicate_rows " + "FROM sys_metadata WHERE state = 'active' " - + "GROUP BY type, name, organization_id, COALESCE(package_id, ''), COALESCE(bundle_member, '') " + + "GROUP BY type, name, COALESCE(package_id, ''), COALESCE(bundle_member, '') " + 'HAVING COUNT(*) > 1', ); // Every bare projection is a bare GROUP BY term; the folded ones are diff --git a/packages/metadata-protocol/src/migrations/overlay-index.ts b/packages/metadata-protocol/src/migrations/overlay-index.ts index f869ff3cac7..c2b66fee61f 100644 --- a/packages/metadata-protocol/src/migrations/overlay-index.ts +++ b/packages/metadata-protocol/src/migrations/overlay-index.ts @@ -114,8 +114,21 @@ * declares, and {@link backfillOverlayBundleMembers} gives each its body's * member before a read can take it for the canonical one. * - * The bare, NULL-distinct `organization_id` part is still a separate question - * with its own ADR-0120 D4 ceremony and is deliberately not decided here. + * ## The second re-keying: the organization leaves the key (ADR-0131 C7b) + * + * The bare, NULL-distinct `organization_id` part is gone: an overlay is the + * environment's (ADR-0131 D6), and the column is retired from all four + * `sys_metadata*` objects. The key is now `(type, name, COALESCE(package_id, + * ''), COALESCE(bundle_member, ''))`. Unlike the member part this TIGHTENS + * uniqueness: two legacy organizations' rows, or an organization's row and + * the environment's, can share it. So on a database the v18 ceremony + * (`os migrate organization-ownership --apply`, its metadata-promotion step) + * has not yet cleared, the probe meets duplicates and the `conflict` arm keeps + * the previous index and names them — ADR-0120 D4, never a dropped row. The + * duplicate probe below groups by the same key, so `os migrate duplicates`, + * the boot report and the index cannot disagree; before this re-key the probe + * folded NULL organizations into one group the NULL-distinct index did not, + * and reported "blocked" for rows the index accepted. */ import { ITEM_KEY_DISCRIMINATORS, itemDiscriminator } from '@objectstack/metadata-core'; @@ -166,7 +179,7 @@ export const OVERLAY_PROBE_INDEX_NAMES: Readonly> = { package_id: '', diff --git a/packages/metadata-protocol/src/migrations/runtime-index-preflight.test.ts b/packages/metadata-protocol/src/migrations/runtime-index-preflight.test.ts index f92c3c69b30..28e282aca5c 100644 --- a/packages/metadata-protocol/src/migrations/runtime-index-preflight.test.ts +++ b/packages/metadata-protocol/src/migrations/runtime-index-preflight.test.ts @@ -123,14 +123,16 @@ describe('kernel:ready index pre-flight (#8725)', () => { 'uniq_sys_setting_organization_id_namespace_key_scope_user_id:table-absent', ]); - // The overlay conflict — and here `organization_id` is BARE, because - // #6418 deliberately did not fold it. A NULL stays a NULL in the key. + // The overlay conflict. [ADR-0131 C7b] The organization is no part of + // the key, so the probe and the index agree: two ACTIVE package-less + // rows of one (type, name) collide. Before the re-key the index's bare, + // NULL-distinct `organization_id` accepted this pair while the probe's + // GROUP BY folded the NULLs and reported it — a false "blocked". expect(by('idx_sys_metadata_overlay_active', results).groups).toEqual([ { key: { type: 'view', name: 'crm_case.board', - organization_id: null, package_id_key: '', // [#22877] A row of a name-only type is the canonical member. bundle_member_key: '', @@ -155,6 +157,26 @@ describe('kernel:ready index pre-flight (#8725)', () => { expect(by('uniq_sys_setting_organization_id_namespace_key_scope_user_id', results).groups).toEqual([]); }); + it('[ADR-0131 C7b] names the legacy organization rows the re-keyed index now refuses: two organizations, or an organization and the environment, on one key', async () => { + const meta = db.prepare( + 'INSERT INTO sys_metadata (id, type, name, organization_id, package_id, state) VALUES (?,?,?,?,?,?)', + ); + meta.run('m6', 'dashboard', 'sales', 'org_a', null, 'active'); + meta.run('m7', 'dashboard', 'sales', 'org_b', null, 'active'); + meta.run('m8', 'report', 'pipeline', 'org_a', 'pkg_crm', 'active'); + meta.run('m9', 'report', 'pipeline', null, 'pkg_crm', 'active'); + // Control: one organization's row alone on its key is no conflict. + meta.run('m10', 'view', 'deal_list', 'org_a', null, 'active'); + + const groups = by('idx_sys_metadata_overlay_active', await collectRuntimeIndexPreflight(exec, { client: 'better-sqlite3' })).groups; + + expect(groups.map((g) => [g.key.type, g.key.name, g.key.package_id_key, g.rowCount]).sort()).toEqual([ + ['dashboard', 'sales', '', 2], + ['report', 'pipeline', 'pkg_crm', 2], + ['view', 'crm_case.board', '', 2], + ]); + }); + it('carries the row scope and key parts each migration actually builds', async () => { const results = await collectRuntimeIndexPreflight(exec); @@ -162,13 +184,13 @@ describe('kernel:ready index pre-flight (#8725)', () => { migration: 'ensureMetadataOverlayIndexes', table: 'sys_metadata', rowScope: "state = 'active'", - keyParts: ['type', 'name', 'organization_id', "COALESCE(package_id, '')", "COALESCE(bundle_member, '')"], + keyParts: ['type', 'name', "COALESCE(package_id, '')", "COALESCE(bundle_member, '')"], }); expect(by('idx_sys_metadata_overlay_draft', results)).toMatchObject({ migration: 'ensureMetadataOverlayIndexes', table: 'sys_metadata', rowScope: "state = 'draft'", - keyParts: ['type', 'name', 'organization_id', "COALESCE(package_id, '')", "COALESCE(bundle_member, '')"], + keyParts: ['type', 'name', "COALESCE(package_id, '')", "COALESCE(bundle_member, '')"], }); // `sys_setting` has no lifecycle column: the declaration means every row. expect(by('uniq_sys_setting_organization_id_namespace_key_scope_user_id', results).rowScope).toBeNull(); @@ -292,7 +314,6 @@ describe('kernel:ready index pre-flight (#8725)', () => { key: { type: 'view', name: 'crm_case.board', - organization_id: null, package_id_key: '', bundle_member_key: '', }, diff --git a/packages/metadata-protocol/src/package-unmanaged-install.test.ts b/packages/metadata-protocol/src/package-unmanaged-install.test.ts index c669dc250e1..afa8f55f6b1 100644 --- a/packages/metadata-protocol/src/package-unmanaged-install.test.ts +++ b/packages/metadata-protocol/src/package-unmanaged-install.test.ts @@ -571,7 +571,7 @@ describe('planUnmanagedImport — the locks it strips', () => { }); describe('importUnmanagedPackage — the verb', () => { - it('copies every item into a NEW base: package_id = the base, organization_id NULL, nothing under the source id', async () => { + it('copies every item into a NEW base: package_id = the base, no organization column, nothing under the source id', async () => { const { protocol, table, packages } = makeProtocol(); const result = await protocol.importUnmanagedPackage({ manifest: crmBody() }); @@ -586,10 +586,10 @@ describe('importUnmanagedPackage — the verb', () => { // The package locks nothing, so the copy dropped nothing. strippedLocks: [], }); - const rows = table('sys_metadata').map((r) => `${r.type}/${r.name}@${r.package_id}|org=${r.organization_id}|${r.state}`); + const rows = table('sys_metadata').map((r) => `${r.type}/${r.name}@${r.package_id}|org=${'organization_id' in r}|${r.state}`); expect(rows.sort()).toEqual([ - 'object/crm_lead@com.example.crm-copy|org=null|active', - 'view/crm_lead_open@com.example.crm-copy|org=null|active', + 'object/crm_lead@com.example.crm-copy|org=false|active', + 'view/crm_lead_open@com.example.crm-copy|org=false|active', ]); // The base: scope-less (writable), the source namespace, a new id. expect(packages.get('com.example.crm-copy')?.manifest).toMatchObject({ id: 'com.example.crm-copy', namespace: 'crm' }); @@ -607,8 +607,8 @@ describe('importUnmanagedPackage — the verb', () => { expect(result.copied).toContainEqual({ type: 'translation', name: 'crm_zh_cn' }); const row = table('sys_metadata').find((r) => r.type === 'translation' && r.name === 'crm_zh_cn' && r.state === 'active'); expect(row).toBeDefined(); - expect({ package_id: row!.package_id, organization_id: row!.organization_id ?? null }) - .toEqual({ package_id: 'com.example.crm-copy', organization_id: null }); + expect({ package_id: row!.package_id, organization: 'organization_id' in row! }) + .toEqual({ package_id: 'com.example.crm-copy', organization: false }); expect(JSON.parse(row!.metadata)).toMatchObject({ locale: 'zh-CN', objects: { crm_lead: { label: '线索' } } }); }); diff --git a/packages/metadata-protocol/src/protocol-publish-drafts-org-scope.test.ts b/packages/metadata-protocol/src/protocol-publish-drafts-org-scope.test.ts index 427356d847c..362e29d1516 100644 --- a/packages/metadata-protocol/src/protocol-publish-drafts-org-scope.test.ts +++ b/packages/metadata-protocol/src/protocol-publish-drafts-org-scope.test.ts @@ -195,19 +195,8 @@ const objectBody = (name: string) => ({ }, }); -/** [#6190] The org-scoped specimen — `view`, one of the five overlay-enabled - * types, was a type that legitimately carried per-org rows before ADR-0131 - * D6. See the third case. */ -const viewBody = (name: string) => ({ - name, - label: 'Project Tasks', - object: 'proj_task', - viewKind: 'list', // [#7741] the inline arm requires the object binding pair - columns: [{ field: 'title', label: 'Title' }], -}); - describe('publishPackageDrafts — env-wide draft under a non-null active org (#3115)', () => { - it('saves the object draft env-wide (organization_id = NULL) when no org is threaded', async () => { + it('saves the object draft env-wide (no organization column, ADR-0131 D7) when no org is threaded', async () => { const { engine, rows } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); @@ -223,7 +212,7 @@ describe('publishPackageDrafts — env-wide draft under a non-null active org (# const draftRows = Array.from(rows.values()).filter((r) => r.state === 'draft'); expect(draftRows).toHaveLength(1); - expect(draftRows[0].organization_id).toBeNull(); + expect('organization_id' in draftRows[0]).toBe(false); expect(draftRows[0].package_id).toBe('app.projects'); }); @@ -264,30 +253,6 @@ describe('publishPackageDrafts — env-wide draft under a non-null active org (# expect(remaining.filter((r) => r.state === 'draft')).toHaveLength(0); const active = remaining.filter((r) => r.state === 'active'); expect(active).toHaveLength(1); - expect(active[0].organization_id).toBeNull(); - }); - - it('never promotes a legacy org-scoped draft — the publish lists env-wide drafts only (ADR-0131 D6)', async () => { - const { engine, rows } = makeStubEngine(); - const protocol = new ObjectStackProtocolImplementation(engine); - - // A legacy per-org draft (organization_id = org_alpha), planted - // directly: no protocol write can create one any more. - await engine.insert('sys_metadata', { - type: 'view', - name: 'proj_task_grid', - organization_id: 'org_alpha', - package_id: 'app.projects', - state: 'draft', - metadata: JSON.stringify(viewBody('proj_task_grid')), - }); - - const res = await protocol.publishPackageDrafts({ packageId: 'app.projects' }); - - expect(res).toMatchObject({ publishedCount: 0, failedCount: 0 }); - expect(Array.from(rows.values()).filter((r) => r.state === 'active')).toHaveLength(0); - const draft = Array.from(rows.values()).filter((r) => r.state === 'draft'); - expect(draft).toHaveLength(1); - expect(draft[0].organization_id).toBe('org_alpha'); + expect('organization_id' in active[0]).toBe(false); }); }); diff --git a/packages/metadata-protocol/src/protocol.app-package-id-key.test.ts b/packages/metadata-protocol/src/protocol.app-package-id-key.test.ts index 1506853188a..33fa7fda09a 100644 --- a/packages/metadata-protocol/src/protocol.app-package-id-key.test.ts +++ b/packages/metadata-protocol/src/protocol.app-package-id-key.test.ts @@ -59,7 +59,6 @@ interface StoredRow { id: string; type: string; name: string; - organization_id: string | null; package_id: string | null; state: string; metadata: string; @@ -69,7 +68,6 @@ const STORED: StoredRow[] = [{ id: 'r_drafted_draft', type: 'app', name: DRAFT_APP_NAME, - organization_id: null, package_id: DRAFT_PKG, state: 'draft', metadata: JSON.stringify({ name: DRAFT_APP_NAME, label: 'Drafted', navigation: [] }), diff --git a/packages/metadata-protocol/src/protocol.audit-org-scope.test.ts b/packages/metadata-protocol/src/protocol.audit-org-scope.test.ts index 57230b6f3b7..7c200893839 100644 --- a/packages/metadata-protocol/src/protocol.audit-org-scope.test.ts +++ b/packages/metadata-protocol/src/protocol.audit-org-scope.test.ts @@ -6,8 +6,8 @@ // // [ADR-0131 D6] The read is the environment's, for every caller: the // `organizationId` request key retired with the per-organization overlay axis, -// so the scope is `organization_id IS NULL` and nothing else. A legacy -// organization-scoped audit row is reported at boot, never read. +// and since ADR-0131 D7 the audit trail declares no organization column, so +// the scope is (type, name) and nothing else. // // This file pins the QUERY SHAPE, next to the code that builds it. The // behavioural half — that the shape SELECTS the right rows through a real SQL @@ -28,11 +28,11 @@ function makeProtocol() { const whereFrom = (find: any) => find.mock.calls[0][1].where; describe('#8747 · ADR-0131 D6 auditMetaItem reads the environment\'s audit trail only', () => { - it('keys on (type, name), the plural folded to singular, and scopes organization_id IS NULL', async () => { + it('keys on (type, name), the plural folded to singular, and names no organization column', async () => { const { p, find } = makeProtocol(); await p.auditMetaItem({ type: 'views', name: 'shared_grid' }); - expect(whereFrom(find)).toEqual({ type: 'view', name: 'shared_grid', organization_id: null }); + expect(whereFrom(find)).toEqual({ type: 'view', name: 'shared_grid' }); }); it('a caller still naming an organization changes nothing: the retired key is not read', async () => { @@ -40,8 +40,7 @@ describe('#8747 · ADR-0131 D6 auditMetaItem reads the environment\'s audit trai const { p, find } = makeProtocol(); await p.auditMetaItem({ type: 'views', name: 'shared_grid', organizationId } as any); const where = whereFrom(find); - expect(where.organization_id, String(organizationId)).toBe(null); - expect(where, String(organizationId)).not.toHaveProperty('$or'); + expect(where, String(organizationId)).toEqual({ type: 'view', name: 'shared_grid' }); } }); }); diff --git a/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts b/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts index 32d8e5c439e..bbe6ad22482 100644 --- a/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts +++ b/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts @@ -424,7 +424,7 @@ describe('[#21899] the repository delete gate lifts the origin-gated type only', return { deleted: rows.delete((o as any)?.where?.id) ? 1 : 0 }; }, }; - return { rows, repo: new SysMetadataRepository({ engine, organizationId: null, orgLabel: 'env' }) }; + return { rows, repo: new SysMetadataRepository({ engine, orgLabel: 'env' }) }; }; it('removes a stored row under a code-defined datasource on the override-artifact intent', async () => { diff --git a/packages/metadata-protocol/src/protocol.duplicate-package-bundle-key.test.ts b/packages/metadata-protocol/src/protocol.duplicate-package-bundle-key.test.ts index 3e707485a50..9d04b52f374 100644 --- a/packages/metadata-protocol/src/protocol.duplicate-package-bundle-key.test.ts +++ b/packages/metadata-protocol/src/protocol.duplicate-package-bundle-key.test.ts @@ -62,11 +62,11 @@ * [ADR-0131 D6] What remains * --------------------------------------------------------------------------- * An organization-scoped duplicate is now refused (403 NOT_OVERRIDABLE), and - * the scan reads ENVIRONMENT rows only (`organization_id IS NULL`), so the two - * tiers no longer meet here and the cross-tier dedup above is gone with them. - * The cases that drove it are deleted; what this file still pins is that every - * environment bundle member is copied, a legacy organization-scoped row is - * not, and another package's row is never scanned. + * every row the scan reads is the environment's (ADR-0131 D7: no organization + * column), so the two tiers no longer meet here and the cross-tier dedup above + * is gone with them. The cases that drove it are deleted; what this file still + * pins is that every environment bundle member is copied, and another + * package's row is never scanned. */ import { describe, expect, it, vi } from 'vitest'; // [#7774] The identity table's home is `@objectstack/metadata-core`, not the @@ -118,7 +118,6 @@ function matches(r: Record, where: Record): boolea function makeProtocol(rows: Array>) { const seeded = rows.map((r, i) => ({ id: `r_${i + 1}`, - organization_id: null, package_id: SRC, state: 'active', checksum: `sha256:seed_${i + 1}`, @@ -172,17 +171,12 @@ describe('[#7932] duplicatePackage keeps every i18n bundle member', () => { expect(Object.keys(ITEM_KEY_DISCRIMINATORS)).toEqual(['email_template']); }); - it('copies every environment bundle member, and never a legacy organization-scoped row', async () => { - // [ADR-0131 D6] The scan is `organization_id IS NULL`: both env-wide - // members are copied verbatim (no dedup collapses them), and the - // legacy org row waits for the promotion ceremony (ADR-0131 C7). + it('copies every environment bundle member', async () => { + // [ADR-0131 D6/D7] Every row is the environment's: both members + // are copied verbatim (no dedup collapses them). const { protocol, saveMetaItem } = makeProtocol([ { type: 'email_template', name: 'auth.welcome', metadata: tpl('auth.welcome', 'en-US') }, { type: 'email_template', name: 'auth.welcome', metadata: tpl('auth.welcome', 'ja-JP') }, - { - type: 'email_template', name: 'auth.welcome', organization_id: ORG, - metadata: tpl('auth.welcome', 'zh-CN'), - }, ]); const res = await duplicate(protocol); diff --git a/packages/metadata-protocol/src/protocol.flow-by-name-shipped-name.test.ts b/packages/metadata-protocol/src/protocol.flow-by-name-shipped-name.test.ts index 07da568ae15..b3fa2b1d9d8 100644 --- a/packages/metadata-protocol/src/protocol.flow-by-name-shipped-name.test.ts +++ b/packages/metadata-protocol/src/protocol.flow-by-name-shipped-name.test.ts @@ -23,9 +23,9 @@ * real composition is `flow-shipped-name-by-name-read.dogfood.test.ts`. * * Controls: a flow name no managed package ships keeps its stored row (the - * tenant's own flow), a shipped name with no stored row is unchanged, an - * organization-scoped flow row is out of the read's reach, and a type in the - * overlay regime keeps its overlay. + * tenant's own flow), a shipped name with no stored row is unchanged, a + * caller naming an organization reads as the environment does, and a type in + * the overlay regime keeps its overlay. */ import { describe, expect, it, vi } from 'vitest'; import { assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core'; @@ -53,17 +53,15 @@ interface StoredRow { id: string; type: string; name: string; - organization_id: string | null; package_id: string | null; state: string; metadata: string; } const storedRow = (type: string, name: string, body: unknown, extra: Partial = {}): StoredRow => ({ - id: `r_${type}_${name}_${extra.organization_id ?? 'env'}`, + id: `r_${type}_${name}_env`, type, name, - organization_id: null, package_id: null, state: 'active', metadata: JSON.stringify(body), @@ -253,14 +251,14 @@ describe('[#20946] the by-name read of a shipped flow name serves the loader\'s expect(served.label).toBe('LOADER'); }); - it('control: an organization-scoped flow row is out of the read\'s reach', async () => { + it('control: a caller naming an organization reads as the environment does', async () => { const { protocol } = harness([ - storedRow('flow', SHIPPED, flowBody(SHIPPED, 'ORG ROW'), { organization_id: ORG_ID }), - storedRow('flow', CUSTOMER, flowBody(CUSTOMER, 'ORG ROW'), { organization_id: ORG_ID }), + storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED ROW')), + storedRow('flow', CUSTOMER, flowBody(CUSTOMER, 'STORED ROW')), ]); expect((await byName(protocol, { type: 'flow', name: SHIPPED, organizationId: ORG_ID })).label).toBe('LOADER'); - expect(await byName(protocol, { type: 'flow', name: CUSTOMER, organizationId: ORG_ID })).toBeUndefined(); + expect((await byName(protocol, { type: 'flow', name: CUSTOMER, organizationId: ORG_ID })).label).toBe('STORED ROW'); }); it('control: an overlay of a packaged type in the overlay regime is still served over the artifact', async () => { diff --git a/packages/metadata-protocol/src/protocol.flow-layered-shipped-name.test.ts b/packages/metadata-protocol/src/protocol.flow-layered-shipped-name.test.ts index 459a8adfa39..76158e5ed09 100644 --- a/packages/metadata-protocol/src/protocol.flow-layered-shipped-name.test.ts +++ b/packages/metadata-protocol/src/protocol.flow-layered-shipped-name.test.ts @@ -24,8 +24,8 @@ * `flow-shipped-name-layered-read.dogfood.test.ts`. * * Controls: a flow name no managed package ships keeps its stored row as the - * effective layer, a shipped name with no stored row is unchanged, an - * organization-scoped flow row is out of the read's reach, and a type in the + * effective layer, a shipped name with no stored row is unchanged, a caller + * naming an organization reads as the environment does, and a type in the * overlay regime keeps overlay-wins. */ import { describe, expect, it } from 'vitest'; @@ -53,17 +53,15 @@ interface StoredRow { id: string; type: string; name: string; - organization_id: string | null; package_id: string | null; state: string; metadata: string; } const storedRow = (type: string, name: string, body: unknown, extra: Partial = {}): StoredRow => ({ - id: `r_${type}_${name}_${extra.organization_id ?? 'env'}`, + id: `r_${type}_${name}_env`, type, name, - organization_id: null, package_id: null, state: 'active', metadata: JSON.stringify(body), @@ -275,20 +273,18 @@ describe('[#21002] the layered read of a shipped flow name reports the loader\'s expect(answer.overlayScope).toBeNull(); }); - it('control: an organization-scoped flow row is out of the read\'s reach', async () => { + it('control: a caller naming an organization reads as the environment does', async () => { const { protocol } = harness([ - storedRow('flow', SHIPPED, flowBody(SHIPPED, 'ORG ROW'), { organization_id: ORG_ID }), - storedRow('flow', CUSTOMER, flowBody(CUSTOMER, 'ORG ROW'), { organization_id: ORG_ID }), + storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED ROW')), + storedRow('flow', CUSTOMER, flowBody(CUSTOMER, 'STORED ROW')), ]); const shipped = await layered(protocol, { type: 'flow', name: SHIPPED, organizationId: ORG_ID }); expect(shipped.effective?.label).toBe('LOADER'); - expect(shipped.overlay).toBeNull(); - expect(shipped.overlayScope).toBeNull(); const customer = await layered(protocol, { type: 'flow', name: CUSTOMER, organizationId: ORG_ID }); - expect(customer.overlay).toBeNull(); - expect(customer.effective).toBeNull(); + expect(customer.effective?.label).toBe('STORED ROW'); + expect(customer.overlayScope).toBe('env'); }); it('control: an overlay of a packaged type in the overlay regime is still the effective layer', async () => { diff --git a/packages/metadata-protocol/src/protocol.i18n-bundle-list-merge.test.ts b/packages/metadata-protocol/src/protocol.i18n-bundle-list-merge.test.ts index 13d89bbf2cd..aac4465ad70 100644 --- a/packages/metadata-protocol/src/protocol.i18n-bundle-list-merge.test.ts +++ b/packages/metadata-protocol/src/protocol.i18n-bundle-list-merge.test.ts @@ -122,7 +122,23 @@ function makeEngine(opts: { items?: unknown[]; rows?: Row[] } = {}) { if ((r as any)[k] !== v) return false; } return true; - }); + // [ADR-0131 D7] The engine shapes a read to the declared + // columns: the family declares no `organization_id`. + }).map(({ organization_id: _physical, ...declared }: any) => declared); + }, + // The physical column a database the ceremony has not migrated + // still holds, read through the table's driver. + getDriverForObject(table: string) { + if (table !== 'sys_metadata') return undefined; + return { + dialectName: 'sqlite', + async execute(sql: string) { + return /column_name/.test(sql) + ? [{ column_name: 'id' }, { column_name: 'organization_id' }] + : (opts.rows ?? []).filter((r: any) => r.organization_id != null && r.organization_id !== '') + .map((r: any) => ({ id: r.id, organization_id: r.organization_id })); + }, + }; }, async findOne(object: string, query?: EngineFindOneQueryInput) { assertEngineFindOnePredicate(object, query); return null; }, diff --git a/packages/metadata-protocol/src/protocol.legacy-overlay-delete.test.ts b/packages/metadata-protocol/src/protocol.legacy-overlay-delete.test.ts index 0f4677d1601..d6c9ad47e80 100644 --- a/packages/metadata-protocol/src/protocol.legacy-overlay-delete.test.ts +++ b/packages/metadata-protocol/src/protocol.legacy-overlay-delete.test.ts @@ -643,7 +643,7 @@ describe('#6960 — SysMetadataRepository: the delete verb diverges, the put ver return { deleted: existed ? 1 : 0 }; }, }; - return { repo: new SysMetadataRepository({ engine, organizationId: null }), rows, historyRows }; + return { repo: new SysMetadataRepository({ engine }), rows, historyRows }; } describe('rolled-back overlayable tier', () => { diff --git a/packages/metadata-protocol/src/protocol.lifecycle-audit-rows.test.ts b/packages/metadata-protocol/src/protocol.lifecycle-audit-rows.test.ts index 79f3f2a9fd7..5a0ad4f3f45 100644 --- a/packages/metadata-protocol/src/protocol.lifecycle-audit-rows.test.ts +++ b/packages/metadata-protocol/src/protocol.lifecycle-audit-rows.test.ts @@ -99,7 +99,6 @@ interface Row { id: string; type: string; name: string; - organization_id: string | null; package_id: string | null; state: string; metadata: string; @@ -121,14 +120,13 @@ interface HistoryRow { previous_checksum: string | null; change_note?: string | null; source?: string | null; - organization_id: string | null; recorded_by?: string | null; recorded_at: string; } /** The ADR-0048 overlay key — draft and active rows for one identity coexist. */ function keyOf(w: Record) { - return `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; + return `${w.type}|${w.name}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; } function matchesMetadataWhere(r: Row, where: Record): boolean { @@ -176,7 +174,6 @@ function makeStubEngine(opts: { failAudit?: boolean } = {}): Harness { }; const matchesHistory = (h: HistoryRow, w: Record): boolean => { - if (w.organization_id !== undefined && h.organization_id !== w.organization_id) return false; if (w.type !== undefined && h.type !== w.type) return false; if (w.name !== undefined && h.name !== w.name) return false; if (w.version !== undefined && h.version !== w.version) return false; @@ -315,7 +312,6 @@ describe('[#7748] the audit trail records the whole lifecycle, not only `save`', expect(opRows(h, 'save')[0]).toMatchObject({ type: 'view', name: 'case_grid', - organization_id: null, operation: 'save', outcome: 'allowed', code: 'ok', @@ -346,7 +342,6 @@ describe('[#7748] the audit trail records the whole lifecycle, not only `save`', expect(opRows(h, 'publish')[0]).toMatchObject({ type: 'view', name: 'case_grid', - organization_id: null, operation: 'publish', outcome: 'allowed', code: 'ok', diff --git a/packages/metadata-protocol/src/protocol.list-slot-prefer-local.test.ts b/packages/metadata-protocol/src/protocol.list-slot-prefer-local.test.ts index 6a31ca7ecbb..0c6dadffbb1 100644 --- a/packages/metadata-protocol/src/protocol.list-slot-prefer-local.test.ts +++ b/packages/metadata-protocol/src/protocol.list-slot-prefer-local.test.ts @@ -20,17 +20,16 @@ * package-less row. `findServedOverlayRow` reads * it from the store; the list reads it over the rows it already holds. * - * 1. The generated pin: every subset of five stored rows (env-wide and - * org-scoped, package-less and package A's, plus a third package B's - * env-wide row), in every order the store can return them, for a request + * 1. The generated pin: every subset of three stored rows (package-less, + * package A's and a third package B's, all env-wide: ADR-0131 D6/D7, the + * family declares no organization column), in every order the store can return them, for a request * with and without an organization. Every package slot in the list serves * the row an oracle written from the rule names, and `getMetaItem` naming * that package serves the same row. * 2. Named: package A's row and the package-less row, both env-wide, both * orders, with and without A's packaged artifact. The slot is A's row. * 3. Named, a request from an organization (ADR-0131 D6): the - * organization's legacy rows are served by no read; the environment's - * rows are. Both orders. + * environment's rows are served. Both orders. * 4. Control: only the package-less row is stored, under A's artifact. The * slot still falls back to it (stamped as A's), as the by-name read does. * 5. The draft preview: A's draft and a package-less draft, both orders. The @@ -57,7 +56,7 @@ const PKG_A = 'com.example.a'; const PKG_B = 'com.example.b'; const NAME = 'v_slot'; -type Scope = 'env-wide' | 'org-scoped'; +type Scope = 'env-wide'; interface RowSpec { scope: Scope; packageId: string | null; label: string } /** The stored rows the tables draw from. */ @@ -65,8 +64,6 @@ const ROWS = { envPackageless: { scope: 'env-wide', packageId: null, label: 'env-wide package-less row' }, envA: { scope: 'env-wide', packageId: PKG_A, label: 'env-wide row of A' }, envB: { scope: 'env-wide', packageId: PKG_B, label: 'env-wide row of B' }, - orgPackageless: { scope: 'org-scoped', packageId: null, label: 'org-scoped package-less row' }, - orgA: { scope: 'org-scoped', packageId: PKG_A, label: 'org-scoped row of A' }, } as const satisfies Record; type RowKey = keyof typeof ROWS; const ROW_KEYS = Object.keys(ROWS) as RowKey[]; @@ -75,7 +72,6 @@ interface StoredRow { id: string; type: string; name: string; - organization_id: string | null; package_id: string | null; state: string; metadata: string; @@ -88,7 +84,6 @@ function stored(key: RowKey, state: 'active' | 'draft' = 'active', type: string id: `r_${key}_${state}`, type, name: NAME, - organization_id: spec.scope === 'org-scoped' ? ORG : null, package_id: spec.packageId, state, metadata: JSON.stringify({ name: NAME, label, ...(type === 'view' ? { object: 'account' } : {}), _provenance: 'org' }), @@ -204,15 +199,14 @@ describe('pin 1 (generated) — every package slot in the list serves the row ge const TABLE = subsets(ROW_KEYS).flatMap((rows) => ([undefined, ORG] as const).map((organizationId) => ({ rows, organizationId }))); - it('completeness: every subset of the five rows, with and without an organization, and both packages are reachable', () => { + it('completeness: every subset of the three rows, with and without an organization, and both packages are reachable', () => { expect(TABLE).toHaveLength(2 ** ROW_KEYS.length * 2); expect(new Set(ROW_KEYS.map((key) => ROWS[key].packageId))).toEqual(new Set([null, PKG_A, PKG_B])); - expect(new Set(ROW_KEYS.map((key) => ROWS[key].scope))).toEqual(new Set(['env-wide', 'org-scoped'])); + expect(new Set(ROW_KEYS.map((key) => ROWS[key].scope))).toEqual(new Set(['env-wide'])); // The arrangements this card is about are in the table: the package's // row beside the package-less row of one scope, in more than one order. const both = TABLE.filter(({ rows }) => - (rows.includes('envA') && rows.includes('envPackageless')) - || (rows.includes('orgA') && rows.includes('orgPackageless'))); + rows.includes('envA') && rows.includes('envPackageless')); expect(both.length).toBeGreaterThan(0); expect(both.every(({ rows }) => orders(rows).length >= 2)).toBe(true); }); @@ -266,17 +260,17 @@ describe('pin 2 — the package\'s row beside the package-less row (one scope, e } }); -describe('pin 3 — [ADR-0131 D6] a request from an organization: its legacy rows are served by no read', () => { - for (const order of orders(['orgPackageless', 'envA', 'envPackageless'])) { - it(`the organization's legacy package-less row is not served over an env-wide row of A · row order ${order.join(', ')}`, async () => { +describe('pin 3 — [ADR-0131 D6] a request from an organization is served the environment\'s rows', () => { + for (const order of orders(['envA', 'envPackageless'])) { + it(`the env-wide row of A is served over the env-wide package-less row · row order ${order.join(', ')}`, async () => { const protocol = harness(order.map((key) => stored(key))); const slots = await listSlots(protocol, PKG_A, { organizationId: ORG }); expect(slots.map((s) => s.label)).toEqual([ROWS.envA.label]); expect((await byName(protocol, PKG_A, { organizationId: ORG }))?.label).toBe(ROWS.envA.label); }); } - for (const order of orders(['orgA', 'envPackageless'])) { - it(`the organization's legacy row of A is not served; A has no slot, and by name A is served the env-wide package-less row · row order ${order.join(', ')}`, async () => { + for (const order of orders(['envPackageless'])) { + it(`no row of A: A has no slot, and by name A is served the env-wide package-less row · row order ${order.join(', ')}`, async () => { const protocol = harness(order.map((key) => stored(key))); const slots = await listSlots(protocol, PKG_A, { organizationId: ORG }); expect(slots).toEqual([]); diff --git a/packages/metadata-protocol/src/protocol.lock-gate-canonical-type.test.ts b/packages/metadata-protocol/src/protocol.lock-gate-canonical-type.test.ts index d5b25b96b3e..7996fecc2bf 100644 --- a/packages/metadata-protocol/src/protocol.lock-gate-canonical-type.test.ts +++ b/packages/metadata-protocol/src/protocol.lock-gate-canonical-type.test.ts @@ -89,7 +89,6 @@ function overlayRow(type: string, name: string, lock: string) { type, name, state: 'active', - organization_id: null, package_id: null, checksum: 'sha256:stored-head', metadata: JSON.stringify({ name, _lock: lock, _lockReason: 'governed by ops' }), @@ -203,8 +202,8 @@ describe('[#9009] getEffectiveLock resolves the overlay row under the canonical type: 'view', name: 'v1', state: 'active', - organization_id: null, }); + expect(h.overlayQueries[0]).not.toHaveProperty('organization_id'); }); it('finds the lock for a manifest-ABSENT plural (`translations`) too', async () => { diff --git a/packages/metadata-protocol/src/protocol.lock-one-resolution.test.ts b/packages/metadata-protocol/src/protocol.lock-one-resolution.test.ts index c0b6f85492d..05d1c2a1d1a 100644 --- a/packages/metadata-protocol/src/protocol.lock-one-resolution.test.ts +++ b/packages/metadata-protocol/src/protocol.lock-one-resolution.test.ts @@ -86,10 +86,13 @@ * each lock level, crossed with the package's own artifact, the env-wide * stored row's lock, every address and topology, each run under BOTH * registration orders; - * - the stored rows × organization × package (5988387087 on #21803): the - * organization holds only another package's row, so the content a request - * naming the package is served (the env-wide row) and the lock's scope (the - * organization's rows) part. The served body states the envelope's lock. + * - the stored rows × package (5988387087 on #21803): another package's + * env-wide row stands beside the package's own, so the content a request + * naming the package is served (its own row) and the lock's scope (every + * env-wide row) part. The served body states the envelope's lock. + * [ADR-0131 D6/D7] A stored row carries no organization: the family + * declares no organization column, so the organization-scoped stored rows + * this table once held are no state the store can be in. * * Every row also asserts that the served body (`getMetaItem`'s `item`, the * layered read's `effective`) states the lock the envelope reports. @@ -102,8 +105,6 @@ * registered first and the delete when B was; both are refused under both * orders now, and the reads say so. * 10. A DISABLED package is still installed: its packaged lock binds. - * 11. The folded position, named: the body served from the env-wide row of - * the package carries no lock the organization's rows do not declare. * * `@objectstack/objectql` cannot be imported here: it depends on this package. * The real `SchemaRegistry`'s enumeration is pinned beside it @@ -140,7 +141,6 @@ interface StoredRow { id: string; type: string; name: string; - organization_id: string | null; package_id: string | null; state: string; metadata: string; @@ -156,20 +156,24 @@ const ARTIFACT_VALUES: readonly ArtifactAxis[] = [ ...MetadataLockSchema.options.map((lock) => ({ artifact: 'declared' as const, lock })), ]; -/** The package-less stored row: none, or env-wide / org-scoped, declaring each level, under either spelling. */ +/** + * The package-less stored row: none, or env-wide, declaring each level, under + * either spelling. [ADR-0131 D6/D7] No stored row is organization-scoped: the + * family declares no organization column. + */ type StoredRowAxis = | { row: 'none' } - | { row: 'stored'; scope: 'env-wide' | 'org-scoped'; lock: Lock; spelling: 'canonical' | 'other (residue)' }; + | { row: 'stored'; scope: 'env-wide'; lock: Lock; spelling: 'canonical' | 'other (residue)' }; const STORED_ROW_VALUES: readonly StoredRowAxis[] = [ { row: 'none' }, - ...(['env-wide', 'org-scoped'] as const).flatMap((scope) => MetadataLockSchema.options.flatMap((lock) => + ...(['env-wide'] as const).flatMap((scope) => MetadataLockSchema.options.flatMap((lock) => (['canonical', 'other (residue)'] as const).map((spelling) => ({ row: 'stored' as const, scope, lock, spelling })))), ]; /** * [#21761] The row bound to the package (ADR-0048): none, or an env-wide * canonical row declaring each level. [#21803] The same shape serves the other - * package's row in the organization. + * package's env-wide row. */ type PackageRowAxis = { row: 'none' } | { row: 'stored'; lock: Lock }; const PACKAGE_ROW_VALUES: readonly PackageRowAxis[] = [ @@ -183,7 +187,7 @@ const AXIS_VALUES = { otherArtifact: ARTIFACT_VALUES, storedRow: STORED_ROW_VALUES, packageRow: PACKAGE_ROW_VALUES, - /** [#21803] Another package's org-scoped canonical row of the item. */ + /** [#21803] Another package's env-wide canonical row of the item. */ otherPackageRow: PACKAGE_ROW_VALUES, requestScope: [undefined, ORG] as const, requestPackage: [undefined, PACKAGE_ID, OTHER_PACKAGE] as const, @@ -224,7 +228,7 @@ const POSITIONS: ReadonlyArray<{ { layer: 'overlay', axis: 'organization', opensWith: ['storedRow', 'requestScope'], card: '#21716' }, { layer: 'overlay', axis: 'package', opensWith: ['storedRow', 'packageRow', 'requestPackage'], card: '#21761' }, { - layer: 'overlay', axis: 'organization', + layer: 'overlay', axis: 'package', opensWith: ['packageRow', 'otherPackageRow', 'requestScope', 'requestPackage'], card: '#21803 (5988387087: the content scope and the lock scope part)', }, @@ -290,7 +294,7 @@ const SLICES: ReadonlyArray<{ readonly name: string; readonly values: SliceValue }, }, { - name: '[#21803] the stored rows × organization × package: the organization holds only another package\'s row', + name: '[#21803] the stored rows × package: another package\'s env-wide row beside the package\'s own', values: { ...AXIS_VALUES, artifact: [ABSENT], @@ -331,7 +335,7 @@ function describePackageRow(r: PackageRowAxis): string { return r.row === 'none' ? '' : `env-wide package row _lock=${r.lock}`; } function describeOtherPackageRow(r: PackageRowAxis): string { - return r.row === 'none' ? '' : `org-scoped other package's row _lock=${r.lock}`; + return r.row === 'none' ? '' : `env-wide other package's row _lock=${r.lock}`; } function titleOf(row: Row): string { return [ @@ -351,7 +355,7 @@ function titleOf(row: Row): string { // ── The oracle: the declared rule, per layer, in the resolution's own order ── /** The stored rows of the row's arrangement, as the oracle reads them. */ -interface OracleRow { scope: 'env-wide' | 'org-scoped'; spelling: 'canonical' | 'other (residue)'; lock: Lock } +interface OracleRow { scope: 'env-wide'; spelling: 'canonical' | 'other (residue)'; lock: Lock } function oracleRows(row: Row): OracleRow[] { const rows: OracleRow[] = []; if (row.storedRow.row === 'stored') { @@ -359,7 +363,7 @@ function oracleRows(row: Row): OracleRow[] { } if (row.packageRow.row === 'stored') rows.push({ scope: 'env-wide', spelling: 'canonical', lock: row.packageRow.lock }); if (row.otherPackageRow.row === 'stored') { - rows.push({ scope: 'org-scoped', spelling: 'canonical', lock: row.otherPackageRow.lock }); + rows.push({ scope: 'env-wide', spelling: 'canonical', lock: row.otherPackageRow.lock }); } return rows; } @@ -428,13 +432,12 @@ function expectedLock(row: Row, side: 'reads' | 'door'): Lock { // ── The harness ────────────────────────────────────────────────────────────── function storedRow( - type: string, organizationId: string | null, lock: Lock, label: string, name = NAME, packageId: string | null = null, + type: string, lock: Lock, label: string, name = NAME, packageId: string | null = null, ): StoredRow { return { - id: `r_${type}_${organizationId ?? 'env'}_${packageId ?? 'pkgless'}`, + id: `r_${type}_env_${packageId ?? 'pkgless'}`, type, name, - organization_id: organizationId, package_id: packageId, state: 'active', metadata: JSON.stringify({ @@ -561,16 +564,15 @@ function rowOrdersFor(row: Row): StoredRow[][] { if (row.storedRow.row === 'stored') { rows.push(storedRow( row.storedRow.spelling === 'canonical' ? 'view' : 'views', - row.storedRow.scope === 'env-wide' ? null : ORG, row.storedRow.lock, `${row.storedRow.scope} row`, )); } if (row.packageRow.row === 'stored') { - rows.push(storedRow('view', null, row.packageRow.lock, 'package row', NAME, PACKAGE_ID)); + rows.push(storedRow('view', row.packageRow.lock, 'package row', NAME, PACKAGE_ID)); } if (row.otherPackageRow.row === 'stored') { - rows.push(storedRow('view', ORG, row.otherPackageRow.lock, 'org row of the other package', NAME, OTHER_PACKAGE)); + rows.push(storedRow('view', row.otherPackageRow.lock, 'env-wide row of the other package', NAME, OTHER_PACKAGE)); } return rows.length < 2 ? [rows] : [rows, [...rows].reverse()]; } @@ -695,11 +697,12 @@ describe('[#21738, #21803] pin 1 — the family\'s enumeration, generated from t expect(AXIS_VALUES.requestPackage, 'packageId absent, the package\'s own, another package').toEqual([undefined, PACKAGE_ID, OTHER_PACKAGE]); }); - it('PR #21801\'s generated product (16 320 rows) is a subset of this table, by its own titles: no row is lost', () => { + it('PR #21801\'s generated product (8 640 rows since ADR-0131 D7) is a subset of this table, by its own titles: no row is lost', () => { const titles = new Set(TABLE.map(titleOf)); // PR #21801's axes and title, frozen here as they were: the product // regenerated from them must be found in this table under the very - // titles it ran under. + // titles it ran under. [ADR-0131 D6/D7] Its 16 320 rows lose the + // 7 680 whose stored row was organization-scoped: no stored row can be. const before: string[] = []; for (const artifact of ARTIFACT_VALUES) for (const storedRowValue of STORED_ROW_VALUES) for (const packageRow of PACKAGE_ROW_VALUES) for (const requestScope of [undefined, ORG]) @@ -718,15 +721,15 @@ describe('[#21738, #21803] pin 1 — the family\'s enumeration, generated from t operation, ].filter((part) => part !== '').join(' · ')); } - expect(before).toHaveLength(16320); - expect(new Set(before).size).toBe(16320); + expect(before).toHaveLength(8640); + expect(new Set(before).size).toBe(8640); expect(before.filter((t) => !titles.has(t))).toEqual([]); }); - it('PR #21737\'s 64-row enumeration (topology × row scope × request scope × lock × operation) is a subset of this table', () => { + it('PR #21737\'s enumeration (topology × row scope × request scope × lock × operation; 32 rows since ADR-0131 D7) is a subset of this table', () => { const titles = new Set(TABLE.map(titleOf)); const folded: string[] = []; - for (const topology of AXIS_VALUES.topology) for (const scope of ['env-wide', 'org-scoped'] as const) + for (const topology of AXIS_VALUES.topology) for (const scope of ['env-wide'] as const) for (const requestScope of AXIS_VALUES.requestScope) for (const lock of MetadataLockSchema.options) for (const operation of AXIS_VALUES.operation) { folded.push(titleOf({ @@ -740,7 +743,7 @@ describe('[#21738, #21803] pin 1 — the family\'s enumeration, generated from t topology, requestSpelling: 'view', operation, })); } - expect(folded).toHaveLength(64); + expect(folded).toHaveLength(32); expect(folded.filter((t) => !titles.has(t))).toEqual([]); }); @@ -842,25 +845,20 @@ describe('[#21738, #21803] pin 1 — the family\'s enumeration, generated from t expect(locks.some((l) => l.row.artifact.artifact === 'no _lock' && l.row.otherArtifact.artifact === 'declared' && l.row.otherArtifact.lock === 'no-delete' && l.row.storedRow.row === 'stored' && l.row.storedRow.lock === 'no-overlay' && l.door === 'full')).toBe(true); - // [#21803, ADR-0131 D6] The folded position is closed: the - // organization's only row (another package's) is in no read's scope, - // so the env-wide package row's 'full' binds the request naming the - // package, as it binds one naming no organization. + // [#21803, ADR-0131 D6] The folded position is closed: another + // package's env-wide row is in the lock's scope, never the content's, + // so the package's own row's 'full' binds the request naming the + // package, whatever organization the request names. expect(locks.some((l) => l.row.otherPackageRow.row === 'stored' && l.row.otherPackageRow.lock === 'none' && l.row.packageRow.row === 'stored' && l.row.packageRow.lock === 'full' && l.row.requestScope === ORG && l.row.requestPackage === PACKAGE_ID && l.reads === 'full')).toBe(true); }); - it('lit control: an org-scoped request is served the env-wide row and reads its lock; an org-scoped row is never served to a request naming none', async () => { - const protocol = harness(undefined, [storedRow('view', null, 'full', 'env-wide row')]); + it('lit control: an org-scoped request is served the env-wide row and reads its lock', async () => { + const protocol = harness(undefined, [storedRow('view', 'full', 'env-wide row')]); const read = await envelope(protocol, 'view', ORG); expect({ lock: read.lock, served: read.byName.item?.label, overlayScope: read.layered.overlayScope }) .toEqual({ lock: 'full', served: 'env-wide row', overlayScope: 'env' }); - const other = harness(undefined, [storedRow('view', ORG, 'full', 'org row')]); - const unscoped = await envelope(other, 'view'); - expect({ lock: unscoped.lock, overlayScope: unscoped.layered.overlayScope }).toEqual({ lock: 'none', overlayScope: null }); - expect(await door(other, 'view', 'save')).toBe('admitted'); - expect(await door(other, 'view', 'delete')).toBe('admitted'); }); it('lit control: the registry double answers the first package registered when asked with no package, and the asked package\'s own when asked with one', () => { @@ -881,7 +879,7 @@ describe('[#21738, #21803] pin 1 — the family\'s enumeration, generated from t describe('[#21738] pin 2 — position 1: an artifact\'s explicit _lock: \'none\' does not override a stored env-wide \'full\'', () => { const artifact = packagedView({ artifact: 'declared', lock: 'none' }, 'v_pos1')!; - const rows = () => [storedRow('view', null, 'full', 'env-wide row', 'v_pos1')]; + const rows = () => [storedRow('view', 'full', 'env-wide row', 'v_pos1')]; for (const { kernel, environmentId } of AXIS_VALUES.topology) { it(`${kernel} kernel: both reads say locked, the served body and the tile say so too, and save / delete are refused ITEM_LOCKED (403)`, async () => { @@ -920,7 +918,7 @@ describe('[#21738] pin 2 — position 1: an artifact\'s explicit _lock: \'none\' describe('[#21738] pin 3 — position 2: getMetaItemLayered reads a stored lock under a packaged item with no _lock', () => { const artifact = packagedView({ artifact: 'no _lock' }, 'v_pos2')!; - const rows = () => [storedRow('view', null, 'full', 'env-wide row', 'v_pos2')]; + const rows = () => [storedRow('view', 'full', 'env-wide row', 'v_pos2')]; for (const { kernel, environmentId } of AXIS_VALUES.topology) { it(`${kernel} kernel: the layered read says locked, as getMetaItem does, and the door refuses`, async () => { @@ -945,8 +943,8 @@ describe('[#21738] pin 3 — position 2: getMetaItemLayered reads a stored lock /** The two stored rows of an arrangement, package-less first, in both orders. */ function bothOrders(pkgless: Lock, ofPackage: Lock, name: string): Array<{ order: string; rows: StoredRow[] }> { const rows = [ - storedRow('view', null, pkgless, 'package-less row', name), - storedRow('view', null, ofPackage, 'package row', name, PACKAGE_ID), + storedRow('view', pkgless, 'package-less row', name), + storedRow('view', ofPackage, 'package row', name, PACKAGE_ID), ]; return [ { order: 'package-less row returned first', rows }, @@ -1065,8 +1063,8 @@ describe('[#21761] pin 6 — the #5706 outage: a failing row read in scope still describe('[#21761] pin 7 — a third package\'s row is in scope: no write the gate refused before is admitted', () => { const OTHER = 'com.example.other'; const rows = () => [ - storedRow('view', null, 'none', 'package row', 'v_third', PACKAGE_ID), - storedRow('view', null, 'full', 'other package row', 'v_third', OTHER), + storedRow('view', 'none', 'package row', 'v_third', PACKAGE_ID), + storedRow('view', 'full', 'other package row', 'v_third', OTHER), ]; for (const reversed of [false, true]) { @@ -1083,22 +1081,6 @@ describe('[#21761] pin 7 — a third package\'s row is in scope: no write the ga }); } - it('[ADR-0131 D6] an organization holding only another package\'s row: that legacy row is in no read\'s scope, and its writes are refused', async () => { - const protocol = harness(ENV_ID, [ - storedRow('view', ORG, 'full', 'org row of the other package', 'v_org', OTHER), - storedRow('view', null, 'none', 'env-wide package row', 'v_org', PACKAGE_ID), - ]); - const read = await envelope(protocol, 'view', ORG, 'v_org', PACKAGE_ID); - expect({ lock: read.lock, editable: read.editable, deletable: read.deletable }) - .toEqual({ lock: 'none', editable: true, deletable: true }); - // Content stays prefer-local: the organization holds no row of the - // package or package-less, so the env-wide package row is served. - expect(read.byName.item?.label).toBe('env-wide package row'); - // [ADR-0131 D6] The organization's writes are refused before the gate. - expect(await door(protocol, 'view', 'save', ORG, 'v_org', PACKAGE_ID)).toEqual(NOT_OVERRIDABLE); - expect(await door(protocol, 'view', 'delete', ORG, 'v_org')).toEqual(NOT_OVERRIDABLE); - }); - it('the list item and the directory tile report the same lock as the item\'s envelope', async () => { const protocol = harness(ENV_ID, bothOrders('full', 'none', 'v_list')[1]!.rows); for (const packageId of [PACKAGE_ID, undefined]) { @@ -1182,7 +1164,7 @@ describe('[#21803] pin 9 — never a widening: A ships no lock, B ships \'no-del for (const { order, artifacts } of twoPackages({ artifact: 'no _lock' }, { artifact: 'declared', lock: 'no-delete' }, 'v_join')) { for (const { request, packageId } of REQUESTS) { it(`${order} · request ${request}: the reads say 'full' and the door refuses both verbs`, async () => { - const protocol = harness(ENV_ID, [storedRow('view', null, 'no-overlay', 'env-wide row', 'v_join')], artifacts()); + const protocol = harness(ENV_ID, [storedRow('view', 'no-overlay', 'env-wide row', 'v_join')], artifacts()); const read = await envelope(protocol, 'view', undefined, 'v_join', packageId); expect({ lock: read.lock, editable: read.editable, deletable: read.deletable }) .toEqual({ lock: 'full', editable: false, deletable: false }); @@ -1212,39 +1194,3 @@ describe('[#21803] pin 10 — a disabled package is still installed: its package }); } }); - -describe('[#21803, ADR-0131 D6] pin 11 — the folded position is closed: a legacy organization row is in neither the content\'s scope nor the lock\'s', () => { - // 5988387087: the organization holds only package B's row; an env-wide row - // of package A declares 'full'. Before ADR-0131 D6 a request naming A in - // the organization was served A's env-wide row while the lock's scope was - // the organization's rows. The reads take no organization now, so the - // content and the lock are both the environment's: 'full'. - const rows = () => [ - storedRow('view', ORG, 'none', 'org row of the other package', 'v_scope', OTHER_PACKAGE), - storedRow('view', null, 'full', 'env-wide package row', 'v_scope', PACKAGE_ID), - ]; - - for (const reversed of [false, true]) { - it(`${reversed ? 'the env-wide row returned first' : 'the org row returned first'}: the envelope and the served body both say 'full'`, async () => { - const protocol = harness(ENV_ID, reversed ? rows().reverse() : rows()); - const read = await envelope(protocol, 'view', ORG, 'v_scope', PACKAGE_ID); - expect({ lock: read.lock, editable: read.editable, deletable: read.deletable }) - .toEqual({ lock: 'full', editable: false, deletable: false }); - expect(read.byName.item?.label).toBe('env-wide package row'); - expect(read.byName.item?._lock).toBe('full'); - expect(read.layered.effective?._lock).toBe('full'); - expect(read.layered.overlay?._lock).toBe('full'); - expect(read.layered.overlayScope).toBe('env'); - // [ADR-0131 D6] The organization's writes are refused before the gate. - expect(await door(protocol, 'view', 'save', ORG, 'v_scope', PACKAGE_ID)).toEqual(NOT_OVERRIDABLE); - expect(await door(protocol, 'view', 'delete', ORG, 'v_scope')).toEqual(NOT_OVERRIDABLE); - }); - } - - it('lit control: the same rows read with no organization bind the env-wide row\'s \'full\'', async () => { - const protocol = harness(ENV_ID, rows()); - const read = await envelope(protocol, 'view', undefined, 'v_scope', PACKAGE_ID); - expect({ lock: read.lock, body: read.byName.item?._lock }).toEqual({ lock: 'full', body: 'full' }); - expect(await door(protocol, 'view', 'save', undefined, 'v_scope', PACKAGE_ID)).toEqual(ITEM_LOCKED); - }); -}); diff --git a/packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts b/packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts index af24442f52e..be8f6d603ad 100644 --- a/packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts +++ b/packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts @@ -25,11 +25,11 @@ * control moved with it. * 2. The measured defect, named: an env-wide `_lock: 'full'` row, an * org-scoped read, save, delete, publish and rollback. - * 3. Precedence: with both rows present, [ADR-0131 D6] every request is - * served the env-wide row — a legacy organization row is served by no - * read — and the door binds that row's `_lock`. - * 4. The organization gate: on a type with no per-org channel the reads - * never serve an org-scoped row, and neither does the door. + * 3, 4. RETIRED with the organization column (ADR-0131 D6/D7): they pinned + * an env-wide row beside a legacy organization row of the same name, a + * store the family can no longer hold (it declares no organization + * column, and the runtime refuses to boot on a database still holding + * one). * * It sits above PR #21693's and PR #21715's per-case pins and replaces neither. * @@ -57,25 +57,23 @@ interface StoredRow { id: string; type: string; name: string; - organization_id: string | null; package_id: string | null; state: string; metadata: string; } /** A tenant-authored row (a `view` unless named), as an author's save of a body declaring `_lock` leaves it at rest. */ -function viewRow(name: string, organizationId: string | null, lock: Lock, type = 'view'): StoredRow { +function viewRow(name: string, lock: Lock, type = 'view'): StoredRow { return { - id: `r_${name}_${organizationId ?? 'env'}`, + id: `r_${name}_env`, type, name, - organization_id: organizationId, package_id: null, state: 'active', metadata: JSON.stringify({ name, // Which row was served, readable off the document. - label: organizationId === null ? 'env-wide row' : 'org row', + label: 'env-wide row', object: 'account', _provenance: 'org', ...(lock === 'none' ? {} : { _lock: lock }), @@ -134,38 +132,6 @@ function harness(environmentId: string | undefined, rows: StoredRow[]) { const settle = (run: Promise) => run.then(() => null, (e: unknown) => e); -type Verdict = { refused: { code: unknown; status: unknown } } | 'admitted'; -const ITEM_LOCKED: Verdict = { refused: { code: 'ITEM_LOCKED', status: 403 } }; -/** [ADR-0131 D6] Every organization-scoped write is refused first, before the `_lock` gate is reached. */ -const NOT_OVERRIDABLE: Verdict = { refused: { code: 'NOT_OVERRIDABLE', status: 403 } }; - -/** - * The door, end to end. Refused ⇔ the ADR-0112 `ITEM_LOCKED` / 403 envelope - * came back (or, for an organization-scoped request, `NOT_OVERRIDABLE` / 403). Admitted ⇔ the ADR-0010 `_lock` gate was reached and answered no - * refusal; whatever the write does after that (validation, a double that - * persists nothing) is not a lock verdict and is not read as one. - */ -async function door( - protocol: ObjectStackProtocolImplementation, name: string, operation: 'save' | 'delete', organizationId?: string, - type = 'view', -): Promise { - const gate = vi.spyOn(protocol as any, operation === 'save' ? 'assertLockAllowsWrite' : 'assertLockAllowsDelete'); - try { - const scope = organizationId ? { organizationId } : {}; - const outcome: any = await settle(operation === 'save' - ? protocol.saveMetaItem({ type, name, item: { name, label: name, object: 'account' }, ...scope }) - : protocol.deleteMetaItem({ type, name, ...scope })); - if (outcome instanceof Error && ['ITEM_LOCKED', 'NOT_OVERRIDABLE'].includes((outcome as any).code)) { - return { refused: { code: (outcome as any).code, status: (outcome as any).status } }; - } - expect(gate, `${type}/${name} ${operation}: not refused, yet the _lock gate was never reached`).toHaveBeenCalledTimes(1); - expect(await gate.mock.results[0]?.value).toBeNull(); - return 'admitted'; - } finally { - gate.mockRestore(); - } -} - /** Both reads' envelopes for the same request; they must agree with each other first. */ async function envelope(protocol: ObjectStackProtocolImplementation, name: string, organizationId?: string, type = 'view') { const scope = organizationId ? { organizationId } : {}; @@ -182,16 +148,12 @@ const TOPOLOGIES = [ { kernel: 'environment', environmentId: ENV_ID }, { kernel: 'host-config', environmentId: undefined }, ] as const; -const REQUEST_SCOPES = [ - { requestScope: 'no organization', organizationId: undefined }, - { requestScope: `organization ${ORG}`, organizationId: ORG }, -] as const; const OPERATIONS = ['save', 'delete'] as const; describe('[#21716] pin 2 — an env-wide _lock: full row binds an organization with no row of its own', () => { for (const { kernel, environmentId } of TOPOLOGIES) { it(`${kernel} kernel: the org-scoped read says locked, and save / delete are refused NOT_OVERRIDABLE (403) before any read`, async () => { - const { protocol, inserted } = harness(environmentId, [viewRow('v_env_full', null, 'full')]); + const { protocol, inserted } = harness(environmentId, [viewRow('v_env_full', 'full')]); expect(await envelope(protocol, 'v_env_full', ORG)).toMatchObject({ lock: 'full', editable: false, deletable: false, served: 'env-wide row', }); @@ -208,7 +170,7 @@ describe('[#21716] pin 2 — an env-wide _lock: full row binds an organization w }); it(`${kernel} kernel: an org-scoped publish and rollback are refused the same way (the shared write gate)`, async () => { - const { protocol } = harness(environmentId, [viewRow('v_env_full', null, 'full')]); + const { protocol } = harness(environmentId, [viewRow('v_env_full', 'full')]); const published: any = await settle(protocol.publishMetaItem({ type: 'view', name: 'v_env_full', organizationId: ORG } as any)); expect({ code: published?.code, status: published?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); const rolledBack: any = await settle(protocol.rollbackMetaItem({ type: 'view', name: 'v_env_full', toVersion: 1, organizationId: ORG } as any)); @@ -216,59 +178,3 @@ describe('[#21716] pin 2 — an env-wide _lock: full row binds an organization w }); } }); - -describe('[#21716, ADR-0131 D6] pin 3 — both rows present: every request is served the env-wide row, and the door binds its lock', () => { - const cases: Array<{ env: Lock; org: Lock }> = [ - { env: 'full', org: 'none' }, - { env: 'none', org: 'full' }, - { env: 'no-delete', org: 'no-overlay' }, - ]; - for (const { kernel, environmentId } of TOPOLOGIES) { - for (const c of cases) { - for (const q of REQUEST_SCOPES) { - it(`${kernel} kernel · env-wide _lock=${c.env}, org _lock=${c.org} · request: ${q.requestScope}`, async () => { - const rows = [viewRow('v_both', null, c.env), viewRow('v_both', ORG, c.org)]; - const { protocol } = harness(environmentId, rows); - const read = await envelope(protocol, 'v_both', q.organizationId); - // [ADR-0131 D6] The read serves the env-wide row to every - // request: a legacy organization row is served by no read… - expect(read).toMatchObject({ - lock: c.env, - served: 'env-wide row', - overlayScope: 'env', - }); - // …and that row's lock is the one the doors enforce. [ADR-0131 D6] - // An org-scoped write is refused before the `_lock` gate. - expect(await door(protocol, 'v_both', 'save', q.organizationId)) - .toEqual(q.organizationId ? NOT_OVERRIDABLE : read.editable ? 'admitted' : ITEM_LOCKED); - expect(await door(protocol, 'v_both', 'delete', q.organizationId)) - .toEqual(q.organizationId ? NOT_OVERRIDABLE : read.deletable ? 'admitted' : ITEM_LOCKED); - }); - } - } - } -}); - -describe('[#21716] pin 4 — a type with no per-org channel: neither the read nor the door serves an org-scoped row', () => { - // `page` was outside the five types the pre-D6 doors let an organization - // write, so an org-scoped row of it is pre-#6190 residue boot hydration - // walks past. The reads gate the - // organization away (ADR-0131 D6: no read takes one) and serve the env-wide - // row. [ADR-0131 D6] Every org-scoped write — the removal included — is - // refused by the org-scope door (`NOT_OVERRIDABLE`) before the `_lock` gate. - const cases: Array<{ env: Lock; org: Lock }> = [ - { env: 'full', org: 'none' }, - { env: 'none', org: 'full' }, - ]; - for (const { kernel, environmentId } of TOPOLOGIES) { - for (const c of cases) { - it(`${kernel} kernel · page: env-wide _lock=${c.env}, org-scoped residue _lock=${c.org} · delete for ${ORG}`, async () => { - const rows = [viewRow('p_both', null, c.env, 'page'), viewRow('p_both', ORG, c.org, 'page')]; - const { protocol } = harness(environmentId, rows); - const read = await envelope(protocol, 'p_both', ORG, 'page'); - expect(read).toMatchObject({ lock: c.env, served: 'env-wide row', overlayScope: 'env' }); - expect(await door(protocol, 'p_both', 'delete', ORG, 'page')).toEqual(NOT_OVERRIDABLE); - }); - } - } -}); diff --git a/packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts b/packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts index f5fd1fc9648..01864743cb2 100644 --- a/packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts +++ b/packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts @@ -382,7 +382,7 @@ describe('[#5707] the layered read stops painting an outage as "nothing was cust it('a healthy store still reports the overlay layer and its scope', async () => { const engine = engineWithRows([], { acct: codeBaseline }); engine.findOne = vi.fn(async (_o: string, opts: any) => - (opts?.where?.organization_id === null + (opts?.where?.type === 'object' && opts?.where?.name === 'acct' && !('organization_id' in (opts?.where ?? {})) ? { type: 'object', name: 'acct', state: 'active', metadata: JSON.stringify({ name: 'acct', label: 'Env overlay' }) } : null), ); diff --git a/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts deleted file mode 100644 index 0afd0f1b137..00000000000 --- a/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#6190, ADR-0131 D6] Cold boot SAYS which stored rows sit in a legacy - * organization's layer — rows no read serves any more. - * - * #6190 made the skip of an organization-scoped `flow` row loud: boot hydrates - * `organization_id IS NULL` rows only, and an organization-authored flow - * stopped firing after a restart with nothing said. ADR-0131 D6 then retired - * the per-organization overlay axis: every read is environment → code, for - * every type, so a legacy organization row of ANY type — a tenant's `view` - * overlay as much as a pre-#6190 flow — is served by no read. The report - * widened with it: every type, active and draft rows, plus the legacy rows of - * the three ledgers the timeline, history, diff and audit reads no longer - * show, and the remedy is the v18 migration ceremony (`os migrate`, ADR-0131 - * D10), which names each row's fate. Nothing is deleted or rewritten here. - * - * Reverse verification: deleting the `reportUnhydratableOrgScopedRows()` call - * from `loadMetaFromDb` turns the "names" cases red and leaves the silence - * cases green (they assert an absence a deleted producer satisfies). - */ -import { describe, expect, it, vi } from 'vitest'; -// [#5619] The producer's OWN write-verb dispatch decisions (#4550 delete / -// #5480 update). From `@objectstack/metadata-core`, never `@objectstack/objectql` -// — objectql depends on THIS package, so that import would close a cycle. -import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; -import { ObjectStackProtocolImplementation } from './protocol.js'; - -interface Row { - id: string; - type: string; - name: string; - organization_id: string | null; - state: string; - metadata: string; -} - -/** - * A stub that honours the two predicates the audit query relies on - * (`organization_id: { $null: false }` and `type: { $in: [...] }`) plus the - * plain equality the boot query uses. `driver-memory`, `driver-sql` and - * `driver-mongodb` all lower `$null`; this mirrors that, and the - * dropped-predicate case gets its own stub below. - */ -function matchesWhere(r: Row, where: Record): boolean { - for (const [k, v] of Object.entries(where)) { - if (k === '$and') { - if (!(v as Array>).every((clause) => matchesWhere(r, clause))) return false; - continue; - } - if (k.startsWith('$')) throw new Error(`matchesWhere: unsupported combinator ${k}`); - if (v === undefined) continue; - const actual = (r as any)[k]; - if (v !== null && typeof v === 'object') { - const ops = v as Record; - if ('$null' in ops) { - const isNull = actual === null || actual === undefined; - if (isNull !== ops.$null) return false; - } - if ('$in' in ops) { - if (!(ops.$in as unknown[]).includes(actual)) return false; - } - if ('$ne' in ops && actual === ops.$ne) return false; - continue; - } - if (actual !== v) return false; - } - return true; -} - -function makeEngine( - rows: Row[], - opts: { dropPredicates?: boolean; ledgers?: Record> } = {}, -) { - const registered: Array<{ type: string; name: string }> = []; - const engine: any = { - async find(table: string, q: { where: Record }) { - if (table !== 'sys_metadata') { - const ledger = (opts.ledgers?.[table] ?? []) as any[]; - return opts.dropPredicates ? ledger : ledger.filter((r) => matchesWhere(r, q.where)); - } - // A driver that cannot lower `$null`/`$in` hands back a superset — - // the exact degradation the JS re-check exists for. - if (opts.dropPredicates) return rows; - return rows.filter((r) => matchesWhere(r, q.where)); - }, - async count(table: string, q: { where: Record }) { - const ledger = (opts.ledgers?.[table] ?? []) as any[]; - return ledger.filter((r) => matchesWhere(r, q.where)).length; - }, - async findOne(object: string, query?: EngineFindOneQueryInput) { - assertEngineFindOnePredicate(object, query); return null; }, - async insert() { return { id: 'x' }; }, - async update(_t: string, data: Record, o?: Record) { - assertEngineUpdateDispatch(data, o); - return { id: null }; - }, - async delete(_t: string, o?: Record) { - assertEngineDeleteDispatch(o); - return { deleted: 0 }; - }, - registry: { - registerItem: (type: string, item: any) => { registered.push({ type, name: item?.name }); }, - registerObject: (item: any) => { registered.push({ type: 'object', name: item?.name }); }, - listItems: () => [], - getItem: () => undefined, - getArtifactItem: () => undefined, - isPackageDisabled: () => false, - }, - }; - return { engine, registered }; -} - -const flowBody = (name: string) => JSON.stringify({ - name, - label: 'Escalate overdue tasks', - type: 'record_change', - status: 'active', - nodes: [ - { id: 'start', type: 'start', label: 'Start', config: { objectName: 'task', triggerType: 'record-after-update' } }, - { id: 'end', type: 'end', label: 'End' }, - ], - edges: [{ id: 'e1', source: 'start', target: 'end' }], -}); - -const viewBody = (name: string) => JSON.stringify({ - name, label: 'Overdue', object: 'task', columns: [{ field: 'name', label: 'Name' }], -}); - -const row = (over: Partial & Pick): Row => ({ - id: `r_${over.type}_${over.name}_${over.organization_id ?? 'env'}`, - organization_id: null, - state: 'active', - metadata: over.type === 'view' ? viewBody(over.name) : flowBody(over.name), - ...over, -}); - -/** One `console.warn` capture, returned as the lines the boot printed. */ -async function bootAndCapture(engine: any): Promise<{ result: any; warns: string[] }> { - const warns: string[] = []; - const spy = vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => { - warns.push(a.map(String).join(' ')); - }); - try { - const protocol = new ObjectStackProtocolImplementation(engine) as any; - const result = await protocol.loadMetaFromDb(); - return { result, warns }; - } finally { - spy.mockRestore(); - } -} - -const AUDIT = '[metadata_org_scoped_unserved]'; - -describe('[#6190, ADR-0131 D6] cold boot names every legacy organization-scoped row, which no read serves', () => { - it('names a legacy organization FLOW row and a tenant VIEW overlay alike, per type, with the ceremony as the remedy', async () => { - const { engine } = makeEngine([ - row({ type: 'flow', name: 'org_sweep', organization_id: 'org_a' }), - row({ type: 'flow', name: 'platform_sweep' }), - row({ type: 'view', name: 'org_grid', organization_id: 'org_a' }), - row({ type: 'view', name: 'org_draft_grid', organization_id: 'org_b', state: 'draft' }), - row({ type: 'view', name: 'platform_grid' }), - ]); - - const { result, warns } = await bootAndCapture(engine); - - // Hydration loads the environment's rows only. - expect(result).toMatchObject({ loaded: 2, errors: 0, storeUnavailable: false }); - - const line = warns.find((w) => w.includes(AUDIT)); - expect(line, `no ${AUDIT} line in: ${JSON.stringify(warns)}`).toBeDefined(); - expect(line).toContain('flow×1 (org_sweep@org_a)'); - expect(line).toContain('view×2 (org_grid@org_a, org_draft_grid@org_b (draft))'); - expect(line).toContain('bind its triggers'); - expect(line).toContain('os migrate'); - expect(line).not.toContain('platform_'); - }); - - it('counts the legacy rows of the three ledgers the reads no longer show', async () => { - const { engine } = makeEngine([], { - ledgers: { - sys_metadata_commit: [{ organization_id: 'org_a' }, { organization_id: null }], - sys_metadata_history: [{ organization_id: 'org_a' }, { organization_id: 'org_b' }], - sys_metadata_audit: [{ organization_id: null }], - }, - }); - - const { warns } = await bootAndCapture(engine); - - const line = warns.find((w) => w.includes(AUDIT)); - expect(line).toContain('sys_metadata_commit×1, sys_metadata_history×2'); - expect(line).not.toContain('sys_metadata_audit×'); - }); - - it('counts every row but samples the names, so a thousand rows cost one line', async () => { - const rows: Row[] = []; - for (let i = 0; i < 9; i++) { - rows.push(row({ type: 'flow', name: `sweep_${i}`, organization_id: `org_${i}` })); - } - const { engine } = makeEngine(rows); - - const { warns } = await bootAndCapture(engine); - - const audit = warns.filter((w) => w.includes(AUDIT)); - expect(audit).toHaveLength(1); - expect(audit[0]).toContain('flow×9'); - expect(audit[0]).toContain('+4 more'); - }); - - it('still reports truthfully when the driver drops the predicates and returns a superset', async () => { - const { engine } = makeEngine([ - row({ type: 'flow', name: 'org_sweep', organization_id: 'org_a' }), - row({ type: 'flow', name: 'platform_sweep' }), - ], { dropPredicates: true }); - - const { warns } = await bootAndCapture(engine); - - const line = warns.find((w) => w.includes(AUDIT)); - expect(line).toContain('org_sweep@org_a'); - expect(line).not.toContain('platform_sweep'); - }); - - it('says nothing at all on a store with no legacy organization row', async () => { - const { engine } = makeEngine([ - row({ type: 'flow', name: 'platform_sweep' }), - row({ type: 'view', name: 'platform_grid' }), - ]); - - const { result, warns } = await bootAndCapture(engine); - - expect(result.loaded).toBe(2); - expect(warns.filter((w) => w.includes(AUDIT))).toEqual([]); - }); - - it('a failing audit probe does not change the boot verdict', async () => { - // #5897: a best-effort extra probe must not turn a healthy boot into - // `storeUnavailable`. - let call = 0; - const { engine } = makeEngine([row({ type: 'flow', name: 'platform_sweep' })]); - const inner = engine.find; - engine.find = async (t: string, q: any) => { - call++; - if (call > 1) throw new Error('probe exploded'); - return inner(t, q); - }; - - const { result, warns } = await bootAndCapture(engine); - - expect(call).toBeGreaterThan(1); - expect(result).toMatchObject({ loaded: 1, errors: 0, storeUnavailable: false }); - expect(warns.filter((w) => w.includes('DB hydration skipped'))).toEqual([]); - // [#15206] …and the failed read is said, not swallowed: its term is - // NOT MEASURED, with the reason. - const audit = warns.filter((w) => w.includes(AUDIT)); - expect(audit).toHaveLength(1); - expect(audit[0]).toContain('NOT MEASURED'); - expect(audit[0]).toContain('sys_metadata (probe exploded)'); - }); - - it('[#15206] a read that fails costs its own term only: the other terms are reported as measured', async () => { - const { engine } = makeEngine([ - row({ type: 'flow', name: 'org_sweep', organization_id: 'org_a' }), - ], { - ledgers: { - sys_metadata_commit: [{ organization_id: 'org_a' }], - sys_metadata_audit: [{ organization_id: 'org_b' }], - }, - }); - const inner = engine.count; - engine.count = async (t: string, q: any) => { - if (t === 'sys_metadata_history') throw new Error('history count refused'); - return inner(t, q); - }; - - const { result, warns } = await bootAndCapture(engine); - - expect(result).toMatchObject({ errors: 0, storeUnavailable: false }); - const line = warns.find((w) => w.includes(AUDIT)); - expect(line, `no ${AUDIT} line in: ${JSON.stringify(warns)}`).toBeDefined(); - expect(line).toContain('flow×1 (org_sweep@org_a)'); - expect(line).toContain('sys_metadata_commit×1, sys_metadata_audit×1'); - expect(line).toContain('NOT MEASURED'); - expect(line).toContain('sys_metadata_history (history count refused)'); - expect(line).not.toContain('sys_metadata_history×'); - }); -}); diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index 931912d2a98..84be541fe6f 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -19,8 +19,10 @@ * lands. * * Rows stored organization-scoped BEFORE this refusal are residue the - * promotion ceremony (ADR-0131 C7) carries; they must not be promotable from - * here, and they are not deleted here either. + * promotion ceremony (ADR-0131 C7) carries before its column-drops; since + * ADR-0131 D7 the family declares no organization column, so no row this + * protocol reads or writes carries one, and the runtime refuses to boot on a + * database still holding it (the v18 boot refusal). * * ## Reverse verification, direction predicted BEFORE running * @@ -54,7 +56,6 @@ interface Row { id: string; type: string; name: string; - organization_id: string | null; package_id: string | null; state: string; metadata: string; @@ -67,16 +68,15 @@ interface HistoryRow { type: string; name: string; version: number; - organization_id: string | null; metadata: string | null; checksum: string | null; operation_type: string; recorded_at: string; } -/** ADR-0048 overlay key — (type, name, org, state, package_id). */ +/** ADR-0048 overlay key — (type, name, state, package_id); ADR-0131 D7: no organization part. */ const keyOf = (w: Record) => - `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; + `${w.type}|${w.name}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; /** Top-level eq + `$or` + explicit-NULL, the subset these paths emit. */ function matchesWhere(r: Row, where: Record): boolean { @@ -121,7 +121,6 @@ function makeStubEngine(artifacts: Array<{ type: string; name: string }> = []) { if (w.type !== undefined && h.type !== w.type) return false; if (w.name !== undefined && h.name !== w.name) return false; if (w.version !== undefined && h.version !== w.version) return false; - if (w.organization_id !== undefined && h.organization_id !== w.organization_id) return false; return true; }) ?? null; } @@ -189,30 +188,6 @@ function makeProtocol(artifacts?: Array<{ type: string; name: string }>, environ return { protocol, rows }; } -/** Seed the residue this PR deliberately does NOT migrate: an org-scoped - * draft row written the way `saveMetaItem` used to write it — through the - * repository, which is where `organization_id` is stamped. Bypasses only the - * new protocol gate, so the row is byte-identical to a legacy one. */ -async function seedLegacyOrgDraft( - protocol: any, - args: { type: string; name: string; body: unknown; organizationId: string; packageId?: string | null }, -): Promise { - await protocol.ensureOverlayIndex(); - const repo = protocol.legacyOrganizationRepo(args.organizationId); - await repo.put( - { type: args.type, name: args.name, org: args.organizationId }, - args.body, - { - parentVersion: null, - actor: null, - source: 'test.legacy-residue', - intent: 'runtime-only', - state: 'draft', - packageId: args.packageId ?? null, - }, - ); -} - const OBJECT = { name: 'org_widget', label: 'Org Widget', @@ -255,8 +230,9 @@ const VIEW = { columns: [{ field: 'title', label: 'Title' }], }; +/** The stored rows; `org` says whether a row carries an organization column at all (ADR-0131 D7: none does). */ const orgRows = (rows: Map) => - Array.from(rows.values()).map((r) => ({ type: r.type, name: r.name, org: r.organization_id, state: r.state })); + Array.from(rows.values()).map((r) => ({ type: r.type, name: r.name, org: 'organization_id' in r, state: r.state })); /** One plugin-registered type: no `DEFAULT_METADATA_TYPE_REGISTRY` entry. */ const PLUGIN_TYPE = 'acme_widget'; @@ -315,29 +291,11 @@ describe('[ADR-0131 D6] no organization-scoped metadata write is accepted', () = }); } - for (const type of TIER_A) { - it(`publishMetaItem of a LEGACY organization-scoped '${type}' draft → refused; the draft stays, no active row is minted`, async () => { - const { protocol, rows } = makeProtocol([], 'env_prod'); - await seedLegacyOrgDraft(protocol, { - type, name: 'org_item', body: { name: 'org_item', label: 'Org item' }, organizationId: 'org_a', - }); - await expect( - protocol.publishMetaItem({ type, name: 'org_item', organizationId: 'org_a' }), - ).rejects.toMatchObject(REFUSED); - expect(orgRows(rows)).toEqual([{ type, name: 'org_item', org: 'org_a', state: 'draft' }]); - }); - } - // ── the package publish ─────────────────────────────────────────────── it('publishPackageDrafts naming an organization → 403 NOT_OVERRIDABLE, nothing promoted', async () => { const { protocol, rows } = makeProtocol([], 'env_prod'); await protocol.saveMetaItem({ type: 'view', name: 'org_grid', item: VIEW, packageId: 'app.demo', mode: 'draft' }); - for (const type of TIER_A) { - await seedLegacyOrgDraft(protocol, { - type, name: 'org_item', body: { name: 'org_item' }, organizationId: 'org_a', packageId: 'app.demo', - }); - } await expect( protocol.publishPackageDrafts({ packageId: 'app.demo', organizationId: 'org_a' }), ).rejects.toMatchObject(REFUSED); @@ -380,45 +338,41 @@ describe('[ADR-0131 D6] no organization-scoped metadata write is accepted', () = // ── the controls: environment-wide writes are accepted ───────────────── - it('control — an environment-wide view save succeeds and stores organization_id NULL', async () => { + it('control — an environment-wide view save succeeds and stores no organization_id', async () => { const { protocol, rows } = makeProtocol([], 'env_prod'); expect((await protocol.saveMetaItem({ type: 'view', name: 'org_grid', item: VIEW })).success).toBe(true); - expect(orgRows(rows)).toEqual([{ type: 'view', name: 'org_grid', org: null, state: 'active' }]); + expect(orgRows(rows)).toEqual([{ type: 'view', name: 'org_grid', org: false, state: 'active' }]); }); it('control — an environment-wide object save succeeds', async () => { const { protocol, rows } = makeProtocol([], 'env_prod'); expect((await protocol.saveMetaItem({ type: 'object', name: 'org_widget', item: OBJECT })).success).toBe(true); - expect(orgRows(rows)).toEqual([{ type: 'object', name: 'org_widget', org: null, state: 'active' }]); + expect(orgRows(rows)).toEqual([{ type: 'object', name: 'org_widget', org: false, state: 'active' }]); }); it('control — an environment-wide brand-new flow saves (the runtime-create tier is intact)', async () => { const { protocol, rows } = makeProtocol([], 'env_prod'); expect((await protocol.saveMetaItem({ type: 'flow', name: 'org_sweep', item: FLOW })).success).toBe(true); - expect(orgRows(rows)).toEqual([{ type: 'flow', name: 'org_sweep', org: null, state: 'active' }]); + expect(orgRows(rows)).toEqual([{ type: 'flow', name: 'org_sweep', org: false, state: 'active' }]); }); - it('control — an environment-wide draft publishes through the package publish, and a legacy organization draft is left as stored', async () => { + it('control — an environment-wide draft publishes through the package publish', async () => { const { protocol, rows } = makeProtocol([], 'env_prod'); await protocol.saveMetaItem({ type: 'view', name: 'org_grid', item: VIEW, packageId: 'app.demo', mode: 'draft', }); - await seedLegacyOrgDraft(protocol, { - type: 'view', name: 'org_legacy', body: { ...VIEW, name: 'org_legacy' }, organizationId: 'org_a', packageId: 'app.demo', - }); const res = await protocol.publishPackageDrafts({ packageId: 'app.demo' }); expect(res.failed).toEqual([]); expect(res).toMatchObject({ success: true, publishedCount: 1, failedCount: 0 }); expect(orgRows(rows).sort((a, b) => a.name.localeCompare(b.name))).toEqual([ - { type: 'view', name: 'org_grid', org: null, state: 'active' }, - { type: 'view', name: 'org_legacy', org: 'org_a', state: 'draft' }, + { type: 'view', name: 'org_grid', org: false, state: 'active' }, ]); }); }); -describe('a package-less organization overlay, two packages shipping its view name', () => { +describe('two packages shipping a view name', () => { const NAME = 'task.intake_form'; const LINK = '/forms/shared-intake'; const formView = (allowAnonymous: boolean, label = 'Intake') => ({ @@ -444,15 +398,19 @@ describe('a package-less organization overlay, two packages shipping its view na return { protocol, rows }; } - /** The organization read's copies of the view, where the doors find the form. */ + /** + * [ADR-0131 C7] The env-wide read's copies of the view, where the doors + * find the form: the legacy organization read is deleted. + */ async function servedCopies(protocol: any): Promise>> { - const org: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_a' }); - return (org.items as any[]).filter((v) => v?.name === NAME); + const envWide: any = await protocol.getMetaItems({ type: 'view' }); + return (envWide.items as any[]).filter((v) => v?.name === NAME); } /** - * The copies the anonymous doors would serve for the slug: open, and not - * withdrawn by the env-wide layer they read beneath the organization's. + * The copies the anonymous doors would serve for the slug, by their own + * composition: open, and not withdrawn by any body of the view name in + * that same env-wide list. */ async function doorsServe(protocol: any): Promise>> { const envWide: any = await protocol.getMetaItems({ type: 'view' }); @@ -461,37 +419,19 @@ describe('a package-less organization overlay, two packages shipping its view na .some((c) => c.slug === 'shared-intake' && !anonymousFormIntakeWithdrawnIn(layer, view, c))); } - /** - * The organization overlay, stored package-less while the form is open - * everywhere — a LEGACY row (no write lands organization-scoped since - * ADR-0131 D6), planted through the repository the way it was written. - */ - async function storeOpenOverlay(protocol: any, rows: Map) { - await protocol.ensureOverlayIndex(); - await protocol.legacyOrganizationRepo('org_a').put( - { type: 'view', name: NAME, org: 'org_a' }, - formView(true, 'Intake (org)'), - { parentVersion: null, actor: null, source: 'test.legacy-residue', intent: 'runtime-only', state: 'active', packageId: null }, - ); - expect(Array.from(rows.values()).filter((r) => r.organization_id === 'org_a').map((r) => r.package_id)) - .toEqual([null]); - } - - it('the organization read serves the overlay once per package, each copy stamped with that package', async () => { - const { protocol, rows } = makeTwoPackageProtocol(); - await storeOpenOverlay(protocol, rows); + it('the doors serve each package\'s shipped copy', async () => { + const { protocol } = makeTwoPackageProtocol(); const copies = await servedCopies(protocol); - expect(copies.map((v) => v.label)).toEqual(['Intake (org)', 'Intake (org)']); + expect(copies.map((v) => v.label)).toEqual(['Intake', 'Intake']); expect(copies.map((v) => v._packageId).sort()).toEqual(['pkg_a', 'pkg_b']); - // Control: before any withdrawal the doors serve the overlay. + // Control: before any withdrawal the doors serve both copies. expect(await doorsServe(protocol)).toHaveLength(2); }); for (const [order, withdrawing] of [['first', 'pkg_a'], ['second', 'pkg_b']] as const) { describe(`the package ${order} in registry order withdraws the name env-wide`, () => { - async function withdrawAfterOverlay() { + async function withdrawAfterSave() { const { protocol, rows } = makeTwoPackageProtocol(); - await storeOpenOverlay(protocol, rows); expect((await protocol.saveMetaItem({ type: 'view', name: NAME, item: formView(false), packageId: withdrawing, })).success).toBe(true); @@ -499,7 +439,7 @@ describe('a package-less organization overlay, two packages shipping its view na } it('the env-wide list the doors read holds the withdrawal beside the other package\'s body', async () => { - const { protocol } = await withdrawAfterOverlay(); + const { protocol } = await withdrawAfterSave(); const envWide: any = await protocol.getMetaItems({ type: 'view' }); const named = (envWide.items as any[]).filter((v) => v?.name === NAME) .map((v) => [v._packageId, v.config.sharing.allowAnonymous]) @@ -507,20 +447,19 @@ describe('a package-less organization overlay, two packages shipping its view na expect(named).toEqual([['pkg_a', withdrawing !== 'pkg_a'], ['pkg_b', withdrawing !== 'pkg_b']]); }); - it('the doors serve no copy of the overlay stored before the withdrawal', async () => { - const { protocol } = await withdrawAfterOverlay(); - // Both stamped copies are still the open overlay, and both are closed. + it('the doors serve no copy once one package withdraws the name', async () => { + const { protocol } = await withdrawAfterSave(); expect((await servedCopies(protocol)).map((v) => v._packageId).sort()).toEqual(['pkg_a', 'pkg_b']); expect(await doorsServe(protocol)).toEqual([]); }); - it('a re-save of the overlay is refused (no write is organization-scoped), and nothing changes', async () => { - const { protocol, rows } = await withdrawAfterOverlay(); - const before = Array.from(rows.values()).filter((r) => r.organization_id === 'org_a'); + it('an organization-scoped save of the name is refused (no write is organization-scoped), and nothing changes', async () => { + const { protocol, rows } = await withdrawAfterSave(); + const before = Array.from(rows.values()); await expect(protocol.saveMetaItem({ type: 'view', name: NAME, item: formView(true, 'Intake (org, again)'), organizationId: 'org_a', })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' }); - expect(Array.from(rows.values()).filter((r) => r.organization_id === 'org_a')).toEqual(before); + expect(Array.from(rows.values())).toEqual(before); }); }); } @@ -740,16 +679,6 @@ describe('each package\'s copy of a view container expands into its own package\ return { protocol, rows, registry: engine.registry }; } - /** An organization overlay of the form, open, as a rollback restores it: the save check never judged it. */ - async function restoreOpenOverlay(protocol: any) { - await protocol.ensureOverlayIndex(); - await protocol.legacyOrganizationRepo('org_a').put( - { type: 'view', name: NAME, org: 'org_a' }, - formView(true, 'Intake (org)'), - { parentVersion: null, actor: null, source: 'test.restored', intent: 'runtime-only', state: 'active', packageId: null }, - ); - } - /** An env-wide copy of the container, stored for `packageId` (package-less when undefined). */ async function saveCopy(protocol: any, packageId: string | undefined, allowAnonymous: boolean, title: string) { expect((await protocol.saveMetaItem({ @@ -783,13 +712,13 @@ describe('each package\'s copy of a view container expands into its own package\ /** * What the anonymous doors serve for the slug, by their own composition - * (`registerFormEndpoints`): the organization's read, each open candidate - * judged over the env-wide view list beneath it. + * (`registerFormEndpoints`): the env-wide read, each open candidate + * judged over that same list (the legacy organization read is deleted, + * ADR-0131 C7). */ async function doorsServe(protocol: any): Promise { - const org: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_a' }); const envWide: any = await protocol.getMetaItems({ type: 'view' }); - return (org.items as any[]).filter((view) => anonymousFormIntakeCandidates(view) + return (envWide.items as any[]).filter((view) => anonymousFormIntakeCandidates(view) .some((c) => c.slug === SLUG && !anonymousFormIntakeWithdrawnIn(envWide.items, view, c))); } @@ -797,9 +726,8 @@ describe('each package\'s copy of a view container expands into its own package\ for (const [kernel, environmentId] of KERNELS) { for (const owner of [undefined, 'pkg_a'] as const) { const where = `${kernel}, ${owner ? 'the copy\'s package owns the object' : 'no code package owns the object'}`; - it(`${where}: the env-wide list holds the shipped withdrawal beside the copy, and the doors serve no copy of the overlay`, async () => { + it(`${where}: the env-wide list holds the shipped withdrawal beside the copy, and the doors serve no copy`, async () => { const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(false)]], environmentId, owner); - await restoreOpenOverlay(protocol); await saveCopy(protocol, 'pkg_a', true, 'Intake (pkg_a copy)'); expect(await envWideBodies(protocol)).toEqual([ @@ -811,9 +739,8 @@ describe('each package\'s copy of a view container expands into its own package\ } } - it('control: with no package withdrawing the form, the doors serve the overlay', async () => { + it('control: with no package withdrawing the form, the doors serve the copy', async () => { const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(true)]], 'env_prod'); - await restoreOpenOverlay(protocol); await saveCopy(protocol, 'pkg_a', true, 'Intake (pkg_a copy)'); expect((await doorsServe(protocol)).length).toBeGreaterThan(0); }); @@ -822,9 +749,8 @@ describe('each package\'s copy of a view container expands into its own package\ describe('(b) the same, with the withdrawal saved in the other package\'s own env-wide copy', () => { for (const [kernel, environmentId] of KERNELS) { for (const order of [['pkg_a', 'pkg_b'], ['pkg_b', 'pkg_a']] as const) { - it(`${kernel}, ${order[0]}'s copy saved first: each copy serves its own package's slot, and the doors serve no copy of the overlay`, async () => { + it(`${kernel}, ${order[0]}'s copy saved first: each copy serves its own package's slot, and the doors serve no copy`, async () => { const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(true)]], environmentId); - await restoreOpenOverlay(protocol); for (const pkg of order) await saveCopy(protocol, pkg, pkg === 'pkg_a', `Intake (${pkg} copy)`); expect(await envWideBodies(protocol)).toEqual([ @@ -841,7 +767,6 @@ describe('each package\'s copy of a view container expands into its own package\ for (const [kernel, environmentId] of KERNELS) { it(`${kernel}: the package-less copy serves each package's slot, so the administrator's switch opens the form for every package`, async () => { const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(false)]], environmentId); - await restoreOpenOverlay(protocol); await saveCopy(protocol, undefined, true, 'Intake (env-wide copy)'); expect(await envWideBodies(protocol)).toEqual([ @@ -854,7 +779,6 @@ describe('each package\'s copy of a view container expands into its own package\ for (const order of [[undefined, 'pkg_b'], ['pkg_b', undefined]] as const) { it(`${kernel}, ${order[0] ?? 'the package-less'} copy saved first: a package's own copy serves that package's slot ahead of the package-less copy`, async () => { const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(true)]], environmentId); - await restoreOpenOverlay(protocol); for (const pkg of order) { if (pkg === undefined && order[0] === 'pkg_b') { await saveUnboundCopyBeside(protocol, true, 'Intake (env-wide copy)'); @@ -912,9 +836,8 @@ describe('each package\'s copy of a view container expands into its own package\ // package-less, as the by-name read naming that package decides. describe('(e) a stored row of the form\'s own name serves its own package\'s slot, and hides no other package\'s copy', () => { for (const [kernel, environmentId] of KERNELS) { - it(`${kernel}: one package's row of the name and another package's withdrawing copy are both in the env-wide list, and the doors serve no copy of the overlay`, async () => { + it(`${kernel}: one package's row of the name and another package's withdrawing copy are both in the env-wide list, and the doors serve no copy`, async () => { const { protocol } = harness([['pkg_a', container(true)], ['pkg_b', container(true)]], environmentId); - await restoreOpenOverlay(protocol); expect((await protocol.saveMetaItem({ type: 'view', name: NAME, item: formView(true, 'Intake (pkg_a row)'), packageId: 'pkg_a', })).success).toBe(true); @@ -1038,15 +961,6 @@ describe('each package\'s copy of a view container expands into its own package\ item: { name: 'task', object: 'task', ...p.m.body(COPY_TITLE, allowAnonymous, SLUG) }, })).success).toBe(true); } - /** An organization overlay of the form under `name`, open, as a rollback restores it. */ - async function restoreOpenOverlayAt(protocol: any, name: string) { - await protocol.ensureOverlayIndex(); - await protocol.legacyOrganizationRepo('org_a').put( - { type: 'view', name, org: 'org_a' }, - { name, label: 'Intake (org)', object: 'task', viewKind: 'form', config: form(true, 'Intake (org)', SLUG) }, - { parentVersion: null, actor: null, source: 'test.restored', intent: 'runtime-only', state: 'active', packageId: null }, - ); - } const titleOf = (v: any): unknown => v?.config?.title ?? v?.config?.label; /** The titles a package's slot of `name` serves in the env-wide list. */ async function slotTitles(protocol: any, name: string, pkg: string): Promise { @@ -1102,11 +1016,10 @@ describe('each package\'s copy of a view container expands into its own package\ it(`${kernel}; ${label(p)}: the withdrawal saved in the copy closes the anonymous doors, and saved open the doors serve`, async () => { for (const allowAnonymous of [false, true]) { const { protocol } = harness(shippedIn(p), environmentId, p.owner); - await restoreOpenOverlayAt(protocol, servedName(p)); await saveCopyOf(protocol, p, allowAnonymous); const served = await doorsServe(protocol); - if (allowAnonymous) expect(served.length, 'saved open: the doors serve the overlay').toBeGreaterThan(0); - else expect(served, 'withdrawn in the copy: the doors serve no copy of the overlay').toEqual([]); + if (allowAnonymous) expect(served.length, 'saved open: the doors serve the copy').toBeGreaterThan(0); + else expect(served, 'withdrawn in the copy: the doors serve no copy').toEqual([]); } }); } @@ -1470,7 +1383,7 @@ describe('each package\'s copy of a view container expands into its own package\ for (const pkg of order) registry.registerItem('page', { name: 'home', label: `Home (shipped by ${pkg})` }, 'name', pkg); engine.registry = registry; rows.set(`page|home|__env__|active|${COPYING}`, { - id: 'r_page_home', type: 'page', name: 'home', organization_id: null, package_id: COPYING, state: 'active', + id: 'r_page_home', type: 'page', name: 'home', package_id: COPYING, state: 'active', metadata: JSON.stringify({ name: 'home', label: ROW_TITLE }), }); const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), undefined) as any; diff --git a/packages/metadata-protocol/src/protocol.package-publish-audit-rows.test.ts b/packages/metadata-protocol/src/protocol.package-publish-audit-rows.test.ts index 13d2ad0e3e1..9bb2beaa6be 100644 --- a/packages/metadata-protocol/src/protocol.package-publish-audit-rows.test.ts +++ b/packages/metadata-protocol/src/protocol.package-publish-audit-rows.test.ts @@ -105,7 +105,6 @@ interface Row { id: string; type: string; name: string; - organization_id: string | null; package_id: string | null; state: string; metadata: string; @@ -127,14 +126,13 @@ interface HistoryRow { previous_checksum: string | null; change_note?: string | null; source?: string | null; - organization_id: string | null; recorded_by?: string | null; recorded_at: string; } /** The ADR-0048 overlay key — draft and active rows for one identity coexist. */ function keyOf(w: Record) { - return `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; + return `${w.type}|${w.name}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; } function matchesMetadataWhere(r: Row, where: Record): boolean { @@ -204,7 +202,6 @@ function makeStubEngine(opts: { failAudit?: boolean } = {}): Harness { }; const matchesHistory = (h: HistoryRow, w: Record): boolean => { - if (w.organization_id !== undefined && h.organization_id !== w.organization_id) return false; if (w.type !== undefined && h.type !== w.type) return false; if (w.name !== undefined && h.name !== w.name) return false; if (w.version !== undefined && h.version !== w.version) return false; @@ -379,7 +376,7 @@ const publishRows = (h: Harness, outcome: 'allowed' | 'denied') => /** * Stage one package-bound draft through the ordinary save path — ENV-WIDE - * (`organization_id IS NULL`), the only scope a write lands in (ADR-0131 D6). + * (ADR-0131 D6/D7: no organization column), the only scope a write lands in. */ async function stageDraft( protocol: ObjectStackProtocolImplementation, @@ -420,7 +417,6 @@ describe('[#8400] publishPackageDrafts audits the batch it publishes', () => { expect(opRows(h, 'save')[0]).toMatchObject({ type: 'view', name: 'case_grid', - organization_id: null, operation: 'save', outcome: 'allowed', code: 'ok', @@ -473,7 +469,6 @@ describe('[#8400] publishPackageDrafts audits the batch it publishes', () => { for (const row of allowed) { expect(row).toMatchObject({ type: 'view', - organization_id: null, operation: 'publish', outcome: 'allowed', code: 'ok', @@ -577,7 +572,6 @@ describe('[#8400] publishPackageDrafts audits the batch it publishes', () => { expect(batchAborted[0]).toMatchObject({ type: 'view', name: 'locked_grid', - organization_id: null, operation: 'publish', outcome: 'denied', // The persisted audit column's own vocabulary, lower-case like @@ -760,7 +754,6 @@ describe('[#8594] a refused publish leaves the INNER verdict, in its own vocabul expect(locked[0]).toMatchObject({ type: 'view', name: 'solo_locked', - organization_id: null, operation: 'publish', outcome: 'denied', // adr0112-ok: D6b — persisted audit column, its own vocabulary @@ -799,7 +792,6 @@ describe('[#8594] a refused publish leaves the INNER verdict, in its own vocabul expect(locked[0]).toMatchObject({ type: 'view', name: 'locked_grid', - organization_id: null, operation: 'publish', outcome: 'denied', // adr0112-ok: D6b — persisted audit column, its own vocabulary @@ -905,7 +897,6 @@ describe('[#8594] a refused publish leaves the INNER verdict, in its own vocabul expect(conflict[0]).toMatchObject({ type: 'view', name: 'raced_grid', - organization_id: null, operation: 'publish', outcome: 'denied', // adr0112-ok: D6b — persisted audit column, its own vocabulary @@ -1103,10 +1094,10 @@ describe('[#8595] a PRE-FLIGHT refused publish leaves a row per violation', () = expect(String(row.note)).toContain(`${NS}_`); } - // Scope: the DRAFT's own partition (env-wide) — the same rule the - // promoted rows follow. + // Scope: the environment's — the audit row carries no organization + // column (ADR-0131 D7), the same rule the promoted rows follow. for (const row of denied) { - expect(row.organization_id).toBeNull(); + expect('organization_id' in row).toBe(false); } // The COMPLIANT sibling gets no row of either kind: the batch is diff --git a/packages/metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts b/packages/metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts index cb4a1493807..91e7a738acf 100644 --- a/packages/metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts +++ b/packages/metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts @@ -308,7 +308,7 @@ describe('[#21908] row 23 — the repository reads the engine-lane slice left ca expect(historyRows.length).toBeGreaterThan(0); const hash = String(historyRows[0].checksum); const ref = { type: 'view', name: 'proj_task_grid' } as any; - const repo = new SysMetadataRepository({ engine, organizationId: null }); + const repo = new SysMetadataRepository({ engine }); await expectSystemOptIn(calls, 'getByHash', async () => { expect(await repo.getByHash(ref, hash)).not.toBeNull(); diff --git a/packages/metadata-protocol/src/protocol.publish-item-draft-org-scope.test.ts b/packages/metadata-protocol/src/protocol.publish-item-draft-org-scope.test.ts index 4a7085545eb..329213fa4f0 100644 --- a/packages/metadata-protocol/src/protocol.publish-item-draft-org-scope.test.ts +++ b/packages/metadata-protocol/src/protocol.publish-item-draft-org-scope.test.ts @@ -207,7 +207,7 @@ describe('publishMetaItem publishes env-wide drafts (#10219 B; ADR-0131 D6)', () const active = remaining.filter((r) => r.state === 'active'); expect(active).toHaveLength(1); // Promoted in the scope it was authored in. - expect(active[0]!.organization_id).toBeNull(); + expect('organization_id' in active[0]!).toBe(false); }); it('reports the promotion under the scope it landed in, so the notification is not a lie', async () => { @@ -248,7 +248,7 @@ describe('publishMetaItem publishes env-wide drafts (#10219 B; ADR-0131 D6)', () expect(remaining.filter((r) => r.state === 'active')).toHaveLength(0); const drafts = remaining.filter((r) => r.state === 'draft'); expect(drafts).toHaveLength(1); - expect(drafts[0]!.organization_id).toBeNull(); + expect('organization_id' in drafts[0]!).toBe(false); }); it('CONTROL — a genuinely absent draft still refuses with NO_DRAFT / 404', async () => { diff --git a/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts b/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts index d2780f05fb2..50c80ce19c7 100644 --- a/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts +++ b/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts @@ -3,29 +3,26 @@ /** * [ADR-0131 D6] Every metadata read is environment → code. * - * 1. A legacy organization-scoped row — one per formerly org-overridable - * (tier-A) type, stored before the per-organization overlay axis retired — - * is invisible to the item, list, layered, history, diff, audit, drafts, - * commits, lock and ETag reads, whatever organization a caller still - * names: the environment's row, or the code definition, is served. - * `overlayScope` is `'env'` or `null`, never `'org'`. + * 1. Whatever organization a caller still names, the item, list, layered, + * audit, drafts, commits, lock and ETag reads of each formerly + * org-overridable (tier-A) type serve the environment's rows: + * `overlayScope` is `'env'`, never `'org'`. [ADR-0131 D6/D7] No stored row + * carries an organization: the family declares no organization column, + * and the runtime refuses to boot on a database still holding one. * 2. [Triage ruling Q1 → C] An environment overlay row of SEALED managed * content — a managed action or hook, written through the * `OS_METADATA_WRITABLE` hatch before the seal — is not served: the * package's definition wins. `permission` keeps its own fork ruling, so a * fork of a packaged permission set is still served (the control). - * 3. The boot report names both populations, per type, with their remedies - * — the v18 migration ceremony for the legacy organization rows, and the - * two remedies for the sealed overlays. Nothing is deleted or rewritten. - * 4. [Triage ruling Q3 A] The one read left that reaches a legacy - * organization's rows: the anonymous form doors' `view` list read - * (`legacyFormOrganizationId`), kept fail-closed until the ceremony. Any - * other type reads the environment alone, whoever passes the key. + * 3. The boot report names the sealed overlays, per type, with their two + * remedies. Nothing is deleted or rewritten. + * 4. [ADR-0131 C7] The anonymous form doors' `view` read of the Default + * Organization's layer (`legacyFormOrganizationId`) was deleted when the + * v18 ceremony began carrying those withdrawals onto the environment. A + * caller still passing the key reads the environment. * - * Reverse verification (one-shot, recorded in the PR): restoring an - * organization row to the served-row lookup (`findServedOverlayRow` with its - * `organization_id` predicate removed) turns §1's item-read pin red; removing - * the sealed arm from `declinesStoredRow` turns §2 red. + * Reverse verification (one-shot, recorded in the PR): removing the sealed arm + * from `declinesStoredRow` turns §2 red. */ import { afterEach, describe, expect, it, vi } from 'vitest'; import { @@ -132,11 +129,10 @@ function harness(seed: Record, artifacts: Artifact[] = []) { return { protocol, tables, finds }; } -const metaRow = (type: string, name: string, org: string | null, body: Row, over: Row = {}): Row => ({ - id: `r_${type}_${name}_${org ?? 'env'}_${String(over.state ?? 'active')}`, +const metaRow = (type: string, name: string, body: Row, over: Row = {}): Row => ({ + id: `r_${type}_${name}_env_${String(over.state ?? 'active')}`, type, name, - organization_id: org, package_id: null, state: 'active', version: 1, @@ -150,59 +146,37 @@ const asCaller = (request: R, caller: object) => ({ ...request afterEach(() => vi.restoreAllMocks()); -describe('§1 [ADR-0131 D6] a legacy organization row is invisible to every read, whatever organization the caller names', () => { +describe('§1 [ADR-0131 D6] every read serves the environment\'s rows, whatever organization the caller names', () => { for (const type of TIER_A) { const seed = () => ({ - sys_metadata: [ - metaRow(type, 'shared', null, { label: 'env' }), - metaRow(type, 'shared', ORG, { label: 'org', _lock: 'full' }), - metaRow(type, 'org_only', ORG, { label: 'org only' }), - metaRow(type, 'org_draft', ORG, { label: 'org draft' }, { state: 'draft' }), - ], - sys_metadata_history: [ - { - id: 'h_org', type, name: 'org_only', organization_id: ORG, version: 1, event_seq: 1, - operation_type: 'put', metadata: JSON.stringify({ name: 'org_only' }), checksum: 'x', - recorded_at: '2026-01-01T00:00:00.000Z', - }, - ], + sys_metadata: [metaRow(type, 'shared', { label: 'env' })], sys_metadata_audit: [ - { id: 'a_env', type, name: 'shared', organization_id: null, operation: 'save', outcome: 'allowed', code: 'ok' }, - { id: 'a_org', type, name: 'shared', organization_id: ORG, operation: 'save', outcome: 'allowed', code: 'ok' }, + { id: 'a_env', type, name: 'shared', operation: 'save', outcome: 'allowed', code: 'ok' }, ], sys_metadata_commit: [ - { id: 'c_env', package_id: PKG, organization_id: null, operation: 'apply', items: '[]', item_count: 0 }, - { id: 'c_org', package_id: PKG, organization_id: ORG, operation: 'apply', items: '[]', item_count: 0 }, + { id: 'c_env', package_id: PKG, operation: 'apply', items: '[]', item_count: 0 }, ], }); for (const caller of CALLERS) { const who = 'organizationId' in caller ? `a caller naming ${ORG}` : 'a caller naming none'; - it(`${type} · ${who}: item, list, layered, history, diff, audit, drafts, commits, lock and ETag`, async () => { + it(`${type} · ${who}: item, list, layered, audit, drafts, commits, lock and ETag`, async () => { const { protocol } = harness(seed()); - // item: the environment's row, its lock; the org-only name answers nothing + // item: the environment's row, its lock const shared = await protocol.getMetaItem(asCaller({ type, name: 'shared' }, caller)); expect(shared.item?.label).toBe('env'); expect(shared.lock).toBe('none'); - expect((await protocol.getMetaItem(asCaller({ type, name: 'org_only' }, caller))).item).toBeUndefined(); // list const list = await protocol.getMetaItems(asCaller({ type }, caller)); const listed = (list.items as Row[]).map((i) => `${String(i.name)}=${String(i.label)}`); expect(listed).toEqual(['shared=env']); - // layered: overlayScope is 'env' or null, never 'org' + // layered: overlayScope is 'env', never 'org' const layeredShared = await protocol.getMetaItemLayered(asCaller({ type, name: 'shared' }, caller)); expect({ label: layeredShared.overlay?.label, scope: layeredShared.overlayScope, lock: layeredShared.lock }) .toEqual({ label: 'env', scope: 'env', lock: 'none' }); - const layeredOrgOnly = await protocol.getMetaItemLayered(asCaller({ type, name: 'org_only' }, caller)); - expect([layeredOrgOnly.overlay, layeredOrgOnly.overlayScope]).toEqual([null, null]); - - // history and diff: the legacy organization's change log is not read - expect((await protocol.historyMetaItem(asCaller({ type, name: 'org_only' }, caller))).events).toEqual([]); - const diff = await settle(protocol.diffMetaItem(asCaller({ type, name: 'org_only' }, caller))); - expect(JSON.stringify(diff)).not.toContain('org_only"}'); // audit const audit = await protocol.auditMetaItem(asCaller({ type, name: 'shared' }, caller)); @@ -226,15 +200,6 @@ describe('§1 [ADR-0131 D6] a legacy organization row is invisible to every read } }); -/** A promise's value, or the error it rejected with. */ -async function settle(p: Promise): Promise { - try { - return await p; - } catch (e) { - return e as Error; - } -} - describe('§2 [triage ruling Q1 → C] an environment overlay of sealed managed content is not served: the package definition wins', () => { const packaged = (type: string, name: string): Artifact => ({ type, name, body: { name, label: 'packaged', _packageId: PKG }, @@ -243,7 +208,7 @@ describe('§2 [triage ruling Q1 → C] an environment overlay of sealed managed for (const type of ['action', 'hook'] as const) { it(`a managed ${type}: the item, list and layered reads serve the package's definition, not the hatch-written row`, async () => { const { protocol } = harness( - { sys_metadata: [metaRow(type, 'managed', null, { label: 'hatch-written' })] }, + { sys_metadata: [metaRow(type, 'managed', { label: 'hatch-written' })] }, [packaged(type, 'managed')], ); expect(protocol.declinesStoredRow(type, 'managed')).toBe(true); @@ -259,7 +224,7 @@ describe('§2 [triage ruling Q1 → C] an environment overlay of sealed managed }); it(`control: an environment ${type} no managed package ships is served from its row`, async () => { - const { protocol } = harness({ sys_metadata: [metaRow(type, 'authored', null, { label: 'authored' })] }); + const { protocol } = harness({ sys_metadata: [metaRow(type, 'authored', { label: 'authored' })] }); expect(protocol.declinesStoredRow(type, 'authored')).toBe(false); expect((await protocol.getMetaItem({ type, name: 'authored' })).item?.label).toBe('authored'); }); @@ -267,7 +232,7 @@ describe('§2 [triage ruling Q1 → C] an environment overlay of sealed managed it('control: a regime-O type keeps its overlay of a packaged item (a view)', async () => { const { protocol } = harness( - { sys_metadata: [metaRow('view', 'managed', null, { label: 'overlay' })] }, + { sys_metadata: [metaRow('view', 'managed', { label: 'overlay' })] }, [packaged('view', 'managed')], ); expect(protocol.declinesStoredRow('view', 'managed')).toBe(false); @@ -276,14 +241,14 @@ describe('§2 [triage ruling Q1 → C] an environment overlay of sealed managed it('control: a fork of a packaged permission set keeps its own ruling and is still served', async () => { const { protocol } = harness( - { sys_metadata: [metaRow('permission', 'managed', null, { label: 'fork', objects: {} })] }, + { sys_metadata: [metaRow('permission', 'managed', { label: 'fork', objects: {} })] }, [packaged('permission', 'managed')], ); expect(protocol.declinesStoredRow('permission', 'managed')).toBe(false); }); }); -describe('§3 the boot report names both populations, per type, with their remedies', () => { +describe('§3 the boot report names the sealed overlays, per type, with their remedies', () => { async function boot(seed: Record, artifacts: Artifact[] = []) { const warns: string[] = []; vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => { warns.push(a.map(String).join(' ')); }); @@ -292,15 +257,13 @@ describe('§3 the boot report names both populations, per type, with their remed return { result, warns, finds }; } - it('one line per population: the legacy organization rows (the ceremony), the sealed overlays (the two remedies)', async () => { + it('one line: the sealed overlays (the two remedies)', async () => { const { warns } = await boot( { sys_metadata: [ - metaRow('view', 'org_grid', ORG, { label: 'org' }), - metaRow('dashboard', 'org_board', ORG, { label: 'org' }), - metaRow('action', 'managed', null, { label: 'hatch-written' }), - metaRow('hook', 'managed', null, { label: 'hatch-written' }), - metaRow('view', 'env_grid', null, { label: 'env' }), + metaRow('action', 'managed', { label: 'hatch-written' }), + metaRow('hook', 'managed', { label: 'hatch-written' }), + metaRow('view', 'env_grid', { label: 'env' }), ], }, [ @@ -309,12 +272,7 @@ describe('§3 the boot report names both populations, per type, with their remed ], ); - const legacy = warns.filter((w) => w.includes('[metadata_org_scoped_unserved]')); - expect(legacy).toHaveLength(1); - expect(legacy[0]).toContain('view×1 (org_grid@org_a)'); - expect(legacy[0]).toContain('dashboard×1 (org_board@org_a)'); - expect(legacy[0]).toContain('os migrate'); - expect(legacy[0]).toContain('Nothing is deleted or rewritten'); + expect(warns.filter((w) => w.includes('[metadata_org_scoped_unserved]'))).toEqual([]); const sealed = warns.filter((w) => w.includes('[metadata_sealed_overlay_unserved]')); expect(sealed).toHaveLength(1); @@ -325,31 +283,8 @@ describe('§3 the boot report names both populations, per type, with their remed expect(sealed[0]).not.toContain('env_grid'); }); - it('it reads no ledger row and no row body: the three ledgers are counted, sys_metadata yields the four columns the line prints', async () => { - const LEDGERS = ['sys_metadata_commit', 'sys_metadata_history', 'sys_metadata_audit']; - const { warns, finds } = await boot({ - sys_metadata: [metaRow('view', 'org_grid', ORG, { label: 'org' })], - sys_metadata_commit: [{ id: 'c1', organization_id: ORG, payload: 'body' }, { id: 'c2', organization_id: null }], - sys_metadata_history: [{ id: 'h1', organization_id: ORG, metadata: 'body' }], - sys_metadata_audit: [{ id: 'a1', organization_id: '' }], - }); - - const legacy = warns.filter((w) => w.includes('[metadata_org_scoped_unserved]')); - expect(legacy).toHaveLength(1); - expect(legacy[0]).toContain('view×1 (org_grid@org_a)'); - expect(legacy[0]).toContain('sys_metadata_commit×1, sys_metadata_history×1'); - expect(legacy[0]).not.toContain('sys_metadata_audit×'); - - // No ledger row is read at all… - expect(finds.filter((f) => LEDGERS.includes(f.object))).toEqual([]); - // …and the legacy sys_metadata read projects the printed columns, never `metadata`. - const legacyReads = finds.filter((f) => f.object === 'sys_metadata' && JSON.stringify(f.where ?? {}).includes('$ne')); - expect(legacyReads).toHaveLength(1); - expect(legacyReads[0].fields).toEqual(['type', 'name', 'organization_id', 'state']); - }); - it('silence on a store with neither population', async () => { - const { warns } = await boot({ sys_metadata: [metaRow('view', 'env_grid', null, { label: 'env' })] }); + const { warns } = await boot({ sys_metadata: [metaRow('view', 'env_grid', { label: 'env' })] }); expect(warns.filter((w) => w.includes('_unserved]'))).toEqual([]); }); @@ -358,8 +293,8 @@ describe('§3 the boot report names both populations, per type, with their remed vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => { warns.push(a.map(String).join(' ')); }); const { protocol } = harness({ sys_metadata: [ - metaRow('datasource', 'host_ds', null, { label: 'stored' }), - metaRow('datasource', 'pkg_ds', null, { label: 'stored' }), + metaRow('datasource', 'host_ds', { label: 'stored' }), + metaRow('datasource', 'pkg_ds', { label: 'stored' }), ], }); protocol.getServicesRegistry = () => new Map([['code-datasource-names', new Set(['host_ds'])]]); @@ -377,29 +312,18 @@ describe('§3 the boot report names both populations, per type, with their remed }); }); -describe('§4 [triage ruling Q3 A] the anonymous form doors\' legacy layer is the one read left that reaches a legacy organization', () => { +describe('§4 [ADR-0131 C7] the anonymous form doors\' legacy organization read is deleted', () => { const seed = () => ({ sys_metadata: [ - metaRow('view', 'intake', null, { label: 'env intake' }), - metaRow('view', 'intake', ORG, { label: 'org intake' }), - metaRow('dashboard', 'board', ORG, { label: 'org board' }), + metaRow('view', 'intake', { label: 'env intake' }), ], }); - it('a view list read with legacyFormOrganizationId prefers that organization\'s body', async () => { - const { protocol } = harness(seed()); - const list = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG }); - expect((list.items as Row[]).map((i) => i.label)).toEqual(['org intake']); - // …and nothing else reads it: the same list without the key, and with the retired key. - for (const caller of CALLERS) { - const plain = await protocol.getMetaItems(asCaller({ type: 'view' }, caller)); - expect((plain.items as Row[]).map((i) => i.label)).toEqual(['env intake']); - } - }); - - it('the key reaches no other type', async () => { - const { protocol } = harness(seed()); - const list = await protocol.getMetaItems({ type: 'dashboard', legacyFormOrganizationId: ORG }); - expect(list.items).toEqual([]); - }); + for (const type of ['view', 'dashboard'] as const) { + it(`a ${type} list read passing the deleted legacyFormOrganizationId key reads the environment`, async () => { + const { protocol } = harness(seed()); + const list = await protocol.getMetaItems({ type, legacyFormOrganizationId: ORG } as { type: string }); + expect((list.items as Row[]).map((i) => i.label)).toEqual(type === 'view' ? ['env intake'] : []); + }); + } }); diff --git a/packages/metadata-protocol/src/protocol.reassign-orphans-durability.test.ts b/packages/metadata-protocol/src/protocol.reassign-orphans-durability.test.ts index 8a71b9f282a..989556cabe5 100644 --- a/packages/metadata-protocol/src/protocol.reassign-orphans-durability.test.ts +++ b/packages/metadata-protocol/src/protocol.reassign-orphans-durability.test.ts @@ -63,7 +63,6 @@ interface MetaRow { id: string; type: string; name: string; - organization_id: string | null; package_id: string | null; } @@ -84,9 +83,9 @@ function makeEngine(rows: MetaRow[]) { const engine = { async find(table: string, opts?: { where?: Record; limit?: number }) { if (table !== 'sys_metadata') return []; - // This double implements ONE `where` shape — the env-wide filter - // `{ organization_id: null }` the producer passes (ADR-0131 D6: - // only env-wide orphans are adopted) — and NO bound, and REFUSES + // This double implements ONE `where` shape — the empty filter the + // producer passes (ADR-0131 D6/D7: every row is the environment's, + // so every orphan is adopted) — and NO bound, and REFUSES // anything else rather than answering it silently. Paging belongs // to tests that do not exist yet. A double looser than the engine // it stands in for converts a green suite into no suite at all @@ -98,10 +97,10 @@ function makeEngine(rows: MetaRow[]) { } const where = opts?.where ?? {}; const keys = Object.keys(where); - if (keys.length !== 1 || keys[0] !== 'organization_id' || where.organization_id !== null) { + if (keys.length !== 0) { throw new Error(`fake engine: unsupported where ${JSON.stringify(where)}`); } - // Every fixture row is env-wide, so the store IS the env-wide answer. + // The store IS the answer. return [...store.values()]; }, async update(_table: string, data: Record, opts: { where: Record }) { @@ -128,7 +127,6 @@ const orphan = (id: string): MetaRow => ({ id, type: 'object', name: `obj_${id}`, - organization_id: null, package_id: null, }); diff --git a/packages/metadata-protocol/src/protocol.scoped-list-fallback.test.ts b/packages/metadata-protocol/src/protocol.scoped-list-fallback.test.ts index 16b480c1178..f3bff94265b 100644 --- a/packages/metadata-protocol/src/protocol.scoped-list-fallback.test.ts +++ b/packages/metadata-protocol/src/protocol.scoped-list-fallback.test.ts @@ -22,17 +22,15 @@ * stand-in serves a slot the package seats and never seats one, so the * scoped list still lists only the items the package ships. * - * 1. The generated pin: every subset of five stored rows (env-wide and - * org-scoped, package-less and package A's, plus package B's env-wide - * row), every row order, with and without an organization, with and + * 1. The generated pin: every subset of three stored rows (package-less, + * package A's and package B's, all env-wide: ADR-0131 D6/D7, the family + * declares no organization column), every row order, with and without an organization, with and * without A's artifact. For packages A and B: when the package ships the * name (its artifact, or a row of its own in scope), the scoped list's * one slot serves the row an oracle written from the rule names, and * `getMetaItem` naming the package serves the same; when it ships * nothing, the scoped list has no slot for the name. - * 2. Named, both row orders: A's artifact beside the package-less row; with - * an organization, the organization's package-less row beside an env-wide - * row of A. + * 2. Named, both row orders: A's artifact beside the package-less row. * 3. Membership: a package-less row of a name the package does not ship * adds no slot to the scoped list, which lists the same names with and * without it, while the unscoped list still serves that row. @@ -58,7 +56,7 @@ const PKG_A = 'com.example.a'; const PKG_B = 'com.example.b'; const NAME = 'd_slot'; -type Scope = 'env-wide' | 'org-scoped'; +type Scope = 'env-wide'; interface RowSpec { scope: Scope; packageId: string | null; label: string } /** The stored rows the tables draw from. */ @@ -66,8 +64,6 @@ const ROWS = { envPackageless: { scope: 'env-wide', packageId: null, label: 'env-wide package-less row' }, envA: { scope: 'env-wide', packageId: PKG_A, label: 'env-wide row of A' }, envB: { scope: 'env-wide', packageId: PKG_B, label: 'env-wide row of B' }, - orgPackageless: { scope: 'org-scoped', packageId: null, label: 'org-scoped package-less row' }, - orgA: { scope: 'org-scoped', packageId: PKG_A, label: 'org-scoped row of A' }, } as const satisfies Record; type RowKey = keyof typeof ROWS; const ROW_KEYS = Object.keys(ROWS) as RowKey[]; @@ -76,7 +72,6 @@ interface StoredRow { id: string; type: string; name: string; - organization_id: string | null; package_id: string | null; state: string; metadata: string; @@ -93,7 +88,6 @@ function stored( id: `r_${key}_${state}_${name}`, type, name, - organization_id: spec.scope === 'org-scoped' ? ORG : null, package_id: spec.packageId, state, metadata: JSON.stringify({ name, label, _provenance: 'org', ...body }), @@ -239,7 +233,7 @@ describe('pin 1 (generated) — the scoped list\'s slot serves the row getMetaIt ([undefined, ORG] as const).flatMap((organizationId) => [false, true].map((artifact) => ({ rows, organizationId, artifact })))); - it('completeness: every subset of the five rows, with and without an organization and A\'s artifact; the fallback arrangements are in it', () => { + it('completeness: every subset of the three rows, with and without an organization and A\'s artifact; the fallback arrangements are in it', () => { expect(TABLE).toHaveLength(2 ** ROW_KEYS.length * 2 * 2); expect(new Set(ROW_KEYS.map((key) => ROWS[key].packageId))).toEqual(new Set([null, PKG_A, PKG_B])); // The arrangements this card is about: a package that ships the name @@ -298,22 +292,6 @@ describe('pin 2 — named arrangements, both row orders: the scoped slot is getM }); } } - for (const order of orders(['orgPackageless', 'envA'])) { - it(`[ADR-0131 D6] organization ${ORG} · the organization's legacy package-less row is not served over an env-wide row of A · row order ${order.join(', ')}`, async () => { - const protocol = harness(order.map((key) => stored(key))); - const slots = await scopedSlots(protocol, PKG_A, { organizationId: ORG }); - expect(slots.map((s) => s.label)).toEqual([ROWS.envA.label]); - expect((await byName(protocol, PKG_A, { organizationId: ORG })).item?.label).toBe(ROWS.envA.label); - }); - } - for (const order of orders(['orgA', 'envPackageless'])) { - it(`[ADR-0131 D6] organization ${ORG} · the organization's legacy row of A is not served over the env-wide package-less row · row order ${order.join(', ')}`, async () => { - const protocol = harness(order.map((key) => stored(key))); - const slots = await scopedSlots(protocol, PKG_A, { organizationId: ORG }); - expect(slots).toEqual([]); - expect((await byName(protocol, PKG_A, { organizationId: ORG })).item?.label).toBe(ROWS.envPackageless.label); - }); - } }); // ── 3. Membership ──────────────────────────────────────────────────────────── @@ -321,8 +299,7 @@ describe('pin 2 — named arrangements, both row orders: the scoped slot is getM describe('pin 3 — membership: a package-less row of a name the package does not ship adds no slot', () => { const OTHER = 'd_other'; for (const organizationId of [undefined, ORG]) { - for (const scopeKey of ['envPackageless', 'orgPackageless'] as const) { - if (scopeKey === 'orgPackageless' && organizationId === undefined) continue; + for (const scopeKey of ['envPackageless'] as const) { it(`${organizationId ? `organization ${organizationId}` : 'no organization'} · a ${ROWS[scopeKey].label} of a name A does not ship`, async () => { const shipped = [stored('envA', { name: 'd_rowed' })]; const items = [ARTIFACT_A]; @@ -334,11 +311,10 @@ describe('pin 3 — membership: a package-less row of a name the package does no expect(listedWithout).toEqual([`d_rowed@${PKG_A}`, `${NAME}@${PKG_A}`]); expect(names(await scopedList(withRow, PKG_A, scope))).toEqual(listedWithout); // The env-wide row is still served where it belongs: the - // unscoped list. [ADR-0131 D6] A legacy organization row is - // served by no list. + // unscoped list. const unscoped: any = await withRow.getMetaItems({ type: TYPE, ...scope }); expect((unscoped.items as any[]).filter((i) => i.name === OTHER).map((i) => i.label)) - .toEqual(scopeKey === 'orgPackageless' ? [] : [ROWS[scopeKey].label]); + .toEqual([ROWS[scopeKey].label]); }); } } @@ -348,10 +324,8 @@ describe('pin 3 — membership: a package-less row of a name the package does no describe('pin 4 — the MetadataService layer: a package-less row stands in for the package\'s runtime item', () => { const RUNTIME_A = { name: NAME, label: 'runtime item of A', _packageId: PKG_A }; - for (const scopeKey of ['envPackageless', 'orgPackageless'] as const) { - // [ADR-0131 D6] A legacy organization row stands in for nothing: the - // runtime item is served. - const served = scopeKey === 'orgPackageless' ? RUNTIME_A.label : ROWS[scopeKey].label; + for (const scopeKey of ['envPackageless'] as const) { + const served = ROWS[scopeKey].label; it(`a ${ROWS[scopeKey].label} of the name: the scoped slot serves ${served}, as getMetaItem naming A does`, async () => { const protocol = harness([stored(scopeKey)], { runtime: [RUNTIME_A] }); const scope = { organizationId: ORG }; @@ -436,9 +410,9 @@ describe('pin 6 — a stored view container of A: a package-less row of a name i describe('pin 7 — the lock: the scoped slot\'s lock family equals getMetaItem\'s envelope where the served row moved', () => { for (const lock of ['no-overlay', 'no-delete', 'full'] as const) { - for (const declaredOn of ['orgPackageless', 'envA'] as const) { - it(`[ADR-0131 D6] organization ${ORG} · env A served, the organization's legacy package-less row not · ${ROWS[declaredOn].label} declares ${lock}`, async () => { - const rows = (['orgPackageless', 'envA'] as const).map((key) => + for (const declaredOn of ['envPackageless', 'envA'] as const) { + it(`organization ${ORG} · env A served over the env-wide package-less row · ${ROWS[declaredOn].label} declares ${lock}`, async () => { + const rows = (['envPackageless', 'envA'] as const).map((key) => stored(key, { body: key === declaredOn ? { _lock: lock, _lockReason: `declared on ${key}` } : {} })); const protocol = harness(rows, { items: [ARTIFACT_A] }); const scope = { organizationId: ORG }; diff --git a/packages/metadata-protocol/src/protocol.stored-migration.test.ts b/packages/metadata-protocol/src/protocol.stored-migration.test.ts index 53c4fb6fb04..0a1cc59e086 100644 --- a/packages/metadata-protocol/src/protocol.stored-migration.test.ts +++ b/packages/metadata-protocol/src/protocol.stored-migration.test.ts @@ -40,7 +40,6 @@ interface Row { id: string; type: string; name: string; - organization_id: string | null; package_id: string | null; state: string; checksum: string | null; @@ -77,7 +76,6 @@ function makeStubEngine( 'sys_metadata', seedRows.map((r) => ({ id: `r_${++nextId}`, - organization_id: null, package_id: null, state: 'active', checksum: `sha256:seed_${nextId}`, @@ -278,20 +276,31 @@ describe('migrateStoredMetadata — apply (#4327)', () => { expect(metaRows(tables)[0]!.state).toBe('draft'); }); - it('walks every org, not just the env-wide bucket — and REPORTS the org-scoped residue it cannot rewrite', async () => { - // The walk is what this case is named for, and the walk is unchanged: - // both buckets are scanned. What changed is the org row's OUTCOME. - // - // [ADR-0131 D6] Environment metadata has no per-organization layer, so - // the pass never re-saves an org-scoped row (that would move it — the - // promotion ceremony's job, ADR-0131 C7). The row is NON-DESTRUCTIVE - // residue: reported `skipped` with the reason, its bytes untouched — - // which keeps this pass a SECOND residue detector alongside the - // cold-boot warn (PR #6600). - const { engine, tables } = makeStubEngine([ - legacyObjectRow, - { ...legacyActionRow, organization_id: 'org_a' }, - ]); + /** + * [ADR-0131 D6/D7] The real engine shapes every read to the declared + * columns, and the family declares no `organization_id`: no row the pass + * reads carries the key. On a database the ceremony has not migrated the + * physical column still holds the organization, so the stub's driver seam + * answers the catalog and the legacy select the way a v17-shape SQLite + * file does. The real-engine pin over such a file is in `@objectstack/cli` + * (`meta-stored-legacy-organization.integration.test.ts`). + */ + function withPhysicalOrganizationColumn(engine: any, legacy: Record): void { + engine.getDriverForObject = (objectName: string) => objectName !== 'sys_metadata' ? undefined : { + dialectName: 'sqlite', + async execute(sql: string) { + return /column_name/.test(sql) + ? [{ column_name: 'id' }, { column_name: 'organization_id' }] + : Object.entries(legacy).map(([id, organization_id]) => ({ id, organization_id })); + }, + }; + } + + it('a legacy organization row — told apart by the PHYSICAL column, not the engine row — is reported, never re-saved', async () => { + const { engine, tables } = makeStubEngine([legacyObjectRow, legacyActionRow]); + const actionRow = metaRows(tables).find((r) => r.type === 'action')!; + expect(actionRow).not.toHaveProperty('organization_id'); + withPhysicalOrganizationColumn(engine, { [actionRow.id]: 'org_a' }); const protocol = new ObjectStackProtocolImplementation(engine); const report = await protocol.migrateStoredMetadata({ apply: true }); @@ -302,12 +311,22 @@ describe('migrateStoredMetadata — apply (#4327)', () => { expect(report.failed).toBe(0); const orgReport = report.rows.find((r: any) => r.type === 'action')!; - expect(orgReport.outcome).toBe('skipped'); + expect(orgReport).toMatchObject({ outcome: 'skipped', organizationId: 'org_a' }); expect(orgReport.reason).toContain('promotion ceremony (ADR-0131 C7)'); // Non-destructive: the stored bytes are exactly as they were. - const orgRow = metaRows(tables).find((r) => r.organization_id === 'org_a')!; - expect(JSON.parse(orgRow.metadata).execute).toBe('convertHandler'); + const stored = metaRows(tables).find((r) => r.id === actionRow.id)!; + expect(JSON.parse(stored.metadata).execute).toBe('convertHandler'); + }); + + it('a driver seam that cannot answer refuses the pass before any row is written', async () => { + const { engine, tables } = makeStubEngine([legacyObjectRow]); + engine.getDriverForObject = () => ({ dialectName: 'sqlite', async execute() { return null; } }); + const protocol = new ObjectStackProtocolImplementation(engine); + const before = JSON.stringify(metaRows(tables)); + + await expect(protocol.migrateStoredMetadata({ apply: true })).rejects.toThrow(/legacy organization rows/); + expect(JSON.stringify(metaRows(tables))).toBe(before); }); it('leaves archived rows alone — they are a record of what was, not served metadata', async () => { diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 714de2edc70..b3351bb62d8 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -97,7 +97,7 @@ import { stripInjectedSystemColumns, // [#7774] The i18n-bundle identity table (#7730) and its reader. A type // listed here is identified by `(name, )`, so every merge in the - // unscoped `/meta` list keys on the pair — see {@link metaItemKey}. + // unscoped `/meta` list keys on the pair — see {@link mergePackageAwareOverlay}. ITEM_KEY_DISCRIMINATORS, itemDiscriminator, type MetadataItem, @@ -122,6 +122,9 @@ import { // the anonymous form doors read it — the runtime authoring gate's input for // its public-form intake advisory (see `tenancyPostureInForce()`). anonymousFormIntakePosture, + // [ADR-0131 D6/D7] A legacy organization's rows, off the physical column, + // on a database the ceremony has not migrated — see migrateStoredMetadata. + readLegacyOrganizationRows, } from '@objectstack/metadata-core'; // [#5532] One vocabulary of "which driver read errors are benign", shared with // `sys-metadata-repository.ts` in this package and with `DatabaseLoader` in @@ -2356,62 +2359,6 @@ function isShippedViewItem(artifact: unknown): boolean { return typeof viewKind === 'string' && viewKind !== ''; } -/** - * ADR-0048 (#1828) — composite dedup identity for the unscoped metadata list. - * - * Two installed packages may legitimately ship the same `type`/`name` - * (e.g. `page/home`); the SchemaRegistry already stores them under distinct - * `${packageId}:${name}` keys. Any list-merge that deduplicates by bare `name` - * collapses the two packages' rows into one (last-write-wins), which is the - * bug this key closes. A `NUL` separator keeps names containing `:` unambiguous. - * - * [#7774] `discriminator` is a third component, and it is OPTIONAL on purpose: - * omitting it produces the exact two-component string this function has always - * produced, so every type identified by `(package, name)` keeps a - * byte-identical key and this change's blast radius is provable rather than - * argued. It is supplied only for a type listed in `ITEM_KEY_DISCRIMINATORS` - * (`email_template` today), whose identity the spec declares as - * `(name, locale)` — an i18n bundle. Without it the second locale's - * `Map.set` overwrote the first and `GET /meta/email_template` served one - * member of a bundle it had every member of. - */ -function metaItemKey( - packageId: string | null | undefined, - name: unknown, - discriminator?: string, -): string { - const base = `${packageId ?? ''}\u0000${String(name)}`; - return discriminator === undefined ? base : `${base}\u0000${discriminator}`; -} - -/** - * [#7774] The bundle discriminator a STORED `sys_metadata` row declares, read - * off the row's serialized body. - * - * The row's own COLUMNS cannot answer this: `sys_metadata`'s overlay - * uniqueness is `(type, name, organization_id, package_id)` — declared on - * `idx_sys_metadata_overlay_active` in `sys-metadata.object.ts` — and the - * table has no locale column at all. An `email_template` overlay's locale - * lives inside the `metadata` JSON payload, which is where its identity is. - * So a row-level merge keying on columns alone cannot tell two members of one - * bundle apart, and the body has to be consulted. - * - * The parse is skipped entirely for an undiscriminated type (all but one - * today): `undefined` comes back before any JSON work, so this costs nothing - * on the paths it does not serve. A body that fails to parse falls back to the - * canonical member instead of throwing — the caller a few lines on parses it - * again for real, and that failure is the one that should surface. - */ -function storedRowDiscriminator(type: string, record: unknown): string | undefined { - if (!ITEM_KEY_DISCRIMINATORS[type]) return undefined; - const raw = (record as { metadata?: unknown } | null | undefined)?.metadata; - let body: unknown = raw; - if (typeof raw === 'string') { - try { body = JSON.parse(raw); } catch { body = undefined; } - } - return itemDiscriminator(type, body); -} - /** * [#22877] The by-name doors' bundle member selector: the request member, and * the `?member=` query parameter in each REST route's closed set (ruling @@ -2555,7 +2502,7 @@ function mutationEventMemberOfBody(type: string, body: unknown): { member?: stri * no package). */ interface ServedOverlayRowCandidate { - readonly scope: 'org' | 'env'; + readonly scope: 'env'; readonly organizationId: string | null; readonly spelling: 'canonical' | 'other'; readonly packageId: string | null | undefined; @@ -2577,14 +2524,12 @@ interface StoredRowPlace { * The first candidate that holds a row is the served row. * * - Scope (ADR-0131 D6): the environment's rows only. A legacy - * organization-scoped row is reported at boot - * ({@link ObjectStackProtocolImplementation.reportUnhydratableOrgScopedRows}) - * and never served. The ONE exception is `legacyOrganizationId`, which only - * the anonymous form doors' read of the Default Organization's legacy - * layer passes (through the list read): that legacy organization's rows, - * then the environment's — kept fail-closed until the v18 migration - * ceremony carries those rows into the environment layer and deletes the - * read with them. + * organization-scoped row is refused at boot + * (the v18 boot refusal, ADR-0131 D10 item 5) + * and never served, by any read: the anonymous form doors' read of the + * Default Organization's legacy layer was deleted when the v18 migration + * ceremony began carrying those rows' withdrawals into the environment + * (ADR-0131 C7, rulings A on #22008 and #22011). * - Then the spelling: the canonical type, then the other spelling (the * at-rest tolerance the reads have always had, #4432). * - Then the package (ADR-0048 prefer-local): with a `packageId`, that @@ -2605,12 +2550,9 @@ interface StoredRowPlace { * scope (`resolveOverlayLockLayer` in `item-lock.ts`, #21761). */ function servedOverlayRowCandidates(address: { - readonly legacyOrganizationId?: string; readonly packageId: string | undefined; }): ServedOverlayRowCandidate[] { - const scopes: Array> = address.legacyOrganizationId - ? [{ scope: 'org', organizationId: address.legacyOrganizationId }, { scope: 'env', organizationId: null }] - : [{ scope: 'env', organizationId: null }]; + const scopes: Array> = [{ scope: 'env', organizationId: null }]; const packages: Array = address.packageId ? [address.packageId, null] : [undefined]; return scopes.flatMap((scope) => (['canonical', 'other'] as const).flatMap((spelling) => packages.map((packageId) => ({ ...scope, spelling, packageId })))); @@ -2689,8 +2631,7 @@ function servedViewExpansion( * • [#21804] When `records` are stored rows the caller read (`rowsRead`, * each record's `stored` place), the winner among them is the one * {@link servedOverlayRowCandidates} picks for `P`: `P`'s own row before - * the package-less one (the form doors' legacy organization row before - * the environment's, where they read that layer). It + * the package-less one. It * is the resolution `getMetaItem` takes, so it does not depend on the * order the store returned the rows in. Without `rowsRead` (the * MetadataService merge, whose `records` are already-resolved items), @@ -2738,7 +2679,7 @@ function mergePackageAwareOverlay( baseItems: unknown[], records: Array<{ data: unknown; packageId: string | undefined; stored?: StoredRowPlace; standIn?: boolean }>, transform?: (data: any, prev: any) => any, - rowsRead?: { readonly legacyOrganizationId?: string }, + rowsRead?: Readonly>, unseated?: WeakSet, ): unknown[] { // Per-SLOT, layer-ordered contributions; `pkg: undefined` = package-less. @@ -2762,7 +2703,7 @@ function mergePackageAwareOverlay( // `real` among a slot's contributions: the first candidate of // {@link servedOverlayRowCandidates} that one of the rows sits at. const servedStoredRow = (list: readonly Contribution[], real: string | undefined): any => { - for (const candidate of servedOverlayRowCandidates({ legacyOrganizationId: rowsRead?.legacyOrganizationId, packageId: real })) { + for (const candidate of servedOverlayRowCandidates({ packageId: real })) { const hit = list.find((c) => c.stored !== undefined && c.stored.organizationId === candidate.organizationId && (c.stored.type === type ? 'canonical' : 'other') === candidate.spelling @@ -7250,12 +7191,12 @@ export class ObjectStackProtocolImplementation implements let rows: Record[]; try { // [ADR-0131 D6] The environment's rows only: a legacy - // organization-scoped row is reported at boot, never read - // ({@link reportUnhydratableOrgScopedRows}). + // organization-scoped row is refused at boot, never read + // (the v18 boot refusal, ADR-0131 D10 item 5). const read = async (type: string): Promise[]> => { // [#21911] The explicit system opt-in — see findServedOverlayRow. - const rs = await this.engine.find('sys_metadata', { - where: { type, state: 'active', organization_id: null }, + const rs = await this.findEnvironmentRows('sys_metadata', { + where: { type, state: 'active' }, context: { isSystem: true }, }); return (rs ?? []) as Record[]; @@ -7778,7 +7719,7 @@ export class ObjectStackProtocolImplementation implements // The row's OWN package binding, read from this replica's DB the // way the recovery callers derive it (#4867 / #4636) — the wire // carries no binding, and must not. - const packageId = await this.resolveOverlayPackageBinding(type, evt.name, orgId, evt.member); + const packageId = await this.resolveOverlayPackageBinding(type, evt.name, evt.member); await this.applyRegistryWriteThrough({ type, name: evt.name, @@ -7890,33 +7831,16 @@ export class ObjectStackProtocolImplementation implements /** * [ADR-0131 D6] THE repository every metadata read and write goes - * through: the environment's (`organization_id IS NULL`). One lazily - * built singleton under the `__env__` key. + * through: the environment's — since C7b the only scope `sys_metadata` + * has. One lazily built singleton under the `__env__` key. */ private getOverlayRepo(): SysMetadataRepository { - return this.repositoryFor(null); - } - - /** - * [ADR-0131 D6/D12] The repository of ONE legacy organization's rows, - * for the one caller left that touches them: the uninstall's removal of - * a package's legacy organization-scoped rows - * ({@link removeLegacyOrganizationRowOnUninstall}). ⛔ No read goes - * through it: a legacy organization-scoped row is reported at boot - * ({@link reportUnhydratableOrgScopedRows}), never served. - */ - private legacyOrganizationRepo(organizationId: string): SysMetadataRepository { - return this.repositoryFor(organizationId); - } - - private repositoryFor(organizationId: string | null): SysMetadataRepository { - const key = organizationId ?? '__env__'; + const key = '__env__'; let repo = this.overlayRepos.get(key); if (!repo) { repo = new SysMetadataRepository({ engine: this.engine as unknown as SysMetadataEngine, - organizationId, - orgLabel: organizationId ?? 'env', + orgLabel: 'env', }); this.overlayRepos.set(key, repo); } @@ -8518,9 +8442,8 @@ export class ObjectStackProtocolImplementation implements * path — that key never had a reader and was retired by #8586.) * * Extracted from {@link getMetaTypes} rather than copied: the listing and - * {@link reportUnhydratableOrgScopedRows} must answer "which types exist - * here" identically, or the audit accuses a type the listing does not - * admit exists (and vice versa). One accessor, no second vocabulary. + * every other caller must answer "which types exist here" identically. + * One accessor, no second vocabulary. */ private async listLiveMetadataTypes(): Promise { const schemaTypes = this.engine.registry.getRegisteredTypes(); @@ -9571,13 +9494,7 @@ export class ObjectStackProtocolImplementation implements ); } - /** - * `legacyFormOrganizationId` is not a request key of the spec's - * `GetMetaItemsRequest`: only the anonymous form doors pass it, for their - * read of the Default Organization's legacy `view` layer (triage ruling - * Q3 A; see {@link readFlattenedMetaItems}). - */ - async getMetaItems(request: { type: string; packageId?: string; previewDrafts?: boolean; legacyFormOrganizationId?: string }) { + async getMetaItems(request: { type: string; packageId?: string; previewDrafts?: boolean }) { // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. This one // is load-bearing twice over: the SchemaRegistry indexes code-authored // items under the SINGULAR type, and the overlay-hydration branch below @@ -9629,7 +9546,7 @@ export class ObjectStackProtocolImplementation implements * `request` arrives already folded by the calling face (#4432). */ private async readFlattenedMetaItems( - request: { type: string; packageId?: string; previewDrafts?: boolean; legacyFormOrganizationId?: string }, + request: { type: string; packageId?: string; previewDrafts?: boolean }, audience: 'served' | 'execution', ) { const { packageId } = request; @@ -9637,16 +9554,12 @@ export class ObjectStackProtocolImplementation implements // // The list serves the environment's stored rows over the code // packages' definitions, and no organization's: a legacy - // organization-scoped row is reported at boot - // ({@link reportUnhydratableOrgScopedRows}) and never served. The one - // exception is `legacyFormOrganizationId`, which only the anonymous - // form doors pass (triage ruling Q3 A): their read of a `view` keeps - // the Default Organization's legacy layer — that organization's body - // preferred, and a withdrawal in either layer closing the form — - // fail-closed until the v18 migration ceremony (ADR-0131 C7) carries - // those rows into the environment layer and deletes this read. Any - // other type reads the environment alone, whoever passes the key. - const orgId = canonicalMetaType(request.type) === 'view' ? request.legacyFormOrganizationId : undefined; + // organization-scoped row is refused at boot + // (the v18 boot refusal, ADR-0131 D10 item 5) and never served — the + // anonymous form doors included: their read of the Default + // Organization's legacy `view` layer was deleted when the v18 + // migration ceremony (ADR-0131 C7) began carrying its withdrawals into + // the environment, fail-closed. let items: unknown[] = []; // Unscoped kernels (control plane): read everything from SchemaRegistry. @@ -9690,9 +9603,8 @@ export class ObjectStackProtocolImplementation implements // was never surfaced whenever system-bridged items populated the // registry). Deduplicate against whatever the registry returned. // - // [ADR-0131 D6] The environment's rows (organization_id IS NULL); the - // anonymous form doors' legacy layer adds one legacy organization's - // rows, which win their slot (see the `orgId` binding above). + // [ADR-0131 D6/D7] The environment's rows: a legacy organization's are + // left out at the read ({@link readActiveOverlayRows}). // [#21761] Every active row of the type in this caller's scopes, // whichever package each is bound to: what each listed item's lock is // selected from ({@link overlayLockLayerAt}'s selection, below). A list @@ -9717,8 +9629,8 @@ export class ObjectStackProtocolImplementation implements // the view containers it expands through the same two calls, so // the two doors cannot disagree about which containers are in // scope for one caller. - const records = await this.readActiveOverlayRows(request, orgId); - lockRows = packageId ? await this.readActiveOverlayRows({ type: request.type }, orgId) : records; + const records = await this.readActiveOverlayRows(request); + lockRows = packageId ? await this.readActiveOverlayRows({ type: request.type }) : records; const standInRows = packageId ? lockRows.filter((row) => (row?.package_id ?? null) === null) : []; if ((records && records.length > 0) || standInRows.length > 0) { const isView = (PLURAL_TO_SINGULAR[request.type] ?? request.type) === 'view'; @@ -9775,7 +9687,7 @@ export class ObjectStackProtocolImplementation implements return this.foldObjectExtendersFromRegistry( request.type, (data as { name?: unknown } | null)?.name, data, ); - }, { legacyOrganizationId: orgId }, unseated); + }, {}, unseated); // [#13407] Expand any aggregated `defineView` container this // READ just merged in, INLINE into this response's own `items` @@ -9788,7 +9700,7 @@ export class ObjectStackProtocolImplementation implements // those gates was rejected rather than attempted. This pass // has no such gate to worry about: `overlays` is already this // request's own correctly org/environment-scoped read (the - // `queryByOrg` merge above), so expanding it into the RETURNED + // `queryActive` read above), so expanding it into the RETURNED // list — and only the returned list — cannot leak one // org/environment's container into another's. // @@ -9993,30 +9905,32 @@ export class ObjectStackProtocolImplementation implements // process-wide registry or to non-preview reads. if (request.previewDrafts) { try { - const queryDrafts = async (oid: string | null, pkg: string | undefined): Promise => { - const whereClause: Record = { type: request.type, state: 'draft', organization_id: oid }; + const queryDrafts = async (pkg: string | undefined): Promise => { + const whereClause: Record = { type: request.type, state: 'draft' }; if (pkg) whereClause.package_id = pkg; // [#21911] The explicit system opt-in — see findServedOverlayRow. - let rs = await this.engine.find('sys_metadata', { where: whereClause, context: { isSystem: true } }); + // [ADR-0131 D6/D7] The environment's drafts only — see + // {@link findEnvironmentRows}. + let rs = await this.findEnvironmentRows('sys_metadata', { where: whereClause, context: { isSystem: true } }); if (!rs || rs.length === 0) { const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type]; if (alt) { - const altWhere: Record = { type: alt, state: 'draft', organization_id: oid }; + const altWhere: Record = { type: alt, state: 'draft' }; if (pkg) altWhere.package_id = pkg; - rs = await this.engine.find('sys_metadata', { where: altWhere, context: { isSystem: true } }); + rs = await this.findEnvironmentRows('sys_metadata', { where: altWhere, context: { isSystem: true } }); } } return rs ?? []; }; - // [ADR-0131 D6] The environment's drafts only, whoever asks. - const draftRecords = await queryDrafts(null, packageId); + // [ADR-0131 D6] The environment's drafts, whoever asks. + const draftRecords = await queryDrafts(packageId); // [#21817] A list scoped to a package previews the package-less // drafts as stand-ins, as its active arm does: the // package-agnostic draft read, filtered back to the // package-less rows. The by-name preview arm naming the // package serves that draft when the package has none. const standInDraftRecords = packageId - ? (await queryDrafts(null, undefined)).filter((record) => (record?.package_id ?? null) === null) + ? (await queryDrafts(undefined)).filter((record) => (record?.package_id ?? null) === null) : []; if (draftRecords.length > 0 || standInDraftRecords.length > 0) { // ADR-0048 (#1828) — package-aware draft overlay (parity with @@ -10035,8 +9949,10 @@ export class ObjectStackProtocolImplementation implements // [#21804] The draft's place, as for the active rows: // a package's previewed slot is the draft the // by-name read's preview arm serves, in any row order. + // [ADR-0131 D6/D7] Every row read here is the + // environment's (filtered at the read). const stored = { - organizationId: (record as { organization_id?: string | null }).organization_id ?? null, + organizationId: null, type: String(record.type ?? request.type), }; return { data, packageId: recPkg, stored }; @@ -10278,9 +10194,10 @@ export class ObjectStackProtocolImplementation implements itemLock = resolveItemLock({ artifact: this.artifactLockLayerAt(address, shipping), overlay: await resolveOverlayLockLayer(address, (spelling) => + // [ADR-0131 D6/D7] Every row is the environment's + // (filtered at the read), so only the spelling selects. (lockRowsByName.get(itemName) ?? []).filter((row) => - (row.organization_id ?? null) === null - && row.type === (spelling === 'canonical' ? request.type : otherType)), { otherSpelling: true }), + row.type === (spelling === 'canonical' ? request.type : otherType)), { otherSpelling: true }), }); } // [#4513] Same governance as the single-item read — the list @@ -10301,13 +10218,64 @@ export class ObjectStackProtocolImplementation implements }; } + /** + * [ADR-0131 D6/D7] THE protocol seam for every read of a `sys_metadata` + * family table: the engine's read, without a legacy organization's rows. + * + * The family declares no organization column, so the engine can neither + * filter on it nor return it. On a database the organization-ownership + * ceremony has not migrated, the physical column still holds each legacy + * organization's rows ({@link readLegacyOrganizationRows}). `os serve` + * refuses such a database; the `os migrate` boots (the ceremony's own + * among them) read it, and none of their reads may serve, list, address, + * lock or bind one organization's row as the environment's. So every read + * goes through here: on a migrated or fresh database this costs one catalog + * read per process (the absent verdict is remembered), and a read that + * cannot answer throws. A read inside a transaction joins it + * (`context.transaction`). + * + * Two reads deliberately do NOT: {@link migrateStoredMetadata}, which must + * see a legacy row to report it, and {@link revertCommit}'s commit read, + * which refuses a legacy commit by name; each reads the column itself. + */ + private async findEnvironmentRows(table: string, query: { where: Record; context?: unknown; [key: string]: unknown }): Promise { + return this.withoutLegacyOrganizationRows(table, await this.engine.find(table, query as never), query.context); + } + + /** + * [ADR-0131 D6/D7] {@link findEnvironmentRows} for one row: the single-row + * read stays the common path; only when the row it returns is a legacy + * organization's is the address re-read for the environment's own row + * (`null` when there is none). + */ + private async findEnvironmentRow(table: string, query: { where: Record; context?: unknown; [key: string]: unknown }): Promise { + const row = await this.engine.findOne(table, query as never); + if (!row) return row ?? null; + const legacy = await this.legacyOrganizationRowsOf(table, query.context); + if (!legacy.has(String((row as { id?: unknown }).id))) return row; + const rows = (await this.engine.find(table, query as never)) as any[] | null | undefined; + return (rows ?? []).find((r) => !legacy.has(String(r?.id))) ?? null; + } + + private async withoutLegacyOrganizationRows(table: string, rows: R[] | null | undefined, context?: unknown): Promise { + if (!rows || rows.length === 0) return rows ?? []; + const legacy = await this.legacyOrganizationRowsOf(table, context); + return legacy.size === 0 ? rows : rows.filter((row) => !legacy.has(String((row as { id?: unknown }).id))); + } + + private legacyOrganizationRowsOf(table: string, context?: unknown): Promise> { + const transaction = (context as { transaction?: unknown } | undefined)?.transaction; + return readLegacyOrganizationRows( + (this.engine as { getDriverForObject?: (objectName: string) => unknown }).getDriverForObject?.(table), + table, + transaction !== undefined ? { transaction } : {}, + ); + } + /** * [#21442, ADR-0131 D6] The active `sys_metadata` rows a read of * `request.type` consults: the environment's rows, restricted to - * `request.packageId` when one is given — plus, for the anonymous form - * doors' read alone, the rows of the legacy organization - * `legacyOrganizationId` names (that organization's row wins its slot; - * read uncached, and retired with the v18 migration ceremony). Moved here unchanged from + * `request.packageId` when one is given. Moved here unchanged from * {@link readFlattenedMetaItems}, the list read, so the by-name read * ({@link resolveRowlessExpandedView}) selects the view containers it * expands by this same rule — ⛔ never a second selection rule, which would @@ -10318,24 +10286,24 @@ export class ObjectStackProtocolImplementation implements */ private async readActiveOverlayRows( request: { type: string; packageId?: string }, - legacyOrganizationId?: string, ): Promise { const { packageId } = request; - const queryByOrg = async (oid: string | null): Promise => { + const queryActive = async (): Promise => { const whereClause: Record = { type: request.type, state: 'active', - organization_id: oid, }; if (packageId) whereClause.package_id = packageId; // [#21911] The explicit system opt-in — see findServedOverlayRow. - let rs = await this.engine.find('sys_metadata', { where: whereClause, context: { isSystem: true } }); + // [ADR-0131 D6/D7] The environment's rows only, on every find — + // see {@link findEnvironmentRows}. + let rs = await this.findEnvironmentRows('sys_metadata', { where: whereClause, context: { isSystem: true } }); if ((!rs || rs.length === 0)) { const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type]; if (alt) { - const altWhere: Record = { type: alt, state: 'active', organization_id: oid }; + const altWhere: Record = { type: alt, state: 'active' }; if (packageId) altWhere.package_id = packageId; - rs = await this.engine.find('sys_metadata', { where: altWhere, context: { isSystem: true } }); + rs = await this.findEnvironmentRows('sys_metadata', { where: altWhere, context: { isSystem: true } }); } } return rs ?? []; @@ -10344,7 +10312,7 @@ export class ObjectStackProtocolImplementation implements // // ⭐ The cache sits HERE, and its position IS the resolution of the // SchemaRegistry-hydration trap #11633 §4 names. What is cached is - // the ROW SET — the value the environment `queryByOrg` call produces — + // the ROW SET — the value the `queryActive` call produces — // never the merged answer below it. Everything downstream of this // point still runs on every call, hit or miss: the overlay parse, // the package-aware merge, `hydrateOverlayIntoRegistry`, the @@ -10382,7 +10350,7 @@ export class ObjectStackProtocolImplementation implements if (cachedRecords !== undefined) { records = cachedRecords as any[]; } else { - records = await queryByOrg(null); + records = await queryActive(); // ⭐ An EMPTY row set is cached too, and that is the main point // rather than an edge case: the empty result is what triggers the // alt-type retry above, so "no overlay rows for this type" is the @@ -10393,21 +10361,7 @@ export class ObjectStackProtocolImplementation implements this.engine, overlayCacheKey, overlayCacheEpoch, records, overlayCacheTtlMs, overlayCacheNow, ); } - if (!legacyOrganizationId) return records; - // The anonymous form doors' legacy layer (triage Q3 A, ADR-0131 C7): - // that organization's rows override the environment's on name - // collision. ADR-0048 (#1828) — key by (package, name), not bare name, - // so a package A row and a package B row of the same name do not - // collapse. [#7774] …and for a bundled type the slot is `(package, - // name, locale)`: an environment row and the organization's row may - // customize DIFFERENT members of one bundle. - const orgRecords = await queryByOrg(legacyOrganizationId); - const mergedMap = new Map(); - const rowKey = (r: any): string => - metaItemKey(r.package_id, r.name, storedRowDiscriminator(request.type, r)); - for (const r of records) mergedMap.set(rowKey(r), r); - for (const r of orgRecords) mergedMap.set(rowKey(r), r); - return Array.from(mergedMap.values()); + return records; } /** @@ -10438,12 +10392,13 @@ export class ObjectStackProtocolImplementation implements if (recPkg && data && typeof data === 'object' && (data as any)._packageId === undefined) { (data as any)._packageId = recPkg; } - // [#6602] The row's own scope travels with its body: the form - // doors' legacy layer merges a legacy organization's rows into - // the environment's, and the two are only distinguishable here. - const recOrg = (record as { organization_id?: string | null }).organization_id ?? null; + // [ADR-0131 D6/D7] Every row is the environment's: the family + // declares no organization column, and the reads that feed this + // ({@link readActiveOverlayRows}) leave a legacy organization's + // rows out off the physical column. (The form doors' legacy layer + // that once needed the row's own scope here is deleted.) return { - name: String(record.name), data, packageId: recPkg, organizationId: recOrg, + name: String(record.name), data, packageId: recPkg, organizationId: null, type: String(record.type ?? request.type), }; }); @@ -10592,8 +10547,9 @@ export class ObjectStackProtocolImplementation implements * reported that package's row while the gate bound whichever row * `findOne` returned first. * - * The environment's rows only (`organization_id` null, ADR-0131 D6): a - * legacy organization-scoped row is reported at boot and never served. + * The environment's rows only (ADR-0131 D6/D7): a legacy organization's + * row, on a database the ceremony has not migrated, is left out by + * {@link findEnvironmentRow}, off the physical column, and never served. * ADR-0048 prefer-local: with a `packageId`, that package's row first and * then the package-less row, never another package's; with none, any row. * @@ -10628,7 +10584,7 @@ export class ObjectStackProtocolImplementation implements const type = candidate.spelling === 'canonical' ? args.type : other; if (type === undefined) continue; const where: Record = { - type, name: args.name, state: args.state, organization_id: candidate.organizationId, + type, name: args.name, state: args.state, // [#22877] A by-name read of a bundled type answers the member // its selector named, else its CANONICAL member, as the // registry's bare-name read does; nothing for every other type. @@ -10638,9 +10594,9 @@ export class ObjectStackProtocolImplementation implements // [#21911, ADR-0096] The explicit system opt-in: a platform store // read, which no caller's grants scope — the door that asked // already authorized the caller, and the protocol scopes the row - // itself (`organization_id` above). It no longer reaches the data - // engine as a principal-less context. - const row = await this.engine.findOne('sys_metadata', { where, context: { isSystem: true } }); + // itself ({@link findEnvironmentRow}: the environment's rows only). + // It no longer reaches the data engine as a principal-less context. + const row = await this.findEnvironmentRow('sys_metadata', { where, context: { isSystem: true } }); if (row) return { row }; } return undefined; @@ -10676,8 +10632,8 @@ export class ObjectStackProtocolImplementation implements const type = spelling === 'canonical' ? address.type : other; if (type === undefined) return []; // [#21911] The explicit system opt-in — see findServedOverlayRow. - const rows: StoredOverlayRow[] | null | undefined = await this.engine.find('sys_metadata', { - where: { type, name: address.name, state: 'active', organization_id: null }, + const rows: StoredOverlayRow[] | null | undefined = await this.findEnvironmentRows('sys_metadata', { + where: { type, name: address.name, state: 'active' }, context: { isSystem: true }, }); return rows ?? []; @@ -10799,8 +10755,8 @@ export class ObjectStackProtocolImplementation implements let item: unknown; // [ADR-0131 D6] Environment → code: the environment's stored row, // else the code packages' definition. No organization's row is read — - // a legacy organization-scoped row is reported at boot - // ({@link reportUnhydratableOrgScopedRows}) and never served. + // a legacy organization-scoped row is refused at boot + // (the v18 boot refusal, ADR-0131 D10 item 5) and never served. // Studio's editor opens a draft buffer with `state: 'draft'`; // runtime loaders omit it and get the live published row. const readState: 'active' | 'draft' = request.state === 'draft' ? 'draft' : 'active'; @@ -10914,7 +10870,7 @@ export class ObjectStackProtocolImplementation implements } // 1. Customization overlay lookup (sys_metadata): the environment's - // row (organization_id IS NULL, ADR-0131 D6) before falling + // row (ADR-0131 D6/D7: {@link findEnvironmentRow}) before falling // through to the in-memory registry / MetadataService. // ADR-0048 prefer-local (a package id prefers that // package's row, then the package-less one, mirroring @@ -11740,8 +11696,9 @@ export class ObjectStackProtocolImplementation implements * unprovisioned table. The `/audit` route's existing * `handleRouteError` turns it into an honest 5xx. * - * [ADR-0131 D6] The environment's rows (`organization_id IS NULL`) only, - * enforced in the query below. + * [ADR-0131 D6/D7] The environment's rows only: the trail table declares no + * organization column, and a legacy organization's rows are left out off + * the physical column ({@link findEnvironmentRows}). */ async auditMetaItem(request: { type: string; @@ -11845,12 +11802,11 @@ export class ObjectStackProtocolImplementation implements return { events: [] }; } try { - // The environment's rows: every write stamps - // `organization_id: null` (ADR-0131 D6). + // Every row is the environment's (ADR-0131 D6; no organization + // column since C7b). const where: Record = { type: singular, name: request.name, - organization_id: null, }; // `order`, NOT `direction`: the QueryAST sort shape is // `SortNodeSchema` = `{ field, order }`, and both drivers normalize @@ -11865,7 +11821,7 @@ export class ObjectStackProtocolImplementation implements // trail is a platform store the audit door already authorized the // caller to read; the protocol builds the `where` itself. // Principal-less, the engine refuses it and this door answered 503. - const rows = await this.engine.find('sys_metadata_audit', { + const rows = await this.findEnvironmentRows('sys_metadata_audit', { where, orderBy: [{ field: 'occurred_at', order: 'desc' }], limit, @@ -12103,9 +12059,8 @@ export class ObjectStackProtocolImplementation implements * refuses an org-scoped write of every type (ADR-0131 D6), on every * minting path, so the only org-scoped `object` rows that can * exist are residue written before that gate (#6190's ruling 2 = A: - * handled non-destructively — made audible by - * {@link reportUnhydratableOrgScopedRows} and disposed of operationally, - * NOT rewritten by a migration). Fail-closed stays the right answer for + * handled non-destructively — carried by the v18 ceremony, and a + * database still holding them refused at boot, ADR-0131 D10 item 5). Fail-closed stays the right answer for * those: the registry entry is genuinely absent, and serving the table * would serve one org's rows to every org. Pinned by * `protocol.org-scoped-write-refused.test.ts`, which keeps `object` as @@ -17920,7 +17875,7 @@ export class ObjectStackProtocolImplementation implements private async storedFlowBindingAgrees(name: string, packageId: string): Promise { try { // [#21911] The explicit system opt-in — see findServedOverlayRow. - const row = await this.engine.findOne('sys_metadata', { + const row = await this.findEnvironmentRow('sys_metadata', { where: { type: 'flow', name, package_id: packageId }, context: { isSystem: true }, }); @@ -18633,7 +18588,6 @@ export class ObjectStackProtocolImplementation implements // canonical, and a fold here would make the assert unfalsifiable. type: entry.type, name: entry.name, - organization_id: null, operation: entry.operation, outcome: entry.outcome, code: entry.code, @@ -19040,8 +18994,8 @@ export class ObjectStackProtocolImplementation implements let rows: Array<{ package_id?: unknown }> | null | undefined; try { // [#21911] The explicit system opt-in — see findServedOverlayRow. - rows = await this.engine.find('sys_metadata', { - where: { type, name, state, organization_id: null, ...overlayMemberWhere(type, member) }, + rows = await this.findEnvironmentRows('sys_metadata', { + where: { type, name, state, ...overlayMemberWhere(type, member) }, context: { isSystem: true }, }); } catch (error) { @@ -19439,8 +19393,9 @@ export class ObjectStackProtocolImplementation implements * * ── [#6602] THE ROW-SCOPE GATE LIVES HERE, AND ITS ARGUMENT IS REQUIRED ── * - * ADR-0005 (revised 2026-05): **only env-wide rows - * (`organization_id IS NULL`) enter the process-wide SchemaRegistry.** + * ADR-0005 (revised 2026-05): **only env-wide rows enter the process-wide + * SchemaRegistry** (ADR-0131 D7: a legacy organization's row is left out + * at the read, {@link findEnvironmentRows}). * Per-org overlays are served on demand by `getMetaItem` / * `getMetaItems` and never grafted into the shared registry, because that * registry has exactly one plain key per `(type, name)` and no org @@ -20689,8 +20644,8 @@ export class ObjectStackProtocolImplementation implements let storedRows: Array> | null | undefined; try { // [#21911] The explicit system opt-in — see findServedOverlayRow. - storedRows = await this.engine.find('sys_metadata', { - where: { type: canonical, name, state: 'active', organization_id: null, ...overlayMemberWhere(canonical, member) }, + storedRows = await this.findEnvironmentRows('sys_metadata', { + where: { type: canonical, name, state: 'active', ...overlayMemberWhere(canonical, member) }, context: { isSystem: true }, }); } catch (error) { @@ -20928,7 +20883,6 @@ export class ObjectStackProtocolImplementation implements private async resolveOverlayPackageBinding( type: string, name: string, - organizationId: string | null, /** * [#22877] The bundle member whose row's binding to read; omitted, the * canonical member. Nothing for a type with no members. @@ -20936,11 +20890,10 @@ export class ObjectStackProtocolImplementation implements member?: string, ): Promise { // [#21911] The explicit system opt-in — see findServedOverlayRow. - const row = await this.engine.findOne('sys_metadata', { + const row = await this.findEnvironmentRow('sys_metadata', { where: { type, name, - organization_id: organizationId, state: 'active', ...overlayMemberWhere(type, member), }, @@ -21205,7 +21158,7 @@ export class ObjectStackProtocolImplementation implements try { // [#21908, ADR-0096 D5] The explicit system opt-in: an existence // probe of the platform store, never a read on a caller's behalf. - const row = await this.engine.findOne('sys_metadata', { where: { type }, context: { isSystem: true } }); + const row = await this.findEnvironmentRow('sys_metadata', { where: { type }, context: { isSystem: true } }); return row != null; } catch (error) { this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); @@ -21276,7 +21229,7 @@ export class ObjectStackProtocolImplementation implements try { // [ADR-0131 D6] A save lands environment-wide, so the pages it meets // are the environment-wide rows. - records = await this.readActiveOverlayRows({ type }, undefined); + records = await this.readActiveOverlayRows({ type }); } catch (error) { this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); } @@ -21506,7 +21459,7 @@ export class ObjectStackProtocolImplementation implements try { // [ADR-0131 D6] A save lands environment-wide, so its siblings are // the environment-wide rows. - records = await this.readActiveOverlayRows({ type }, undefined); + records = await this.readActiveOverlayRows({ type }); } catch (error) { // [#5532] The readers' rule: only an unprovisioned store means "no // rows". Any other failure is not answered as "no sibling". @@ -23207,8 +23160,10 @@ export class ObjectStackProtocolImplementation implements * * ## What it walks * - * `active` and `draft` rows, every org (the env-wide `organization_id IS - * NULL` bucket included). `archived` / `deprecated` rows are deliberately + * `active` and `draft` rows. On a database the organization-ownership + * ceremony has not migrated, a legacy organization's row is told apart by + * its physical `organization_id` ({@link readLegacyOrganizationRows}) and + * reported, never re-saved (below). `archived` / `deprecated` rows are deliberately * not read: they are not served metadata, and rewriting them would edit a * record of what *was*. `sys_metadata_history` is untouched for the same * reason the addendum gives — converting a version body would break the @@ -23400,6 +23355,16 @@ export class ObjectStackProtocolImplementation implements // door already authorized an operator to scan the whole store. rows.push(...await this.engine.find('sys_metadata', { where: { state }, context: { isSystem: true } })); } + // [ADR-0131 D6/D7] The family declares no organization column, so the + // rows above never carry one. On a database the organization-ownership + // ceremony has not migrated, the column is still on disk and a row with + // a value in it is an organization's: told apart here, off the physical + // column, so the arm below still skips it. `os serve` refuses such a + // database; `os migrate meta --stored` does not, which is why this pass + // reads the column itself. A read that cannot answer throws. + const legacyOrganizations = await readLegacyOrganizationRows( + (this.engine as { getDriverForObject?: (objectName: string) => unknown }).getDriverForObject?.('sys_metadata'), + ); for (const row of rows) { const rawType = String(row.type ?? ''); @@ -23424,7 +23389,7 @@ export class ObjectStackProtocolImplementation implements report.scanned++; const state: 'active' | 'draft' = row.state === 'draft' ? 'draft' : 'active'; - const organizationId: string | null = row.organization_id ?? null; + const organizationId: string | null = legacyOrganizations.get(String(row.id ?? '')) ?? null; const packageId: string | null = row.package_id ?? null; const base = { id: String(row.id ?? ''), @@ -24243,12 +24208,12 @@ export class ObjectStackProtocolImplementation implements // store read, which no caller's grants scope. The publish door // already authorized the caller, the request carries no // execution context to forward (only its `actor`), and the - // protocol scopes the row itself (`organization_id` above), + // protocol scopes the row itself ({@link findEnvironmentRow}), // exactly as its sibling store reads do. Principal-less, the // engine now refuses it. - draftRow = await this.engine.findOne('sys_metadata', { + draftRow = await this.findEnvironmentRow('sys_metadata', { where: { - type: singularType, name: request.name, organization_id: null, state: 'draft', + type: singularType, name: request.name, state: 'draft', // [#22877] The addressed member's draft (the canonical // one when none was named) — the one `repo.get` / // `repo.promoteDraft` below address. @@ -25010,8 +24975,9 @@ export class ObjectStackProtocolImplementation implements `Package '${request.packageId}'`, request, ); await this.ensureOverlayIndex(); - // Environment-wide drafts only: the environment repository lists - // `organization_id IS NULL` rows, so a legacy organization-scoped draft + // Environment-wide drafts only: the environment repository lists the + // environment's rows (a legacy organization's is left out off the + // physical column), so a legacy organization-scoped draft // is never promoted here (it waits for the promotion ceremony, // ADR-0131 C7). const repo = this.getOverlayRepo(); @@ -25314,9 +25280,9 @@ export class ObjectStackProtocolImplementation implements // [#22877] …and the draft's own bundle member: another // member's active row is not this member's pre-publish state, // and the revert plan records the member it describes. - const activeRow = (await this.engine.findOne('sys_metadata', { + const activeRow = (await this.findEnvironmentRow('sys_metadata', { where: { - organization_id: null, type: d.type, name: d.name, state: 'active', + type: d.type, name: d.name, state: 'active', ...overlayMemberWhere(d.type, d.member), }, context: { isSystem: true }, @@ -26000,7 +25966,7 @@ export class ObjectStackProtocolImplementation implements try { drafts = await this.getOverlayRepo().listDrafts({ packageId: request.packageId }); // [#21911] The explicit system opt-in — see findServedOverlayRow. - publishedRows = (await this.engine.find('sys_metadata', { + publishedRows = (await this.findEnvironmentRows('sys_metadata', { where: packageScopedRowWhere('active', { packageId: request.packageId }), limit: 1, context: { isSystem: true }, @@ -26111,7 +26077,7 @@ export class ObjectStackProtocolImplementation implements let rows: any[]; try { // [#21911] The explicit system opt-in — see findServedOverlayRow. - rows = (await this.engine.find('sys_metadata', { where, context: { isSystem: true } })) as any[]; + rows = (await this.findEnvironmentRows('sys_metadata', { where, context: { isSystem: true } })) as any[]; } catch (e) { // [#12536] …through the classifier: a marked application refusal // is not a store fault. See {@link metadataReadFailureError}. @@ -26194,23 +26160,19 @@ export class ObjectStackProtocolImplementation implements for (const row of ordered) { const state: 'active' | 'draft' = row.state === 'draft' ? 'draft' : 'active'; try { - if (row.organization_id) { - await this.removeLegacyOrganizationRowOnUninstall(row, state, request.actor); - } else { - await this.deleteMetaItem({ - type: row.type, - name: row.name, - state, - // [#23165, ADR-0048] The package being uninstalled - // owns this row, so the delete names it. Named no - // package, the delete of a name a second package also - // holds was a driver-order pick between the two rows - // (and is now refused). - packageId: request.packageId, - ...(request.actor ? { actor: request.actor } : {}), - ...(dropStorage ? { dropStorage: true } : {}), - }); - } + await this.deleteMetaItem({ + type: row.type, + name: row.name, + state, + // [#23165, ADR-0048] The package being uninstalled + // owns this row, so the delete names it. Named no + // package, the delete of a name a second package also + // holds was a driver-order pick between the two rows + // (and is now refused). + packageId: request.packageId, + ...(request.actor ? { actor: request.actor } : {}), + ...(dropStorage ? { dropStorage: true } : {}), + }); deleted.push({ type: row.type, name: row.name, state }); } catch (e: any) { // [#8136] NO filter here, deliberately, and this comment is why @@ -26299,54 +26261,6 @@ export class ObjectStackProtocolImplementation implements }; } - /** - * [ADR-0131 D6/D12] Remove ONE organization-scoped row stored before the - * per-organization overlay axis retired, as part of uninstalling the - * package it is bound to. The uninstall is environment-wide, so the - * package's legacy rows leave with it rather than being stranded for the - * promotion ceremony (ADR-0131 C7) to carry into the environment layer - * for a package that is gone. - * - * The one organization-scoped write left in this class, and it only - * REMOVES: reachable from {@link deletePackage} alone, never from a - * request (every write verb refuses an organization-scoped request, - * {@link organizationScopedWriteRefusal}). Through the repository, so the - * removal is transactional and leaves its history tombstone, like every - * other delete. A legacy row is presentational (only the five - * formerly org-overridable types could be stored per organization), so - * there is no table to tear down and no registry entry to retire — - * boot hydration never registered one. - */ - private async removeLegacyOrganizationRowOnUninstall( - row: { type: string; name: string; organization_id: string }, - state: 'active' | 'draft', - actor: string | undefined, - ): Promise { - const type = PLURAL_TO_SINGULAR[row.type] ?? row.type; - const repo = this.legacyOrganizationRepo(row.organization_id); - const ref = { type, name: row.name, org: row.organization_id } as Parameters[0]; - const current = await repo.get(ref, { state }); - if (!current) return; - await repo.delete(ref, { - parentVersion: current.hash, - actor: actor ?? null, - source: 'protocol.deletePackage', - intent: 'runtime-only', - state, - }); - await this.recordMetadataAudit({ - type, - name: row.name, - organizationId: null, - operation: 'delete', - outcome: 'allowed', - code: 'ok', - ...(actor ? { actor } : {}), - source: 'protocol.deletePackage', - note: `${state}; legacy organization-scoped row (organization '${row.organization_id}') removed with its package`, - }); - } - /** * ADR-0070 D4 — duplicate a writable base into a NEW package (the Airtable * "duplicate base" gesture). Clones every ACTIVE item the source owns into @@ -26506,10 +26420,9 @@ export class ObjectStackProtocolImplementation implements const where: Record = { package_id: request.sourcePackageId, state: 'active', - organization_id: null, }; // [#21911] The explicit system opt-in — see findServedOverlayRow. - const rows = (await this.engine.find('sys_metadata', { where, context: { isSystem: true } })) as any[]; + const rows = (await this.findEnvironmentRows('sys_metadata', { where, context: { isSystem: true } })) as any[]; // Rows other than objects and qualified view items (each below) are // renamed by the source-prefix swap alone; a name without the prefix @@ -27238,8 +27151,8 @@ export class ObjectStackProtocolImplementation implements try { for (let at = 0; at < names.length; at += 200) { // [#21911] The explicit system opt-in — see findServedOverlayRow. - const rows = (await this.engine.find('sys_metadata', { - where: { organization_id: null, name: { $in: names.slice(at, at + 200) } }, + const rows = (await this.findEnvironmentRows('sys_metadata', { + where: { name: { $in: names.slice(at, at + 200) } }, context: { isSystem: true }, })) as Array<{ type?: unknown; name?: unknown; package_id?: unknown }>; for (const row of rows ?? []) { @@ -27381,9 +27294,9 @@ export class ObjectStackProtocolImplementation implements // it would be an organization-scoped write; the promotion ceremony // (ADR-0131 C7) carries it to the environment layer, where a later // adoption sees it. - const where: Record = { organization_id: null }; + const where: Record = {}; // [#21911] The explicit system opt-in — see findServedOverlayRow. - const rows = (await this.engine.find('sys_metadata', { where, context: { isSystem: true } })) as any[]; + const rows = (await this.findEnvironmentRows('sys_metadata', { where, context: { isSystem: true } })) as any[]; const orphans = rows.filter( (r) => (r?.package_id == null || r.package_id === '' || r.package_id === 'sys_metadata') // [#23198] An overlay of an artifact item keeps its binding @@ -27574,8 +27487,6 @@ export class ObjectStackProtocolImplementation implements ...(args.eventSeqEnd !== undefined ? { event_seq_end: args.eventSeqEnd } : {}), items: JSON.stringify(args.items), item_count: args.items.length, - // [ADR-0131 D6/D7] The commit ledger is environment-level. - organization_id: null, created_at: new Date().toISOString(), }); return { commitId }; @@ -27702,20 +27613,19 @@ export class ObjectStackProtocolImplementation implements createdAt?: string; }>> { try { - // [ADR-0131 D6] The environment's timeline: `organization_id IS - // NULL`, whoever asks. A legacy organization-scoped commit row is - // reported at boot ({@link reportUnhydratableOrgScopedRows}) and + // [ADR-0131 D6] The environment's timeline, whoever asks. A legacy organization-scoped commit row is + // refused at boot (the v18 boot refusal, ADR-0131 D10 item 5) and // never served — the same rule as the legacy `sys_metadata` rows // it recorded, which no read serves either. So the timeline an // operator reverts from ({@link rollbackToPackageCommit} filters // this list) holds exactly the commits whose changes the reads // serve, and no organization's history is shown to another // organization's operator. - const where: Record = { package_id: request.packageId, organization_id: null }; + const where: Record = { package_id: request.packageId }; // [#21908, ADR-0096 D5] The explicit system opt-in: the commit ledger is a - // platform store the door already authorized; the protocol scopes the - // `where` by organization itself. Principal-less, the engine refuses it. - const rows = (await this.engine.find('sys_metadata_commit', { + // platform store the door already authorized. Principal-less, the + // engine refuses it. + const rows = (await this.findEnvironmentRows('sys_metadata_commit', { where, ...(request.limit ? { limit: request.limit } : {}), context: { isSystem: true }, @@ -27823,10 +27733,18 @@ export class ObjectStackProtocolImplementation implements } // [ADR-0131 D6] A commit recorded in a legacy organization's layer // describes organization-scoped rows; reverting it would write them. + // [ADR-0131 D7] The commit ledger declares no organization column, so + // the engine row never carries one: the commit's organization is read + // off the physical column, on a database the ceremony has not migrated. { + const legacyCommits = await readLegacyOrganizationRows( + (this.engine as { getDriverForObject?: (objectName: string) => unknown }) + .getDriverForObject?.('sys_metadata_commit'), + 'sys_metadata_commit', + ); const legacy = ObjectStackProtocolImplementation.organizationScopedWriteRefusal( `Commit '${request.commitId}'`, - row.organization_id, + legacyCommits.get(String(row.id)) ?? null, 'It records a change to that organization\'s legacy layer, which no write reaches any more.', ); if (legacy) throw legacy; @@ -28160,7 +28078,7 @@ export class ObjectStackProtocolImplementation implements // fallible query downstream of a write that already succeeded — // the shape that ends in a `catch {}` swallowing a real outage // (#4867). Per ITEM, because a batch mixes bindings. - const restorePackageId = await this.resolveOverlayPackageBinding(it.type, it.name, itemOrgId, member); + const restorePackageId = await this.resolveOverlayPackageBinding(it.type, it.name, member); const restored = await repo.restoreVersion(ref, restoreToVersion, { actor, source: 'protocol.revertCommit', @@ -28322,7 +28240,7 @@ export class ObjectStackProtocolImplementation implements // [#21908, ADR-0096 D5] The explicit system opt-in: the commit ledger is a // platform store the door already authorized; the protocol scopes the // `where` by organization itself. Principal-less, the engine refuses it. - const target = (await this.engine.findOne('sys_metadata_commit', { where, context: { isSystem: true } })) as any; + const target = (await this.findEnvironmentRow('sys_metadata_commit', { where, context: { isSystem: true } })) as any; if (!target) { const err: any = new Error(`No commit '${request.commitId}'.`); err.code = 'COMMIT_NOT_FOUND'; @@ -28521,7 +28439,7 @@ export class ObjectStackProtocolImplementation implements // it afterwards would put a fallible query downstream of a write that // already succeeded — the shape that ends in a `catch {}` swallowing a // real outage (#4867). - const rollbackPackageId = await this.resolveOverlayPackageBinding(singularType, request.name, orgId, member); + const rollbackPackageId = await this.resolveOverlayPackageBinding(singularType, request.name, member); try { const result = await repo.restoreVersion(ref, request.toVersion, { // #4556 — NULL, not 'system', for an actor-less rollback. @@ -28737,8 +28655,6 @@ export class ObjectStackProtocolImplementation implements if (expandedFrom !== undefined) { return this.diffMetaItem({ ...request, name: expandedFrom.container.name }); } - // [ADR-0131 D6] The environment's versions only. - const orgId = null; // [#8798] Read the history rows DIRECTLY, once. `historyMetaItem` // cannot serve this function: its `MetadataEvent` shape doesn't carry // the per-(type,name) `version` a diff selects versions by, so its @@ -28763,18 +28679,16 @@ export class ObjectStackProtocolImplementation implements // `protocol.diff-dead-history-read.test.ts`. const histRows: Array<{ version: number; body: Record | null }> = []; try { - const engineAny = this.engine as any; // [#21908, ADR-0096 D5] The explicit system opt-in: the history - // lineage is a platform store the diff door already authorized; - // the protocol scopes it by organization itself. Principal-less, + // lineage is a platform store the diff door already authorized. + // Principal-less, // the engine refuses it, and the `catch` below would have answered // an empty diff for an item that has history. // [#22877] The canonical member's lineage only — a by-name diff of a // bundled type compares the member a by-name read serves, never a // second locale's snapshot as its "earlier version". - const rows: any[] = historyRowsOfMember(singularType, await engineAny.find('sys_metadata_history', { + const rows: any[] = historyRowsOfMember(singularType, await this.findEnvironmentRows('sys_metadata_history', { where: { - organization_id: orgId, type: singularType, name: request.name, }, @@ -28867,9 +28781,9 @@ export class ObjectStackProtocolImplementation implements // bodies repeat earlier ones. // [#21908, ADR-0096 D5] The explicit system opt-in, as the history // read above. - const current = (await this.engine.findOne('sys_metadata', { + const current = (await this.findEnvironmentRow('sys_metadata', { where: { - organization_id: orgId, type: singularType, name: request.name, state: 'active', + type: singularType, name: request.name, state: 'active', // [#22877] The canonical member, as the by-name read serves. ...overlayMemberWhere(singularType), }, @@ -29517,7 +29431,6 @@ export class ObjectStackProtocolImplementation implements const scopedWhere: Record = { type: request.type, name: request.name, - organization_id: null, }; try { @@ -29526,7 +29439,7 @@ export class ObjectStackProtocolImplementation implements // authorized the caller to remove, addressed by the protocol's own // `scopedWhere` and then by id. Principal-less, the engine refuses // both. - const existing = await this.engine.findOne('sys_metadata', { where: scopedWhere, context: { isSystem: true } }); + const existing = await this.findEnvironmentRow('sys_metadata', { where: scopedWhere, context: { isSystem: true } }); if (!existing) { return { success: true, @@ -29637,9 +29550,9 @@ export class ObjectStackProtocolImplementation implements * * Per ADR-0005, environment-kernel mode ALSO hydrates from sys_metadata — * customization overlay rows must survive restart. The where-clause below - * is `{ state: 'active', organization_id: null }`: the environment's rows - * only (ADR-0131 D6 — a legacy organization-scoped row is served by no - * read, and {@link reportUnhydratableOrgScopedRows} names it). There is no + * is `{ state: 'active' }`: every row is the environment's (ADR-0131 + * D6/D7 — the family declares no organization column, and the runtime + * refuses to boot on a database still holding one). There is no * `environment_id` constraint — ADR-0005 (revised 2026-05) gave each * environment its own database. * @@ -29723,13 +29636,25 @@ export class ObjectStackProtocolImplementation implements // rows this registry takes. this.storeHydratedIntoRegistry = true; try { - // [ADR-0131 D6] Hydrate only the environment's rows - // (organization_id IS NULL); no read serves any other. + // [ADR-0131 D6/D7] The environment's rows only. The family declares + // no organization column, so the predicate cannot name it; on a + // database the ceremony has not migrated (an `os migrate` boot, the + // ceremony's own among them — `os serve` refuses one) a legacy + // organization's rows are left out off the physical column, never + // registered as the environment's ({@link findEnvironmentRows}). const where: Record = { state: 'active', - organization_id: null, }; - const records = await this.engine.find('sys_metadata', { where }); + const records = await this.findEnvironmentRows('sys_metadata', { where }); + // Asked even when no row came back: the verdict on a migrated or + // fresh store ("no column") is remembered for the process, so the + // reads and writes after boot — some inside a transaction — never + // need the raw seam there. + if (records.length === 0) { + await readLegacyOrganizationRows( + (this.engine as { getDriverForObject?: (objectName: string) => unknown }).getDriverForObject?.('sys_metadata'), + ); + } // [#22877] The table answered, so it is provisioned: give rows stored // before `bundle_member` existed their body's member now, before any // by-name read or write can address them. Hydration below reads the @@ -29879,7 +29804,8 @@ export class ObjectStackProtocolImplementation implements data, { packageId: (record as { package_id?: string | null }).package_id ?? undefined, - organizationId: (record as { organization_id?: string | null }).organization_id ?? null, + // [ADR-0131 D6/D7] Filtered at the read: every row here is the environment's. + organizationId: null, }, ); // [#20312] Reported AFTER the page has loaded, and only @@ -29934,11 +29860,11 @@ export class ObjectStackProtocolImplementation implements } } } - // [ADR-0131 D6] Say out loud which rows this load walked past: - // the sealed overlays above, and (#6190) every legacy - // organization-scoped row the filter skipped. + // [ADR-0131 D6] Say out loud which rows this load walked past: the + // sealed overlays above. (A legacy organization-scoped row cannot + // be here: since C7b the column is gone, and a database still + // holding one is refused at boot until the v18 ceremony has run.) this.reportSealedOverlayRows(sealedOverlays); - await this.reportUnhydratableOrgScopedRows(); } catch (e: unknown) { // #5841 — the ONE benign reason this whole read can fail is // `sys_metadata` not being provisioned yet: on a first boot, before @@ -30022,147 +29948,6 @@ export class ObjectStackProtocolImplementation implements ); } - /** - * [#6190, ADR-0131 D6] Say out loud which stored rows no read serves - * because they sit in a legacy organization's layer. - * - * Boot hydrates `organization_id IS NULL` rows only, and since ADR-0131 D6 - * every read is environment → code: no read, of any type, serves a row - * stored organization-scoped, and no write can store a new one - * ({@link organizationScopedWriteRefusal}). The rows stored before the - * per-organization overlay axis retired are left at rest — ⛔ nothing here - * deletes or rewrites one. The v18 migration ceremony (ADR-0131 D10, `os - * migrate`: plan → backup → apply → post-check) carries them and names - * each row's fate: the Default Organization's rows of the five - * presentational types are promoted to the environment layer, and a name - * another organization also holds is reported for the operator to choose. - * Until then this line is the operator's account of what is not served. - * - * Shape decisions, all deliberate: - * - * - **Every type.** The narrowing is type-blind, so the report is too: a - * view a tenant customised and a flow a tenant authored before #6190 - * are both unserved now. No registry is consulted, so a plugin type - * that is not live this boot is reported all the same. - * - **Active and draft rows** of `sys_metadata` — the listing of pending - * drafts no longer shows a legacy organization's drafts either — plus - * a count of the legacy rows of the three ledgers the timeline, - * history, diff and audit reads no longer show. - * - **One predicate, re-checked in JS.** A driver that cannot lower - * `$null: false` hands back a superset, and a superset must not become - * a false line. - * - **One aggregated line.** Counts per type plus a capped sample of - * names, so a tenant with a thousand such rows costs one line. - * - **Each read is its own term.** A read that fails for any reason but - * a missing table (an unprovisioned ledger holds no legacy row) costs - * its own term only: the line names it NOT MEASURED, with the reason, - * beside the terms that were measured. - * - **Best-effort, and non-fatal by construction.** A diagnostic must - * never be the reason a boot fails, so its own catch swallows: the - * caller's outer catch classifies REAL hydration outages - * (`storeUnavailable`, #5897) and this must not be able to reach it. - */ - private async reportUnhydratableOrgScopedRows(): Promise { - /** Names printed per type before the line collapses to a count. */ - const SAMPLE_PER_TYPE = 5; - try { - // A legacy row is one whose `organization_id` names an - // organization: neither NULL nor the empty string. - const LEGACY = { $and: [{ organization_id: { $null: false } }, { organization_id: { $ne: '' } }] }; - // An unprovisioned ledger holds no legacy row; any other failure is - // this diagnostic's own, and its term reports it (below). - const orNoTable = async (object: string, read: () => Promise, empty: T): Promise => { - try { - return await read(); - } catch (error) { - if (isMissingTableError(error, object)) return empty; - throw error; - } - }; - - // [#15206] Each of the four reads is its own term: one that fails - // costs its own term, never the others, and is printed NOT - // MEASURED with its reason — never a zero, never a silent line. - const unmeasured: string[] = []; - const isolated = async (object: string, read: () => Promise): Promise => { - try { - await read(); - } catch (error) { - unmeasured.push(`${object} (${error instanceof Error ? error.message : String(error)})`); - } - }; - - // Only the columns the line prints — never a row's body. The - // three ledgers below are counted, never read: this runs on every - // boot, and the populations it names can be large. - let rows: any[] = []; - await isolated('sys_metadata', async () => { - rows = (await orNoTable('sys_metadata', async () => ((await this.engine.find('sys_metadata', { - where: { state: { $in: ['active', 'draft'] }, ...LEGACY }, - fields: ['type', 'name', 'organization_id', 'state'], - context: { isSystem: true }, - })) ?? []) as any[], [] as any[])) - // A driver that cannot lower the predicate hands back a - // superset; the names this line prints are re-checked here. - .filter((row) => { - const org = (row as { organization_id?: string | null }).organization_id; - return org !== null && org !== undefined && org !== ''; - }); - }); - const counts = new Map(); - const samples = new Map(); - for (const row of rows) { - const singular = PLURAL_TO_SINGULAR[String(row.type)] ?? String(row.type); - counts.set(singular, (counts.get(singular) ?? 0) + 1); - const names = samples.get(singular) ?? []; - const draft = row.state === 'draft' ? ' (draft)' : ''; - if (names.length < SAMPLE_PER_TYPE) names.push(`${String(row.name)}@${String(row.organization_id)}${draft}`); - samples.set(singular, names); - } - const ledgers: string[] = []; - for (const ledger of ['sys_metadata_commit', 'sys_metadata_history', 'sys_metadata_audit']) { - await isolated(ledger, async () => { - const n = await orNoTable(ledger, () => this.engine.count(ledger, { - where: LEGACY, - context: { isSystem: true }, - }), 0); - if (n > 0) ledgers.push(`${ledger}×${n}`); - }); - } - if (rows.length === 0 && ledgers.length === 0 && unmeasured.length === 0) return; - - const detail = Array.from(counts.entries()) - .map(([type, count]) => { - const names = samples.get(type) ?? []; - const more = count > names.length ? `, +${count - names.length} more` : ''; - return `${type}×${count} (${names.join(', ')}${more})`; - }) - .join('; '); - console.warn( - `[Protocol] [metadata_org_scoped_unserved] ` + - (rows.length > 0 - ? `${rows.length} sys_metadata row(s) are stored in a legacy organization's layer, which ` + - `ADR-0131 D6 retired: no read serves them, boot does not load them, and the environment's ` + - `row or the package's definition is served in their place: ${detail}. A 'flow' listed here ` + - `does not bind its triggers. ` - : '') + - (ledgers.length > 0 - ? `Legacy organization-scoped ledger rows, which the commit timeline, history, diff and audit ` + - `reads no longer show: ${ledgers.join(', ')}. ` - : '') + - (unmeasured.length > 0 - ? `NOT MEASURED, so this account may be incomplete — the read failed: ${unmeasured.join(', ')}. ` - : '') + - `Nothing is deleted or rewritten: the v18 migration ceremony (\`os migrate\`, ADR-0131 D10) ` + - `carries these rows and names each one's fate — promoted to the environment layer, or reported ` + - `when another organization holds the same name.`, - ); - } catch { - // Diagnostics never break boot — see the TSDoc. Deliberately not - // routed to the caller's outer catch: that one classifies real - // hydration outages, and a failed extra probe is not one. - } - } /** * [ADR-0131 D6, triage ruling Q1 → C] Say out loud which stored diff --git a/packages/metadata-protocol/src/protocol.view-expansion-reconcile-call-sites.test.ts b/packages/metadata-protocol/src/protocol.view-expansion-reconcile-call-sites.test.ts index 77d9a5ef5bf..71f05c64cab 100644 --- a/packages/metadata-protocol/src/protocol.view-expansion-reconcile-call-sites.test.ts +++ b/packages/metadata-protocol/src/protocol.view-expansion-reconcile-call-sites.test.ts @@ -97,14 +97,11 @@ type Route = /** The write-through or the heal is called after the write, in the same member, before its next write. */ | { readonly sameMember: 'applyRegistryWriteThrough' | 'restoreArtifactRegistryView' } /** The member hands the row to its callers, and each of them calls `then`, which calls the write-through. */ - | { readonly handedTo: 'callers'; readonly then: 'runPublishSideEffects' } - /** An organization-scoped row, which never enters the registry (#6602): nothing to reconcile. */ - | { readonly organizationScoped: true }; + | { readonly handedTo: 'callers'; readonly then: 'runPublishSideEffects' }; const WRITES: ReadonlyArray<{ readonly member: string; readonly verb: string; readonly route: Route }> = [ { member: 'saveMetaItem', verb: 'repo.put(', route: { sameMember: 'applyRegistryWriteThrough' } }, { member: 'promoteDraftForPublish', verb: 'repo.promoteDraft(', route: { handedTo: 'callers', then: 'runPublishSideEffects' } }, - { member: 'removeLegacyOrganizationRowOnUninstall', verb: 'repo.delete(', route: { organizationScoped: true } }, { member: 'revertCommit', verb: 'repo.delete(', route: { sameMember: 'restoreArtifactRegistryView' } }, { member: 'revertCommit', verb: 'repo.restoreVersion(', route: { sameMember: 'applyRegistryWriteThrough' } }, { member: 'rollbackMetaItem', verb: 'repo.restoreVersion(', route: { sameMember: 'applyRegistryWriteThrough' } }, @@ -156,24 +153,18 @@ describe('[#23198] every stored-row write in protocol.ts reaches the reconcile', } }); - it('an organization-scoped write reaches only a legacy organization\'s repository', () => { - for (const row of WRITES.filter((r) => 'organizationScoped' in r.route)) { - expect(bodyOf(row.member)).toContain('const repo = this.legacyOrganizationRepo('); - } - }); - it('a repository is bound under the one spelling the census reads', () => { // A repository bound under any other name would write past the census. - const ACQUIRE = /this\.(?:getOverlayRepo\(\)|legacyOrganizationRepo\(|repositoryFor\()|new SysMetadataRepository\(/; - const OWN = ['getOverlayRepo', 'legacyOrganizationRepo', 'repositoryFor']; + const ACQUIRE = /this\.getOverlayRepo\(\)|new SysMetadataRepository\(/; + const OWN = ['getOverlayRepo']; const stray = codeMatches(ACQUIRE).filter(({ offset, member }) => { if (OWN.includes(member)) return false; const line = LINES[lineOf(offset)].trim(); - return !/^const repo = this\.(?:getOverlayRepo\(\)|legacyOrganizationRepo\([^)]*\));$/.test(line) + return !/^const repo = this\.getOverlayRepo\(\);$/.test(line) && !/this\.getOverlayRepo\(\)\.listDrafts\(/.test(line); }); expect(stray.map(({ member, offset }) => `${member}: ${LINES[lineOf(offset)].trim()}`)).toEqual([]); - expect(codeMatches(/new SysMetadataRepository\(/).map((m) => m.member)).toEqual(['repositoryFor']); + expect(codeMatches(/new SysMetadataRepository\(/).map((m) => m.member)).toEqual(['getOverlayRepo']); }); }); diff --git a/packages/metadata-protocol/src/sys-metadata-repository-13997-authored-at-canonicalisation.test.ts b/packages/metadata-protocol/src/sys-metadata-repository-13997-authored-at-canonicalisation.test.ts index 6ffd3855922..17426e4d2ad 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository-13997-authored-at-canonicalisation.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository-13997-authored-at-canonicalisation.test.ts @@ -196,7 +196,6 @@ describe('#13997 — authoredAt is canonical ISO-8601 text, whatever the dialect engine = makeFakeEngine(); repo = new SysMetadataRepository({ engine, - organizationId: 'org_alpha', orgLabel: 'org_alpha', }); }); diff --git a/packages/metadata-protocol/src/sys-metadata-repository-14037-event-ts-canonicalisation.test.ts b/packages/metadata-protocol/src/sys-metadata-repository-14037-event-ts-canonicalisation.test.ts index 482e4ec9456..aa525f5cdde 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository-14037-event-ts-canonicalisation.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository-14037-event-ts-canonicalisation.test.ts @@ -209,7 +209,6 @@ describe('#14037 — MetadataEvent.ts is canonical ISO text, whatever the dialec engine = makeFakeEngine(); repo = new SysMetadataRepository({ engine, - organizationId: 'org_alpha', orgLabel: 'org_alpha', }); await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' }); diff --git a/packages/metadata-protocol/src/sys-metadata-repository-14078-invalid-date-total-arm.test.ts b/packages/metadata-protocol/src/sys-metadata-repository-14078-invalid-date-total-arm.test.ts index 6ed75e18dfa..cebddbdd229 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository-14078-invalid-date-total-arm.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository-14078-invalid-date-total-arm.test.ts @@ -100,7 +100,6 @@ function makeEngine(row: Record | null) { function makeRepo(row: Record | null) { return new SysMetadataRepository({ engine: makeEngine(row) as never, - organizationId: 'org_alpha', orgLabel: 'org_alpha', }); } diff --git a/packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts b/packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts index 9571f2335a5..51963ead628 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts @@ -198,7 +198,6 @@ function makeFakeEngine(seed: Row[] = []) { const draftRow = (stamps: Row): Row => ({ type: 'view', name: 'case_grid', - organization_id: null, state: 'draft', package_id: null, metadata: '{"label":"Cases"}', @@ -209,11 +208,10 @@ const draftRow = (stamps: Row): Row => ({ ...stamps, }); -function makeRepo(engine: ReturnType, organizationId: string | null = null) { +function makeRepo(engine: ReturnType) { return new SysMetadataRepository({ engine: engine as never, - organizationId, - orgLabel: organizationId ?? 'env', + orgLabel: 'env', } as never); } @@ -234,21 +232,6 @@ describe('#14938 — listDrafts emits canonical ISO text for updatedAt, whatever expect(drafts[0]!.updatedAt).toBe(PG_INSTANT.toISOString()); expectConformsToDeclaration(drafts); }); - - it('[ADR-0131 D6] lists the environment\'s drafts only, whichever repository asks: a legacy organization draft is not listed', async () => { - const engine = makeFakeEngine([ - draftRow({ organization_id: 'org_alpha', updated_at: PG_INSTANT }), - draftRow({ name: 'lead_grid', updated_at: PG_INSTANT }), - ]); - const repo = makeRepo(engine, 'org_alpha'); - - expect(engine.rows[0]!.updated_at).toBeInstanceOf(Date); - - const drafts = await repo.listDrafts(); - expect(drafts.map((d) => d.name)).toEqual(['lead_grid']); - for (const draft of drafts) expect(draft.updatedAt).toBe(PG_INSTANT.toISOString()); - expectConformsToDeclaration(drafts); - }); }); describe('§B the created_at fallback — the second link of the same chain', () => { diff --git a/packages/metadata-protocol/src/sys-metadata-repository.bundle-member.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.bundle-member.test.ts index a1a5608b9ce..86351ce844b 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.bundle-member.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.bundle-member.test.ts @@ -107,7 +107,7 @@ const body = (locale: string | undefined, subject: string): Record) { - return new SysMetadataRepository({ engine: engine as never, organizationId: null, orgLabel: 'env' } as never); + return new SysMetadataRepository({ engine: engine as never, orgLabel: 'env' } as never); } /** Last-write-wins, the way the save door's unpinned save resolves its parent. */ diff --git a/packages/metadata-protocol/src/sys-metadata-repository.contract.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.contract.test.ts index fe3fbe5dc2b..c61b51130f1 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.contract.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.contract.test.ts @@ -170,7 +170,6 @@ function makeRepo(): SysMetadataRepository { // `fullRef().org` agree with the suite's `refOf()` — the repository ignores // `ref.org` and stamps its own, which is exactly the single-org shape the // "different orgs have independent sequences" clause is written to skip. - organizationId: null, orgLabel: 'system', }); created.push(repo); @@ -202,7 +201,7 @@ runRepositoryContractTests('SysMetadataRepository', makeRepo, { describe('SysMetadataRepository — what the contract suite does and does not reach', () => { it('every suite write lands on the ACTIVE row; no draft row is ever created', async () => { const engine = makeFakeEngine(); - const repo = new SysMetadataRepository({ engine, organizationId: null, orgLabel: 'system' }); + const repo = new SysMetadataRepository({ engine, orgLabel: 'system' }); created.push(repo); const ref = { org: 'system', type: 'view' as const, name: 'sample_view' }; @@ -216,7 +215,7 @@ describe('SysMetadataRepository — what the contract suite does and does not re it('every suite write lands on the UNBOUND row — `package_id` is null throughout', async () => { const engine = makeFakeEngine(); - const repo = new SysMetadataRepository({ engine, organizationId: null, orgLabel: 'system' }); + const repo = new SysMetadataRepository({ engine, orgLabel: 'system' }); created.push(repo); const ref = { org: 'system', type: 'view' as const, name: 'sample_view' }; @@ -227,7 +226,7 @@ describe('SysMetadataRepository — what the contract suite does and does not re it('is single-org: `ref.org` is ignored, which is WHY the multi-org clause self-skips', async () => { const engine = makeFakeEngine(); - const repo = new SysMetadataRepository({ engine, organizationId: null, orgLabel: 'system' }); + const repo = new SysMetadataRepository({ engine, orgLabel: 'system' }); created.push(repo); const orgA = { org: 'org_a', type: 'view' as const, name: 'sample_view' }; const orgB = { org: 'org_b', type: 'view' as const, name: 'sample_view' }; @@ -241,7 +240,7 @@ describe('SysMetadataRepository — what the contract suite does and does not re repo.put(orgB, { label: 'b1' }, { parentVersion: null, actor: 't' }), ).rejects.toThrow(); expect(engine.rows()).toHaveLength(1); - expect(engine.rows()[0]!.organization_id).toBeNull(); + expect('organization_id' in engine.rows()[0]!).toBe(false); // …and the ref it stamps back is this repository's own label, not the // caller's. const got = await repo.get(orgA); @@ -392,7 +391,6 @@ describe('SysMetadataRepository — invariant 6, both halves (#10842)', () => { }; const repo = new SysMetadataRepository({ engine: gated, - organizationId: null, orgLabel: 'system', }); created.push(repo); @@ -432,7 +430,6 @@ describe('SysMetadataRepository — invariant 6, both halves (#10842)', () => { }; const repo = new SysMetadataRepository({ engine: flaky, - organizationId: null, orgLabel: 'system', }); created.push(repo); @@ -622,7 +619,7 @@ describe('SysMetadataRepository — a declared ordered map: object `fields` (#21 const storedOrder = (engine: Engine) => Object.keys((JSON.parse(activeRow(engine).metadata as string) as { fields: object }).fields); const repoOn = (engine: Engine) => { - const repo = new SysMetadataRepository({ engine, organizationId: null, orgLabel: 'system' }); + const repo = new SysMetadataRepository({ engine, orgLabel: 'system' }); created.push(repo); return repo; }; diff --git a/packages/metadata-protocol/src/sys-metadata-repository.draft-drain.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.draft-drain.test.ts index cc3a01e03d9..cb4fd4f9e90 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.draft-drain.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.draft-drain.test.ts @@ -222,7 +222,6 @@ describe('#4981 — a failed draft drain is discriminated, not blanket-swallowed engine = makeFakeEngine(); repo = new SysMetadataRepository({ engine, - organizationId: 'org_alpha', orgLabel: 'org_alpha', }); errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -395,7 +394,6 @@ describe('#4981 — a failed draft drain is discriminated, not blanket-swallowed const engine2 = makeFakeEngine(); const repo2 = new SysMetadataRepository({ engine: engine2, - organizationId: 'org_alpha', orgLabel: 'org_alpha', }); await repo2.put(ref, view('A'), { parentVersion: null, actor: 'studio', state: 'draft' }); diff --git a/packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts index aa729773a7e..96a4c2c11ef 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts @@ -95,7 +95,6 @@ const REF = { org: 'system', type: 'view' as const, name: 'k9qk_member.member_li function makeRepo(engine: ReturnType) { return new SysMetadataRepository({ engine: engine as never, - organizationId: null, orgLabel: 'env', } as never); } @@ -133,36 +132,14 @@ describe('SysMetadataRepository draft-save package inheritance (#11087)', () => expect(draft.package_id ?? null).toBeNull(); }); - it('an ORG-scoped save inherits from the env-wide active row — the ADR-0005 overlay reach (#11087 layer 1b)', async () => { - // The console's session carries an active org, so its draft lands in the - // org scope while the active base row is env-wide. A same-org-only - // inheritance lookup finds nothing there (measured live), so the reach - // must mirror listDrafts' $or contract. - const engine = makeFakeEngine([ - { - type: REF.type, name: REF.name, organization_id: null, state: 'active', - package_id: 'app.k9qk', metadata: '{"label":"Member"}', checksum: 'sha-active', version: 1, - }, - ]); - const repo = new SysMetadataRepository({ - engine: engine as never, - organizationId: 'org_1', - orgLabel: 'org_1', - } as never); - await repo.put(REF, { label: 'Member v2' }, { parentVersion: null, actor: 't', state: 'draft' as const }); - const draft = engine.rows.find((r) => r.state === 'draft')!; - expect(draft.organization_id).toBe('org_1'); // org-scoped save, unchanged - expect(draft.package_id).toBe('app.k9qk'); // inherited across scopes - }); - it('adopts a pre-fix orphan draft (NULL package) instead of forking a second draft row', async () => { const engine = makeFakeEngine([ { - type: REF.type, name: REF.name, organization_id: null, state: 'active', + type: REF.type, name: REF.name, state: 'active', package_id: 'app.k9qk', metadata: '{"label":"Member"}', checksum: 'sha-active', version: 1, }, { - type: REF.type, name: REF.name, organization_id: null, state: 'draft', + type: REF.type, name: REF.name, state: 'draft', package_id: null, metadata: '{"label":"Member","description":"orphan"}', checksum: 'sha-orphan', version: 2, }, ]); @@ -187,7 +164,7 @@ describe('SysMetadataRepository draft-save package inheritance (#11087)', () => */ describe('SysMetadataRepository.headAt — the row put locks against (#22128)', () => { const seededActive = (pkg: string | null) => ({ - type: REF.type, name: REF.name, organization_id: null, state: 'active', + type: REF.type, name: REF.name, state: 'active', package_id: pkg, metadata: '{"label":"Member"}', checksum: 'sha-active', version: 1, }); @@ -214,7 +191,7 @@ describe('SysMetadataRepository.headAt — the row put locks against (#22128)', const engine = makeFakeEngine([ seededActive('app.k9qk'), { - type: REF.type, name: REF.name, organization_id: null, state: 'draft', + type: REF.type, name: REF.name, state: 'draft', package_id: null, metadata: '{"label":"orphan"}', checksum: 'sha-orphan', version: 2, }, ]); @@ -241,17 +218,6 @@ describe('SysMetadataRepository.headAt — the row put locks against (#22128)', expect((await repo.headAt(REF, { state: 'active', packageId: 'app.k9qk' })).head?.hash).toBe('sha-active'); }); - it('an org-scoped draft address resolves across the ADR-0005 reach, as the inheriting put does', async () => { - const engine = makeFakeEngine([seededActive('app.k9qk')]); - const repo = new SysMetadataRepository({ - engine: engine as never, - organizationId: 'org_1', - orgLabel: 'org_1', - } as never); - const first = await repo.put(REF, { label: 'org draft' }, { parentVersion: null, actor: 't', state: 'draft' as const }); - expect((await repo.headAt(REF, { state: 'draft' })).head?.hash).toBe(first.version); - }); - it('a brand-new item drafted first: no head, and the package-less binding (nothing to inherit)', async () => { const repo = makeRepo(makeFakeEngine()); expect(await repo.headAt(REF, { state: 'draft' })).toEqual({ head: null, packageId: null }); @@ -271,7 +237,7 @@ describe('SysMetadataRepository.headAt — the row put locks against (#22128)', */ describe('[#23297] SysMetadataRepository draft inheritance reads every active row', () => { const activeRow = (pkg: string | null, checksum: string) => ({ - type: REF.type, name: REF.name, organization_id: null, state: 'active', + type: REF.type, name: REF.name, state: 'active', package_id: pkg, metadata: '{"label":"Member"}', checksum, version: 1, }); diff --git a/packages/metadata-protocol/src/sys-metadata-repository.history-counters.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.history-counters.test.ts index 4ed64b48401..425cbeee3b8 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.history-counters.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.history-counters.test.ts @@ -194,7 +194,7 @@ describe('#4867 — history counters are never invented from a failed read', () beforeEach(() => { discriminator.mockClear(); engine = makeFakeEngine(); - repo = new SysMetadataRepository({ engine, organizationId: 'org_alpha', orgLabel: 'org_alpha' }); + repo = new SysMetadataRepository({ engine, orgLabel: 'org_alpha' }); errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); }); @@ -350,7 +350,6 @@ describe('#4867 — history counters are never invented from a failed read', () const engine2 = makeFakeEngine(); const repo2 = new SysMetadataRepository({ engine: engine2, - organizationId: 'org_alpha', orgLabel: 'org_alpha', }); engine2.breakHistoryReads(connectionReset); @@ -386,7 +385,7 @@ describe('#4867 — the discriminator is the shared exported one, not a local co it('consults the exported `isMissingTableError` with the driver error itself', async () => { const engine = makeFakeEngine(); - const repo = new SysMetadataRepository({ engine, organizationId: 'org_alpha', orgLabel: 'org_alpha' }); + const repo = new SysMetadataRepository({ engine, orgLabel: 'org_alpha' }); engine.breakHistoryReads(noSuchTable); await repo.put({ org: 'org_alpha', type: 'view', name: 'case_grid' }, view('A'), { @@ -400,7 +399,7 @@ describe('#4867 — the discriminator is the shared exported one, not a local co it("follows the shared function's verdict — flipping it flips the behaviour", async () => { const engine = makeFakeEngine(); - const repo = new SysMetadataRepository({ engine, organizationId: 'org_alpha', orgLabel: 'org_alpha' }); + const repo = new SysMetadataRepository({ engine, orgLabel: 'org_alpha' }); // A connection reset, declared benign BY THE SHARED MODULE. A local copy // (or an inlined `catch { return 1 }`) would ignore this and answer 1 for diff --git a/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts index e92e5daefa9..0658301330e 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts @@ -237,7 +237,7 @@ describe('#7682 — the refusal discriminates on package writability', () => { resetEnvWritableMetadataTypes(); ObjectStackProtocolImplementation.resetEnvWritableCache(); engine = makeFakeEngine(); - repo = new SysMetadataRepository({ engine: engine as never, organizationId: null, orgLabel: 'env' }); + repo = new SysMetadataRepository({ engine: engine as never, orgLabel: 'env' }); }); afterEach(() => { @@ -478,7 +478,7 @@ describe('#7682 — the refusal discriminates on package writability', () => { it('a package-less hatch write under an ORG kernel lands no per-org override either', async () => { openHatch(); const orgRepo = new SysMetadataRepository({ - engine: engine as never, organizationId: 'org_acme', orgLabel: 'org_acme', + engine: engine as never, orgLabel: 'org_acme', }); const err = await putWith(orgRepo, { type: 'permission', name: 'showcase_contributor', intent: 'override-artifact', @@ -1230,7 +1230,7 @@ describe('#8361 — the create-side hatch clause reaches saveMetaItem', () => { // this stays byte-identical. const engine = makeFakeEngine(); const repo = new SysMetadataRepository({ - engine: engine as never, organizationId: null, orgLabel: 'env', + engine: engine as never, orgLabel: 'env', }); const err = await putWith(repo, { type: 'job', name: 'nightly_sweep', intent: 'runtime-only', packageId: READ_ONLY_PKG, @@ -1275,7 +1275,7 @@ describe('[#20910] the repository doors name a Regime C type\'s sanctioned path, resetEnvWritableMetadataTypes(); ObjectStackProtocolImplementation.resetEnvWritableCache(); engine = makeFakeEngine(); - repo = new SysMetadataRepository({ engine: engine as never, organizationId: null, orgLabel: 'env' }); + repo = new SysMetadataRepository({ engine: engine as never, orgLabel: 'env' }); }); afterEach(() => { diff --git a/packages/metadata-protocol/src/sys-metadata-repository.recorded-by.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.recorded-by.test.ts index 8760e1d7ccb..a9bc7dee7fb 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.recorded-by.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.recorded-by.test.ts @@ -113,7 +113,7 @@ describe('#4556 — recorded_by holds a user id or NULL, never a sentinel', () = beforeEach(() => { engine = makeFakeEngine(); - repo = new SysMetadataRepository({ engine, organizationId: 'org_alpha', orgLabel: 'org_alpha' }); + repo = new SysMetadataRepository({ engine, orgLabel: 'org_alpha' }); }); it('put with actor: null writes SQL NULL — not "system", not any other string', async () => { diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index b4d901f7ef0..ad9ce06f167 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -10,8 +10,8 @@ * * What this layer DOES (M0 + M1): * - get / put / delete / list against `sys_metadata` - * - tenancy scope = `organization_id` (per-org overlays only; - * project/branch concepts removed — see ADR-0008 §0 amendment) + * - scope = the environment (ADR-0131 D6: no organization column since + * C7b; project/branch concepts removed — see ADR-0008 §0 amendment) * - hash stamping with `hashSpec` (PR-10a guarantees stability) * - watch() implemented via an in-memory event broadcaster fed by * every successful put/delete on THIS instance; a NUMERIC `since` @@ -45,7 +45,6 @@ * body → metadata (JSON string) * hash (sha256) → checksum (text(64)) * monotonic version int → version (number) - * org isolation → organization_id (lookup) * actor → updated_by (lookup, optional; every write) * first write's actor → created_by (lookup, optional; insert only) * @@ -57,7 +56,7 @@ * switch. */ -import { hashSpec, orderedMapKeys, ConflictError } from '@objectstack/metadata-core'; +import { hashSpec, orderedMapKeys, ConflictError, readLegacyOrganizationRows } from '@objectstack/metadata-core'; // #4867 — the SAME discriminator `DatabaseLoader` uses (#4825), imported, not // re-implemented. A second hand-rolled "which driver errors are benign?" here // would be two vocabularies for one question, which is the dual-source debt @@ -397,13 +396,7 @@ export interface SysMetadataEngine { export interface SysMetadataRepositoryOptions { engine: SysMetadataEngine; - /** - * Tenancy scope. `null` writes to env-wide overlay rows; a string - * scopes to one organization (the supported shared-DB tenant model - * — see ADR-0005 amendment). - */ - organizationId?: string | null; - /** Org label embedded in returned MetaRefs. Defaults to organizationId or `"system"`. */ + /** Org label embedded in returned MetaRefs. Defaults to `"system"`. */ orgLabel?: string; } @@ -514,8 +507,9 @@ export function resetEnvWritableMetadataTypes(): void { * #7779 each had to repair on one copy at a time — so the revert asks this * function, never a `where` of its own. * - * [ADR-0131 D6] The environment's rows only (`organization_id IS NULL`): a - * legacy organization-scoped row is reported at boot, never read. + * [ADR-0131 D6, C7b] Every row is the environment's: the organization column + * is retired from `sys_metadata`, and a database still holding legacy + * organization rows is refused at boot until the v18 ceremony has run. * * Deliberately NOT exported from the package entry: it is a shared predicate * between this module and `protocol.ts`, not a public read. @@ -524,7 +518,7 @@ export function packageScopedRowWhere( state: 'draft' | 'active', filter?: { type?: string; packageId?: string }, ): Record { - const where: Record = { state, organization_id: null }; + const where: Record = { state }; if (filter?.type) where.type = filter.type; if (filter?.packageId) where.package_id = filter.packageId; return where; @@ -607,7 +601,6 @@ interface WatchSubscription { export class SysMetadataRepository implements MetadataRepository { private readonly engine: SysMetadataEngine; - private readonly organizationId: string | null; private readonly orgLabel: string; /** @@ -641,8 +634,7 @@ export class SysMetadataRepository implements MetadataRepository { constructor(opts: SysMetadataRepositoryOptions) { this.engine = opts.engine; - this.organizationId = opts.organizationId ?? null; - this.orgLabel = opts.orgLabel ?? (opts.organizationId ?? 'system'); + this.orgLabel = opts.orgLabel ?? 'system'; } /** @@ -707,7 +699,7 @@ export class SysMetadataRepository implements MetadataRepository { // platform plumbing under a door that already authorized the caller, and // it scopes its own rows by organization. None of them reaches the data // engine as a principal-less context. - const row = await this.engine.findOne('sys_metadata', { + const row = await this.findEnvironmentRow({ where: this.whereFor( ref, state, @@ -822,11 +814,10 @@ export class SysMetadataRepository implements MetadataRepository { let targetPackageId: string | null = packageId ?? null; let ambiguousPackages: readonly string[] | undefined; if (state === 'draft' && packageId == null) { - // The overlaid base is resolved with the SAME two-scope reach the draft - // list applies (`listDrafts`' $or contract): an org-scoped caller's - // active row usually lives ENV-WIDE (`organization_id IS NULL`) — the - // ADR-0005 overlay order — so a same-org-only lookup here found nothing - // for exactly the console-session saves this inheritance exists for + // The overlaid base is the environment's active row, as the draft list + // reads it (ADR-0131 D6: every row is the environment's) — a lookup + // that missed it found nothing for exactly the console-session saves + // this inheritance exists for // (measured live: an org-scoped view draft stayed `package_id NULL` // over an env-wide active row bound to `app.k9qk`). const activeWhere: Record = { @@ -835,18 +826,13 @@ export class SysMetadataRepository implements MetadataRepository { state: 'active', }; if (member !== undefined) activeWhere[OVERLAY_MEMBER_COLUMN] = member; - if (this.organizationId != null) { - activeWhere.$or = [ - { organization_id: this.organizationId }, - { organization_id: null }, - ]; - } else { - activeWhere.organization_id = null; - } // [#23297] Every active row, never the first one: see this method's // header. A failed read propagates (the caller's write fails), as the // single-row read did. - const activeRows = await this.engine.find('sys_metadata', { where: activeWhere, context }); + const activeRows = await this.withoutLegacyOrganizationRows( + await this.engine.find('sys_metadata', { where: activeWhere, context }), + context, + ); const bindings = new Set(); for (const activeRow of (activeRows ?? []) as Array<{ package_id?: unknown }>) { if (typeof activeRow?.package_id === 'string' && activeRow.package_id !== '') bindings.add(activeRow.package_id); @@ -854,7 +840,7 @@ export class SysMetadataRepository implements MetadataRepository { if (bindings.size === 1) targetPackageId = [...bindings][0] ?? null; else if (bindings.size > 1) ambiguousPackages = [...bindings].sort(); } - let row = await this.engine.findOne('sys_metadata', { + let row = await this.findEnvironmentRow({ where: this.whereFor(ref, state, targetPackageId, member), context, }); @@ -865,7 +851,7 @@ export class SysMetadataRepository implements MetadataRepository { // That package-less row is the one the write updates; `put`'s stamp // (`existingPkg ?? targetPackageId`) then adopts it into the package. if (!row && state === 'draft' && packageId == null && targetPackageId !== null) { - row = await this.engine.findOne('sys_metadata', { + row = await this.findEnvironmentRow({ where: this.whereFor(ref, state, null, member), context, }); @@ -876,8 +862,7 @@ export class SysMetadataRepository implements MetadataRepository { /** * Resolve a historical version by content hash (ADR-0009). * - * Looks up `sys_metadata_history` by `(organization_id, type, name, - * checksum)`. Returns null if no row matches. `executionPinned` types + * Looks up `sys_metadata_history` by `(type, name, checksum)`. Returns null if no row matches. `executionPinned` types * are guaranteed to find their body here because history GC skips * them. */ @@ -887,7 +872,6 @@ export class SysMetadataRepository implements MetadataRepository { // [#21908] The explicit system opt-in, as `get` carries — see its note. const row = await this.engine.findOne(this.historyTable, { where: { - organization_id: this.organizationId, type: full.type, name: full.name, checksum: hash, @@ -1001,7 +985,6 @@ export class SysMetadataRepository implements MetadataRepository { const parentRowData: Record = { type: ref.type, name: ref.name, - organization_id: this.organizationId, metadata: JSON.stringify(body), checksum: hash, state, @@ -1064,7 +1047,6 @@ export class SysMetadataRepository implements MetadataRepository { previous_checksum: existingHash, change_note: opts.message, source: opts.source ?? 'sys-metadata-repo', - organization_id: this.organizationId, // #4556 — `recorded_by` is a lookup('sys_user'). A write with no // actor stores NULL, never a sentinel string: an id that resolves // to no row is a foreign key that lies. @@ -1155,7 +1137,7 @@ export class SysMetadataRepository implements MetadataRepository { const state: OverlayState = opts.state ?? 'active'; const result = await this.withTxn(async (ctx) => { - const existing = await this.engine.findOne('sys_metadata', { + const existing = await this.findEnvironmentRow({ where: this.whereFor( ref, state, @@ -1199,7 +1181,7 @@ export class SysMetadataRepository implements MetadataRepository { if (state === 'active') { // Tombstone row — metadata/checksum are intentionally null. - // Identity is preserved via (organization_id, type, name, version); + // Identity is preserved via (type, name, version); // the parent row's id is not retained. await this.engine.insert( this.historyTable, @@ -1215,7 +1197,6 @@ export class SysMetadataRepository implements MetadataRepository { previous_checksum: existingHash, change_note: opts.message, source: opts.source ?? 'sys-metadata-repo', - organization_id: this.organizationId, // #4556 — NULL, not a sentinel, when the delete had no actor. recorded_by: opts.actor ?? null, recorded_at: now, @@ -1354,7 +1335,7 @@ export class SysMetadataRepository implements MetadataRepository { // so a second package's same-name draft can no longer be the row this // promotion picks up. Omitted → unchanged "match any package" behaviour for // callers that have no package in hand (`publishMetaItem`). - const draftRow = await this.engine.findOne('sys_metadata', { + const draftRow = await this.findEnvironmentRow({ where: this.whereFor( ref, 'draft', @@ -1470,7 +1451,6 @@ export class SysMetadataRepository implements MetadataRepository { const full = this.fullRef(ref); const row = await this.engine.findOne(this.historyTable, { where: { - organization_id: this.organizationId, type: full.type, name: full.name, version: targetVersion, @@ -1528,7 +1508,7 @@ export class SysMetadataRepository implements MetadataRepository { // A missing active row (deleted, or never published) yields `null` — the // unbound row, which is the only defined answer: `sys_metadata_history` // carries no `package_id` column, so a vanished binding is not recoverable. - const activeRow = await this.engine.findOne('sys_metadata', { + const activeRow = await this.findEnvironmentRow({ where: this.whereFor(ref, 'active', undefined, member), context: { isSystem: true }, }); @@ -1550,16 +1530,15 @@ export class SysMetadataRepository implements MetadataRepository { async *list(filter: ListFilter): AsyncIterable { this.assertOpen(); const where: Record = { - organization_id: this.organizationId, state: 'active', }; if (filter.type) where.type = filter.type; // [#21908] The explicit system opt-in, as `get` carries — see its note. - const rows = await this.engine.find('sys_metadata', { + const rows = await this.withoutLegacyOrganizationRows(await this.engine.find('sys_metadata', { where, limit: filter.limit, context: { isSystem: true }, - }); + })); for (const row of rows) { if (filter.nameContains && !String(row.name).includes(filter.nameContains)) continue; const item = this.rowToItem( @@ -1610,7 +1589,9 @@ export class SysMetadataRepository implements MetadataRepository { this.assertOpen(); // [ADR-0131 D6] The environment's drafts, whichever repository asks. const where = packageScopedRowWhere('draft', filter); - const rows = await this.engine.find('sys_metadata', { where, context: { isSystem: true } }); + const rows = await this.withoutLegacyOrganizationRows( + await this.engine.find('sys_metadata', { where, context: { isSystem: true } }), + ); return (rows as any[]).map((row) => ({ type: row.type, name: row.name, @@ -1668,7 +1649,6 @@ export class SysMetadataRepository implements MetadataRepository { this.assertOpen(); const full = this.fullRef(ref); const where: Record = { - organization_id: this.organizationId, type: full.type, name: full.name, }; @@ -1753,7 +1733,7 @@ export class SysMetadataRepository implements MetadataRepository { ): Promise { // [#21908] The explicit system opt-in, as `get` carries — see its note. const rows = await this.engine.find(this.historyTable, { - where: { organization_id: this.organizationId }, + where: {}, context: { isSystem: true }, }); const out: MetadataEvent[] = []; @@ -1782,10 +1762,10 @@ export class SysMetadataRepository implements MetadataRepository { * replayed out of `sys_metadata_history` before any live event is yielded. * `since` used to be nothing but a DROP filter on live events, so an event * that had already committed was unreachable through `watch()` however low - * `since` was set — the repository held a durable per-org `event_seq` log - * and never read it here. It does now, through the same - * `find(sys_metadata_history, { organization_id })` query `nextEventSeq()` - * issues and the same row→event mapping `history()` uses. + * `since` was set — the repository held a durable `event_seq` log and + * never read it here. It does now, through the same + * `find(sys_metadata_history)` query `nextEventSeq()` issues and the same + * row→event mapping `history()` uses. * * **No `since`**: live events only. Deliberate, and now written into the * contract (`repository.ts` invariant 6) rather than left to be inherited @@ -1971,6 +1951,50 @@ export class SysMetadataRepository implements MetadataRepository { // ── helpers ───────────────────────────────────────────────────────── + /** + * [ADR-0131 D6/D7] The environment's row at `where`. The family declares no + * organization column, so the engine can neither filter on it nor return + * it; on a database the organization-ownership ceremony has not migrated, a + * legacy organization's row still sits under the same address, and a write + * that resolved to it would rewrite that organization's row in place + * (measured: the ceremony's template promotion did, through + * `saveMetaItem`). The single-row read stays the common path; only when the + * row it returns is a legacy organization's ({@link readLegacyOrganizationRows}, + * off the physical column) is the address re-read for the environment's own + * row — `null` when there is none, so the write creates it. A read of the + * column that cannot answer throws, and the write fails with it. + * + * The `sys_metadata_history` reads are not filtered; they read every + * lineage of the address. `restoreVersion` (by type, name and version) and + * `history()` are reached only through `os serve`'s doors, which refuse a + * database the ceremony has not migrated (the v18 boot refusal). The + * version and event counters are reached by every write, the ceremony's + * included: there a legacy lineage can only raise the next number, never + * select a row. + */ + private async findEnvironmentRow(query: { where: Record; context?: Record }): Promise { + const row = await this.engine.findOne('sys_metadata', query); + if (!row) return row ?? null; + const legacy = await this.legacyOrganizationRowIds(query.context); + if (!legacy.has(String((row as { id?: unknown }).id))) return row; + const rows = await this.engine.find('sys_metadata', query); + return ((rows ?? []) as any[]).find((r) => !legacy.has(String(r?.id))) ?? null; + } + + /** [ADR-0131 D6/D7] `rows` without a legacy organization's — see {@link findEnvironmentRow}. */ + private async withoutLegacyOrganizationRows(rows: R[] | null | undefined, context?: Record): Promise { + if (!rows || rows.length === 0) return rows ?? []; + const legacy = await this.legacyOrganizationRowIds(context); + return legacy.size === 0 ? rows : rows.filter((row) => !legacy.has(String((row as { id?: unknown }).id))); + } + + /** Inside a write, the statements join its transaction (`context.transaction`). */ + private async legacyOrganizationRowIds(context?: Record): Promise> { + const driver = (this.engine as { getDriverForObject?: (objectName: string) => unknown }).getDriverForObject?.('sys_metadata'); + const transaction = (context as { transaction?: unknown } | undefined)?.transaction; + return new Set((await readLegacyOrganizationRows(driver, 'sys_metadata', transaction !== undefined ? { transaction } : {})).keys()); + } + private assertOpen(): void { if (this.closed) throw new Error('SysMetadataRepository is closed'); } @@ -2362,7 +2386,6 @@ export class SysMetadataRepository implements MetadataRepository { const where: Record = { type: ref.type, name: ref.name, - organization_id: this.organizationId, state, }; // ADR-0048 — when the caller scopes by package, the overlay row is keyed by @@ -2543,10 +2566,10 @@ export class SysMetadataRepository implements MetadataRepository { } /** - * Per-org monotonic event sequence. Reads `MAX(event_seq) + 1` from - * `sys_metadata_history` scoped by `organization_id`. MUST be called - * inside a transaction (the only caller is the put/delete txn body) — - * concurrent writers in the same org race otherwise. + * The environment's monotonic event sequence. Reads `MAX(event_seq) + 1` + * from `sys_metadata_history`. MUST be called inside a transaction (the + * only caller is the put/delete txn body) — concurrent writers race + * otherwise. * * #4867 (same shape as #4825 on the legacy `DatabaseLoader` path; rule from * #4632) — discriminate by error TYPE. This used to `catch { return 1 }` @@ -2573,7 +2596,7 @@ export class SysMetadataRepository implements MetadataRepository { private async nextEventSeq(ctx: any): Promise { try { const rows = await this.engine.find(this.historyTable, { - where: { organization_id: this.organizationId }, + where: {}, context: { ...ctx, isSystem: true }, }); let max = 0; @@ -2587,7 +2610,7 @@ export class SysMetadataRepository implements MetadataRepository { return this.historyCounterVerdict( error, 'event_seq', - 'the per-org history cursor that history ordering and rollback targeting both stand on', + 'the history cursor that history ordering and rollback targeting both stand on', ); } } @@ -2614,7 +2637,6 @@ export class SysMetadataRepository implements MetadataRepository { try { const rows = await this.engine.find(this.historyTable, { where: { - organization_id: this.organizationId, type: ref.type, name: ref.name, }, diff --git a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts index 152cac16f49..78ea7ddafa1 100644 --- a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts +++ b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts @@ -36,7 +36,7 @@ import { ObjectStackProtocolImplementation } from './index.js'; import { SysMetadataRepository } from './sys-metadata-repository.js'; interface Row { - id: string; type: string; name: string; organization_id: string | null; + id: string; type: string; name: string; package_id: string | null; state: string; metadata: string; checksum?: string; version?: number; } @@ -49,7 +49,7 @@ function matches(r: Row, where: Record): boolean { } function keyOf(w: Record) { - return `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; + return `${w.type}|${w.name}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; } /** @@ -104,13 +104,9 @@ function makeStubEngine() { return { engine, rows, registered }; } -/** - * [ADR-0131 D6] The save door refuses every organization-scoped write, so an - * organization-scoped view row — one stored before that release, which the - * reads still serve — is planted at rest through the repository. - */ -async function plantOrgRow(engine: any, organizationId: string, name: string, item: unknown): Promise<{ success: true }> { - await new SysMetadataRepository({ engine, organizationId, orgLabel: organizationId }) +/** A stored environment row, written through the overlay repository the way a Studio save writes it. */ +async function plantEnvRow(engine: any, name: string, item: unknown): Promise<{ success: true }> { + await new SysMetadataRepository({ engine }) .put({ type: 'view', name } as any, item, { parentVersion: null, actor: null }); return { success: true }; } @@ -293,7 +289,7 @@ describe('#7736 a runtime-authored view container is served', () => { * isolation because it operates only on rows already scoped to THIS * request's own org/environment (the pre-existing `queryByOrg` merge). */ -describe('#13407 environment-scoped runtime containers are served; [ADR-0131 D6] a legacy organization container only by the anonymous form doors\' legacy read', () => { +describe('#13407 environment-scoped runtime containers are served', () => { it('derives the object binding from the containers own top-level `object` field, not just `list.data.object`', async () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); @@ -312,40 +308,27 @@ describe('#13407 environment-scoped runtime containers are served; [ADR-0131 D6] expect(switcherMatches(list.items, 'crm_lead').map((v: any) => v.name)).toEqual(['crm_lead.default']); }); - it('the cards own repro, at the one read left that reaches a legacy organization row: the anonymous form doors\' legacy layer expands it', async () => { + it('the cards own repro, on a stored environment row: the list read expands it', async () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); - await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); + await plantEnvRow(engine, 'crm_lead', leadContainer); - const list: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_acme' }); + const list: any = await protocol.getMetaItems({ type: 'view' }); const served = switcherMatches(list.items, 'crm_lead').map((v: any) => v.name).sort(); expect(served).toEqual(['crm_lead.default', 'crm_lead.pipeline']); }); - it('[ADR-0131 D6] every other read serves no legacy organization container, whatever organization it names', async () => { - const { engine } = makeStubEngine(); - const protocol = new ObjectStackProtocolImplementation(engine); - await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); - - for (const request of [{ type: 'view' }, { type: 'view', organizationId: 'org_acme' }]) { - const list: any = await protocol.getMetaItems(request as any); - expect(switcherMatches(list.items, 'crm_lead')).toEqual([]); - } - expect((await protocol.getMetaItem({ type: 'view', name: 'crm_lead.default', organizationId: 'org_acme' } as any)).item) - .toBeUndefined(); - }); - it('POSITIVE CONTROL: a pre-existing independent ViewItem for the same object is still served alongside the newly-expanded container', async () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); - await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); - await plantOrgRow(engine, 'org_acme', 'crm_lead.mine', { + await plantEnvRow(engine, 'crm_lead', leadContainer); + await plantEnvRow(engine, 'crm_lead.mine', { name: 'crm_lead.mine', object: 'crm_lead', viewKind: 'list', label: 'My Leads', config: { type: 'grid', data: { provider: 'object', object: 'crm_lead' }, columns: [{ field: 'name' }] }, }); - const list: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_acme' }); + const list: any = await protocol.getMetaItems({ type: 'view' }); const served = switcherMatches(list.items, 'crm_lead').map((v: any) => v.name).sort(); // A fix that merely "returned everything" could not distinguish these: // the pre-existing independent item and the two newly-expanded @@ -353,23 +336,11 @@ describe('#13407 environment-scoped runtime containers are served; [ADR-0131 D6] expect(served).toEqual(['crm_lead.default', 'crm_lead.mine', 'crm_lead.pipeline']); }); - it('ORG ISOLATION HELD: a container authored in org_acme is invisible reading as org_globex, and visible reading as org_acme', async () => { - const { engine } = makeStubEngine(); - const protocol = new ObjectStackProtocolImplementation(engine); - await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); - - const globex: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_globex' }); - expect(switcherMatches(globex.items, 'crm_lead')).toEqual([]); - - const acme: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_acme' }); - expect(switcherMatches(acme.items, 'crm_lead')).toHaveLength(2); - }); - - it('the isolation-safe path never registers an org-scoped expansion into the shared SchemaRegistry', async () => { + it('the isolation-safe path never registers a read\'s expansion into the shared SchemaRegistry', async () => { const { engine, registered } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); - await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); - await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_acme' }); + await plantEnvRow(engine, 'crm_lead', leadContainer); + await protocol.getMetaItems({ type: 'view' }); // The RESPONSE carries the expansion (re-confirmed above); the SHARED // registry — read by every org/environment this kernel serves — must @@ -392,10 +363,10 @@ describe('#13407 environment-scoped runtime containers are served; [ADR-0131 D6] }); describe('anti-vacuity — the closure does not become "expand anything"', () => { - it('a DIFFERENT object in the SAME org still serves nothing for it', async () => { + it('a DIFFERENT object still serves nothing for it, whatever organization the caller names', async () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); - await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); + await plantEnvRow(engine, 'crm_lead', leadContainer); const list: any = await protocol.getMetaItems({ type: 'view', organizationId: 'org_acme' } as any); expect(switcherMatches(list.items, 'crm_account')).toEqual([]); }); @@ -439,7 +410,6 @@ describe('#21334 a container on another package\'s object never takes that packa const REPAIR = 'com.example.repairassets'; const TASK = 'showcase_task'; const DEFAULT = `${TASK}.default`; - const ORG = 'org_acme'; const data = { provider: 'object', object: TASK }; const PACKAGED_COLUMNS = ['title', 'project', 'assignee', 'status', 'priority', 'due_date', 'progress'] .map((field) => ({ field })); @@ -645,12 +615,11 @@ describe('#21334 a container on another package\'s object never takes that packa const CONTAINERS = [ { arm: 'package-scoped (saved into another writable package)', packageId: REPAIR, organizationId: undefined, ownPackage: REPAIR }, { arm: 'package-less, environment-wide', packageId: undefined, organizationId: undefined, ownPackage: undefined }, - // [ADR-0131 D6] No organization-scoped arm: a legacy organization - // container is served by no read (pinned in the ISOLATION case below). + // [ADR-0131 D6/D7] No organization-scoped arm: no stored row carries + // an organization. ] as { arm: string; packageId: string | undefined; organizationId: string | undefined; ownPackage: string | undefined }[]; - const save = (protocol: Protocol, name: string, item: unknown, c: (typeof CONTAINERS)[number]) => c.organizationId - ? plantOrgRow((protocol as any).engine, c.organizationId, name, item) - : protocol.saveMetaItem({ + const save = (protocol: Protocol, name: string, item: unknown, c: (typeof CONTAINERS)[number]) => + protocol.saveMetaItem({ type: 'view', name, item, ...(c.packageId ? { packageId: c.packageId } : {}), } as any); @@ -705,7 +674,7 @@ describe('#21334 a container on another package\'s object never takes that packa // A row bound to the package that owns the object (an installed // package's own stored view), written straight to the store. rows.set('own-pkg-row', { - id: 'r_own', type: 'view', name: 'os_qa_same_pkg', organization_id: null, + id: 'r_own', type: 'view', name: 'os_qa_same_pkg', package_id: SHOWCASE, state: 'active', metadata: JSON.stringify(probe('os_qa_same_pkg')), }); @@ -777,7 +746,7 @@ describe('#21334 a container on another package\'s object never takes that packa const { protocol, rows } = showcaseHarness('env_local'); const nameless = { object: TASK, list: { type: 'grid', columns: ['title'] }, listViews: { in_progress: { label: 'Mine', type: 'grid', columns: ['title'] } } }; rows.set('nameless-row', { - id: 'r_nameless', type: 'view', name: 'os_qa_nameless', organization_id: null, + id: 'r_nameless', type: 'view', name: 'os_qa_nameless', package_id: REPAIR, state: 'active', metadata: JSON.stringify(nameless), }); @@ -895,18 +864,6 @@ describe('#21334 a container on another package\'s object never takes that packa }); } - it('[ADR-0131 D6] ISOLATION — a legacy organization-scoped container\'s names answer nothing, for any caller', async () => { - const { protocol } = showcaseHarness(environmentId); - await plantOrgRow((protocol as any).engine, ORG, OWN, { name: OWN, object: TASK, ...MEMBER_CASES['listViews.*'].member }); - const expanded = MEMBER_CASES['listViews.*'].servedAs; - - expect(await byNameDoor(protocol, expanded, ORG)).toBeUndefined(); - expect(ownNames(await objectDoor(protocol, ORG))).toEqual([]); - expect(ownNames(await objectDoor(protocol, 'org_globex'))).toEqual([]); - expect(await byNameDoor(protocol, expanded, 'org_globex')).toBeUndefined(); - expect(await byNameDoor(protocol, expanded)).toBeUndefined(); - }); - for (const organizationId of [undefined] as Array) { it(`a tenant overlay of the package's own container (${organizationId ? 'organization-scoped' : 'environment-wide'}): each name it expands answers the overlay's view by name, not the packaged one`, async () => { const { protocol } = showcaseHarness(environmentId); @@ -915,9 +872,7 @@ describe('#21334 a container on another package\'s object never takes that packa list: { label: 'Customized', type: 'grid', data, columns: [{ field: 'title' }] }, listViews: { in_progress: { label: 'Customized In Progress', type: 'grid', data, columns: [{ field: 'title' }] } }, }; - await (organizationId - ? plantOrgRow((protocol as any).engine, organizationId, TASK, overlay) - : protocol.saveMetaItem({ type: 'view', name: TASK, item: overlay } as any)); + await protocol.saveMetaItem({ type: 'view', name: TASK, item: overlay } as any); const served = await objectDoor(protocol, organizationId); for (const name of [DEFAULT, `${TASK}.in_progress`]) { @@ -994,9 +949,8 @@ describe('#21334 a container on another package\'s object never takes that packa }; /** The control: a name the container expands that has no row of its own. */ const ROWLESS = `${TASK}.in_progress`; - const saveView = (protocol: Protocol, name: string, item: unknown, organizationId?: string) => (organizationId - ? plantOrgRow((protocol as any).engine, organizationId, name, item) - : protocol.saveMetaItem({ type: 'view', name, item } as any)); + const saveView = (protocol: Protocol, name: string, item: unknown, _organizationId?: string) => + protocol.saveMetaItem({ type: 'view', name, item } as any); /** The two doors answer `name` with one item, and that item is the one `expectItem` names. */ const expectBothDoors = async ( protocol: Protocol, name: string, organizationId: string | undefined, expectItem: (v: any) => void, @@ -1032,7 +986,7 @@ describe('#21334 a container on another package\'s object never takes that packa ]; for (const write of order === 'the container first' ? writes : [...writes].reverse()) await write(); expect( - [...rows.values()].filter((r) => r.name === DEFAULT && r.organization_id === (organizationId ?? null)), + [...rows.values()].filter((r) => r.name === DEFAULT), 'the save door stored the row under the expanded name', ).toHaveLength(1); @@ -1056,20 +1010,6 @@ describe('#21334 a container on another package\'s object never takes that packa }); } } - - it('[ADR-0131 D6] a legacy organization row of the name is no caller\'s row: every caller is served the container\'s expansion', async () => { - const { protocol } = showcaseHarness(environmentId); - await saveView(protocol, TASK, container); - await saveView(protocol, DEFAULT, row, ORG); - - const expectTheDefaultExpansion = (v: any) => { - expect(v?.label).toBe('FromContainer'); - expect(v?.config?.columns).toEqual([{ field: 'title' }]); - }; - await expectBothDoors(protocol, DEFAULT, ORG, expectTheDefaultExpansion); - await expectBothDoors(protocol, DEFAULT, undefined, expectTheDefaultExpansion); - await expectBothDoors(protocol, DEFAULT, 'org_globex', expectTheDefaultExpansion); - }); }); } }); @@ -1149,9 +1089,9 @@ describe('#21334 a container on another package\'s object never takes that packa const saved = (await save(protocol, m.servedAs, byName, c)) as any; const stored = [...rows.values()] .filter((r) => r.name === m.servedAs) - .map((r) => ({ package_id: r.package_id, organization_id: r.organization_id, state: r.state })); + .map((r) => ({ package_id: r.package_id, state: r.state, organization: 'organization_id' in r })); expect(stored, `${kernelName(environmentId)}: stored once, in the container's scope`) - .toEqual([{ package_id: c.packageId ?? null, organization_id: null, state: 'active' }]); + .toEqual([{ package_id: c.packageId ?? null, state: 'active', organization: false }]); expect((await byNameDoor(protocol, m.servedAs))?.label).toBe('ByName'); expect(named(await objectDoor(protocol), m.servedAs).map((v) => v.label)).toEqual(['ByName']); outcomes.push({ success: saved?.success, stored, ...(await answer(protocol, m.servedAs)) }); @@ -1761,16 +1701,6 @@ describe('#21334 a container on another package\'s object never takes that packa serves: [{ object: TASK, name: `${TASK}.${OWN}`, label: 'Probe' }, { object: TASK, name: DEFAULT, label: 'All Tasks' }], }, }, - { - shape: 'an environment-wide container saved under the expanded name of an organization\'s container', - scopes: ['environment-wide'], - given: [{ name: LEAD, item: storedLead, organizationId: ORG }], - save: { name: `${LEAD}.pipeline`, item: { object: LEAD, list: leadList('Everyone') } }, - allowed: { - because: 'the caller\'s selection decides: an organization\'s rows are not an environment-wide caller\'s, and reading every organization\'s rows at an environment-wide save would be a cross-tenant read at a write door', - serves: [{ object: LEAD, name: `${LEAD}.default`, label: 'Everyone' }], - }, - }, ]; /** The ruling's acceptance list, verbatim in substance: each must be a row. */ @@ -1827,7 +1757,6 @@ describe('#21334 a container on another package\'s object never takes that packa const writeIn = (protocol: Protocol, write: Write, callerOrganizationId: string | undefined, mode?: 'draft' | 'publish') => { const organizationId = write.organizationId === undefined ? callerOrganizationId : (write.organizationId ?? undefined); - if (organizationId) return plantOrgRow((protocol as any).engine, organizationId, write.name, write.item); return protocol.saveMetaItem({ type: 'view', name: write.name, item: write.item, ...(write.packageId ? { packageId: write.packageId } : {}), @@ -1837,7 +1766,7 @@ describe('#21334 a container on another package\'s object never takes that packa }; const refusalOf = (write: Promise) => write.then(() => null, (e: any) => e); const viewRowsOf = (rows: Map) => - [...rows.values()].filter((r) => r.type === 'view').map((r) => [r.name, r.organization_id, r.state, r.metadata]).sort(); + [...rows.values()].filter((r) => r.type === 'view').map((r) => [r.name, r.state, r.metadata]).sort(); /** `name` answers one view item carrying `label` on BOTH doors. */ const expectServed = async (protocol: Protocol, served: Served, organizationId: string | undefined) => { const listed = named( @@ -1942,7 +1871,7 @@ describe('#21334 a container on another package\'s object never takes that packa ] as const; const storeAtRest = (rows: Map, name: string, body: Record, organizationId?: string) => rows.set(`at-rest:${name}:${organizationId ?? 'env'}`, { - id: `r_${name}`, type: 'view', name, organization_id: organizationId ?? null, + id: `r_${name}`, type: 'view', name, package_id: null, state: 'active', metadata: JSON.stringify({ name, ...body }), }); diff --git a/packages/metadata/src/loaders/database-loader.test.ts b/packages/metadata/src/loaders/database-loader.test.ts index 0dd22855b88..b2983bd4933 100644 --- a/packages/metadata/src/loaders/database-loader.test.ts +++ b/packages/metadata/src/loaders/database-loader.test.ts @@ -390,8 +390,8 @@ describe('DatabaseLoader', () => { }); }); - describe('multi-tenant isolation', () => { - it('should filter by organizationId when configured (environmentId accepted but ignored — ADR-0008 §0)', async () => { + describe('organization scope (ADR-0131 D6/D7)', () => { + it('accepts organizationId and environmentId for back-compat and scopes nothing by either', async () => { const tenantLoader = new DatabaseLoader({ driver: mockDriver, organizationId: 'org-1', @@ -400,28 +400,20 @@ describe('DatabaseLoader', () => { await tenantLoader.save('object', 'account', { name: 'account' }); - // The create call should include organization_id but NOT environment_id - // (environment_id was removed from the metadata layer in the ADR-0008 §0 - // branch/project-removal amendment). - expect(mockDriver.create).toHaveBeenCalledWith( - 'sys_metadata', - expect.objectContaining({ organization_id: 'org-1' }) - ); - expect(mockDriver.create).not.toHaveBeenCalledWith( - 'sys_metadata', - expect.objectContaining({ environment_id: expect.anything() }) - ); + // The family declares no organization column (ADR-0131 D7) and the + // metadata layer lost environment_id (ADR-0008 §0): the create names + // neither. + for (const [table, row] of (mockDriver.create as any).mock.calls) { + expect(row, String(table)).not.toHaveProperty('organization_id'); + expect(row, String(table)).not.toHaveProperty('environment_id'); + } + expect(mockDriver.create).toHaveBeenCalledWith('sys_metadata', expect.anything()); - // The find calls should filter by organization_id (no environment_id). await tenantLoader.load('object', 'account'); - expect(mockDriver.findOne).toHaveBeenCalledWith( - 'sys_metadata', - expect.objectContaining({ - where: expect.objectContaining({ organization_id: 'org-1' }), - }) - ); const findOneCalls = (mockDriver.findOne as any).mock.calls; + expect(findOneCalls.length).toBeGreaterThan(0); for (const [, opts] of findOneCalls) { + expect((opts?.where ?? {})).not.toHaveProperty('organization_id'); expect((opts?.where ?? {})).not.toHaveProperty('environment_id'); } }); diff --git a/packages/metadata/src/loaders/database-loader.ts b/packages/metadata/src/loaders/database-loader.ts index 4da1f0736df..aac2d91c039 100644 --- a/packages/metadata/src/loaders/database-loader.ts +++ b/packages/metadata/src/loaders/database-loader.ts @@ -19,7 +19,7 @@ import type { MetadataRecord, MetadataHistoryRecord, } from '@objectstack/spec/system'; -import { SysMetadataObject, SysMetadataHistoryObject, hashSpec } from '@objectstack/metadata-core'; +import { SysMetadataObject, SysMetadataHistoryObject, hashSpec, readLegacyOrganizationRows } from '@objectstack/metadata-core'; import { applyConversionsToStoredItem } from '@objectstack/spec'; import { lowerFilterCondition, type FilterLoweringOptions } from '@objectstack/spec/data'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; @@ -32,6 +32,11 @@ import { LRUCache } from '../utils/lru-cache.js'; import { isMissingTableError, isSchemaAlreadyExistsError } from '@objectstack/types'; import { migrateProjectIdToEnvironmentId } from '../migrations/migrate-project-id-to-environment-id.js'; +/** [ADR-0131 D7] The tables whose physical `organization_id` can outlive their declaration. */ +const LEGACY_FAMILY_TABLES: ReadonlySet = new Set([ + 'sys_metadata', 'sys_metadata_audit', 'sys_metadata_commit', 'sys_metadata_history', +]); + /** * Canonicalise a driver-materialised timestamp into the ISO-8601 string * `MetadataStats.mtime` is declared as. @@ -231,7 +236,12 @@ export interface DatabaseLoaderOptions { /** The table name to store history records (default: 'sys_metadata_history') */ historyTableName?: string; - /** Organization ID for multi-tenant isolation */ + /** + * @deprecated since ADR-0131 D6/D7 (the `sys_metadata` family declares no + * organization column). Every row is the environment's, so this option is + * accepted for back-compat but ignored — writes do not set + * `organization_id` and filters do not constrain on it. + */ organizationId?: string; /** @@ -277,7 +287,6 @@ export class DatabaseLoader implements MetadataLoader { private engine?: IDataEngine; private tableName: string; private historyTableName: string; - private organizationId?: string; private trackHistory: boolean; private schemaReady = false; private historySchemaReady = false; @@ -312,7 +321,8 @@ export class DatabaseLoader implements MetadataLoader { this.engine = options.engine; this.tableName = options.tableName ?? 'sys_metadata'; this.historyTableName = options.historyTableName ?? 'sys_metadata_history'; - this.organizationId = options.organizationId; + // ADR-0131 D6/D7: `organizationId` is accepted for back-compat but ignored. + void options.organizationId; // ADR-0008 §0: `environmentId` option is accepted for back-compat but ignored. void options.environmentId; this.trackHistory = options.trackHistory !== false; // Default to true @@ -402,17 +412,41 @@ export class DatabaseLoader implements MetadataLoader { // reconcile the two schemas again — not to reinstate the cast. private async _find(table: string, query: DriverQuery): Promise[]> { - if (this.engine) { - return this.engine.find(table, query); - } - return this.driver!.find(table, query); + const rows = this.engine ? await this.engine.find(table, query) : await this.driver!.find(table, query); + return this.withoutLegacyOrganizationRows(table, rows); } private async _findOne(table: string, query: DriverQuery): Promise | null> { - if (this.engine) { - return this.engine.findOne(table, query); - } - return this.driver!.findOne(table, query); + const row = this.engine ? await this.engine.findOne(table, query) : await this.driver!.findOne(table, query); + if (!row) return row ?? null; + const legacy = await this.legacyOrganizationRows(table); + if (!legacy.has(String(row.id))) return row; + return (await this._find(table, query))[0] ?? null; + } + + /** + * [ADR-0131 D6/D7] The family's rows without a legacy organization's. The + * family declares no organization column; on a database the + * organization-ownership ceremony has not migrated, the physical column + * still holds each legacy organization's rows, and a loader wired to the raw + * driver would read them as the environment's. Told apart off that column + * ({@link readLegacyOrganizationRows}); a read that cannot answer throws. + */ + private async withoutLegacyOrganizationRows( + table: string, + rows: Record[] | null | undefined, + ): Promise[]> { + if (!rows || rows.length === 0) return rows ?? []; + const legacy = await this.legacyOrganizationRows(table); + return legacy.size === 0 ? rows : rows.filter((row) => !legacy.has(String(row.id))); + } + + private legacyOrganizationRows(table: string): Promise> { + if (!LEGACY_FAMILY_TABLES.has(table)) return Promise.resolve(new Map()); + const driver = this.engine + ? (this.engine as { getDriverForObject?: (objectName: string) => unknown }).getDriverForObject?.(table) + : this.driver; + return readLegacyOrganizationRows(driver, table); } private async _count(table: string, query: DriverQuery): Promise { @@ -462,8 +496,8 @@ export class DatabaseLoader implements MetadataLoader { } /** - * Compute the next per-org `event_seq` for `sys_metadata_history`. - * Reads `MAX(event_seq) + 1` for the configured `organization_id`. + * Compute the next `event_seq` for `sys_metadata_history`. + * Reads `MAX(event_seq) + 1` over the environment's history (ADR-0131 D6). * Legacy path — not transactional, so concurrent writes can collide. * The canonical (transactional) producer is `SysMetadataRepository`. * @@ -486,9 +520,7 @@ export class DatabaseLoader implements MetadataLoader { * caller ({@link createHistoryRecord}) owns the consequence. */ private async nextEventSeq(): Promise { - const where: Record = this.organizationId - ? { organization_id: this.organizationId } - : {}; + const where: Record = {}; try { const rows = await this._find(this.historyTableName, { where }); let max = 0; @@ -679,18 +711,15 @@ export class DatabaseLoader implements MetadataLoader { /** * Build base filter conditions for queries. - * Filters by organizationId when configured. `environmentId` is accepted - * for back-compat but no longer constrains the query — see - * ADR-0008 §0 (branch/project removal). + * Neither `organizationId` (ADR-0131 D6/D7: the family declares no + * organization column) nor `environmentId` (ADR-0008 §0) constrains the + * query — both options are accepted for back-compat only. */ private baseFilter(type: string, name?: string): Record { const filter: Record = { type }; if (name !== undefined) { filter.name = name; } - if (this.organizationId) { - filter.organization_id = this.organizationId; - } return filter; } @@ -789,7 +818,6 @@ export class DatabaseLoader implements MetadataLoader { changeNote, recordedBy, recordedAt: now, - ...(this.organizationId ? { organizationId: this.organizationId } : {}), }; try { @@ -807,7 +835,6 @@ export class DatabaseLoader implements MetadataLoader { recorded_by: historyRecord.recordedBy, recorded_at: historyRecord.recordedAt, source: 'database-loader', - ...(this.organizationId ? { organization_id: this.organizationId } : {}), }); } catch (error) { // Log error but don't fail the main operation @@ -873,7 +900,7 @@ export class DatabaseLoader implements MetadataLoader { strategy: (row.strategy as MetadataRecord['strategy']) ?? 'merge', owner: row.owner as string | undefined, state: (row.state as MetadataRecord['state']) ?? 'active', - organizationId: row.organization_id as string | undefined, + organizationId: undefined /* [ADR-0131 D7] every row read here is the environment's */, environmentId: row.environment_id as string | undefined, version: (row.version as number) ?? 1, checksum: row.checksum as string | undefined, @@ -1203,10 +1230,6 @@ export class DatabaseLoader implements MetadataLoader { name, version, }; - if (this.organizationId) { - filter.organization_id = this.organizationId; - } - const row = await this._findOne(this.historyTableName, { where: filter, }); @@ -1222,7 +1245,7 @@ export class DatabaseLoader implements MetadataLoader { checksum: row.checksum as string, previousChecksum: row.previous_checksum as string | undefined, changeNote: row.change_note as string | undefined, - organizationId: row.organization_id as string | undefined, + organizationId: undefined /* [ADR-0131 D7] every row read here is the environment's */, recordedBy: row.recorded_by as string | undefined, // [#16422] `MetadataHistoryRecord.recordedAt` is a REQUIRED // `z.string().datetime()`, so it is the one site in this family with no @@ -1271,7 +1294,6 @@ export class DatabaseLoader implements MetadataLoader { type, name, }; - if (this.organizationId) historyFilter.organization_id = this.organizationId; if (options?.operationType) historyFilter.operation_type = options.operationType; if (options?.since) historyFilter.recorded_at = { $gte: options.since }; if (options?.until) { @@ -1327,7 +1349,7 @@ export class DatabaseLoader implements MetadataLoader { checksum: row.checksum as string, previousChecksum: row.previous_checksum as string | undefined, changeNote: row.change_note as string | undefined, - organizationId: row.organization_id as string | undefined, + organizationId: undefined /* [ADR-0131 D7] every row read here is the environment's */, recordedBy: row.recorded_by as string | undefined, // [#16422] The other door onto the same column — same shared spelling, // same caller-side terminal value. See `getHistoryRecord` above for @@ -1478,7 +1500,6 @@ export class DatabaseLoader implements MetadataLoader { state: 'active', version: 1, source: 'database', - ...(this.organizationId ? { organization_id: this.organizationId } : {}), created_at: now, updated_at: now, }); diff --git a/packages/metadata/src/utils/history-cleanup-dst.test.ts b/packages/metadata/src/utils/history-cleanup-dst.test.ts index 928b1f9e86d..a5518f46e2f 100644 --- a/packages/metadata/src/utils/history-cleanup-dst.test.ts +++ b/packages/metadata/src/utils/history-cleanup-dst.test.ts @@ -102,8 +102,10 @@ interface Captured { /** * A manager over a driver stub that records the filter each call site builds. - * `HistoryCleanupManager` reads `driver`, `historyTableName` and - * `organizationId` off the loader by property, so a plain object is enough. + * `HistoryCleanupManager` reads `driver` and `historyTableName` off the loader + * by property, so a plain object is enough. `organizationId` is planted to + * prove it scopes nothing (ADR-0131 D6/D7: the family declares no + * organization column). */ function managerFor( policy: MetadataHistoryRetentionPolicy, @@ -317,13 +319,13 @@ describe('#15824 fences — retention did not become timezone-aware', () => { }); describe('#15824 fences — the rest of both filters is untouched', () => { - it('the delete filter still scopes by organization and excludes executionPinned types', async () => { + it('the delete filter names no organization and excludes executionPinned types', async () => { const captured: Captured = {}; await at('America/New_York', '2026-03-08T07:00:00.000Z', () => managerFor({ maxAgeDays: 1 }, captured, 'org_42').runCleanup(), ); const filter = captured.deleteFilter!; - expect(filter.organization_id).toBe('org_42'); + expect(filter).not.toHaveProperty('organization_id'); expect((filter.type as { $nin: string[] }).$nin.length).toBeGreaterThan(0); expect(filter.recorded_at).toEqual({ $lt: utcWindowStart('2026-03-08T07:00:00.000Z', 1) }); }); @@ -334,7 +336,7 @@ describe('#15824 fences — the rest of both filters is untouched', () => { managerFor({ maxAgeDays: 1 }, captured, 'org_42').getCleanupStats(), ); const where = captured.countWhere!; - expect(where.organization_id).toBe('org_42'); + expect(where).not.toHaveProperty('organization_id'); expect((where.type as { $nin: string[] }).$nin.length).toBeGreaterThan(0); expect(where.recorded_at).toEqual({ $lt: utcWindowStart('2026-10-03T15:30:00.000Z', 1) }); }); diff --git a/packages/metadata/src/utils/history-cleanup.ts b/packages/metadata/src/utils/history-cleanup.ts index e0351a8bb90..1ee8799aeff 100644 --- a/packages/metadata/src/utils/history-cleanup.ts +++ b/packages/metadata/src/utils/history-cleanup.ts @@ -78,9 +78,9 @@ export class HistoryCleanupManager { async runCleanup(): Promise<{ deleted: number; errors: number }> { const driver = (this.dbLoader as any).driver as IDataDriver; const historyTableName = (this.dbLoader as any).historyTableName as string; - const organizationId = (this.dbLoader as any).organizationId as string | undefined; - // `environmentId` was removed from the metadata layer (ADR-0008 §0 - // amendment). Cleanup is now scoped by `organization_id` only. + // Neither `environmentId` (ADR-0008 §0 amendment) nor `organization_id` + // (ADR-0131 D6/D7: the family declares no organization column) scopes + // cleanup: every history row is the environment's. let deleted = 0; let errors = 0; @@ -101,10 +101,6 @@ export class HistoryCleanupManager { recorded_at: { $lt: cutoffISO }, }; - if (organizationId) { - filter.organization_id = organizationId; - } - if (pinnedTypes.length > 0) { // Exclude executionPinned rows from bulk age delete. filter.type = { $nin: pinnedTypes }; @@ -124,7 +120,6 @@ export class HistoryCleanupManager { try { // Get all unique metadata items keyed by (type, name) const baseWhere: Record = {}; - if (organizationId) baseWhere.organization_id = organizationId; const metaItems = await driver.find(historyTableName, { where: baseWhere, @@ -240,7 +235,6 @@ export class HistoryCleanupManager { }> { const driver = (this.dbLoader as any).driver as IDataDriver; const historyTableName = (this.dbLoader as any).historyTableName as string; - const organizationId = (this.dbLoader as any).organizationId as string | undefined; let recordsByAge = 0; let recordsByCount = 0; @@ -252,7 +246,6 @@ export class HistoryCleanupManager { try { const baseWhere: Record = {}; - if (organizationId) baseWhere.organization_id = organizationId; // Count records that would be deleted by age if (this.policy.maxAgeDays) { diff --git a/packages/objectql/src/layered-overlay-integration.test.ts b/packages/objectql/src/layered-overlay-integration.test.ts index 761ce15daf6..f1ac44f7df9 100644 --- a/packages/objectql/src/layered-overlay-integration.test.ts +++ b/packages/objectql/src/layered-overlay-integration.test.ts @@ -149,7 +149,6 @@ describe('LayeredRepository(overlay over artifact) — ADR-0008 PR-10c', () => { overlay = new SysMetadataRepository({ engine: makeFakeEngine(), - organizationId: 'org_alpha', orgLabel: 'org_alpha', }); diff --git a/packages/objectql/src/plugin.integration.test.ts b/packages/objectql/src/plugin.integration.test.ts index 3edc3524bad..1d00d84c5eb 100644 --- a/packages/objectql/src/plugin.integration.test.ts +++ b/packages/objectql/src/plugin.integration.test.ts @@ -1054,7 +1054,7 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => { // Assert — sys_metadata should have been queried const metaQuery = findCalls.find((c) => c.object === 'sys_metadata'); expect(metaQuery).toBeDefined(); - expect(metaQuery!.query.where).toEqual({ state: 'active', organization_id: null }); + expect(metaQuery!.query.where).toEqual({ state: 'active' }); // Assert — items should be restored into the registry const registry = (kernel.getService('objectql') as any).registry; diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 2e0a2ac85e2..71de5edbb48 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -2,6 +2,7 @@ import { ObjectQL, SECURITY_SERVICE_SOURCE_SLOT } from './engine.js'; import { assembleMetadataProtocol } from '@objectstack/metadata-protocol'; +import { readLegacyOrganizationRows } from '@objectstack/metadata-core'; import type { MetadataAuthoringChannel } from '@objectstack/metadata-protocol'; import { Plugin, PluginContext } from '@objectstack/core'; import { resolveArtifactPackageOrder, artifactPackageId, readDeploymentOrgScopingEntitlement } from '@objectstack/core'; @@ -9,10 +10,6 @@ import { resolveArtifactPackageOrder, artifactPackageId, readDeploymentOrgScopin // multi-package stack no body declares, and the id they take. See // `unclaimedTopLevelObjects` below. import { unclaimedTopLevel } from '@objectstack/metadata'; -// [ADR-0131 D6] The one row-value test for a legacy organization's -// `sys_metadata` row: no metadata read serves one, so the authored hook and -// action binders skip it too. -import { isLegacyOrganizationRow } from '@objectstack/metadata-core'; import { applyConversionsToStoredItem } from '@objectstack/spec'; import { StorageNameMapping } from '@objectstack/spec/system'; import { applyProtection } from '@objectstack/spec/shared'; @@ -2547,6 +2544,17 @@ export class ObjectQLPlugin implements Plugin { }); } + /** + * [ADR-0131 D6/D7] The ids of a legacy organization's `sys_metadata` rows, + * read off the physical column through the table's driver — empty on a + * migrated or fresh database. Throws when the read cannot answer. + */ + private async legacyOrganizationRowIds(): Promise> { + const driver = (this.ql as { getDriverForObject?: (objectName: string) => unknown } | undefined) + ?.getDriverForObject?.('sys_metadata'); + return new Set((await readLegacyOrganizationRows(driver)).keys()); + } + /** * Read the ACTIVE runtime-authored hook rows from `sys_metadata`. * @@ -2554,11 +2562,13 @@ export class ObjectQLPlugin implements Plugin { * going through the metadata service, because env-scoped kernels have no * DatabaseLoader so the service never surfaces these rows. * - * [ADR-0131 D6] The environment's rows only (`organization_id IS NULL`), - * the rule every metadata read follows: a legacy organization-scoped hook - * row is not bound — the protocol's boot report - * (`reportUnhydratableOrgScopedRows`) names it, and the v18 migration - * ceremony carries it. + * [ADR-0131 D6/D7] The environment's rows only: a legacy + * organization-scoped hook row is not bound, and the v18 migration ceremony + * carries it. The family declares no organization column, so the engine row + * never carries one; on a database the ceremony has not migrated (which + * `os serve` refuses, but an `os migrate` boot binds on) the legacy rows are + * told apart by the physical column ({@link legacyOrganizationRowIds}). A + * read of it that cannot answer is a failed read (`null`) like any other. * * Returns `null` when the read failed (e.g. no sys_metadata table on this * kernel) — callers must treat that as "couldn't read", NOT "zero hooks", @@ -2587,10 +2597,11 @@ export class ObjectQLPlugin implements Plugin { context: { isSystem: true }, })) ?? []; } + const legacy = await this.legacyOrganizationRowIds(); const hooks: any[] = []; for (const row of rows) { - // [ADR-0131 D6] The environment's rows only — see the TSDoc. - if (isLegacyOrganizationRow(row)) continue; + // [ADR-0131 D6/D7] The environment's rows only — see the TSDoc. + if (legacy.has(String(row.id))) continue; try { const data = this.convertStoredRow( ctx, @@ -2775,9 +2786,11 @@ export class ObjectQLPlugin implements Plugin { context: { isSystem: true }, })) ?? []; } + // [ADR-0131 D6/D7] The environment's rows only — see readAuthoredHookRows. + const legacy = await this.legacyOrganizationRowIds(); const actions: any[] = []; for (const row of rows) { - if (isLegacyOrganizationRow(row)) continue; // [ADR-0131 D6] + if (legacy.has(String(row.id))) continue; const data = parseRow(row, 'action'); if (data && typeof data.name === 'string') actions.push(data); } @@ -2790,7 +2803,7 @@ export class ObjectQLPlugin implements Plugin { context: { isSystem: true }, })) ?? []; for (const row of objectRows) { - if (isLegacyOrganizationRow(row)) continue; // [ADR-0131 D6] + if (legacy.has(String(row.id))) continue; // [ADR-0131 D6/D7] const obj = parseRow(row, 'object'); if (!obj || typeof obj.name !== 'string' || !Array.isArray(obj.actions)) continue; for (const action of obj.actions) { diff --git a/packages/objectql/src/protocol-layered-get.test.ts b/packages/objectql/src/protocol-layered-get.test.ts index bdf55384186..00096f838fd 100644 --- a/packages/objectql/src/protocol-layered-get.test.ts +++ b/packages/objectql/src/protocol-layered-get.test.ts @@ -46,7 +46,7 @@ describe('ObjectStackProtocolImplementation - getMetaItemLayered', () => { it('returns both layers when env-wide overlay exists', async () => { mockEngine.findOne.mockImplementation((_table: string, opts: any) => { - if (opts.where?.organization_id === null) { + if (opts.where?.type === 'view' && opts.where?.name === 'my_view' && !('organization_id' in opts.where)) { return Promise.resolve({ metadata: overlayBody }); } return Promise.resolve(null); @@ -59,13 +59,12 @@ describe('ObjectStackProtocolImplementation - getMetaItemLayered', () => { expect(result.effective).toMatchObject({ label: 'Customised Label' }); }); - it('[ADR-0131 D6] reports the environment overlay even when a legacy organization row exists', async () => { - const orgOverlay = { ...overlayBody, label: 'Org-Specific Label' }; + it('[ADR-0131 D6] a caller naming an organization is reported the environment overlay', async () => { mockEngine.findOne.mockImplementation((_table: string, opts: any) => { - if (opts.where?.organization_id === 'org_alpha') { - return Promise.resolve({ metadata: orgOverlay }); + if (opts.where && 'organization_id' in opts.where) { + throw new Error('a sys_metadata read named the retired organization column'); } - if (opts.where?.organization_id === null) { + if (opts.where?.type === 'view' && opts.where?.name === 'my_view') { return Promise.resolve({ metadata: overlayBody }); } return Promise.resolve(null); diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index b838c972f16..dc620874b51 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -63,20 +63,19 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { // ADR-0005 (revised 2026-05): per-organization overlay isolation // ═══════════════════════════════════════════════════════════════ - describe('[ADR-0131 D6] no read serves a legacy organization row', () => { - // The per-organization overlay axis is retired: every metadata read is - // environment → code. A legacy organization-scoped row of the same - // `(type, name)` as an environment row is not read at all — not even - // when a caller still names its organization. - const orgOrEnv = (orgBody: unknown, envBody: unknown) => (_table: string, opts: any) => { - if (opts?.where?.organization_id === 'org_alpha') return Promise.resolve(orgBody); - if (opts?.where?.organization_id === null) return Promise.resolve(envBody); - return Promise.resolve(Array.isArray(envBody) ? [] : null); + describe('[ADR-0131 D6/D7] every read is the environment\'s, whatever organization the caller names', () => { + // The per-organization overlay axis is retired and the family declares + // no organization column: no query names one, and a caller naming an + // organization is served the environment's rows. + const envOnly = (envBody: unknown) => (_table: string, opts: any) => { + if (opts?.where && 'organization_id' in opts.where) { + throw new Error('a sys_metadata read named the retired organization column'); + } + return Promise.resolve(envBody); }; - it('getMetaItem serves the environment row, and never asks for the organization\'s', async () => { - mockEngine.findOne.mockImplementation(orgOrEnv( - { type: 'view', name: 'test_grid', state: 'active', metadata: JSON.stringify({ ...sampleView, label: 'Org Alpha' }) }, + it('getMetaItem serves the environment row, and never names an organization', async () => { + mockEngine.findOne.mockImplementation(envOnly( { type: 'view', name: 'test_grid', state: 'active', metadata: JSON.stringify({ ...sampleView, label: 'Env Default' }) }, )); const result = await protocol.getMetaItem({ @@ -84,21 +83,19 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { } as { type: string; name: string }); expect((result.item as any).label).toBe('Env Default'); for (const [table, query] of mockEngine.findOne.mock.calls) { - if (table === 'sys_metadata') expect((query as any)?.where?.organization_id ?? null).toBeNull(); + if (table === 'sys_metadata') expect((query as any)?.where ?? {}).not.toHaveProperty('organization_id'); } }); - it('getMetaItems lists the environment rows only, whatever organization the caller names', async () => { - mockEngine.find.mockImplementation(orgOrEnv( - [ - { type: 'view', name: 'shared', state: 'active', metadata: JSON.stringify({ name: 'shared', label: 'Org Alpha' }) }, - { type: 'view', name: 'alpha_only', state: 'active', metadata: JSON.stringify({ name: 'alpha_only', label: 'Alpha Only' }) }, - ], - [ - { type: 'view', name: 'shared', state: 'active', metadata: JSON.stringify({ name: 'shared', label: 'Env Default' }) }, - { type: 'view', name: 'env_only', state: 'active', metadata: JSON.stringify({ name: 'env_only', label: 'Env Only' }) }, - ], - )); + it('getMetaItems lists the environment rows, whatever organization the caller names', async () => { + mockEngine.find.mockImplementation((table: string, opts: any) => envOnly( + table === 'sys_metadata' && opts?.where?.state !== 'draft' + ? [ + { type: 'view', name: 'shared', state: 'active', metadata: JSON.stringify({ name: 'shared', label: 'Env Default' }) }, + { type: 'view', name: 'env_only', state: 'active', metadata: JSON.stringify({ name: 'env_only', label: 'Env Only' }) }, + ] + : [], + )(table, opts)); const result = await protocol.getMetaItems({ type: 'view', organizationId: 'org_alpha' } as { type: string }); const names = (result.items as any[]).map((i) => i.name).sort(); expect(names).toEqual(['env_only', 'shared']); @@ -239,7 +236,7 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { expect(mockEngine.findOne).toHaveBeenCalledWith('sys_metadata', { // ADR-0048 — package-less save scopes the lookup to the global row. - where: { type: 'app', name: 'test_app', organization_id: null, state: 'active', package_id: null }, + where: { type: 'app', name: 'test_app', state: 'active', package_id: null }, // [#21911] The platform store read carries the explicit system opt-in. context: { isSystem: true }, }); @@ -266,7 +263,7 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { }); expect(mockEngine.findOne).toHaveBeenCalledWith('sys_metadata', { - where: { type: 'view', name: 'test_grid', organization_id: null, state: 'active', package_id: 'com.acme.beta' }, + where: { type: 'view', name: 'test_grid', state: 'active', package_id: 'com.acme.beta' }, // [#21911] The platform store read carries the explicit system opt-in. context: { isSystem: true }, }); @@ -633,7 +630,7 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { expect(result.item).toMatchObject(sampleApp); expect(mockEngine.findOne).toHaveBeenCalledWith('sys_metadata', { - where: { type: 'app', name: 'test_app', state: 'active', organization_id: null }, + where: { type: 'app', name: 'test_app', state: 'active' }, // [#21911] The platform store read carries the explicit system opt-in. context: { isSystem: true }, }); @@ -678,7 +675,7 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { expect(reads.map((r: any) => r.where?.type)).toEqual(['app', 'apps', 'app']); expect(reads.every((r: any) => r.table === 'sys_metadata')).toBe(true); expect(reads[2].where).toMatchObject({ - type: 'app', name: 'test_app', state: 'active', organization_id: null, package_id: null, + type: 'app', name: 'test_app', state: 'active', package_id: null, }); expect((result as any).version).toBeNull(); }); @@ -925,7 +922,7 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { // DB *is* queried (always-merge semantics) so seeded metadata // surfaces even when the registry already has unrelated items. expect(mockEngine.find).toHaveBeenCalledWith('sys_metadata', { - where: { type: 'app', state: 'active', organization_id: null }, + where: { type: 'app', state: 'active' }, // [#21911] The platform store read carries the explicit system opt-in. context: { isSystem: true }, }); @@ -1074,7 +1071,7 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { expect(result.items).toHaveLength(1); expect(result.items[0]).toMatchObject(sampleApp); expect(mockEngine.find).toHaveBeenCalledWith('sys_metadata', { - where: { type: 'app', state: 'active', organization_id: null }, + where: { type: 'app', state: 'active' }, // [#21911] The platform store read carries the explicit system opt-in. context: { isSystem: true }, }); @@ -1271,7 +1268,7 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { await protocol.loadMetaFromDb(); expect(mockEngine.find).toHaveBeenCalledWith('sys_metadata', { - where: { state: 'active', organization_id: null } + where: { state: 'active' } }); }); diff --git a/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts b/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts index 3d29170aa60..f8238382523 100644 --- a/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts +++ b/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts @@ -35,10 +35,12 @@ * * ── The fix, and where it lives ───────────────────────────────────────── * - * [ADR-0131 D6] Since the per-organization overlay axis retired, the one - * list read left that merges a legacy organization's rows is the anonymous - * form doors' `view` read (`legacyFormOrganizationId`); the listing cases - * below drive that read, because it is the one the invariant still guards. + * [ADR-0131 D6, C7] Since the per-organization overlay axis retired, no list + * read merges a legacy organization's rows: the last one, the anonymous form + * doors' `view` read (`legacyFormOrganizationId`), was deleted when the v18 + * ceremony began carrying those rows' withdrawals onto the environment. The + * listing cases below still pass that key, and pin that it reads the + * environment and hydrates nothing of the organization's. * * `hydrateOverlayIntoRegistry` is the ONE shared choke point all three * hydration callers already route through (#4521 made it so: boot, the @@ -56,6 +58,12 @@ * env-wide cases green — the env-wide rows are the #4521 read-your-writes * behaviour this fix must NOT regress, and they never carry an org to * refuse. Measured direction is recorded in the PR body. + * + * [ADR-0131 D6/D7] The family declares no organization column now, so the + * read-side legs that planted a legacy organization row are retired with it: + * no stored row carries an organization, and the runtime refuses to boot on a + * database still holding one. The write-side legs and the delete-chain legs, + * which drive organization-scoped REQUESTS, stay. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -67,7 +75,6 @@ import { assertEngineDeleteDispatch } from './engine-delete-dispatch.js'; import { assertEngineFindOnePredicate } from './engine-findone-predicate.js'; const ORG_A = 'org_a'; -const ORG_B = 'org_b'; /** * A `sys_metadata`-shaped fake engine over one row array, so the repository @@ -144,10 +151,9 @@ const flowBody = (name: string, label: string) => ({ edges: [{ id: 'e1', source: 'start', target: 'end' }], }); -const metaRow = (type: string, body: Record, organizationId: string | null) => ({ +const metaRow = (type: string, body: Record) => ({ type, name: body.name, - organization_id: organizationId, package_id: null, state: 'active', metadata: JSON.stringify(body), @@ -160,10 +166,6 @@ const namesIn = (items: unknown[]): string[] => /** Names visible in a `getMetaItems` result, which answers an `{ items }` envelope. */ const namesOf = (listed: { items?: unknown[] }): string[] => namesIn(listed?.items ?? []); -/** One item out of a `getMetaItems` result, by name. */ -const itemNamed = (listed: { items?: unknown[] }, name: string): any => - (listed?.items ?? []).find((i) => (i as { name?: string })?.name === name); - describe('#6602 — the premise, read from the registry rather than restated', () => { it('view is the specimen: an overlay-enabled type, the tier the per-org leak rode before ADR-0131 D6', () => { // If a later ruling closes this flag, the overlay tier this file drives @@ -289,20 +291,8 @@ describe('#6602 — READ seam: getMetaItems hydration refuses org-scoped rows', protocol = new ObjectStackProtocolImplementation(engine); }); - it('an org-scoped listing SERVES the org row but does not graft it', async () => { - engine.plant(metaRow('view', viewBody('org_grid', 'Org A grid'), ORG_A)); - - const listed = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); - - // Org readers keep their overlay — closing the leak must not close - // the feature. - expect(namesOf(listed as any)).toContain('org_grid'); - // …and the process-wide registry stays clean. - expect(registry.getItem('view', 'org_grid')).toBeUndefined(); - }); - it('an ENV-WIDE listing still hydrates — the read side is not narrowed', async () => { - engine.plant(metaRow('view', viewBody('env_grid', 'Env grid'), null)); + engine.plant(metaRow('view', viewBody('env_grid', 'Env grid'))); const listed = await protocol.getMetaItems({ type: 'view' }); @@ -310,122 +300,6 @@ describe('#6602 — READ seam: getMetaItems hydration refuses org-scoped rows', expect((registry.getItem('view', 'env_grid') as any)?.label).toBe('Env grid'); }); - it('a MIXED listing hydrates the env-wide row and only that one', async () => { - engine.plant(metaRow('view', viewBody('env_grid', 'Env grid'), null)); - engine.plant(metaRow('view', viewBody('org_grid', 'Org A grid'), ORG_A)); - - const listed = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); - - expect(namesOf(listed as any).sort()).toEqual(['env_grid', 'org_grid']); - expect(registry.getItem('view', 'env_grid')).toBeDefined(); - expect(registry.getItem('view', 'org_grid')).toBeUndefined(); - }); - - it('an org overlay of an env-wide name does not OVERWRITE the hydrated env-wide entry', async () => { - engine.plant(metaRow('view', viewBody('shared_grid', 'Env grid'), null)); - engine.plant(metaRow('view', viewBody('shared_grid', 'Org A grid'), ORG_A)); - - // The env-wide body reaches the registry the way it legitimately does - // — an unscoped read (boot's `loadMetaFromDb` is the other one). - await protocol.getMetaItems({ type: 'view' }); - expect((registry.getItem('view', 'shared_grid') as any)?.label).toBe('Env grid'); - - // Org A's own listing still shows org A's body (org-over-env merge)… - const listedA = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); - expect(itemNamed(listedA as any, 'shared_grid')?.label).toBe('Org A grid'); - - // …and the shared registry still holds the ENV-WIDE one. This is the - // collision direction of the leak: pre-fix the org body overwrote the - // plain key here, so every reader in the process — the control plane - // and org B alike — started serving org A's customization. - expect((registry.getItem('view', 'shared_grid') as any)?.label).toBe('Env grid'); - }); - - it('an org-scoped listing hydrates NOTHING for a name its org overlays', async () => { - // Measured, and stated rather than presumed: `getMetaItems` merges the - // two record sets by (package, name) with the ORG row winning, so the - // env-wide row it shadows is not in the set this loop walks at all. - // The subtraction is the leak only — pre-fix this listing hydrated org - // A's body under the plain key, never the env-wide one, so nothing - // legitimate is lost. The env-wide entry arrives from boot / the - // unscoped read, as the case above shows. - engine.plant(metaRow('view', viewBody('shared_grid', 'Env grid'), null)); - engine.plant(metaRow('view', viewBody('shared_grid', 'Org A grid'), ORG_A)); - - await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); - - expect(registry.getItem('view', 'shared_grid')).toBeUndefined(); - }); -}); - -describe('#6602 — the disclosure shape, end to end', () => { - let registry: SchemaRegistry; - let engine: any; - let protocol: ObjectStackProtocolImplementation; - - beforeEach(() => { - registry = new SchemaRegistry({ multiTenant: false }); - registry.logLevel = 'silent'; - engine = makeEngine(registry); - protocol = new ObjectStackProtocolImplementation(engine); - }); - - it("org B's listing never contains org A's item — org A LISTS first", async () => { - // The seam the write-side fix alone would not have closed: one - // org-scoped listing call used to graft org A's body, and every - // later reader started from it. - engine.plant(metaRow('view', viewBody('org_a_only', 'Org A only'), ORG_A)); - - await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); - const listedB = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_B }); - - expect(namesOf(listedB as any)).not.toContain('org_a_only'); - // The unscoped control-plane listing is a third reader of the same - // shared registry, and it must not see org A's item either. - const listedControlPlane = await protocol.getMetaItems({ type: 'view' }); - expect(namesOf(listedControlPlane as any)).not.toContain('org_a_only'); - }); - - it("org B reads the ENV-WIDE body, not org A's, for a colliding name", async () => { - engine.plant(metaRow('view', viewBody('shared_grid', 'Env grid'), null)); - engine.plant(metaRow('view', viewBody('shared_grid', 'Org A grid'), ORG_A)); - - await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); - const listedB = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_B }); - - expect(itemNamed(listedB as any, 'shared_grid')?.label).toBe('Env grid'); - }); -}); - -describe('[ADR-0131 D6] the by-name read serves no legacy organization row, and never hydrates one', () => { - let registry: SchemaRegistry; - let engine: any; - let protocol: ObjectStackProtocolImplementation; - - beforeEach(() => { - registry = new SchemaRegistry({ multiTenant: false }); - registry.logLevel = 'silent'; - engine = makeEngine(registry); - protocol = new ObjectStackProtocolImplementation(engine); - }); - - it('getMetaItem answers nothing for an organization-only row, whoever asks, and grafts nothing', async () => { - engine.plant(metaRow('view', viewBody('org_grid', 'Org A grid'), ORG_A)); - - const got: any = await protocol.getMetaItem({ type: 'view', name: 'org_grid', organizationId: ORG_A } as { type: string; name: string }); - expect(got.item).toBeUndefined(); - expect(registry.getItem('view', 'org_grid')).toBeUndefined(); - }); - - it('getMetaItem serves the env-wide body to every caller for a colliding name', async () => { - engine.plant(metaRow('view', viewBody('shared_grid', 'Env grid'), null)); - engine.plant(metaRow('view', viewBody('shared_grid', 'Org A grid'), ORG_A)); - - for (const organizationId of [ORG_A, ORG_B]) { - const got: any = await protocol.getMetaItem({ type: 'view', name: 'shared_grid', organizationId } as { type: string; name: string }); - expect(got.item.label, organizationId).toBe('Env grid'); - } - }); }); describe('#6602 — the delete chain needs no re-keying under this fix', () => { @@ -551,22 +425,6 @@ describe('#6780 — the registry heal is org-gated: an org DELETE never evicts t protocol = new ObjectStackProtocolImplementation(engine); }); - it("org A deleting its OWN overlay leaves the env-wide entry standing (the card's sequence)", async () => { - await protocol.saveMetaItem({ type: 'view', name: 'shared_grid', item: viewBody('shared_grid', 'Env grid') }); - engine.plant(metaRow('view', viewBody('shared_grid', 'Org A grid'), ORG_A)); - // #6602 holding: the org row never reached the shared registry. - expect((registry.getItem('view', 'shared_grid') as any)?.label).toBe('Env grid'); - - // [ADR-0131 D6] An org-scoped delete is now refused before any read. - await expect( - protocol.deleteMetaItem({ type: 'view', name: 'shared_grid', organizationId: ORG_A } as any), - ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - - // Pre-fix this read was `undefined` — the whole defect, in one line. - expect(registry.getItem('view', 'shared_grid')).toBeDefined(); - expect((registry.getItem('view', 'shared_grid') as any)?.label).toBe('Env grid'); - }); - it('the SELF-HEAL branch respects the same scope — a no-op org DELETE is inert', async () => { // The cheapest eviction door of the three, and the one a gate on the // delete-ful branch alone would have left open: org A has no overlay @@ -625,20 +483,4 @@ describe('#6780 — the registry heal is org-gated: an org DELETE never evicts t expect(registry.getItem('view', 'env_grid')).toBeUndefined(); }); - it('an env-wide delete heals even while an org overlay of the same name exists', async () => { - // The direction the gate must NOT over-reach in: the org row is not a - // reason to leave the env-wide entry stale. Scope is read from the - // DELETE, never from what else happens to be stored. - await protocol.saveMetaItem({ type: 'view', name: 'shared_grid', item: viewBody('shared_grid', 'Env grid') }); - engine.plant(metaRow('view', viewBody('shared_grid', 'Org A grid'), ORG_A)); - - await protocol.deleteMetaItem({ type: 'view', name: 'shared_grid' }); - - expect(registry.getItem('view', 'shared_grid')).toBeUndefined(); - // Org A's legacy row is untouched by an env-wide delete: the one read - // left that reaches it (the anonymous form doors' legacy layer) still - // finds it. - const legacy: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); - expect(itemNamed(legacy, 'shared_grid')?.label).toBe('Org A grid'); - }); }); diff --git a/packages/objectql/src/protocol-publish-package-drafts.test.ts b/packages/objectql/src/protocol-publish-package-drafts.test.ts index 51464247b89..82ca8ca600d 100644 --- a/packages/objectql/src/protocol-publish-package-drafts.test.ts +++ b/packages/objectql/src/protocol-publish-package-drafts.test.ts @@ -61,7 +61,7 @@ function makeCaptureEngine(activeRows: ActiveRow[] = []) { const hit = activeRows.find( (r) => r.type === where.type && r.name === where.name - && (r.organizationId ?? null) === (where.organization_id ?? null), + && !('organization_id' in where), ); // Explicitly `null`, never `undefined`-by-omission — see the docblock. return hit ? { version: hit.version } : null; @@ -155,9 +155,9 @@ describe('protocol.publishPackageDrafts (ADR-0033 / ADR-0067 D2)', () => { // one per draft, in draft order, each in the draft's own scope. (Reads // after these belong to the ADR-0038 L3 probes, which run post-commit.) expect(captureReads.slice(0, 3)).toEqual([ - { organization_id: null, type: 'object', name: 'course', state: 'active' }, - { organization_id: null, type: 'object', name: 'student', state: 'active' }, - { organization_id: null, type: 'view', name: 'course_list', state: 'active' }, + { type: 'object', name: 'course', state: 'active' }, + { type: 'object', name: 'student', state: 'active' }, + { type: 'view', name: 'course_list', state: 'active' }, ]); expect(promote).toHaveBeenCalledTimes(3); expect((promote.mock.calls[0][0] as any)).toMatchObject({ type: 'object', name: 'course' }); @@ -205,8 +205,8 @@ describe('protocol.publishPackageDrafts (ADR-0033 / ADR-0067 D2)', () => { // The capture ran for both items, in their own scope — this is what makes // the values below evidence rather than defaults. expect(captureReads.slice(0, 2)).toEqual([ - { organization_id: null, type: 'object', name: 'course', state: 'active' }, - { organization_id: null, type: 'object', name: 'student', state: 'active' }, + { type: 'object', name: 'course', state: 'active' }, + { type: 'object', name: 'student', state: 'active' }, ]); // One commit, carrying one entry per promoted item, each with the state // that was actually READ. diff --git a/packages/objectql/src/protocol-save-meta-repo-path.test.ts b/packages/objectql/src/protocol-save-meta-repo-path.test.ts index 9269088d793..2d885341e08 100644 --- a/packages/objectql/src/protocol-save-meta-repo-path.test.ts +++ b/packages/objectql/src/protocol-save-meta-repo-path.test.ts @@ -429,14 +429,14 @@ describe('#16225 a `sys_metadata` read is not answered from the journal tables', // ── The pin ────────────────────────────────────────────────────── // Asserted on STORE-ONLY columns rather than on the row count. A - // journal row is addressed to the same `(type, name, organization_id)` + // journal row is addressed to the same `(type, name)` // as the store row, which is this file's whole `keyOf`, so under a // merged map the audit row simply OVERWRITES the store row and a // count- or name-based assertion still reads one row called // `case_grid`. `checksum` and `state` are written by the store leg // alone; a journal row carries neither. const stored = await engine.find('sys_metadata', { - where: { type: 'view', organization_id: null }, + where: { type: 'view' }, }); expect( stored.map((r: Row) => r.name), @@ -445,7 +445,7 @@ describe('#16225 a `sys_metadata` read is not answered from the journal tables', expect( stored.map((r: Row) => [typeof r.checksum, r.state]), 'and the row it answers with must be a STORE row, not a journal row wearing ' - + 'the same `(type, name, organization_id)`', + + 'the same `(type, name)`', ).toEqual([['string', 'active']]); }); }); diff --git a/packages/objectql/src/sys-metadata-repository-list-drafts.test.ts b/packages/objectql/src/sys-metadata-repository-list-drafts.test.ts index febc9b4ff60..b7e21f20ce0 100644 --- a/packages/objectql/src/sys-metadata-repository-list-drafts.test.ts +++ b/packages/objectql/src/sys-metadata-repository-list-drafts.test.ts @@ -10,11 +10,11 @@ import { SysMetadataRepository } from '@objectstack/metadata-protocol'; * "pending changes" view and a just-built app package aren't shown as empty. */ const ROWS = [ - { type: 'object', name: 'course', state: 'draft', package_id: 'app.edu', organization_id: null, updated_at: 't1', updated_by: 'ai' }, - { type: 'object', name: 'student', state: 'draft', package_id: 'app.edu', organization_id: null, updated_at: 't2', updated_by: 'ai' }, - { type: 'object', name: 'legacy', state: 'draft', package_id: null, organization_id: null, updated_at: 't3' }, - { type: 'view', name: 'course_list', state: 'draft', package_id: 'app.edu', organization_id: null, updated_at: 't5', updated_by: 'ai' }, - { type: 'object', name: 'live', state: 'active', package_id: 'app.edu', organization_id: null, updated_at: 't4' }, + { type: 'object', name: 'course', state: 'draft', package_id: 'app.edu', updated_at: 't1', updated_by: 'ai' }, + { type: 'object', name: 'student', state: 'draft', package_id: 'app.edu', updated_at: 't2', updated_by: 'ai' }, + { type: 'object', name: 'legacy', state: 'draft', package_id: null, updated_at: 't3' }, + { type: 'view', name: 'course_list', state: 'draft', package_id: 'app.edu', updated_at: 't5', updated_by: 'ai' }, + { type: 'object', name: 'live', state: 'active', package_id: 'app.edu', updated_at: 't4' }, ]; const matchesWhere = (r: any, clause: any): boolean => @@ -24,14 +24,14 @@ const matchesWhere = (r: any, clause: any): boolean => : (r as any)[k] === v, ); -function makeRepo(rows = ROWS, organizationId: string | null = null) { +function makeRepo(rows: Array> = ROWS) { // Minimal engine whose find() does equality WHERE matching, plus `$or`. const find = vi.fn(async (_table: string, q: any) => { const where = q?.where ?? {}; return rows.filter((r) => matchesWhere(r, where)); }); const engine = { find } as any; - const repo = new SysMetadataRepository({ engine, organizationId, orgLabel: 'env' }); + const repo = new SysMetadataRepository({ engine, orgLabel: 'env' }); return { repo, find }; } @@ -57,28 +57,14 @@ describe('SysMetadataRepository.listDrafts (ADR-0033)', () => { expect(out.map((d) => d.name).sort()).toEqual(['course', 'course_list', 'student']); }); - it('surfaces env-wide (org IS NULL) drafts even when the repo has a non-null active org', async () => { - // Regression (orphaned-draft bug): AI-authored metadata is written env-wide - // (organization_id NULL). A non-null active org used a strict equality that - // dropped those drafts → they showed in preview but the Publish CTA never - // appeared, so the user could not publish them. - const { repo } = makeRepo(ROWS, 'org_acme'); + it('[ADR-0131 D6/D7] lists every draft: the family declares no organization column, so none is filtered on', async () => { + const { repo, find } = makeRepo(); const names = (await repo.listDrafts()).map((d) => d.name).sort(); expect(names).toEqual(['course', 'course_list', 'legacy', 'student']); expect(names).not.toContain('live'); - }); - - it('[ADR-0131 D6] lists the environment\'s drafts only — no organization\'s, whichever repository asks', async () => { - const rows = [ - { type: 'object', name: 'env_wide', state: 'draft', package_id: 'p', organization_id: null, updated_at: 't1', updated_by: 'u' }, - { type: 'object', name: 'org_scoped', state: 'draft', package_id: 'p', organization_id: 'org_acme', updated_at: 't2', updated_by: 'u' }, - { type: 'object', name: 'other_org', state: 'draft', package_id: 'p', organization_id: 'org_other', updated_at: 't3', updated_by: 'u' }, - ]; - const { repo } = makeRepo(rows, 'org_acme'); - const out = await repo.listDrafts(); - expect(out.map((d) => d.name)).toEqual(['env_wide']); + for (const [, q] of find.mock.calls) expect(q?.where ?? {}).not.toHaveProperty('organization_id'); // The header carries no organization: every draft listed is the environment's. - expect(Object.keys(out[0]!)).not.toContain('organizationId'); + expect(Object.keys((await repo.listDrafts())[0]!)).not.toContain('organizationId'); }); it('filters by type', async () => { @@ -87,11 +73,11 @@ describe('SysMetadataRepository.listDrafts (ADR-0033)', () => { expect(out.map((d) => d.name)).toEqual(['course_list']); }); - it('queries state=draft scoped to org, threading type + packageId into WHERE', async () => { + it('queries state=draft, threading type + packageId into WHERE', async () => { const { repo, find } = makeRepo(); await repo.listDrafts({ type: 'object', packageId: 'app.edu' }); expect(find).toHaveBeenCalledWith('sys_metadata', { - where: { organization_id: null, state: 'draft', type: 'object', package_id: 'app.edu' }, + where: { state: 'draft', type: 'object', package_id: 'app.edu' }, // [#21911] The platform store read carries the explicit system opt-in. context: { isSystem: true }, }); @@ -148,7 +134,6 @@ describe('SysMetadataRepository.listDrafts (ADR-0033)', () => { name: 'account', state: 'draft', package_id: 'app.hr', - organization_id: null, updated_at: 't1', updated_by: 'ai', // The column the repository reads the stored document from today diff --git a/packages/objectql/src/sys-metadata-repository.test.ts b/packages/objectql/src/sys-metadata-repository.test.ts index d070739684f..e55511e5a49 100644 --- a/packages/objectql/src/sys-metadata-repository.test.ts +++ b/packages/objectql/src/sys-metadata-repository.test.ts @@ -170,7 +170,6 @@ describe('SysMetadataRepository', () => { engine = makeFakeEngine(); repo = new SysMetadataRepository({ engine, - organizationId: 'org_alpha', orgLabel: 'org_alpha', }); }); @@ -480,8 +479,8 @@ describe('SysMetadataRepository', () => { parentVersion: null, actor: 'studio', source: 'studio', }); expect(engine.historyRows).toHaveLength(1); + expect(engine.historyRows[0]).not.toHaveProperty('organization_id'); expect(engine.historyRows[0]).toMatchObject({ - organization_id: 'org_alpha', type: 'view', name: 'case_grid', operation_type: 'create', diff --git a/packages/objectql/test-typecheck-debt.json b/packages/objectql/test-typecheck-debt.json index fb48290e375..eb6e7b50a66 100644 --- a/packages/objectql/test-typecheck-debt.json +++ b/packages/objectql/test-typecheck-debt.json @@ -131,9 +131,6 @@ "TS18048: 'out.enable' is possibly 'undefined'.": 7, "TS2339: Property 'indexed' does not exist on type '…'.": 3 }, - "src/sys-metadata-repository-list-drafts.test.ts": { - "TS2345: Argument of type '…' is not assignable to parameter of type '…'.": 1 - }, "src/sys-metadata-repository.test.ts": { "TS2322: Type '\"theme\"' is not assignable to type '…'.": 2, "TS2322: Type '\"trigger\"' is not assignable to type '…'.": 1, diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 74ed687a8a8..e9cb7ae48db 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -2982,13 +2982,9 @@ export const enObjects: NonNullable = { deprecated: "deprecated" } }, - organization_id: { - label: "Organization", - help: "Organization for multi-tenant isolation." - }, environment_id: { label: "Environment (deprecated)", - help: "DEPRECATED. Use organization_id for tenant isolation." + help: "DEPRECATED. Overlays are environment rows (ADR-0131 D6); no tenant column." }, version: { label: "Version" @@ -3037,7 +3033,7 @@ export const enObjects: NonNullable = { sys_metadata_history: { label: "Metadata History", pluralLabel: "Metadata History", - description: "Durable event log of metadata overlay changes (per-org, append-only)", + description: "Durable event log of metadata overlay changes (environment-wide, append-only)", fields: { id: { label: "ID" @@ -3082,10 +3078,6 @@ export const enObjects: NonNullable = { source: { label: "Source" }, - organization_id: { - label: "Organization", - help: "Organization for multi-tenant isolation." - }, recorded_by: { label: "Recorded By", help: "User who made this change. NULL = system-initiated (boot sync, migration, scheduled job) — never a sentinel string." @@ -3119,9 +3111,6 @@ export const enObjects: NonNullable = { name: { label: "Name" }, - organization_id: { - label: "Organization" - }, operation: { label: "Operation", options: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 109f159cd48..57cd574eb7b 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -2982,13 +2982,9 @@ export const esESObjects: NonNullable = { deprecated: "Obsoleto" } }, - organization_id: { - label: "Organización", - help: "Organización para el aislamiento multi-tenant." - }, environment_id: { label: "Entorno (obsoleto)", - help: "OBSOLETO. Use organization_id para el aislamiento del tenant." + help: "OBSOLETO. Las personalizaciones son filas del entorno (ADR-0131 D6); no hay columna de tenant." }, version: { label: "Versión" @@ -3082,10 +3078,6 @@ export const esESObjects: NonNullable = { source: { label: "Origen" }, - organization_id: { - label: "Organización", - help: "Organización para el aislamiento multi-tenant." - }, recorded_by: { label: "Registrado por", help: "Usuario que realizó este cambio. NULL = iniciado por el sistema (sincronización al arrancar, migración, tarea programada); nunca una cadena centinela." @@ -3119,9 +3111,6 @@ export const esESObjects: NonNullable = { name: { label: "Nombre" }, - organization_id: { - label: "Organización" - }, operation: { label: "Operación", options: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 5e702edb832..443b6c0bbb1 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -2982,13 +2982,9 @@ export const jaJPObjects: NonNullable = { deprecated: "非推奨" } }, - organization_id: { - label: "組織", - help: "マルチテナント分離のための組織。" - }, environment_id: { label: "環境(非推奨)", - help: "非推奨。テナント分離には organization_id を使用してください。" + help: "非推奨。オーバーレイは環境の行です(ADR-0131 D6)。テナント列はありません。" }, version: { label: "バージョン" @@ -3082,10 +3078,6 @@ export const jaJPObjects: NonNullable = { source: { label: "ソース" }, - organization_id: { - label: "組織", - help: "マルチテナント分離のための組織。" - }, recorded_by: { label: "記録者", help: "この変更を行ったユーザー。NULL = システムによる変更(起動時の同期、マイグレーション、スケジュールジョブ)— センチネル文字列は使いません。" @@ -3119,9 +3111,6 @@ export const jaJPObjects: NonNullable = { name: { label: "名前" }, - organization_id: { - label: "組織" - }, operation: { label: "操作", options: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index d68d0a315a2..844278c9c5d 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -2982,13 +2982,9 @@ export const zhCNObjects: NonNullable = { deprecated: "已废弃" } }, - organization_id: { - label: "组织", - help: "用于多租户隔离的组织。" - }, environment_id: { label: "环境(已废弃)", - help: "已废弃。请使用 organization_id 进行租户隔离。" + help: "已废弃。覆盖层是环境级的行(ADR-0131 D6),没有租户列。" }, version: { label: "版本" @@ -3082,10 +3078,6 @@ export const zhCNObjects: NonNullable = { source: { label: "来源" }, - organization_id: { - label: "组织", - help: "用于多租户隔离的组织。" - }, recorded_by: { label: "记录人", help: "做出此变更的用户。NULL 表示由系统发起(启动同步、迁移、定时任务)——绝不是哨兵字符串。" @@ -3119,9 +3111,6 @@ export const zhCNObjects: NonNullable = { name: { label: "名称" }, - organization_id: { - label: "组织" - }, operation: { label: "操作", options: { diff --git a/packages/plugins/plugin-security/src/metadata-family-data-door-gate.test.ts b/packages/plugins/plugin-security/src/metadata-family-data-door-gate.test.ts new file mode 100644 index 00000000000..8bb1c80f210 --- /dev/null +++ b/packages/plugins/plugin-security/src/metadata-family-data-door-gate.test.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D7 · #23429 E2, the #22107 precedent] Who reads the `sys_metadata` + * family through the generic data door, on the real `SecurityPlugin` + * middleware, with the family's REAL declarations. + * + * The family declares no organization column (`systemFields.tenant: false`), + * so there is no tenant wall on it, and a walled deployment's + * `organization_admin` holds a `'*'` grant with `viewAllRecords`. Without an + * object-level gate that admin would read every organization's audit trail, + * history bodies and commit ranges. `sys_metadata_audit`, `_history` and + * `_commit` therefore require `studio.access` (platform-scoped: no tenant + * administrator holds it by role); `sys_metadata` itself stays ungated — its + * rows are what `/meta` already serves to every organization. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + SysMetadataAuditObject, + SysMetadataCommitObject, + SysMetadataHistoryObject, + SysMetadataObject, + assertEngineFindOnePredicate, + type EngineFindOneQueryInput, +} from '@objectstack/metadata-core'; +import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin } from './security-plugin.js'; + +/** The real middleware over one real declaration (the `security-plugin.test.ts` harness shape). */ +async function middlewareFor( + schema: any, + permissionSets: PermissionSet[], + opts: { walled: boolean; fallback: string }, +) { + let middleware: any; + const findOne = vi.fn(async (object: string, query: any) => { + assertEngineFindOnePredicate(object, query as EngineFindOneQueryInput); + return null; + }); + const ql = { + registerMiddleware: (mw: any) => { if (!middleware) middleware = mw; }, + getSchema: () => schema, + findOne, + find: vi.fn(async () => [] as Record[]), + }; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: ql, + metadata: { get: async () => schema, list: async () => permissionSets }, + }; + if (opts.walled) { + services['org-scoping'] = { name: 'com.objectstack.org-scoping' }; + services['tenancy'] = { posture: 'isolated' }; + } + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: opts.fallback }); + await plugin.init(ctx); + await plugin.start(ctx); + return async (systemPermissions: string[]) => { + const opCtx: any = { + object: schema.name, operation: 'find', ast: { where: undefined }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [], systemPermissions }, + }; + await middleware(opCtx, async () => {}); + return opCtx; + }; +} + +/** A walled deployment's organization administrator: the `'*'` superuser bits, the tenant RLS, no platform capability. */ +const organizationAdmin: PermissionSet = { + name: 'organization_admin', label: 'Organization admin', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true } }, + rowLevelSecurity: [ + { name: 'tenant_isolation', object: '*', operation: 'all', using: 'organization_id = current_user.organization_id' }, + ], +} as any; +/** The same grant, plus the Studio capability every `/meta` trail door admits. */ +const studioHolder: PermissionSet = { ...organizationAdmin, name: 'studio_author', systemPermissions: ['studio.access'] } as any; +/** A single deployment's plain member: a read wildcard and nothing else. */ +const member: PermissionSet = { + name: 'member_default', label: 'Member', + objects: { '*': { allowRead: true } }, +} as any; + +const GATED = [ + ['sys_metadata_audit', SysMetadataAuditObject], + ['sys_metadata_history', SysMetadataHistoryObject], + ['sys_metadata_commit', SysMetadataCommitObject], +] as const; + +describe('[ADR-0131 D7] the sys_metadata family through the generic data door', () => { + it('the gating capability is platform-scoped, so no tenant administrator holds it by role', () => { + expect(PLATFORM_CAPABILITIES.find((c) => c.name === 'studio.access')?.scope).toBe('platform'); + }); + + for (const [name, object] of GATED) { + describe(name, () => { + it('declares no organization column, and gates every read on studio.access', () => { + expect((object as any).systemFields?.tenant).toBe(false); + expect(Object.keys((object as any).fields)).not.toContain('organization_id'); + expect((object as any).requiredPermissions).toEqual(['studio.access']); + }); + + it('under a wall: the organization admin (\'*\' + viewAllRecords, no capability) is REFUSED', async () => { + const read = await middlewareFor(object, [organizationAdmin], { walled: true, fallback: 'organization_admin' }); + await expect(read([])).rejects.toMatchObject({ name: 'PermissionDeniedError' }); + }); + + it('under a wall: the studio.access holder reads', async () => { + const read = await middlewareFor(object, [studioHolder], { walled: true, fallback: 'studio_author' }); + await expect(read(['studio.access'])).resolves.toBeDefined(); + }); + + it('single: the studio.access holder reads; a plain member is refused (the table no longer answers the read wildcard alone)', async () => { + const author = await middlewareFor(object, [studioHolder], { walled: false, fallback: 'studio_author' }); + await expect(author(['studio.access'])).resolves.toBeDefined(); + const plain = await middlewareFor(object, [member], { walled: false, fallback: 'member_default' }); + await expect(plain([])).rejects.toMatchObject({ name: 'PermissionDeniedError' }); + }); + }); + } + + it('control: sys_metadata itself stays ungated — the organization admin and a single member both read it', async () => { + expect((SysMetadataObject as any).requiredPermissions).toBeUndefined(); + const admin = await middlewareFor(SysMetadataObject, [organizationAdmin], { walled: true, fallback: 'organization_admin' }); + await expect(admin([])).resolves.toBeDefined(); + const plain = await middlewareFor(SysMetadataObject, [member], { walled: false, fallback: 'member_default' }); + await expect(plain([])).resolves.toBeDefined(); + }); +}); diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts b/packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts index e2b8e2cffb6..1e037f65a6a 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts @@ -86,7 +86,7 @@ function makeFakeEngine(opts?: { listItemsThrows?: boolean }) { const rows = new Map(); const historyRows: Row[] = []; const keyOf = (w: Record, table = 'sys_metadata') => - `${table}|${String(w.type)}|${String(w.name)}|${String(w.organization_id ?? 'null')}|${String(w.state ?? 'active')}`; + `${table}|${String(w.type)}|${String(w.name)}|${String(w.state ?? 'active')}`; const findRow = (where: Record) => { if (where.id !== undefined) { for (const [k, r] of rows) if (r.id === where.id) return { key: k, row: r }; @@ -255,7 +255,10 @@ describe('#11843 — the lock answers at the metadata door', () => { expect(err).toBeNull(); const rows = metaRowsOf(engine); expect(rows).toHaveLength(1); - expect(rows[0]).toMatchObject({ package_id: null, organization_id: null }); + expect(rows[0]).toMatchObject({ package_id: null }); + // [ADR-0131 D7] The family declares no organization column: the save + // writes no organization key at all — every row is the environment's. + expect(rows[0]).not.toHaveProperty('organization_id'); }, 30_000); it('hatch OPEN: a runtime-shadow set (definition living only in sys_metadata) is NOT locked — ADR-0094 D5-R', async () => { diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts index efcfa2a1b5f..e17ab9d3fbd 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts @@ -70,7 +70,26 @@ function makeQl(declared: any[] = []) { const rows = tableFor(object); if (!rows) return []; const hit = rows.filter((r) => matches(r, q?.where)); - return typeof q?.limit === 'number' ? hit.slice(0, q.limit) : hit; + const bounded = typeof q?.limit === 'number' ? hit.slice(0, q.limit) : hit; + // [ADR-0131 D7] The engine shapes a read to the declared columns, and + // the family declares no `organization_id`: the key never comes back. + return object === 'sys_metadata' + ? bounded.map(({ organization_id: _physical, ...declared }) => declared) + : bounded; + }, + // The physical column, as a database the ceremony has not migrated still + // holds it, read through the table's driver. + getDriverForObject(object: string) { + if (object !== 'sys_metadata') return undefined; + return { + dialectName: 'sqlite', + async execute(sql: string) { + return /column_name/.test(sql) + ? [{ column_name: 'id' }, { column_name: 'organization_id' }] + : metaRows.filter((r) => r.organization_id != null && r.organization_id !== '') + .map((r) => ({ id: r.id, organization_id: r.organization_id })); + }, + }; }, }; } @@ -181,7 +200,9 @@ describe('pin 4 — the detection reading NAMES the forked sets', () => { it('an ORG-scoped overlay row is out of scope, the same as everywhere else in this family', async () => { // Env-wide overlays only (#10103 residue, deliberately out of scope), the - // boundary the retired reconciler and drift diagnostic drew. + // boundary the retired reconciler and drift diagnostic drew. [ADR-0131 D7] + // Told apart by the physical column on a database the ceremony has not + // migrated: the engine row never carries the key. const a = declaredSet(); const ql = makeQl([a]); ql.permRows.push(rowFor(a)); diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts index 993aa5f936e..32c52681e33 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts @@ -63,6 +63,7 @@ * silent fork, which is why the LOCK's provenance read is not a page at all. */ +import { readLegacyOrganizationRows } from '@objectstack/metadata-core'; import { classifyPackagedPermissionSet } from './packaged-permission-set-lock.js'; import { seedCtx } from './organization-scope.js'; @@ -121,12 +122,19 @@ export async function detectPackagedPermissionSetOverlays( ): Promise { if (!ql || typeof ql.find !== 'function') return { count: 0, names: [], findings: [] }; + // [ADR-0131 D6/D7] Env-wide only. The family declares no organization + // column, so the engine row never carries one: a legacy organization's row is + // told apart by the physical column, on a database the ceremony has not + // migrated. A read that cannot answer throws, and the boot wiring reports it. + const legacy = await readLegacyOrganizationRows( + typeof ql.getDriverForObject === 'function' ? ql.getDriverForObject('sys_metadata') : undefined, + ); // Both type spellings, same as the reconciler and the drift diagnostic. const overlayIdsByName = new Map(); for (const type of ['permission', 'permissions']) { const rows = await tryFind(ql, 'sys_metadata', { type, state: 'active' }, OVERLAY_PAGE_LIMIT); for (const r of rows) { - if ((r?.organization_id ?? null) !== null || !r?.name) continue; // env-wide only + if (!r?.name || legacy.has(String(r.id))) continue; // env-wide only const name = String(r.name); const ids = overlayIdsByName.get(name); if (ids) ids.push(String(r.id)); else overlayIdsByName.set(name, [String(r.id)]); diff --git a/packages/plugins/plugin-security/src/security-catalog-echo.test.ts b/packages/plugins/plugin-security/src/security-catalog-echo.test.ts index 488f7cc6160..9483c1a40d3 100644 --- a/packages/plugins/plugin-security/src/security-catalog-echo.test.ts +++ b/packages/plugins/plugin-security/src/security-catalog-echo.test.ts @@ -498,11 +498,25 @@ describe('the boot pass echoes every stored env-wide overlay, and only those', ( { type: 'position', name: 'env_position', state: 'active', organization_id: null }, { type: 'permission', name: 'org_set', state: 'active', organization_id: 'org_a' }, ]; + rows.forEach((row, i) => { (row as Row & { id?: string }).id = `row_${i}`; }); const engine = { registry: { listItems: () => [] }, + // [ADR-0131 D7] The engine shapes a read to the declared columns (no + // `organization_id`): an organization's row is told apart through the + // driver's physical column. find: async (object: string, query: { where: Row; limit?: number }) => { const hits = object === 'sys_metadata' ? rows.filter((row) => matches(row, query.where)) : []; - return typeof query?.limit === 'number' ? hits.slice(0, query.limit) : hits; + const bounded = typeof query?.limit === 'number' ? hits.slice(0, query.limit) : hits; + return bounded.map(({ organization_id: _physical, ...declared }) => declared); + }, + getDriverForObject: (object: string) => object !== 'sys_metadata' ? undefined : { + dialectName: 'sqlite', + async execute(sql: string) { + return /column_name/.test(sql) + ? [{ column_name: 'id' }, { column_name: 'organization_id' }] + : rows.filter((r) => r.organization_id != null && r.organization_id !== '') + .map((r) => ({ id: (r as Row & { id?: string }).id, organization_id: r.organization_id })); + }, }, }; const overlays: Record = {}; diff --git a/packages/plugins/plugin-security/src/security-catalog-echo.ts b/packages/plugins/plugin-security/src/security-catalog-echo.ts index 09e70d543a2..ebf55247189 100644 --- a/packages/plugins/plugin-security/src/security-catalog-echo.ts +++ b/packages/plugins/plugin-security/src/security-catalog-echo.ts @@ -79,6 +79,7 @@ * stored row is gone. */ +import { readLegacyOrganizationRows } from '@objectstack/metadata-core'; import { seedCtx } from './organization-scope.js'; import { ENV_PROJECTION_MARKER, @@ -284,9 +285,20 @@ export async function echoStoredSecurityCatalogOverlays( // The plural spelling is a legacy `type` column value the protocol still reads. for (const stored of [type, `${type}s`]) { let rows: any[]; + // [ADR-0131 D6/D7] Env-wide overlays only. The family declares no + // organization column, so the engine row never carries one: a legacy + // organization's row is told apart off the physical column, on a + // database the ceremony has not migrated. A read that cannot answer is a + // failed read, below. + let legacy: ReadonlyMap = new Map(); try { const found = await ql.find('sys_metadata', { where: { type: stored, state: 'active' } }, { context: seedCtx() }); rows = Array.isArray(found) ? found : []; + if (rows.length > 0) { + legacy = await readLegacyOrganizationRows( + typeof ql.getDriverForObject === 'function' ? ql.getDriverForObject('sys_metadata') : undefined, + ); + } } catch (e) { logger?.warn( `[security] stored ${type} overlays could not be read at boot, so none is echoed into the security ` + @@ -297,7 +309,7 @@ export async function echoStoredSecurityCatalogOverlays( continue; } for (const row of rows) { - if ((row?.organization_id ?? null) !== null || !row?.name) continue; // env-wide overlays only + if (!row?.name || legacy.has(String(row.id))) continue; // env-wide overlays only names.add(String(row.name)); } } diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index 30a61f1c588..a782dff964d 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -30,6 +30,7 @@ "@objectstack/plugin-security": "workspace:*", "@objectstack/plugin-sharing": "workspace:*", "@objectstack/plugin-webhooks": "workspace:*", + "@objectstack/runtime": "workspace:*", "@objectstack/service-analytics": "workspace:*", "@objectstack/service-datasource": "workspace:*", "@objectstack/service-messaging": "workspace:*", diff --git a/packages/qa/dogfood/test/datasource-restore-environment-rows.dogfood.test.ts b/packages/qa/dogfood/test/datasource-restore-environment-rows.dogfood.test.ts index 35d0aeebcd5..6f55e45caff 100644 --- a/packages/qa/dogfood/test/datasource-restore-environment-rows.dogfood.test.ts +++ b/packages/qa/dogfood/test/datasource-restore-environment-rows.dogfood.test.ts @@ -6,10 +6,13 @@ // in a legacy organization's layer was restored, served by both doors and // pooled, while the boot report named it as served by no read. // -// Pinned: neither door serves the legacy row and no pool opens from it; the -// environment row is served; the boot report still names the legacy row, and -// it stays at rest; the by-name reads behind the plugin's writes never reach -// it. The routes are mounted the way `packages/cli/src/commands/serve.ts` does. +// [ADR-0131 D6/D7] The `sys_metadata` family now declares no organization +// column, so no row can sit in an organization's layer (and the runtime refuses +// to boot on a database still holding one). Pinned: the environment row is +// restored across the restart, served by both doors and pooled; the by-name +// reads behind the plugin's writes address the environment's rows; the boot +// prints no legacy-organization line. The routes are mounted the way +// `packages/cli/src/commands/serve.ts` does. import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { registerDatasourceAdminRoutes } from '@objectstack/service-datasource'; @@ -19,11 +22,9 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -/** A fixture datasource stored in a legacy organization's layer. */ -const LEGACY = 'dogfood_legacy_ds_22835'; -const LEGACY_ORG = 'org_dogfood_legacy_22835'; -const LEGACY_LABEL = 'Legacy organization row 22835'; -/** A fixture datasource stored in the environment's layer: the control. */ +/** A datasource name with no stored row, written through the doors below. */ +const FRESH = 'dogfood_fresh_ds_22835'; +/** A fixture datasource stored in the environment's layer. */ const ENV = 'dogfood_env_ds_22835'; const SYS = { isSystem: true, positions: [], permissions: [] }; @@ -37,8 +38,6 @@ describe('[#22835] the datasource-admin plugin reads environment rows only (show let dir: string; /** Every `console.warn` line the measured boot printed. */ let bootWarnings: string[] = []; - /** The legacy row as it was stored, before the measured boot. */ - let plantedLegacy: Row | undefined; const routes = () => ({ name: 'dogfood.datasource-admin-routes', @@ -79,27 +78,21 @@ describe('[#22835] the datasource-admin plugin reads environment rows only (show }; return connection.listConnectionStates().some((s) => s.name === name); }; - const legacyRows = async () => (await storedRows(LEGACY)).filter((r) => r.organization_id === LEGACY_ORG); - const environmentRows = async (name: string) => (await storedRows(name)).filter((r) => r.organization_id == null); + const environmentRows = async (name: string) => storedRows(name); beforeAll(async () => { dir = mkdtempSync(join(tmpdir(), 'dogfood-22835-')); await boot(); - // The rows an earlier release left, written with system context the way - // that release's store held them: one in a legacy organization's layer, - // one in the environment's. + // The environment's row, written with system context the way the store + // holds it. const now = new Date().toISOString(); - for (const [name, organizationId, label] of [ - [LEGACY, LEGACY_ORG, LEGACY_LABEL], - [ENV, null, 'Environment row 22835'], - ] as const) { - await ql().insert('sys_metadata', { - type: 'datasource', name, organization_id: organizationId, package_id: null, scope: 'platform', - state: 'active', version: 1, checksum: null, created_at: now, updated_at: now, - metadata: JSON.stringify({ name, label, driver: 'sqlite', config: { filename: join(dir, `${name}.db`) } }), - }, { context: SYS }); - } - [plantedLegacy] = await legacyRows(); + await ql().insert('sys_metadata', { + type: 'datasource', name: ENV, package_id: null, scope: 'platform', + state: 'active', version: 1, checksum: null, created_at: now, updated_at: now, + metadata: JSON.stringify({ + name: ENV, label: 'Environment row 22835', driver: 'sqlite', config: { filename: join(dir, `${ENV}.db`) }, + }), + }, { context: SYS }); await stack.stop(); const warn = vi.spyOn(console, 'warn'); @@ -116,20 +109,17 @@ describe('[#22835] the datasource-admin plugin reads environment rows only (show if (dir) rmSync(dir, { recursive: true, force: true }); }); - it('premise: both rows are at rest, and the boot report names the legacy one', async () => { - expect(plantedLegacy).toBeDefined(); - expect(await environmentRows(ENV)).toHaveLength(1); - const report = bootWarnings.filter((line) => line.includes('[metadata_org_scoped_unserved]')); - expect(report).toHaveLength(1); - expect(report[0]).toContain(`${LEGACY}@${LEGACY_ORG}`); + it('premise: the environment row is at rest, carries no organization column, and the boot names no legacy row', async () => { + const rows = await environmentRows(ENV); + expect(rows).toHaveLength(1); + expect(rows[0]).not.toHaveProperty('organization_id'); + expect(bootWarnings.filter((line) => line.includes('[metadata_org_scoped_unserved]'))).toEqual([]); }); - it('neither door serves the legacy row, and no pool is opened from it; the environment row is served', async () => { - expect((await call('GET', `/datasources/${LEGACY}`)).status).toBe(404); - expect((await call('GET', `/meta/datasource/${LEGACY}`)).status).toBe(404); - expect(await adminNames()).not.toContain(LEGACY); - expect(await metaNames()).not.toContain(LEGACY); - expect(pooled(LEGACY)).toBe(false); + it('the environment row is served by both doors and pooled; a name with no row is served by neither', async () => { + expect((await call('GET', `/datasources/${FRESH}`)).status).toBe(404); + expect((await call('GET', `/meta/datasource/${FRESH}`)).status).toBe(404); + expect(pooled(FRESH)).toBe(false); expect((await call('GET', `/datasources/${ENV}`)).status).toBe(200); expect((await call('GET', `/meta/datasource/${ENV}`)).status).toBe(200); @@ -138,27 +128,26 @@ describe('[#22835] the datasource-admin plugin reads environment rows only (show expect(pooled(ENV)).toBe(true); }); - it('the writes\' by-name reads never reach the legacy row, and the row is kept as it was', async () => { - // An admin create under the legacy row's name writes an environment row of - // its own; its remove takes that row away again. + it('the writes\' by-name reads address the environment\'s rows', async () => { + // An admin create writes an environment row; its remove takes that row + // away again. const created = await call('POST', '/datasources', { - name: LEGACY, label: 'Admin create 22835', driver: 'sqlite', config: { filename: join(dir, 'admin.db') }, + name: FRESH, label: 'Admin create 22835', driver: 'sqlite', config: { filename: join(dir, 'admin.db') }, }); expect(created.status, JSON.stringify(created.json)).toBe(201); - expect(await environmentRows(LEGACY)).toHaveLength(1); - expect((await call('DELETE', `/datasources/${LEGACY}`)).status).toBe(204); - expect(await environmentRows(LEGACY)).toHaveLength(0); + expect(await environmentRows(FRESH)).toHaveLength(1); + expect((await call('DELETE', `/datasources/${FRESH}`)).status).toBe(204); + expect(await environmentRows(FRESH)).toHaveLength(0); // A `/meta` save under the name: the admin door serves the saved body. - const saved = await call('PUT', `/meta/datasource/${LEGACY}`, { - name: LEGACY, label: 'Meta save 22835', driver: 'sqlite', config: { filename: join(dir, 'meta.db') }, + const saved = await call('PUT', `/meta/datasource/${FRESH}`, { + name: FRESH, label: 'Meta save 22835', driver: 'sqlite', config: { filename: join(dir, 'meta.db') }, }); expect(saved.status, JSON.stringify(saved.json)).toBe(200); - const served = await call('GET', `/datasources/${LEGACY}`); + const served = await call('GET', `/datasources/${FRESH}`); expect(served.status, JSON.stringify(served.json)).toBe(200); expect(served.json.data?.datasource?.label).toBe('Meta save 22835'); - // Neither deleted nor rewritten. - expect(await legacyRows()).toEqual([plantedLegacy]); + expect(await environmentRows(FRESH)).toHaveLength(1); }); }); diff --git a/packages/qa/dogfood/test/flow-shipped-name-by-name-read.dogfood.test.ts b/packages/qa/dogfood/test/flow-shipped-name-by-name-read.dogfood.test.ts index 9a830fca35d..558d623594b 100644 --- a/packages/qa/dogfood/test/flow-shipped-name-by-name-read.dogfood.test.ts +++ b/packages/qa/dogfood/test/flow-shipped-name-by-name-read.dogfood.test.ts @@ -31,7 +31,6 @@ // - by name and in the list, that name answers the same body; // - the stored row is still reported as a shadowed contender; // - a shipped name with no stored row is unchanged (control); -// - an organization-scoped row stays out of reach (control); // - a name no managed package ships still answers its stored row (control). import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -56,8 +55,6 @@ const SUBJECT = 'showcase_urgent_task_alert'; /** A shipped flow given no stored row at all: the control. */ const CONTROL = 'showcase_task_completed'; /** A shipped flow given an ORGANIZATION-scoped row only. */ -const ORG_SUBJECT = 'showcase_task_assigned_notify'; -const ORG_ID = 'org_dogfood_20946'; /** A shipped screen flow whose body seeds the customer flow below (no trigger). */ const CUSTOMER_SOURCE = 'showcase_reassign_wizard'; /** A flow name no managed package ships, given an environment-wide stored row. */ @@ -162,13 +159,12 @@ describe('the by-name flow read agrees with the list for a shipped name across a stack = await boot(dbFile); const first = await engine(); const store = await ql(); - for (const name of [SUBJECT, CONTROL, ORG_SUBJECT, CUSTOMER_SOURCE]) loader[name] = await first.getFlow(name); + for (const name of [SUBJECT, CONTROL, CUSTOMER_SOURCE]) loader[name] = await first.getFlow(name); const now = new Date().toISOString(); - const row = (name: string, organizationId: string | null, body: Record) => ({ + const row = (name: string, body: Record) => ({ type: 'flow', name, - organization_id: organizationId, package_id: null, state: 'active', version: 1, @@ -179,9 +175,8 @@ describe('the by-name flow read agrees with the list for a shipped name across a }); const customer = { ...plainCopy(loader[CUSTOMER_SOURCE]), name: CUSTOMER, label: CUSTOMER_LABEL }; for (const data of [ - row(SUBJECT, null, storedBodyFrom(loader[SUBJECT])), - row(ORG_SUBJECT, ORG_ID, storedBodyFrom(loader[ORG_SUBJECT])), - row(CUSTOMER, null, customer), + row(SUBJECT, storedBodyFrom(loader[SUBJECT])), + row(CUSTOMER, customer), ]) { await store.insert('sys_metadata', data, { context: SYSTEM_CTX }); } @@ -203,9 +198,8 @@ describe('the by-name flow read agrees with the list for a shipped name across a where: { type: 'flow', state: 'active' }, context: SYSTEM_CTX, }); - const names = rows.map((r) => `${String(r.name)}@${String(r.organization_id ?? '')}`); + const names = rows.map((r) => `${String(r.name)}@`); expect(names).toContain(`${SUBJECT}@`); - expect(names).toContain(`${ORG_SUBJECT}@${ORG_ID}`); expect(names).toContain(`${CUSTOMER}@`); expect((await engine()).packagedFlowOwner(SUBJECT)).toBe(SHOWCASE_PACKAGE); expect(loader[SUBJECT]?.label).not.toBe(STORED_LABEL); @@ -256,14 +250,6 @@ describe('the by-name flow read agrees with the list for a shipped name across a expect(nodeIds(read.doc)).toEqual(nodeIds(loader[CONTROL])); }); - it('control: an organization-scoped row stays out of the by-name read\'s reach', async () => { - const read = await byName(ORG_SUBJECT); - - expect(read.status).toBe(200); - expect(read.doc.label).toBe(loader[ORG_SUBJECT]?.label); - expect(nodeIds(read.doc)).toEqual(nodeIds(loader[ORG_SUBJECT])); - }); - it('control: a flow name no managed package ships answers its stored row by name and in the list', async () => { const read = await byName(CUSTOMER); const list = await listed(CUSTOMER); diff --git a/packages/qa/dogfood/test/flow-shipped-name-layered-read.dogfood.test.ts b/packages/qa/dogfood/test/flow-shipped-name-layered-read.dogfood.test.ts index e035563319c..e07acf7a349 100644 --- a/packages/qa/dogfood/test/flow-shipped-name-layered-read.dogfood.test.ts +++ b/packages/qa/dogfood/test/flow-shipped-name-layered-read.dogfood.test.ts @@ -34,7 +34,6 @@ // - the layered read, the by-name read and the list agree on that body; // - the deprecated layers flag on the by-name door answers the same; // - a shipped name with no stored row is unchanged (control); -// - an organization-scoped row stays out of reach (control); // - a name no managed package ships keeps its stored row as the effective // layer (control). // @@ -64,8 +63,6 @@ const SUBJECT = 'showcase_urgent_task_alert'; /** A shipped flow given no stored row at all: the control. */ const CONTROL = 'showcase_task_completed'; /** A shipped flow given an ORGANIZATION-scoped row only. */ -const ORG_SUBJECT = 'showcase_task_assigned_notify'; -const ORG_ID = 'org_dogfood_21002'; /** A shipped screen flow whose body seeds the customer flow below (no trigger). */ const CUSTOMER_SOURCE = 'showcase_reassign_wizard'; /** A flow name no managed package ships, given an environment-wide stored row. */ @@ -185,13 +182,12 @@ describe('the layered flow read agrees with the by-name read and the list for a stack = await boot(dbFile); const first = await engine(); const store = await ql(); - for (const name of [SUBJECT, CONTROL, ORG_SUBJECT, CUSTOMER_SOURCE]) loader[name] = await first.getFlow(name); + for (const name of [SUBJECT, CONTROL, CUSTOMER_SOURCE]) loader[name] = await first.getFlow(name); const now = new Date().toISOString(); - const row = (name: string, organizationId: string | null, body: Record) => ({ + const row = (name: string, body: Record) => ({ type: 'flow', name, - organization_id: organizationId, package_id: null, state: 'active', version: 1, @@ -202,9 +198,8 @@ describe('the layered flow read agrees with the by-name read and the list for a }); const customer = { ...plainCopy(loader[CUSTOMER_SOURCE]), name: CUSTOMER, label: CUSTOMER_LABEL }; for (const data of [ - row(SUBJECT, null, storedBodyFrom(loader[SUBJECT])), - row(ORG_SUBJECT, ORG_ID, storedBodyFrom(loader[ORG_SUBJECT])), - row(CUSTOMER, null, customer), + row(SUBJECT, storedBodyFrom(loader[SUBJECT])), + row(CUSTOMER, customer), ]) { await store.insert('sys_metadata', data, { context: SYSTEM_CTX }); } @@ -226,9 +221,8 @@ describe('the layered flow read agrees with the by-name read and the list for a where: { type: 'flow', state: 'active' }, context: SYSTEM_CTX, }); - const names = rows.map((r) => `${String(r.name)}@${String(r.organization_id ?? '')}`); + const names = rows.map((r) => `${String(r.name)}@`); expect(names).toContain(`${SUBJECT}@`); - expect(names).toContain(`${ORG_SUBJECT}@${ORG_ID}`); expect(names).toContain(`${CUSTOMER}@`); expect((await engine()).packagedFlowOwner(SUBJECT)).toBe(SHOWCASE_PACKAGE); expect(loader[SUBJECT]?.label).not.toBe(STORED_LABEL); @@ -285,16 +279,6 @@ describe('the layered flow read agrees with the by-name read and the list for a expect(nodeIds(read.doc.effective)).toEqual(nodeIds(loader[CONTROL])); }); - it('control: an organization-scoped row stays out of the layered door\'s reach', async () => { - const read = await layers(ORG_SUBJECT); - - expect(read.status).toBe(200); - expect(read.doc.overlay).toBeNull(); - expect(read.doc.overlayScope).toBeNull(); - expect(read.doc.effective?.label).toBe(loader[ORG_SUBJECT]?.label); - expect(nodeIds(read.doc.effective)).toEqual(nodeIds(loader[ORG_SUBJECT])); - }); - it('control: a flow name no managed package ships keeps its stored row as the effective layer', async () => { const read = await layers(CUSTOMER); const byNameRead = await byName(CUSTOMER); diff --git a/packages/qa/dogfood/test/flow-shipped-name-published-door.dogfood.test.ts b/packages/qa/dogfood/test/flow-shipped-name-published-door.dogfood.test.ts index d807ffda47b..d6440e6dbd6 100644 --- a/packages/qa/dogfood/test/flow-shipped-name-published-door.dogfood.test.ts +++ b/packages/qa/dogfood/test/flow-shipped-name-published-door.dogfood.test.ts @@ -174,7 +174,6 @@ describe('the published-snapshot door answers the loader\'s body for a shipped f const row = (name: string, body: Record) => ({ type: 'flow', name, - organization_id: null, package_id: null, state: 'active', version: 1, @@ -215,7 +214,7 @@ describe('the published-snapshot door answers the loader\'s body for a shipped f where: { state: 'active' }, context: SYSTEM_CTX, }); - const keys = rows.map((r) => `${String(r.type)}/${String(r.name)}@${String(r.organization_id ?? '')}`); + const keys = rows.map((r) => `${String(r.type)}/${String(r.name)}@`); expect(keys).toContain(`flow/${SUBJECT}@`); expect(keys).toContain(`flow/${CUSTOMER}@`); expect(keys).toContain(`object/${OBJECT_NAME}@`); diff --git a/packages/qa/dogfood/test/flow-shipped-name-stored-row-boot.dogfood.test.ts b/packages/qa/dogfood/test/flow-shipped-name-stored-row-boot.dogfood.test.ts index 9dbf0dccc5a..f476aed24d3 100644 --- a/packages/qa/dogfood/test/flow-shipped-name-stored-row-boot.dogfood.test.ts +++ b/packages/qa/dogfood/test/flow-shipped-name-stored-row-boot.dogfood.test.ts @@ -27,13 +27,12 @@ // and that the trigger which fires is bound to the armed body. A stored row is // only read back by a cold boot, so the stack is booted twice on one file. // -// ## What each case pins (the triage direction's four pins) +// ## What each case pins // // - a shipped name with a stored row arms and executes the loader's body; // - the receipt and the armed body agree, and the stored row is its own // contender in it, never the package; // - a shipped name with no stored row is unchanged (the control); -// - an organization-scoped row stays out of reach. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; @@ -57,8 +56,6 @@ const SUBJECT = 'showcase_urgent_task_alert'; /** A shipped flow given no stored row at all: the control. */ const CONTROL = 'showcase_task_completed'; /** A shipped flow given an ORGANIZATION-scoped row only. */ -const ORG_SUBJECT = 'showcase_task_assigned_notify'; -const ORG_ID = 'org_dogfood_20913'; /** The node id the stored body renames the subject's notify node to. */ const STORED_NODE = 'stored_alert_20913'; @@ -127,16 +124,15 @@ describe('a stored row under a shipped flow name is reported, never armed, acros stack = await boot(dbFile); const first = await engine(); const store = await ql(); - for (const name of [SUBJECT, CONTROL, ORG_SUBJECT]) { + for (const name of [SUBJECT, CONTROL]) { const flow = await first.getFlow(name); loaderLabels[name] = flow?.label; loaderNodes[name] = nodeIds(flow); } const now = new Date().toISOString(); - const row = (name: string, organizationId: string | null) => ({ + const row = (name: string) => ({ type: 'flow', name, - organization_id: organizationId, package_id: null, state: 'active', version: 1, @@ -145,15 +141,9 @@ describe('a stored row under a shipped flow name is reported, never armed, acros updated_at: now, }); const subjectLoader = (await first.getFlow(SUBJECT)) as unknown as Record; - const orgLoader = (await first.getFlow(ORG_SUBJECT)) as unknown as Record; await store.insert( 'sys_metadata', - { ...row(SUBJECT, null), metadata: JSON.stringify(storedBodyFrom(subjectLoader, STORED_NODE)) }, - { context: SYSTEM_CTX }, - ); - await store.insert( - 'sys_metadata', - { ...row(ORG_SUBJECT, ORG_ID), metadata: JSON.stringify(storedBodyFrom(orgLoader, STORED_NODE)) }, + { ...row(SUBJECT), metadata: JSON.stringify(storedBodyFrom(subjectLoader, STORED_NODE)) }, { context: SYSTEM_CTX }, ); await stack.stop(); @@ -173,9 +163,8 @@ describe('a stored row under a shipped flow name is reported, never armed, acros where: { type: 'flow', state: 'active' }, context: SYSTEM_CTX, }); - const names = rows.map((r) => `${String(r.name)}@${String(r.organization_id ?? '')}`); + const names = rows.map((r) => `${String(r.name)}@`); expect(names).toContain(`${SUBJECT}@`); - expect(names).toContain(`${ORG_SUBJECT}@${ORG_ID}`); }); it('a shipped name with a stored row arms the loader\'s body after both boot steps', async () => { @@ -236,11 +225,4 @@ describe('a stored row under a shipped flow name is reported, never armed, acros expect(e.getShadowedFlows().find((r) => r.name === CONTROL)).toBeUndefined(); }); - it('an organization-scoped row stays out of reach: the loader\'s body, no receipt', async () => { - const e = await engine(); - const armed = await e.getFlow(ORG_SUBJECT); - expect(armed?.label).toBe(loaderLabels[ORG_SUBJECT]); - expect(nodeIds(armed)).toEqual(loaderNodes[ORG_SUBJECT]); - expect(e.getShadowedFlows().find((r) => r.name === ORG_SUBJECT)).toBeUndefined(); - }); }); diff --git a/packages/qa/dogfood/test/flow-unshipped-name-layered-code.dogfood.test.ts b/packages/qa/dogfood/test/flow-unshipped-name-layered-code.dogfood.test.ts index ac4352cba57..faeeb87a252 100644 --- a/packages/qa/dogfood/test/flow-unshipped-name-layered-code.dogfood.test.ts +++ b/packages/qa/dogfood/test/flow-unshipped-name-layered-code.dogfood.test.ts @@ -171,7 +171,6 @@ describe('the layered flow read answers a null code layer for a name no package const row = (name: string, body: Record) => ({ type: 'flow', name, - organization_id: null, package_id: null, state: 'active', version: 1, diff --git a/packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts b/packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts new file mode 100644 index 00000000000..0d55bd3f74c --- /dev/null +++ b/packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts @@ -0,0 +1,141 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0131 D10 item 5] The v18 boot refusal must not deadlock the upgrade. +// +// The ceremony (`os migrate organization-ownership --apply --plan-file FILE`) refuses to name a +// grant's set until `sys_user_permission_set.permission_set` exists, and says +// "Boot this release once against the database (its schema sync adds the +// column)". The boot refusal refuses a v17-shape database until the ceremony's +// marker is verified. If the refusal fired BEFORE the boot's additive schema +// sync, the refused boot would add nothing, the ceremony would keep refusing, +// and a 17.x database could never upgrade. +// +// Measured here end to end, on the showcase composition over a SQLite file a +// v17 runtime could have left — `sys_user_permission_set` without its +// `permission_set` column; `sys_migration_journal` and `sys_metadata` (objects +// that declare no organization column) still carrying a physical +// `organization_id`: +// +// 1. the v18 boot is refused, naming the command — and the refused boot has +// ALREADY added the column the ceremony waits for: the guard runs at +// `kernel:ready`, after every plugin's `start()`, which is where the schema +// sync runs; +// 2. `os migrate organization-ownership` writes its plan, and +// `--apply --plan-file PLAN` (the real CLI, as an operator runs it) +// completes with no boot in between, and verifies its marker; +// 3. the next v18 boot succeeds. +// +// ⚠ Needs the `sys_metadata` family's no-column schema beside the refusal: +// against a release whose family still declares the column, step 2 stops at +// `column-drops` (the declaration keeps it). The two land together. + +import { spawnSync } from 'node:child_process'; +import { copyFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { DatabaseSync } from 'node:sqlite'; +import { afterAll, describe, expect, it } from 'vitest'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { + OrganizationOwnershipBootGuardPlugin, + isOrganizationOwnershipBootRefusal, +} from '@objectstack/runtime'; +import { bootShowcase } from './showcase-boot.js'; + +const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); +const CLI_BIN = join(dirname(createRequire(import.meta.url).resolve('@objectstack/cli/package.json')), 'bin', 'run.js'); + +const columnsOf = (file: string, table: string): string[] => { + const db = new DatabaseSync(file); + try { + return (db.prepare('SELECT name FROM pragma_table_info(?)').all(table) as Array<{ name: string }>).map((r) => r.name); + } finally { + db.close(); + } +}; + +/** A v18 boot of the showcase over `file`, the guard composed as `os serve` composes it; the refusal, or null. */ +async function boot(file: string): Promise { + try { + const stack = await bootShowcase({ + databaseFile: file, + security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets] }), + extraPlugins: [new OrganizationOwnershipBootGuardPlugin()], + }); + await stack.stop(); + return null; + } catch (error) { + return error; + } +} + +describe('[ADR-0131 D10 item 5] a 17.x database upgrades through the boot refusal, never around it', () => { + const dir = mkdtempSync(join(tmpdir(), 'dogfood-ownership-order-')); + const file = join(dir, 'showcase.db'); + /** + * The ceremony's output, copied the moment it is verified. An operator's + * refused `os serve` process has exited before the ceremony runs; the + * refused boot here lives on in this process, its SQLite driver never torn + * down (bootstrap failed before any handle came back), and a later boot over + * the SAME path met its stale image — measured: the columns back and no + * marker. Booting the copied bytes is the operator's sequence. + */ + const migrated = join(dir, 'showcase.migrated.db'); + let refusal: unknown; + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('1. a v17-shape database is refused, naming the command', async () => { + const seed = new DatabaseSync(file); + seed.exec(` + CREATE TABLE sys_user_permission_set (id TEXT PRIMARY KEY, user_id TEXT, permission_set_id TEXT, organization_id TEXT); + CREATE TABLE sys_migration_journal (id TEXT PRIMARY KEY, organization_id TEXT); + CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, organization_id TEXT); + `); + seed.close(); + expect(columnsOf(file, 'sys_user_permission_set')).not.toContain('permission_set'); + + refusal = await boot(file); + expect(isOrganizationOwnershipBootRefusal(refusal), String(refusal)).toBe(true); + expect(String((refusal as Error).message)).toContain('os migrate organization-ownership'); + expect((refusal as { tables?: string[] }).tables).toEqual(expect.arrayContaining(['sys_metadata', 'sys_migration_journal'])); + }, 240_000); + + it('⭐ 1b. the refused boot has already run the additive schema sync: the column the ceremony waits for exists', () => { + expect(isOrganizationOwnershipBootRefusal(refusal)).toBe(true); + expect(columnsOf(file, 'sys_user_permission_set')).toContain('permission_set'); + }); + + it('⭐ 2. the ceremony completes with no boot in between, and verifies its marker', () => { + // As an operator runs it (S4, #23437): the plan first, then the apply of + // that reviewed plan file. + const ceremony = (args: string[]) => spawnSync(process.execPath, [ + CLI_BIN, 'migrate', 'organization-ownership', '--database-url', `file:${file}`, ...args, + ], { cwd: SHOWCASE_DIR, encoding: 'utf8', timeout: 300_000, env: { ...process.env, NO_COLOR: '1' } }); + const planFile = join(dir, 'plan.json'); + const plan = ceremony(['--out', planFile]); + expect(plan.status, `${plan.stdout}\n${plan.stderr}`).toBe(0); + const run = ceremony(['--apply', '--plan-file', planFile, '--backup-taken', '--out', join(dir, 'apply.json')]); + expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0); + const db = new DatabaseSync(file); + try { + const marker = db.prepare('SELECT verified_at, blocking FROM sys_migration WHERE id = ?') + .get('adr-0131-organization-ownership') as { verified_at: unknown; blocking: unknown } | undefined; + expect(marker?.verified_at, 'the ceremony verified its marker').toBeTruthy(); + expect(Number(marker?.blocking)).toBe(0); + } finally { + db.close(); + } + expect(columnsOf(file, 'sys_migration_journal')).not.toContain('organization_id'); + expect(columnsOf(file, 'sys_metadata')).not.toContain('organization_id'); + copyFileSync(file, migrated); + }, 320_000); + + it('⭐ 3. the next boot succeeds', async () => { + expect(await boot(migrated)).toBeNull(); + }, 240_000); +}); diff --git a/packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts b/packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts index 65a45a881c3..ea6c27c88b1 100644 --- a/packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts +++ b/packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts @@ -207,7 +207,7 @@ describe('ADR-0048 N.3: a package-held position or permission-set name the envir ['position', position, { name: position, label: 'Saved before the lock' }], ] as const) { await ql.insert('sys_metadata', { - type, name, organization_id: null, package_id: null, state: 'active', version: 1, checksum: null, + type, name, package_id: null, state: 'active', version: 1, checksum: null, created_at: now, updated_at: now, metadata: JSON.stringify(body), }, SYS); } @@ -239,7 +239,7 @@ describe('ADR-0048 N.3: a package-held position or permission-set name the envir const now = new Date().toISOString(); for (const name of ['org_admin', 'everyone']) { await ql.insert('sys_metadata', { - type: 'position', name, organization_id: null, package_id: null, state: 'active', version: 1, checksum: null, + type: 'position', name, package_id: null, state: 'active', version: 1, checksum: null, created_at: now, updated_at: now, metadata: JSON.stringify({ name, label: `Repurposed ${name}` }), }, SYS); } diff --git a/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts b/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts index d4c6de2b16e..b08ffe2c06b 100644 --- a/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts @@ -195,7 +195,6 @@ describe('dogfood: the object-extension fold and the i18n catalog disagree on sc await ql.insert('sys_metadata', { type: 'object', name: 'showcase_account', - organization_id: null, package_id: null, state: 'active', version: 1, diff --git a/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts index ba6d80c2543..432e93bdde2 100644 --- a/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts @@ -11,20 +11,19 @@ // retired the per-organization overlay axis, that save lands ENVIRONMENT-WIDE // even when the admin has an active organization. // -// The anonymous form doors still read the Default Organization's layer for -// its withdrawals, fail-closed, until ADR-0131 C7 carries those rows to the -// environment layer (triage ruling Q3 A on the retirement card). No door can -// write such a row any more, so this file PLANTS legacy organization rows -// at rest in `sys_metadata`, as a door left them before the retirement, and -// pins: +// The anonymous form doors read the environment alone (ADR-0131 C7): their +// read of the Default Organization's legacy layer is deleted, because the v18 +// ceremony (`os migrate organization-ownership --apply`) carries every +// organization-layer withdrawal onto the environment, fail-closed (rulings A +// on #22008 and #22011; the carry is pinned in the cli's +// `organization-ownership-apply.test.ts`). Since ADR-0131 D7 `sys_metadata` +// declares no organization column, so no organization row can exist to be +// read, and this file pins: // -// - a legacy organization overlay that keeps the form open does not survive -// an environment withdrawal: both anonymous doors answer -// `404 FORM_NOT_FOUND` and nothing lands; -// - ⭐ a legacy organization WITHDRAWAL still closes the form while it is -// open environment-wide (the read Q3 A keeps); -// - the admin's save with an active organization is environment-wide; -// - open at both layers (control): both doors accept, and the row lands in +// - the shipped form is served to both anonymous doors; +// - the admin's save with an active organization is environment-wide, and its +// withdrawal closes both doors (`404 FORM_NOT_FOUND`) with nothing landing; +// - open environment-wide (control): both doors accept, and the row lands in // the organization the doors resolve. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -76,23 +75,6 @@ describe('showcase: a public form withdrawal at any metadata layer holds', () => return { status: res.status, json: (await res.json()) as Record }; }; - /** Plant a LEGACY organization-scoped overlay, as a door wrote one before ADR-0131 D6. */ - const plantLegacyOrgOverlay = async (allowAnonymous: boolean) => { - const item = structuredClone(published); - item.config.sharing.allowAnonymous = allowAnonymous; - const at = { type: 'view', name: 'showcase_inquiry.contact', organization_id: organizationId }; - const now = new Date().toISOString(); - const [existing] = await ql.find('sys_metadata', { where: { ...at, state: 'active' }, context: SYS }); - if (existing) { - await ql.update('sys_metadata', { metadata: JSON.stringify(item), updated_at: now }, { where: { id: existing.id }, context: SYS }); - } else { - await ql.insert('sys_metadata', { - ...at, package_id: null, state: 'active', version: 1, checksum: null, - created_at: now, updated_at: now, metadata: JSON.stringify(item), - }, { context: SYS }); - } - }; - const saved = async (allowAnonymous: boolean) => { const r = await save(allowAnonymous); expect(r.status, JSON.stringify(r.json)).toBe(200); @@ -121,28 +103,19 @@ describe('showcase: a public form withdrawal at any metadata layer holds', () => await stack?.stop(); }); - it('PRECONDITION: a legacy organization overlay that keeps the form open is served', async () => { - await plantLegacyOrgOverlay(true); + it('PRECONDITION: the shipped form is served', async () => { expect(await probe()).toEqual(OPEN); }); - it('withdrawn by an admin WITH an active organization: the save is environment-wide, and it closes the form beneath the open legacy overlay', async () => { + it('withdrawn by an admin WITH an active organization: the save is environment-wide, and it closes the form', async () => { await scope(organizationId); expect(await saved(false), 'an org-active admin\'s save is environment-wide (ADR-0131 D6)').toMatch(/env-wide/); expect(await probe()).toEqual(CLOSED); }); - it('⭐ a legacy organization WITHDRAWAL still closes the form while it is open environment-wide (fail-closed until C7)', async () => { - await scope(null); - expect(await saved(true)).toMatch(/env-wide/); - await plantLegacyOrgOverlay(false); - expect(await probe()).toEqual(CLOSED); - }); - - it('open at both layers (control): both doors accept and the row lands in the organization', async () => { + it('open environment-wide (control): both doors accept and the row lands in the organization', async () => { await scope(organizationId); expect(await saved(true)).toMatch(/env-wide/); - await plantLegacyOrgOverlay(true); const marker = `layer_probe_${probeSeq + 1}`; expect(await probe()).toEqual(OPEN); const [row] = await ql.find('showcase_inquiry', { where: { name: marker }, context: SYS }); diff --git a/packages/qa/dogfood/vitest.config.ts b/packages/qa/dogfood/vitest.config.ts index 0516660b120..53dd5406012 100644 --- a/packages/qa/dogfood/vitest.config.ts +++ b/packages/qa/dogfood/vitest.config.ts @@ -299,6 +299,16 @@ export default defineConfig({ find: /^@objectstack\/cloud-connection$/, replacement: path.resolve(__dirname, '../../cloud-connection/src/index.ts'), }, + // [ADR-0131 D10 item 5] `organization-ownership-boot-refusal-order.dogfood.test.ts` + // mounts `OrganizationOwnershipBootGuardPlugin` on a real boot; the + // refusal is the pin's subject, so the verdict is aliased to THIS + // checkout's source, not to the last `pnpm build`. The refusal is + // discriminated structurally (its `code`), so the harness's own + // built runtime instance beside it changes no verdict. + { + find: /^@objectstack\/runtime$/, + replacement: path.resolve(__dirname, '../../runtime/src/index.ts'), + }, // [#21788] `external-import-saves-like-meta.dogfood.test.ts` mounts // `ExternalDatasourceServicePlugin` on a real boot (the harness // does not) and drives the import door. The plugin's save path is diff --git a/packages/rest/src/meta-delete-drop-storage-other-rows.test.ts b/packages/rest/src/meta-delete-drop-storage-other-rows.test.ts index 59c1ba3e878..507e6c9d2aa 100644 --- a/packages/rest/src/meta-delete-drop-storage-other-rows.test.ts +++ b/packages/rest/src/meta-delete-drop-storage-other-rows.test.ts @@ -145,7 +145,7 @@ async function boot(environmentId?: string) { const body = ticket(`Ticket ${packageId ?? 'unbound'}`); const now = new Date().toISOString(); await engine.insert('sys_metadata', { - id: `seed_active_${packageId ?? 'unbound'}`, type: 'object', name: NAME, organization_id: null, + id: `seed_active_${packageId ?? 'unbound'}`, type: 'object', name: NAME, package_id: packageId, state: 'active', metadata: JSON.stringify(body), checksum: hashSpec(body, 'object'), version: 1, created_at: now, updated_at: now, }, { context: { isSystem: true } } as any); diff --git a/packages/rest/src/meta-draft-head-package-inheritance.test.ts b/packages/rest/src/meta-draft-head-package-inheritance.test.ts index c463e3291d4..ccdb64aa5b9 100644 --- a/packages/rest/src/meta-draft-head-package-inheritance.test.ts +++ b/packages/rest/src/meta-draft-head-package-inheritance.test.ts @@ -161,7 +161,7 @@ async function boot(opts: { item?: { type: string; name: string }; permissions?: const seed = async (state: 'active' | 'draft', packageId: string | null, body: Record) => { const now = new Date().toISOString(); await engine.insert('sys_metadata', { - id: `seed_${state}_${packageId ?? 'unbound'}`, type: item.type, name: item.name, organization_id: null, + id: `seed_${state}_${packageId ?? 'unbound'}`, type: item.type, name: item.name, package_id: packageId, state, metadata: JSON.stringify(body), checksum: hashSpec(body, item.type), version: 1, created_at: now, updated_at: now, }, { context: { isSystem: true } } as any); diff --git a/packages/rest/src/public-form-intake-availability.test.ts b/packages/rest/src/public-form-intake-availability.test.ts index d55273f3996..c6e2b04cc25 100644 --- a/packages/rest/src/public-form-intake-availability.test.ts +++ b/packages/rest/src/public-form-intake-availability.test.ts @@ -171,9 +171,9 @@ describe('[#21476] a public form that cannot take intake on this posture is not it('CONTROL: single posture, walled object — accepted, and the object is not even read for the predicate', async () => { const s = build({ tenancy: 'single' }); expect((await s.post()).statusCode).toBe(201); - // The organization's view read and the env-wide one beneath it (a - // withdrawal at either layer closes the form); no object read. - expect(s.getMetaItems.mock.calls.map(([r]) => r.type)).toEqual(['view', 'view']); + // The env-wide view read alone (ADR-0131 C7: the organization layer's + // read is deleted); no object read. + expect(s.getMetaItems.mock.calls.map(([r]) => r.type)).toEqual(['view']); }); it('CONTROL: a degraded walled request reads the posture IN FORCE (single) — accepted', async () => { diff --git a/packages/rest/src/public-form-withdrawal.test.ts b/packages/rest/src/public-form-withdrawal.test.ts index 34e4d9bcadb..352c9097d59 100644 --- a/packages/rest/src/public-form-withdrawal.test.ts +++ b/packages/rest/src/public-form-withdrawal.test.ts @@ -1,33 +1,30 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// [#21331] Withdrawing a public form from anonymous intake takes effect on -// every intake door. Both doors (`GET /forms/:slug` and -// `POST /forms/:slug/submit`) read the form in the organization the request -// belongs to: the tenancy service's `defaultOrgId()`. That is the same -// organization whose overlay the administrator's editor shows. A withdrawal -// saved in that organization therefore closes both doors, and nothing is -// written. +// [#21331, ADR-0131 C7] Withdrawing a public form from anonymous intake takes +// effect on every intake door. Both doors (`GET /forms/:slug` and +// `POST /forms/:slug/submit`) read the form in the environment, like every +// other metadata read: environment → code. Until v18 they also read the +// Default Organization's legacy layer; the v18 ceremony carries every +// organization-layer withdrawal onto the environment, fail-closed (rulings A +// on #22008 and #22011; pinned end to end in the cli's +// `organization-ownership-apply.test.ts`), and that read is deleted. // // Pinned both sides: -// - withdrawn in the organization: both doors answer 404 FORM_NOT_FOUND and -// `createData` is never called; -// - published in the organization (the control): both doors accept, and -// every metadata read the doors make names that organization; -// - withdrawn env-wide with no organization to resolve: both doors refuse; -// - tenancy never registered: the env-wide read, unchanged; +// - no view read names an organization, whatever the tenancy answers; +// - withdrawn in the environment: both doors answer 404 FORM_NOT_FOUND and +// `createData` is never called; published (the control): both accept; +// - tenancy never registered: the same read; // - tenancy registered but unreachable: both doors refuse (fail closed). // -// A withdrawal is a kill switch: an explicit withdrawal of the same form (the -// same view and slot, the link kept with a switch cleared) at any layer the -// doors read closes it, and layering may only narrow intake, never re-open it. -// The doors therefore read the env-wide layer beneath the organization's too: -// - withdrawn env-wide, re-opened in the organization: both doors refuse; -// - withdrawn in the organization, open env-wide: both doors refuse; -// - open in both: both doors accept; -// - a form only the organization has (no env-wide body): it is served; -// - not a withdrawal (the link cleared env-wide, a switch merely absent, or -// another view sharing the slug withdrawn): it is served; -// - the same view re-pointed at a new slug in the organization: still closed. +// A withdrawal is a kill switch: the list holds one item per package of a +// view name, and an explicit withdrawal of the same form (matched by slot or +// by slug, the link kept with a switch cleared) in any of them closes it: +// - another package's withdrawal of the same view name closes the form; +// - every package's body open (control): served; +// - not a withdrawal (the link cleared, a switch merely absent, or another +// view sharing the slug withdrawn): served; +// - the form moved to a new slug in one package's body while another's +// withdraws the old one: still closed. import { describe, it, expect, vi } from 'vitest'; import { RestServer } from './rest-server'; @@ -86,34 +83,24 @@ function notRegistered(): Error { } interface Setup { - /** What the env-wide (organization-less) read answers. */ + /** What the environment's read answers. */ envWide: boolean; - /** What the organization read answers; `undefined` = no overlay there. */ - inOrg?: boolean; /** The tenancy provider's behaviour. */ tenancy: 'org' | 'no-org' | 'not-registered' | 'unreachable'; - /** Replaces the form's whole `sharing` on every read (the `envWide`/`inOrg` switch is then ignored). */ + /** Replaces the form's whole `sharing` (the `envWide` switch is then ignored). */ sharing?: Record; - /** Replaces the env-wide read's whole view list (the organization read is unchanged). */ + /** Replaces the environment's whole view list. */ envWideViews?: unknown[]; - /** Extra views every read answers alongside the form view. */ + /** Extra views the read answers alongside the form view. */ extraViews?: unknown[]; - /** Replaces the organization read's whole view list (the env-wide read is unchanged). */ - orgViews?: unknown[]; } function build(setup: Setup) { const createData = vi.fn().mockResolvedValue({ object: 'inquiry', id: 'rec_1', record: {} }); - // [ADR-0131 D6, triage ruling Q3 A] The doors' `view` read reaches the - // Default Organization's legacy layer through `legacyFormOrganizationId`, the - // one read key left that names an organization. - const getMetaItems = vi.fn(async (req: { type: string; organizationId?: string; legacyFormOrganizationId?: string }) => { + const getMetaItems = vi.fn(async (req: Record & { type: string }) => { if (req.type === 'view') { - const org = req.legacyFormOrganizationId; - if (org !== ORG && setup.envWideViews) return setup.envWideViews; - if (org === ORG && setup.orgViews) return setup.orgViews; - const effective = org === ORG && setup.inOrg !== undefined ? setup.inOrg : setup.envWide; - return [formView(effective, setup.sharing), ...(setup.extraViews ?? [])]; + if (setup.envWideViews) return setup.envWideViews; + return [formView(setup.envWide, setup.sharing), ...(setup.extraViews ?? [])]; } if (req.type === 'object') return [inquiryObject]; return []; @@ -163,8 +150,19 @@ function build(setup: Setup) { } describe('[#21331] public form withdrawal reaches every intake door', () => { - it('withdrawn in the organization: both doors answer FORM_NOT_FOUND and nothing is written', async () => { - const s = build({ envWide: true, inOrg: false, tenancy: 'org' }); + it('[ADR-0131 C7] no view read names an organization, whatever the tenancy answers: one environment read per door', async () => { + const s = build({ envWide: true, tenancy: 'org' }); + expect((await s.get()).statusCode).toBe(200); + expect((await s.post()).statusCode).toBe(201); + const views = s.getMetaItems.mock.calls.map(([r]) => r).filter((r) => r.type === 'view'); + expect(views).toHaveLength(2); + for (const r of s.getMetaItems.mock.calls.map(([req]) => req)) { + expect(Object.keys(r).filter((k) => /organization/i.test(k))).toEqual([]); + } + }); + + it('withdrawn in the environment: both doors answer FORM_NOT_FOUND and nothing is written', async () => { + const s = build({ envWide: false, tenancy: 'org' }); const get = await s.get(); expect(get.statusCode).toBe(404); expect(get.body.code).toBe('FORM_NOT_FOUND'); @@ -174,8 +172,8 @@ describe('[#21331] public form withdrawal reaches every intake door', () => { expect(s.createData).not.toHaveBeenCalled(); }); - it('published in the organization (control): both doors accept, every read names the organization', async () => { - const s = build({ envWide: true, inOrg: true, tenancy: 'org' }); + it('published in the environment (control): both doors accept', async () => { + const s = build({ envWide: true, tenancy: 'org' }); const get = await s.get(); expect(get.statusCode).toBe(200); expect(get.body.object).toBe('inquiry'); @@ -183,32 +181,19 @@ describe('[#21331] public form withdrawal reaches every intake door', () => { const post = await s.post(); expect(post.statusCode).toBe(201); expect(s.createData).toHaveBeenCalledTimes(1); - // [ADR-0131 D6] No read names an organization by the retired key; the - // `view` read alone reaches the legacy layer, and the object read is the - // environment's. - const reads = s.getMetaItems.mock.calls.map(([r]) => [r.type, r.legacyFormOrganizationId]); - expect(reads.length).toBeGreaterThan(0); - for (const [r] of s.getMetaItems.mock.calls) expect(r.organizationId).toBeUndefined(); - for (const [type, legacy] of reads) { - if (type !== 'view') expect(legacy).toBeUndefined(); - } - // One resolution per door: the legacy organization's view read, then the env-wide one. - expect(reads.filter(([type]) => type === 'view').map(([, o]) => o)).toEqual([ORG, undefined, ORG, undefined]); }); - it('withdrawn env-wide with no organization to resolve: both doors refuse', async () => { + it('withdrawn with no organization to resolve: both doors refuse', async () => { const s = build({ envWide: false, tenancy: 'no-org' }); expect((await s.get()).body.code).toBe('FORM_NOT_FOUND'); expect((await s.post()).body.code).toBe('FORM_NOT_FOUND'); expect(s.createData).not.toHaveBeenCalled(); - for (const [r] of s.getMetaItems.mock.calls) expect(r.legacyFormOrganizationId).toBeUndefined(); }); - it('tenancy never registered: the env-wide read, unchanged', async () => { - const s = build({ envWide: true, inOrg: false, tenancy: 'not-registered' }); + it('tenancy never registered: the same environment read', async () => { + const s = build({ envWide: true, tenancy: 'not-registered' }); expect((await s.get()).statusCode).toBe(200); expect((await s.post()).statusCode).toBe(201); - for (const [r] of s.getMetaItems.mock.calls) expect(r.legacyFormOrganizationId).toBeUndefined(); }); it('tenancy registered but unreachable: both doors refuse instead of reading env-wide (fail closed)', async () => { @@ -254,7 +239,7 @@ describe('either declared switch withdraws a public form from every anonymous do } }); -describe('a public form withdrawal is a kill switch: layering only narrows intake', () => { +describe('a public form withdrawal is a kill switch: one package\'s withdrawal of a view name closes its form', () => { const expectClosed = async (s: ReturnType) => { const get = await s.get(); expect([get.statusCode, get.body.code]).toEqual([404, 'FORM_NOT_FOUND']); @@ -263,92 +248,58 @@ describe('a public form withdrawal is a kill switch: layering only narrows intak expect(s.createData).not.toHaveBeenCalled(); }; - it('withdrawn env-wide (allowAnonymous false), re-opened in the organization: both doors refuse', async () => { - await expectClosed(build({ envWide: false, inOrg: true, tenancy: 'org' })); - }); - - it('withdrawn env-wide through `enabled: false`, re-opened in the organization: both doors refuse', async () => { - const withdrawn = formView(true, { enabled: false, allowAnonymous: true, publicLink: '/forms/contact-us' }); - await expectClosed(build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [withdrawn] })); - }); - - it('not a withdrawal: the public link cleared env-wide leaves the organization\'s open form served', async () => { - const cleared = formView(true, { enabled: true, allowAnonymous: false }); - const s = build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [cleared] }); - expect((await s.get()).statusCode).toBe(200); - expect((await s.post()).statusCode).toBe(201); - }); - - // The list reads serve one item per package for a name, each carrying its + // The list read serves one item per package for a name, each carrying its // `_packageId`. Known limit (fails closed): the package is not compared, so // a withdrawal of a view name closes that name in every package. - it('another package\'s withdrawal of the same view name closes this package\'s form too', async () => { - const openA = { ...formView(true), _packageId: 'pkg_a' }; - await expectClosed(build({ - envWide: true, inOrg: true, tenancy: 'org', - orgViews: [openA], - envWideViews: [{ ...formView(true), _packageId: 'pkg_a' }, { ...formView(false), _packageId: 'pkg_b' }], - })); + for (const tenancy of ['org', 'no-org'] as const) { + it(`another package's withdrawal of the same view name closes this package's form (tenancy ${tenancy})`, async () => { + await expectClosed(build({ + envWide: true, tenancy, + envWideViews: [{ ...formView(true), _packageId: 'pkg_a' }, { ...formView(false), _packageId: 'pkg_b' }], + })); + }); + } + + it('withdrawn through `enabled: false` in another package: closed', async () => { + const withdrawn = { ...formView(true, { enabled: false, allowAnonymous: true, publicLink: '/forms/contact-us' }), _packageId: 'pkg_b' }; + await expectClosed(build({ envWide: true, tenancy: 'org', envWideViews: [{ ...formView(true), _packageId: 'pkg_a' }, withdrawn] })); }); it('control: every package\'s body of the view name open serves the form', async () => { - const openA = { ...formView(true), _packageId: 'pkg_a' }; const s = build({ - envWide: true, inOrg: true, tenancy: 'org', - orgViews: [openA], + envWide: true, tenancy: 'org', envWideViews: [{ ...formView(true), _packageId: 'pkg_a' }, { ...formView(true), _packageId: 'pkg_b' }], }); expect((await s.get()).statusCode).toBe(200); expect((await s.post()).statusCode).toBe(201); }); - it('the same package\'s withdrawal of the view name closes its form, beside another package\'s open one', async () => { - const openA = { ...formView(true), _packageId: 'pkg_a' }; - await expectClosed(build({ - envWide: true, inOrg: true, tenancy: 'org', - orgViews: [openA], - envWideViews: [{ ...formView(false), _packageId: 'pkg_a' }, { ...formView(true), _packageId: 'pkg_b' }], - })); - }); - - it('another view withdrawing the same slug env-wide does not close this view\'s form', async () => { - const other = { ...formView(false), name: 'legacy_contact' }; - const s = build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [formView(true), other] }); + it('not a withdrawal: another package\'s body with the public link cleared leaves the form served', async () => { + const cleared = { ...formView(true, { enabled: true, allowAnonymous: false }), _packageId: 'pkg_b' }; + const s = build({ envWide: true, tenancy: 'org', envWideViews: [{ ...formView(true), _packageId: 'pkg_a' }, cleared] }); expect((await s.get()).statusCode).toBe(200); expect((await s.post()).statusCode).toBe(201); }); - it('withdrawn in the organization, open env-wide: both doors refuse', async () => { - await expectClosed(build({ envWide: true, inOrg: false, tenancy: 'org' })); - }); - - it('open in both layers (control): both doors accept', async () => { - const s = build({ envWide: true, inOrg: true, tenancy: 'org' }); + it('only an explicit false withdraws: a body with the link, enabled true and allowAnonymous absent closes nothing', async () => { + const absent = { ...formView(true, { enabled: true, publicLink: '/forms/contact-us' }), _packageId: 'pkg_b' }; + const s = build({ envWide: true, tenancy: 'org', envWideViews: [{ ...formView(true), _packageId: 'pkg_a' }, absent] }); expect((await s.get()).statusCode).toBe(200); expect((await s.post()).statusCode).toBe(201); - expect(s.createData).toHaveBeenCalledTimes(1); }); - it('a form only the organization carries (no env-wide body, control): both doors accept', async () => { - const s = build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [] }); - expect((await s.get()).statusCode).toBe(200); - expect((await s.post()).statusCode).toBe(201); - }); - - it('only an explicit false withdraws: env-wide link + enabled true with allowAnonymous absent does not close the org\'s open form', async () => { - const absent = formView(true, { enabled: true, publicLink: '/forms/contact-us' }); - const s = build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [absent] }); + it('another view withdrawing the same slug does not close this view\'s form', async () => { + const other = { ...formView(false), name: 'legacy_contact' }; + const s = build({ envWide: true, tenancy: 'org', envWideViews: [formView(true), other] }); expect((await s.get()).statusCode).toBe(200); expect((await s.post()).statusCode).toBe(201); }); for (const link of ['/forms/contact-us-2', '/forms/Contact-Us']) { - it(`the same view re-pointed at a new slug (${link}) in the organization stays closed`, async () => { + it(`the form moved to a new slug (${link}) in one package, withdrawn at the old one in another: still closed (same slot)`, async () => { const slug = link.replace('/forms/', ''); - const s = build({ - envWide: true, inOrg: true, tenancy: 'org', envWideViews: [formView(false)], - sharing: { enabled: true, allowAnonymous: true, publicLink: link }, - }); + const moved = { ...formView(true, { enabled: true, allowAnonymous: true, publicLink: link }), _packageId: 'pkg_a' }; + const s = build({ envWide: true, tenancy: 'org', envWideViews: [moved, { ...formView(false), _packageId: 'pkg_b' }] }); const get = await s.get(slug); expect([get.statusCode, get.body.code]).toEqual([404, 'FORM_NOT_FOUND']); const post = await s.post(slug); @@ -360,9 +311,9 @@ describe('a public form withdrawal is a kill switch: layering only narrows intak for (const tenancy of ['org', 'no-org'] as const) { it(`two different views sharing a slug in one read do not close each other (tenancy ${tenancy})`, async () => { // Another view (another app's form) names the same slug and withdraws - // it, in every read: this view's open form is still served. + // it: this view's open form is still served. const other = { ...formView(false), name: 'legacy_contact' }; - const s = build({ envWide: true, tenancy, extraViews: [other], ...(tenancy === 'org' ? { inOrg: true } : {}) }); + const s = build({ envWide: true, tenancy, extraViews: [other] }); expect((await s.get()).statusCode).toBe(200); expect((await s.post()).statusCode).toBe(201); expect(s.createData).toHaveBeenCalledTimes(1); diff --git a/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts b/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts index 167e149d5b4..3589abdb7ca 100644 --- a/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts +++ b/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts @@ -21,7 +21,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; -import { ObjectStackProtocolImplementation, SysMetadataRepository } from '@objectstack/metadata-protocol'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server.js'; const META = '/api/v1/meta'; @@ -313,16 +313,6 @@ function boot() { return { rows, historyRows, - /** - * A LEGACY organization-scoped write, as a door made one before ADR-0131 D6, - * planted at rest — the protocol now refuses it (`403 NOT_OVERRIDABLE`). - */ - plantLegacyOrgRow: async (type: string, name: string, label = MARKER, organizationId = ORG_A) => { - const repo = new SysMetadataRepository({ engine, organizationId, orgLabel: organizationId }); - const ref = { org: organizationId, type, name } as Parameters[0]; - const head = await repo.get(ref); - return repo.put(ref, bodyFor(type, name, label), { parentVersion: head?.hash ?? null, actor: null }); - }, as(tenantId: string | undefined) { session = tenantId === undefined ? { userId: 'u1', systemPermissions: ['manage_metadata'] } @@ -429,22 +419,6 @@ describe('#13406 · ADR-0131 D6 the /history and /diff read doors read the envir expect(read.body?.events?.length).toBe(1); }); - it('⭐ a LEGACY organization-scoped log is served by neither door, not even to its own organization', async () => { - await b.plantLegacyOrgRow('dashboard', 'legacy_logged'); - await b.plantLegacyOrgRow('dashboard', 'legacy_logged', MARKER_2); - expect( - b.historyRowsFor('dashboard', 'legacy_logged', ORG_A).length, - 'the legacy log was not planted; the case proves nothing', - ).toBe(2); - - const read = await b.history('dashboard', 'legacy_logged'); - expect(read.status).toBe(200); - expect(read.body?.events ?? [], 'a legacy org change log was served').toEqual([]); - - const diffed = await b.diff('dashboard', 'legacy_logged', { from: '1', to: '2' }); - expect(diffed.status).toBe(200); - expect(diffed.body?.changed ?? [], 'a diff of legacy org revisions was served').toEqual([]); - }); }); describe('the single-item read beside them', () => { diff --git a/packages/rest/src/rest-server-meta-read-org-scope.test.ts b/packages/rest/src/rest-server-meta-read-org-scope.test.ts index 74c66665d55..2476f250b5c 100644 --- a/packages/rest/src/rest-server-meta-read-org-scope.test.ts +++ b/packages/rest/src/rest-server-meta-read-org-scope.test.ts @@ -26,7 +26,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; -import { ObjectStackProtocolImplementation, SysMetadataRepository } from '@objectstack/metadata-protocol'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server.js'; const META = '/api/v1/meta'; @@ -56,9 +56,6 @@ const MARKER = 'AUTHORED_AT_RUNTIME'; /** The second revision's marker — two PUTs, two history events. */ const MARKER_2 = 'AUTHORED_AT_RUNTIME_REV2'; -/** The label a planted LEGACY organization-scoped row carries. */ -const LEGACY_MARKER = 'LEGACY_ORG_ROW'; - /** * A SPEC-VALID body per type, carrying `label` as the marker the reads assert * on. Real bodies, not `{ label }` stubs: the write door runs full spec @@ -337,17 +334,6 @@ function boot() { return { rows, historyRows, - /** - * Plant a LEGACY organization-scoped row (and its change log) at rest, - * as a door wrote it before ADR-0131 D6 — the protocol now refuses an - * organization-scoped write (`403 NOT_OVERRIDABLE`). - */ - plantLegacyOrgRow: async (type: string, name: string, label = LEGACY_MARKER, organizationId = ORG_A) => { - const repo = new SysMetadataRepository({ engine, organizationId, orgLabel: organizationId }); - const ref = { org: organizationId, type, name } as Parameters[0]; - const head = await repo.get(ref); - return repo.put(ref, bodyFor(type, name, label), { parentVersion: head?.hash ?? null, actor: null }); - }, as(tenantId: string | undefined) { session = tenantId === undefined ? { userId: 'u1', systemPermissions: ['manage_metadata'] } @@ -400,18 +386,6 @@ function storedRowsFor { - return { - // [ADR-0090 D1] `sharingModel` is required at the write door; without - // it this fixture fails on the WRITE and never reaches the read. - name, - label: MARKER, - sharingModel: 'private', - fields: { task_ref: { type: 'lookup', label: 'Task', reference: target } }, - }; -} - /** The item an operator is about to delete — what `bodyFor('view', …)` binds to. */ const TARGET_OBJECT = 'task'; @@ -488,31 +462,6 @@ describe('#9454 · ADR-0131 D6 every REST /meta read door serves what the write }); }); - describe('⭐ a LEGACY organization-scoped row is served by no read door (environment → code)', () => { - it.each([CACHED_ARM, UNCACHED_ARM])('%s: shadowed by nothing — its own organization is served the environment row', async (type) => { - // Fixture proof first: the legacy row really is org-scoped. - await b.put(type, 'legacy_item'); - await b.plantLegacyOrgRow(type, 'legacy_item'); - expect(storedRowsFor(b.rows, type, 'legacy_item', ORG_A).length, 'the legacy row was not planted').toBe(1); - expect(storedRowsFor(b.rows, type, 'legacy_item', null).length).toBe(1); - - const read = await b.get(type, 'legacy_item'); - expect(read.status).toBe(200); - expect( - servedDocument(read.body)?.label, - 'a legacy org row shadowed the environment save — the writes-first hazard', - ).toBe(MARKER); - }); - - it.each([CACHED_ARM, UNCACHED_ARM])('%s: alone, it is not served or listed to its own organization', async (type) => { - await b.plantLegacyOrgRow(type, 'legacy_only'); - expect(storedRowsFor(b.rows, type, 'legacy_only', ORG_A).length, 'the legacy row was not planted').toBe(1); - - const read = await b.get(type, 'legacy_only'); - expect(servedDocument(read.body)?.label, 'a legacy org row was served').not.toBe(LEGACY_MARKER); - expect(listedNames((await b.list(type)).body)).not.toContain('legacy_only'); - }); - }); }); // ── the history seams of this harness ───────────────────────────────────── @@ -543,14 +492,6 @@ describe('#13764 · ADR-0131 D6 the history seams serve the environment change l } }); - it('⭐ does not serve a legacy organization-scoped change log, not even to its own organization', async () => { - await b.plantLegacyOrgRow(UNCACHED_ARM, 'legacy_logged'); - expect(b.historyRowsFor(UNCACHED_ARM, 'legacy_logged', ORG_A).length, 'no legacy log was planted').toBe(1); - - const read = await b.history(UNCACHED_ARM, 'legacy_logged'); - expect(read.status).toBe(200); - expect(read.body?.events ?? [], 'a legacy org change log was served').toEqual([]); - }); }); // ── [#13753 · #15622] `GET /meta/diagnostics` ────────────────────────────── @@ -589,41 +530,6 @@ describe('#13753 · ADR-0131 D6 GET /meta/diagnostics sweeps the environment par } }); - it('⭐ a legacy organization-scoped item is not swept, not even for its own organization, on either arm', async () => { - await b.plantLegacyOrgRow(CACHED_ARM, 'legacy_only'); - expect(storedRowsFor(b.rows, CACHED_ARM, 'legacy_only', ORG_A).length, 'the legacy row was not planted').toBe(1); - - const typed = await b.diagnostics({ type: CACHED_ARM }); - expect(typed.status).toBe(200); - expect(typed.body?.stats?.[CACHED_ARM]?.count ?? 0).toBe(0); - const untyped = await b.diagnostics(); - expect(untyped.status).toBe(200); - expect(untyped.body?.scannedTypes, 'the untyped arm did not sweep the registry').toBeGreaterThan(1); - expect(untyped.body?.stats?.[CACHED_ARM]?.count ?? 0).toBe(0); - }); - - it('?type=object does not resurrect a pre-#6190 phantom org row either', async () => { - const written = await b.put(NON_OVERRIDABLE, 'accounts'); - expect(written.status, 'the control never wrote').toBe(200); - b.rows.set( - keyOf({ type: NON_OVERRIDABLE, name: 'phantom_orders', organization_id: ORG_A, state: 'active' }), - { - id: 'phantom_1', - type: NON_OVERRIDABLE, - name: 'phantom_orders', - organization_id: ORG_A, - package_id: null, - state: 'active', - metadata: JSON.stringify(bodyFor(NON_OVERRIDABLE, 'phantom_orders')), - }, - ); - expect(storedRowsFor(b.rows, NON_OVERRIDABLE, 'phantom_orders', ORG_A).length).toBe(1); - - const swept = await b.diagnostics({ type: NON_OVERRIDABLE }); - expect(swept.status).toBe(200); - expect(swept.body?.stats?.[NON_OVERRIDABLE]?.count).toBe(1); - }); - it('the response is the SAME wire shape — no new key, and 200 either way', async () => { await b.put(CACHED_ARM, 'authored_at_runtime'); const swept = await b.diagnostics(); @@ -668,39 +574,6 @@ describe('#13753 · ADR-0131 D6 GET /meta/:type/:name/references sweeps the envi } }); - it('a legacy organization-scoped source is not swept', async () => { - await b.plantLegacyOrgRow(CACHED_ARM, 'legacy_task_list'); - expect(storedRowsFor(b.rows, CACHED_ARM, 'legacy_task_list', ORG_A).length, 'the legacy row was not planted').toBe(1); - const used = await b.references(NON_OVERRIDABLE, TARGET_OBJECT); - expect(used.status).toBe(200); - expect(namesOf(used.body, CACHED_ARM)).not.toContain('legacy_task_list'); - }); - - it('a non-overridable SOURCE stays env-wide — no phantom row is resurrected', async () => { - const written = await b.put(NON_OVERRIDABLE, 'env_orders'); - expect(written.status, 'the control never wrote').toBe(200); - const envRow = storedRowsFor(b.rows, NON_OVERRIDABLE, 'env_orders', null); - expect(envRow.length).toBe(1); - envRow[0].metadata = JSON.stringify(objectReferencing('env_orders', TARGET_OBJECT)); - - b.rows.set( - keyOf({ type: NON_OVERRIDABLE, name: 'phantom_orders', organization_id: ORG_A, state: 'active' }), - { - id: 'phantom_ref_1', - type: NON_OVERRIDABLE, - name: 'phantom_orders', - organization_id: ORG_A, - package_id: null, - state: 'active', - metadata: JSON.stringify(objectReferencing('phantom_orders', TARGET_OBJECT)), - }, - ); - const used = await b.references(NON_OVERRIDABLE, TARGET_OBJECT); - expect(used.status).toBe(200); - expect(namesOf(used.body, NON_OVERRIDABLE), 'the env-wide source was not swept').toContain('env_orders'); - expect(namesOf(used.body, NON_OVERRIDABLE)).not.toContain('phantom_orders'); - }); - it('the response is the SAME wire shape — one `references` key, no new field', async () => { await b.put(CACHED_ARM, 'task_list'); const used = await b.references(NON_OVERRIDABLE, TARGET_OBJECT); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index d69a2b87312..e191bad335b 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -81,6 +81,7 @@ import { // Which form candidates the anonymous form doors serve — the one rule the // metadata protocol also judges organization-scoped `view` writes by. anonymousFormIntakeCandidates, + // …and whether another body of the same view name withdraws a candidate. anonymousFormIntakeWithdrawnIn, // [#21476] Whether such a form can take intake on this posture, and why not // — the one predicate the runtime authoring gate's advisory reads too. @@ -306,18 +307,6 @@ export type RestProtocol = DataProtocol & MetadataProtocol; */ type TransportScopedMetaRequest = R & { environmentId?: string }; -/** - * [ADR-0131 D6, triage ruling Q3 A] The anonymous form doors' `view` list - * read: the one metadata read left that reaches a legacy organization's rows. - * `legacyFormOrganizationId` is not a key of the spec's `GetMetaItemsRequest` - * — every other read is environment → code — and the protocol honours it for - * `view` alone: the Default Organization's legacy body is preferred, and a - * withdrawal in either layer closes the form, fail-closed until the v18 - * migration ceremony (ADR-0131 C7) carries those rows into the environment - * layer and deletes this read with them. - */ -type LegacyFormViewsRequest = TransportScopedMetaRequest & { legacyFormOrganizationId?: string }; - // [#23050 slice 1] `ServerScopedDataRequest`, the data doors' request envelope, moved to // `data-action-routes.ts` (imported below): exporting it from this file for that module // changed the published `dist/*.d.ts` by one line, and a second copy is not an option. @@ -1911,13 +1900,6 @@ type MetaReadVerdict = // admin read below and the runtime authoring gate's save/publish advisory read // them from there, so none of the three can disagree with another. -/** [#21331] The organization an anonymous form request reads the form in (`defaultOrgId()`). */ -async function anonymousFormOrganization(tenancy: any): Promise { - if (!tenancy || typeof tenancy.defaultOrgId !== 'function') return undefined; - const organizationId = await tenancy.defaultOrgId(); - return typeof organizationId === 'string' && organizationId ? organizationId : undefined; -} - /** * [#21476] Put the admin read's intake reasons in `_diagnostics.warnings`, where * a derived view warning already goes (`stampRenameWarning`). A declared read @@ -9765,22 +9747,25 @@ export class RestServer { // `sharing.allowAnonymous === true` and a `publicLink` naming the slug. // // A withdrawal is a kill switch: layering may only narrow anonymous - // intake, never re-open it. A candidate is served only when no layer - // beneath the read it is found in explicitly withdraws the same form - // (`anonymousFormIntakeWithdrawnIn`: the same view name, matched by - // slot or by slug, the link kept with a switch set to `false`). - // Another view publishing the same slug is a different form and closes - // nothing. + // intake, never re-open it. The doors read one list, the + // environment's (environment → code), which holds one item per + // package of a view name, and judge every candidate against that same + // list as the layer beneath it: a candidate is served only when no + // body of its view name explicitly withdraws the same form + // (`anonymousFormIntakeWithdrawnIn`: matched by slot or by slug, the + // link kept with a switch set to `false`), so a withdrawal in one + // package closes the name's form in every package (a known limit that + // fails closed). Another view publishing the same slug is a different + // form and closes nothing. const findPublicFormView = ( views: any[], slug: string, - layers: ReadonlyArray>, ): { view: any; form: any; object: string } | null => { for (const view of views ?? []) { if (!view || typeof view !== 'object') continue; for (const c of anonymousFormIntakeCandidates(view)) { if (c.slug !== slug) continue; - if (layers.some((layer) => anonymousFormIntakeWithdrawnIn(layer, view, c))) continue; + if (anonymousFormIntakeWithdrawnIn(views, view, c)) continue; const objectName = anonymousFormObjectName(view, c.form); if (!objectName) continue; return { view, form: c.form, object: objectName }; @@ -9789,24 +9774,17 @@ export class RestServer { return null; }; - // [#21331] WHICH organization's metadata an anonymous form request - // reads. Before ADR-0131 D6 an administrator's edit of a packaged - // form was saved as an overlay of THEIR organization, and an org-less - // read missed every such edit, including the one that withdraws the - // form from anonymous intake. Since D6 no write lands - // organization-scoped and no other read serves such a row; this - // `view` read alone keeps the Default Organization's legacy layer - // (`legacyFormOrganizationId`, triage ruling Q3 A), fail-closed, until - // the v18 migration ceremony (ADR-0131 C7) carries those rows. + // [ADR-0131 D6, C7] WHICH metadata an anonymous form request reads: + // the environment's, like every other metadata read. Until v18 the + // `view` read also kept the Default Organization's legacy layer, so an + // organization's withdrawal still closed the form. The v18 ceremony + // (`os migrate organization-ownership --apply`, its metadata-promotion + // step) carries every organization-layer withdrawal onto the + // environment, fail-closed (rulings A on #22008 and #22011), and that + // read is deleted with it. // - // The answer is the tenancy service's `defaultOrgId()`: the - // organization a single-posture deployment binds every principal to. - // It is the organization the administrator's own session is in, and - // the one the engine stamps on the row this request inserts. It is - // `undefined` in two cases. Before any organization exists, no - // organization overlay can exist either. A walled posture has no - // install organization for an org-less request, so the env-wide state - // governs there exactly as before. + // The tenancy service is still read: it answers the posture the + // intake-availability rule judges (#21476). // // Asked ONCE per request, in `resolveFormBySlug`. Every door below // reads the form through that one resolution, so no door keeps its @@ -9816,7 +9794,7 @@ export class RestServer { // Fails CLOSED. A tenancy service that is registered but cannot be // reached raises `AuthzStoreUnavailableError`, the classification // `classifyAdmissionTenancyPosture` applies to the same seam. The - // door then refuses instead of falling back to the env-wide read. + // door then refuses instead of judging intake without a posture. // Only the registry's own "never registered" brand reads as the // supported no-tenancy composition. The wiring mirrors // `resolveProtocol`, so the tenancy service and the protocol always @@ -9829,11 +9807,9 @@ export class RestServer { const p = await this.resolveProtocol(environmentId, req); if (typeof (p as any).getMetaItems !== 'function') return null; const tenancy = await this.resolveAnonymousFormTenancy(environmentId, req); - const organizationId = await anonymousFormOrganization(tenancy); - const viewsRequest: LegacyFormViewsRequest = { + const viewsRequest: TransportScopedMetaRequest = { type: 'view', ...(environmentId ? { environmentId } : {}), - ...(organizationId ? { legacyFormOrganizationId: organizationId } : {}), }; const listOf = (result: any): any[] => (Array.isArray(result?.items) ? result.items @@ -9841,22 +9817,7 @@ export class RestServer { ? result : []); const items = listOf(await p.getMetaItems(viewsRequest)); - // The organization read prefers the organization's overlay of a - // view over the env-wide one, so on its own it cannot see an - // env-wide withdrawal that overlay disagrees with. Read the - // env-wide layer too, and let its withdrawal of the same form close - // it: an organization overlay can narrow intake, never re-open it. - // (The read the form is found in holds only that view's own body, - // which is open, so it withdraws nothing of its own.) - const layers: any[][] = []; - if (organizationId) { - const envWideRequest: TransportScopedMetaRequest = { - type: 'view', - ...(environmentId ? { environmentId } : {}), - }; - layers.push(listOf(await p.getMetaItems(envWideRequest))); - } - const match = findPublicFormView(items, slug, layers); + const match = findPublicFormView(items, slug); if (!match) return null; // [#21476] A form that cannot take intake on this posture is not // offered: `null` here IS the withdrawn form's answer on both diff --git a/packages/runtime/src/audit-meta-item-org-scope.integration.test.ts b/packages/runtime/src/audit-meta-item-org-scope.integration.test.ts index 7b654cb4c19..b75c00b1eb7 100644 --- a/packages/runtime/src/audit-meta-item-org-scope.integration.test.ts +++ b/packages/runtime/src/audit-meta-item-org-scope.integration.test.ts @@ -17,8 +17,9 @@ // the audit tab goes blank on every deployment that authors through REST. // // That second one is not hypothetical. The REST `PUT /meta/:type/:name` door -// passes NO `organizationId`, so every row it writes is stamped -// `organization_id: null`. A fix that kept only the equality limb would look +// passes NO `organizationId`, so every row it writes is the environment's +// (since ADR-0131 D7 no audit row carries an organization column at all). A +// fix that kept only the equality limb would look // correct in a shape assertion and return nothing in production. So the // env-wide row below is the DISCRIMINATING control, not a courtesy case: it is // the assertion that separates "correctly scoped" from "hides everything". @@ -45,11 +46,9 @@ import { const ORG_A = 'org_alpha'; const ORG_B = 'org_beta'; -const ORG_C = 'org_gamma'; const NAME = 'shared_grid'; const ACTOR_A = 'alice@alpha.example'; -const ACTOR_B = 'bob@beta.example'; const ACTOR_ENV = 'package-installer'; let cleanup: Array<() => void> = []; @@ -93,129 +92,64 @@ const viewBody = (name: string) => ({ }); /** - * Three audit-bearing rows on ONE view name: two tenant overlays and one - * env-wide package write. + * Two audit-bearing saves on ONE view name, both through the production writer + * (`saveMetaItem`): a package install and a later Studio edit. * - * [ADR-0131 D6] The protocol now refuses every organization-scoped write, so - * the two tenant overlays — and the audit rows that recorded them — are - * planted AT REST as the legacy rows a release before D6 left behind, shaped - * as `SysMetadataRepository.put` and `recordMetadataAudit` wrote them. The - * env-wide row is still written by the production writer (`saveMetaItem`), - * so the discriminating control keeps the stamp production produces. The - * organization-scoped READ this file pins is S5's to re-premise. + * [ADR-0131 D6/D7] The protocol refuses every organization-scoped write, and + * the audit table declares no organization column, so no organization's row + * can exist to be hidden: the read under test is the environment's trail, + * whatever organization a caller names. #8747's disclosure stays closed by + * construction; what this file still pins against a real driver is that the + * read SELECTS that trail — every save of the item, and no other item's. */ -/** One legacy organization-scoped save, at rest: its overlay row and its audit row. */ -async function plantLegacyOrgSave(engine: any, org: string, name: string, actor: string) { - await engine.insert('sys_metadata', { - type: 'view', name, organization_id: org, package_id: null, state: 'active', - metadata: JSON.stringify(viewBody(name)), checksum: `legacy_${org}_${name}`, version: 1, - }, { context: { isSystem: true } }); - await engine.insert('sys_metadata_audit', { - occurred_at: new Date().toISOString(), actor, source: 'studio', - type: 'view', name, organization_id: org, - operation: 'save', outcome: 'allowed', code: 'ok', - lock_state: 'none', lock_overridden: false, note: 'active', - }, { context: { isSystem: true } }); -} - -async function seedThreeOrgs(protocol: any, engine: any) { - await plantLegacyOrgSave(engine, ORG_A, NAME, ACTOR_A); - await plantLegacyOrgSave(engine, ORG_B, NAME, ACTOR_B); +async function seedTwoSaves(protocol: any) { await protocol.saveMetaItem({ type: 'view', name: NAME, item: viewBody(NAME), actor: ACTOR_ENV, source: 'package', }); + await protocol.saveMetaItem({ + type: 'view', name: NAME, item: { ...viewBody(NAME), label: 'Shared grid (edited)' }, + actor: ACTOR_A, source: 'studio', + }); } const actorsOf = (result: any) => (result.events as any[]).map((e) => e.actor).sort(); -describe('#8747 auditMetaItem organization scope (real engine + real SqlDriver)', () => { - it('seeds three organizations onto one (type, name) — the precondition the scope is judged against', async () => { +describe('#8747 auditMetaItem reads the environment\'s trail (real engine + real SqlDriver)', () => { + it('seeds two saves onto one (type, name), and no audit row carries an organization column', async () => { const { engine, protocol } = await boot(); - await seedThreeOrgs(protocol, engine); - - const raw = (await engine.find('sys_metadata_audit', { where: {} })) as any[]; - const stamps = raw - .filter((r) => r.name === NAME) - .map((r) => `${r.actor}:${r.organization_id ?? 'ENV'}`) - .sort(); + await seedTwoSaves(protocol); - // The write path stamps all three distinctly. If this ever collapses, the - // scope assertions below would pass vacuously, so it is asserted first. - expect(stamps).toEqual([ - `${ACTOR_A}:${ORG_A}`, - `${ACTOR_B}:${ORG_B}`, - `${ACTOR_ENV}:ENV`, - ]); + const raw = ((await engine.find('sys_metadata_audit', { where: {} })) as any[]).filter((r) => r.name === NAME); + // The write path records both. If this ever collapses, the read + // assertions below would pass vacuously, so it is asserted first. + expect(raw.map((r) => r.actor).sort()).toEqual([ACTOR_A, ACTOR_ENV].sort()); + for (const row of raw) expect(row).not.toHaveProperty('organization_id'); }); - it('[ADR-0131 D6] a read naming an organization sees the env-wide rows only — not its own legacy rows, not another organization\'s', async () => { - const { engine, protocol } = await boot(); - await seedThreeOrgs(protocol, engine); + it('[ADR-0131 D6] every caller — an organization, another, none, an explicit null — reads the same trail', async () => { + const { protocol } = await boot(); + await seedTwoSaves(protocol); - for (const organizationId of [ORG_A, ORG_B]) { + for (const organizationId of [ORG_A, ORG_B, undefined, null]) { const actors = actorsOf(await (protocol as any).auditMetaItem({ - type: 'view', name: NAME, organizationId, + type: 'view', name: NAME, ...(organizationId === undefined ? {} : { organizationId }), })); - // The env-wide rows stay — THE discriminating control: every write is - // env-wide and must stay in the tab. No organization's legacy row is - // read: the trail is the environment's (#8747's disclosure stays closed - // in both directions). - expect(actors, organizationId).toEqual([ACTOR_ENV]); + expect(actors, String(organizationId)).toEqual([ACTOR_A, ACTOR_ENV].sort()); } }); - it('an organization with no rows of its own still sees the env-wide rows, and only those', async () => { - const { engine, protocol } = await boot(); - await seedThreeOrgs(protocol, engine); - - // A tenant that has never overlaid this item must still see the package - // install that put it there — and nobody else's overlays. - const actors = actorsOf(await (protocol as any).auditMetaItem({ - type: 'view', name: NAME, organizationId: ORG_C, - })); - - expect(actors).toEqual([ACTOR_ENV]); - }); - - it('an org-less read is fail-closed: env-wide rows only, never every tenant\'s', async () => { - const { engine, protocol } = await boot(); - await seedThreeOrgs(protocol, engine); - - // This is the exact call shape that leaked before the fix — the production - // route omitted `organizationId` entirely. It must no longer be a skeleton - // key. `?? null` folds it onto the env-wide read, symmetric with what an - // org-less write produces. - const actors = actorsOf(await (protocol as any).auditMetaItem({ - type: 'view', name: NAME, - })); - - expect(actors).toEqual([ACTOR_ENV]); - expect(actors).not.toContain(ACTOR_A); - expect(actors).not.toContain(ACTOR_B); - }); - - it('an explicit organizationId: null reads env-wide rows, same as omitting it', async () => { - const { engine, protocol } = await boot(); - await seedThreeOrgs(protocol, engine); - - const actors = actorsOf(await (protocol as any).auditMetaItem({ - type: 'view', name: NAME, organizationId: null, - })); - - expect(actors).toEqual([ACTOR_ENV]); - }); - - it('scoping does not disturb the (type, name) key — a different item is still excluded', async () => { - const { engine, protocol } = await boot(); - await seedThreeOrgs(protocol, engine); - await plantLegacyOrgSave(engine, ORG_A, 'other_grid', 'carol@alpha.example'); + it('the (type, name) key holds — a different item is excluded', async () => { + const { protocol } = await boot(); + await seedTwoSaves(protocol); + await protocol.saveMetaItem({ + type: 'view', name: 'other_grid', item: viewBody('other_grid'), + actor: 'carol@alpha.example', source: 'studio', + }); - const actors = actorsOf(await (protocol as any).auditMetaItem({ - type: 'view', name: NAME, - })); + const actors = actorsOf(await (protocol as any).auditMetaItem({ type: 'view', name: NAME })); expect(actors).not.toContain('carol@alpha.example'); - expect(actors).toEqual([ACTOR_ENV]); + expect(actors).toEqual([ACTOR_A, ACTOR_ENV].sort()); }); }); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 5d42ad0c478..fba777003d5 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -67,6 +67,15 @@ export { DriverPlugin } from './driver-plugin.js'; // runs that started and never concluded, and owns the `migration-plans` // registry `os migrate resume` looks plans up in. export { MigrationRecoveryPlugin, describeInterruptedRun } from './migration-recovery-plugin.js'; +export { + OrganizationOwnershipBootGuardPlugin, + OrganizationOwnershipBootRefusal, + ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE, + assertOrganizationOwnershipMigrated, + findV17ShapeTables, + isOrganizationOwnershipBootRefusal, + organizationOwnershipRefusalMessage, +} from './organization-ownership-boot-guard.js'; export { DefaultDatasourcePlugin } from './default-datasource-plugin.js'; export type { DefaultDatasourceDefinition, DefaultDatasourcePluginOptions } from './default-datasource-plugin.js'; export { AppPlugin, collectBundleHooks, collectBundleFunctions, collectBundleFunctionEntries, collectBundleActions } from './app-plugin.js'; diff --git a/packages/runtime/src/meta-write-org-scope.test.ts b/packages/runtime/src/meta-write-org-scope.test.ts index 06432d13ea5..ab4781f4b85 100644 --- a/packages/runtime/src/meta-write-org-scope.test.ts +++ b/packages/runtime/src/meta-write-org-scope.test.ts @@ -3,7 +3,7 @@ /** * [ADR-0131 D6, C5 stage S3] The dispatcher threads NO organization into a * metadata write: every type, one of the five overlay-enabled types included, lands - * environment-wide (`organization_id` NULL) whatever the session's active + * environment-wide (no organization column since ADR-0131 D7) whatever the session's active * organization. The per-organization overlay axis is retired; the CONTROL cases * that used to pin `view` landing org-scoped are the stage's pins now, and they * read the stored ROW through the real stack. @@ -377,7 +377,7 @@ describe('#7018 — the registry decides whether a metadata write carries the se // `loadMetaFromDb` walks past and the `kernel:ready` flow binder // (`getMetaItems({type:'flow'})`, no org) never sees, so the automation // fires until the next restart and then silently stops. - expect(row!.organization_id).toBeNull(); + expect('organization_id' in row!).toBe(false); }); it('the same is true for `object`, whose org-scoped rows 404 every record after a restart', async () => { @@ -393,7 +393,7 @@ describe('#7018 — the registry decides whether a metadata write carries the se const res = responseOf(await dispatcher.handleMetadata(`/object/${OBJECT.name}`, ctx(ACTIVE_ORG), 'PUT', OBJECT)); expect(res.status).toBe(200); - expect(metaRow(engine, 'object', OBJECT.name)!.organization_id).toBeNull(); + expect('organization_id' in metaRow(engine, 'object', OBJECT.name)!).toBe(false); }); it('the receipt an org session gets back is the one a no-org session gets', async () => { @@ -421,7 +421,7 @@ describe('#7018 — the registry decides whether a metadata write carries the se expect(res.status).toBe(200); // The per-organization overlay axis is retired: the Studio save of a // view belongs to the whole deployment. - expect(metaRow(engine, 'view', VIEW.name)!.organization_id).toBeNull(); + expect('organization_id' in metaRow(engine, 'view', VIEW.name)!).toBe(false); const read = responseOf(await dispatcher.handleMetadata(`/view/${VIEW.name}`, ctx(ACTIVE_ORG), 'GET')); expect(read.status).toBe(200); }); @@ -432,7 +432,7 @@ describe('#7018 — the registry decides whether a metadata write carries the se const res = responseOf(await dispatcher.handleMetadata(`/views/${VIEW.name}`, ctx(ACTIVE_ORG), 'PUT', VIEW)); expect(res.status).toBe(200); - expect(metaRow(engine, 'view', VIEW.name)!.organization_id).toBeNull(); + expect('organization_id' in metaRow(engine, 'view', VIEW.name)!).toBe(false); }); // ── POST /packages/:id/publish-drafts — the ADR-0045 visibility flip ── @@ -448,7 +448,7 @@ describe('#7018 — the registry decides whether a metadata write carries the se item: { ...APP, _unpublished: true }, packageId: 'crm_pkg', }); - expect(metaRow(engine, 'app', APP.name)!.organization_id).toBeNull(); + expect('organization_id' in metaRow(engine, 'app', APP.name)!).toBe(false); // `publishPackageDrafts` is stubbed to "nothing to promote" — the // materialized regime has no drafts left, which is exactly the branch @@ -467,7 +467,7 @@ describe('#7018 — the registry decides whether a metadata write carries the se // One row, still env-wide — not a second, org-scoped row shadowing it. const appRows = engine.metaRows().filter((r: any) => r.type === 'app' && r.state === 'active'); expect(appRows).toHaveLength(1); - expect(appRows[0].organization_id).toBeNull(); + expect('organization_id' in appRows[0]).toBe(false); // And the flip is real where it counts: the env-wide read — the one // cold boot and the App Switcher do — now sees a published app. An @@ -615,7 +615,7 @@ describe('#10503 · ADR-0131 D6 no spelling carries an organization through the expect(resPlural.status).toBe(200); const pluralRow = metaRow(viaPlural.engine, singular, item.name); expect(pluralRow).toBeDefined(); - expect(pluralRow!.organization_id).toBeNull(); + expect('organization_id' in pluralRow!).toBe(false); // The live control, in the same file and the same direction: the // singular twin's scoping is what the plural must equal, and it @@ -626,7 +626,7 @@ describe('#10503 · ADR-0131 D6 no spelling carries an organization through the ); expect(resSingular.status).toBe(200); const singularRow = metaRow(viaSingular.engine, singular, item.name); - expect(singularRow!.organization_id).toBeNull(); + expect('organization_id' in singularRow!).toBe(false); expect(pluralRow!.organization_id).toBe(singularRow!.organization_id); }); @@ -648,7 +648,7 @@ describe('#10503 · ADR-0131 D6 no spelling carries an organization through the (r: any) => r.type === singular && r.name === item.name && r.state === 'active', ); expect(rows).toHaveLength(1); - expect(rows[0].organization_id).toBeNull(); + expect('organization_id' in rows[0]).toBe(false); }); it(`CONTROL — with NO active org, /${plural} still lands env-wide`, async () => { @@ -659,7 +659,7 @@ describe('#10503 · ADR-0131 D6 no spelling carries an organization through the expect(responseOf( await dispatcher.handleMetadata(`/${plural}/${item.name}`, ctx(), 'PUT', item), ).status).toBe(200); - expect(metaRow(engine, singular, item.name)!.organization_id).toBeNull(); + expect('organization_id' in metaRow(engine, singular, item.name)!).toBe(false); }); } @@ -682,7 +682,7 @@ describe('#10503 · ADR-0131 D6 no spelling carries an organization through the const res = responseOf(await dispatcher.handleMetadata(`/objects/${OBJECT.name}`, ctx(ACTIVE_ORG), 'PUT', OBJECT)); expect(res.status).toBe(200); - expect(metaRow(engine, 'object', OBJECT.name)!.organization_id).toBeNull(); + expect('organization_id' in metaRow(engine, 'object', OBJECT.name)!).toBe(false); }); // ── the class, not the two specimens ────────────────────────────────── diff --git a/packages/runtime/src/organization-ownership-boot-guard.kernel.test.ts b/packages/runtime/src/organization-ownership-boot-guard.kernel.test.ts new file mode 100644 index 00000000000..b8c6c782ac0 --- /dev/null +++ b/packages/runtime/src/organization-ownership-boot-guard.kernel.test.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D10 item 5; ruling 6163114098, Q3 A] The v18 boot refusal on a + * REAL kernel: the guard's `kernel:ready` throw must stop `bootstrap()`, which + * the unit pins beside this file cannot show (they call the hook by hand). + * + * The composition is `os serve`'s data spine — the SQL driver over a SQLite + * file, ObjectQL, the platform objects — plus the guard. Each case seeds the + * file before the boot, the way a v17 runtime or the ceremony left it: + * + * 1. a v17-shape database (a table whose object declares no organization + * column still carries a physical `organization_id`) is refused, with the + * refusal's code, and the kernel never reaches `running`; + * 2. the same database with the ceremony's marker verified boots; + * 3. a fresh database boots. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { afterEach, describe, expect, it } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; +import { DriverPlugin } from './driver-plugin.js'; +import { + isOrganizationOwnershipBootRefusal, + OrganizationOwnershipBootGuardPlugin, +} from './organization-ownership-boot-guard.js'; + +const dirs: string[] = []; +const kernels: ObjectKernel[] = []; + +function databaseFile(): string { + const dir = mkdtempSync(join(tmpdir(), 'os-ownership-guard-')); + dirs.push(dir); + return join(dir, 'data.db'); +} + +/** + * What a v17 runtime left: `sys_migration_journal` (its object declares no + * organization column, `systemFields.tenant: false`) still carrying the + * injected one. + */ +function seedV17Shape(file: string, marker?: { verifiedAt: string | null }): void { + const db = new DatabaseSync(file); + db.exec('CREATE TABLE sys_migration_journal (id TEXT PRIMARY KEY, organization_id TEXT)'); + if (marker) { + db.exec('CREATE TABLE sys_migration (id TEXT PRIMARY KEY, last_run_at TEXT, verified_at TEXT, blocking INTEGER)'); + db.prepare('INSERT INTO sys_migration (id, last_run_at, verified_at, blocking) VALUES (?, ?, ?, ?)') + .run('adr-0131-organization-ownership', '2026-10-14T00:00:00.000Z', marker.verifiedAt, 0); + } + db.close(); +} + +async function boot(file: string): Promise<{ kernel: ObjectKernel; error: unknown }> { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + kernels.push(kernel); + await kernel.use(new DriverPlugin(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: file }, + useNullAsDefault: true, + }))); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new PlatformObjectsPlugin() as never); + await kernel.use(new OrganizationOwnershipBootGuardPlugin()); + try { + await kernel.bootstrap(); + return { kernel, error: null }; + } catch (error) { + return { kernel, error }; + } +} + +afterEach(async () => { + for (const kernel of kernels.splice(0)) { + if (kernel.getState() === 'running') await kernel.shutdown(); + } + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe('the v18 boot refusal on a real kernel (ADR-0131 D10 item 5)', () => { + it('⭐ a v17-shape database stops the boot with the refusal, before the kernel runs', async () => { + const file = databaseFile(); + seedV17Shape(file); + const { kernel, error } = await boot(file); + expect(isOrganizationOwnershipBootRefusal(error)).toBe(true); + expect((error as { tables?: string[] }).tables).toEqual(['sys_migration_journal']); + expect(kernel.getState()).not.toBe('running'); + }); + + it('⭐ the same database, its ceremony marker verified, boots', async () => { + const file = databaseFile(); + seedV17Shape(file, { verifiedAt: '2026-10-14T01:00:00.000Z' }); + const { kernel, error } = await boot(file); + expect(error).toBeNull(); + expect(kernel.getState()).toBe('running'); + }); + + it('control: an unverified marker is still refused', async () => { + const file = databaseFile(); + seedV17Shape(file, { verifiedAt: null }); + const { error } = await boot(file); + expect(isOrganizationOwnershipBootRefusal(error)).toBe(true); + }); + + it('⭐ a fresh database boots', async () => { + const { kernel, error } = await boot(databaseFile()); + expect(error).toBeNull(); + expect(kernel.getState()).toBe('running'); + }); +}); diff --git a/packages/runtime/src/organization-ownership-boot-guard.test.ts b/packages/runtime/src/organization-ownership-boot-guard.test.ts new file mode 100644 index 00000000000..565fdf93cb2 --- /dev/null +++ b/packages/runtime/src/organization-ownership-boot-guard.test.ts @@ -0,0 +1,173 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D10 item 5; ruling 6163114098, Q3 A] The v18 boot refusal's pins: + * a database still in the v17 shape is refused and the command named; a + * database whose ceremony verified boots; a fresh database boots. Plus the + * fail-closed read, and a driver with no physical columns left inert. + * + * The columns are read from a real SQLite database through the guard's own + * raw-SQL probe; the engine is a minimal double exposing the three seams the + * guard reads (the registry, the driver an object binds to, `findOne`). + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { DatabaseSync } from 'node:sqlite'; +import { + assertOrganizationOwnershipMigrated, + isOrganizationOwnershipBootRefusal, + OrganizationOwnershipBootGuardPlugin, + ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE, +} from './organization-ownership-boot-guard.js'; + +let db: DatabaseSync; + +/** The objects the registry serves: two no-column objects and one tenant-walled one. */ +const OBJECTS = [ + { name: 'sys_job', systemFields: { tenant: false }, fields: { status: {} } }, + { name: 'sys_metadata', systemFields: { tenant: false }, fields: { type: {}, name: {} } }, + { name: 'crm_deal', fields: { amount: {} } }, +]; + +function engine(opts: { client?: unknown; dialectName?: string; failRead?: boolean } = {}) { + const driver = { + ...(opts.dialectName ? { dialectName: opts.dialectName } : {}), + config: { client: opts.client ?? 'better-sqlite3' }, + async execute(sql: string, params: unknown[] = []) { + if (opts.failRead) throw new Error('SQLITE_BUSY: database is locked'); + return db.prepare(sql).all(...(params as string[])); + }, + }; + return { + registry: { getAllObjects: () => OBJECTS }, + getDriverForObject: () => driver, + async findOne(object: string, query: Record) { + const exists = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).all(object).length > 0; + if (!exists) return null; + const id = String((query.where as { id?: unknown } | undefined)?.id); + return db.prepare(`SELECT * FROM ${object} WHERE id = ?`).get(id) ?? null; + }, + }; +} + +/** The tables a v17 runtime created: every one carries the injected organization column. */ +function v17Shape(): void { + db.exec(` + CREATE TABLE sys_job (id TEXT PRIMARY KEY, status TEXT, organization_id TEXT); + CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, type TEXT, name TEXT, organization_id TEXT); + CREATE TABLE crm_deal (id TEXT PRIMARY KEY, amount REAL, organization_id TEXT); + CREATE TABLE sys_migration (id TEXT PRIMARY KEY, last_run_at TEXT, verified_at TEXT, applied_at TEXT, blocking INTEGER, advisory INTEGER); + `); +} + +const marker = (verifiedAt: string | null, blocking = 0): void => { + db.prepare('INSERT INTO sys_migration VALUES (?, ?, ?, ?, ?, ?)').run( + 'adr-0131-organization-ownership', '2026-10-14T00:00:00Z', verifiedAt, '2026-10-14T00:00:00Z', blocking, 0, + ); +}; + +async function refusalOf(run: () => Promise): Promise { + try { + await run(); + } catch (error) { + return error; + } + throw new Error('expected the boot to be refused, and it was not'); +} + +beforeEach(() => { + db = new DatabaseSync(':memory:'); +}); +afterEach(() => { + db.close(); +}); + +describe('the v18 boot refusal (ADR-0131 D10 item 5)', () => { + it('⭐ a v17-shape database is refused, naming the command and every table still carrying the column', async () => { + v17Shape(); + const err = await refusalOf(() => assertOrganizationOwnershipMigrated(engine())); + expect(isOrganizationOwnershipBootRefusal(err)).toBe(true); + expect(err.code).toBe(ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE); + expect(err.tables).toEqual(['sys_job', 'sys_metadata']); + expect(err.message).toMatch(/still in the v17 organization-ownership shape: sys_job, sys_metadata/); + expect(err.message).toMatch(/os migrate organization-ownership --apply/); + expect(err.message).toMatch(/the ceremony has not run on it/); + }); + + it('a marker that is not verified (an unfinished ceremony, or one whose post-check found discrepancies) is refused', async () => { + v17Shape(); + marker(null); + expect((await refusalOf(() => assertOrganizationOwnershipMigrated(engine()))).message).toMatch(/not finished on it/); + db.exec(`DELETE FROM sys_migration`); + marker('2026-10-14T01:00:00Z', 2); + expect(isOrganizationOwnershipBootRefusal(await refusalOf(() => assertOrganizationOwnershipMigrated(engine())))).toBe(true); + }); + + it('⭐ a database whose ceremony verified boots', async () => { + v17Shape(); + marker('2026-10-14T01:00:00Z'); + await expect(assertOrganizationOwnershipMigrated(engine())).resolves.toBeUndefined(); + }); + + it('⭐ a migrated database (the columns dropped) boots, marker or not', async () => { + db.exec(` + CREATE TABLE sys_job (id TEXT PRIMARY KEY, status TEXT); + CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, type TEXT, name TEXT); + CREATE TABLE crm_deal (id TEXT PRIMARY KEY, amount REAL, organization_id TEXT); + `); + await expect(assertOrganizationOwnershipMigrated(engine())).resolves.toBeUndefined(); + }); + + it('⭐ a fresh database boots: no table it creates carries a retired column, and no marker is needed', async () => { + await expect(assertOrganizationOwnershipMigrated(engine())).resolves.toBeUndefined(); + }); + + it('a column read that fails is refused, never taken for a migrated database', async () => { + v17Shape(); + const err = await refusalOf(() => assertOrganizationOwnershipMigrated(engine({ failRead: true }))); + expect(isOrganizationOwnershipBootRefusal(err)).toBe(true); + expect(err.message).toMatch(/could not read whether this database is still in the v17/); + }); + + it('⭐ a driver whose knex client is a dialect CLASS (SqliteWasmDriver, the default for a served file database) is read by its dialectName, and refused', async () => { + v17Shape(); + class Client_WasmSqlite {} + const err = await refusalOf(() => assertOrganizationOwnershipMigrated(engine({ client: Client_WasmSqlite, dialectName: 'sqlite' }))); + expect(isOrganizationOwnershipBootRefusal(err)).toBe(true); + expect(err.tables).toEqual(['sys_job', 'sys_metadata']); + }); + + it('a driver with no physical columns (memory, MongoDB) is inert', async () => { + v17Shape(); + await expect(assertOrganizationOwnershipMigrated(engine({ client: 'memory' }))).resolves.toBeUndefined(); + }); + + it('the plugin refuses at kernel:ready: its hook throws the refusal, which stops the boot', async () => { + v17Shape(); + const hooks = new Map Promise>(); + const ctx: any = { + hook: (event: string, handler: () => Promise) => hooks.set(event, handler), + getService: (name: string) => (name === 'objectql' ? engine() : undefined), + logger: {}, + }; + const plugin = new OrganizationOwnershipBootGuardPlugin(); + await plugin.init(ctx); + await plugin.start(ctx); + expect([...hooks.keys()]).toEqual(['kernel:ready']); + const err = await refusalOf(() => hooks.get('kernel:ready')!()); + expect(err.code).toBe(ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE); + }); + + it('no escape hatch: no environment variable stands the refusal down', async () => { + v17Shape(); + const saved = { ...process.env }; + try { + for (const key of ['OS_ALLOW_UNMIGRATED_ORGANIZATION_OWNERSHIP', 'OS_SKIP_ORGANIZATION_OWNERSHIP_CHECK', 'OS_ALLOW_DEGRADED_TENANCY']) { + process.env[key] = '1'; + } + expect(isOrganizationOwnershipBootRefusal(await refusalOf(() => assertOrganizationOwnershipMigrated(engine())))).toBe(true); + } finally { + process.env = saved; + } + }); +}); diff --git a/packages/runtime/src/organization-ownership-boot-guard.ts b/packages/runtime/src/organization-ownership-boot-guard.ts new file mode 100644 index 00000000000..db41037901a --- /dev/null +++ b/packages/runtime/src/organization-ownership-boot-guard.ts @@ -0,0 +1,246 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Plugin, PluginContext } from '@objectstack/core'; +import { + isDataMigrationFlagVerified, + DATA_MIGRATION_FLAG_OBJECT, + ORGANIZATION_OWNERSHIP_MIGRATION_ID, + type DataMigrationFlag, +} from '@objectstack/spec/system'; + +/** + * OrganizationOwnershipBootGuardPlugin — the v18 boot refusal (ADR-0131 D10 + * item 5; ruling 6163114098, Q3 A). + * + * A v18 runtime against a database still in the v17 shape would serve it with + * v18 semantics it was never migrated for: the organization columns the D7 + * objects and the `sys_metadata` family no longer declare still hold their + * legacy rows, and nothing reads them. The ceremony that carries those rows, + * `os migrate organization-ownership --apply`, is an operator's act, never a + * boot step (D10). So the runtime refuses to start until it has run, and says + * which command to run. + * + * ## What is refused + * + * At `kernel:ready` — the reader shape of {@link MigrationRecoveryPlugin}, + * refusing where that plugin warns — the guard asks one question: does a + * table whose object declares NO organization column (`systemFields.tenant: + * false` and no declared `organization_id`) still carry a PHYSICAL + * `organization_id`? Schema sync only adds, so on a database a v17 runtime + * created the answer is yes until the ceremony's column-drops step has run. If + * it is, the guard reads the ceremony's `sys_migration` marker + * ({@link ORGANIZATION_OWNERSHIP_MIGRATION_ID}); unless that marker is + * verified, it throws {@link OrganizationOwnershipBootRefusal}, which stops + * the boot before `kernel:listening` opens the socket. + * + * - A FRESH database is never refused: no table it creates carries a retired + * column, so the question answers no without a marker. + * - A database whose ceremony verified boots, whatever its columns. + * - A read that fails is refused, never passed: "I could not check" is not + * "there is nothing to find" (the `readDataMigrationFlag` asymmetry). + * - ⛔ No escape hatch: no `OS_ALLOW_*` / `OS_SKIP_*` variable and no flag + * skips the check (D10 verbatim). A 17.x runtime is the way to keep 17.x + * semantics on an unmigrated database. + * + * ## Where it runs, and where it must not + * + * Composed by `os serve` (and so `os dev`) only, beside + * `MigrationRecoveryPlugin`. ⛔ Never in `createStandaloneStack`'s own list: + * every CLI data and schema boot shares that builder, the ceremony's own boot + * among them, and a guard there would refuse the one command that clears it. + * + * A driver with no physical columns to read (memory, MongoDB) has no v17 + * shape to find: the guard is inert there, as the recovery plugin is on a + * kernel with no journal. + */ + +/** The machine code a host discriminates this refusal by, across module instances. */ +export const ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE = 'ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED' as const; + +const ORG = 'organization_id'; +const CEREMONY = 'os migrate organization-ownership'; + +export class OrganizationOwnershipBootRefusal extends Error { + readonly code = ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE; + constructor( + message: string, + /** The tables still carrying a retired organization column; empty when the read itself failed. */ + readonly tables: readonly string[], + ) { + super(message); + this.name = 'OrganizationOwnershipBootRefusal'; + } +} + +/** Structural, not `instanceof`: a host may hold two module instances. */ +export function isOrganizationOwnershipBootRefusal(error: unknown): error is OrganizationOwnershipBootRefusal { + return !!error && typeof error === 'object' && (error as { code?: unknown }).code === ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE; +} + +type Dialect = 'sqlite' | 'postgres' | 'mysql'; + +/** + * The driver's dialect. `SqlDriver.dialectName` is the public, structural + * answer, and the only one that names a driver whose knex `client` is a + * dialect CLASS rather than a string — `SqliteWasmDriver`, the default SQLite + * driver of a served file database. Reading `config.client` alone left the + * guard inert on exactly that driver. The client string is the fallback for a + * driver that exposes no `dialectName`. + */ +function dialectOfDriver(driver: { dialectName?: unknown; config?: { client?: unknown } } | undefined): Dialect | null { + const named = driver?.dialectName; + if (named === 'sqlite' || named === 'postgres' || named === 'mysql') return named; + return dialectOf(driver?.config?.client); +} + +function dialectOf(client: unknown): Dialect | null { + const c = String(client ?? '').toLowerCase(); + if (['sqlite3', 'sqlite', 'better-sqlite3'].includes(c)) return 'sqlite'; + if (['postgres', 'pg', 'postgresql', 'pgnative'].includes(c)) return 'postgres'; + if (['mysql', 'mysql2'].includes(c)) return 'mysql'; + return null; +} + +/** One table's column names, per dialect (the ceremony's own `columnListSql`). */ +function columnListSql(dialect: Dialect): string { + if (dialect === 'sqlite') return 'SELECT name AS column_name FROM pragma_table_info(?)'; + if (dialect === 'postgres') { + return 'SELECT column_name FROM information_schema.columns WHERE table_schema = ANY (current_schemas(false)) AND table_name = ?'; + } + return 'SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ?'; +} + +/** The rows of a raw `execute`, whatever shape the dialect hands back. */ +function rowsOf(result: unknown): Record[] { + if (Array.isArray(result)) { + return (Array.isArray(result[0]) ? result[0] : result) as Record[]; + } + const rows = (result as { rows?: unknown } | null)?.rows; + return Array.isArray(rows) ? (rows as Record[]) : []; +} + +function columnNameOf(row: Record): string | null { + const v = row.column_name ?? row.COLUMN_NAME ?? row.name; + return typeof v === 'string' ? v : null; +} + +interface GuardEngine { + registry?: { getAllObjects?: () => unknown[] }; + getDriverForObject?: (object: string) => unknown; + findOne?: (object: string, query: Record) => Promise; + getObject?: (name: string) => unknown; +} + +/** + * The tables in the v17 shape: an object that declares no organization column + * whose physical table still carries one. Only SQL drivers are read; any other + * driver is skipped. Throws when a column read fails. + */ +export async function findV17ShapeTables(engine: GuardEngine): Promise { + const objects = (engine.registry?.getAllObjects?.() ?? []) as Array<{ + name?: string; + systemFields?: { tenant?: unknown }; + fields?: Record; + }>; + const found: string[] = []; + for (const object of objects) { + const name = object?.name; + if (typeof name !== 'string' || object.systemFields?.tenant !== false) continue; + if (object.fields && Object.prototype.hasOwnProperty.call(object.fields, ORG)) continue; + const driver = engine.getDriverForObject?.(name) as + | { dialectName?: unknown; config?: { client?: unknown }; execute?: (sql: string, params?: unknown[]) => Promise } + | undefined; + const dialect = dialectOfDriver(driver); + if (!dialect || typeof driver?.execute !== 'function') continue; + const columns = rowsOf(await driver.execute(columnListSql(dialect), [name])) + .map(columnNameOf) + .filter((c): c is string => c !== null); + if (columns.includes(ORG)) found.push(name); + } + return found.sort(); +} + +/** The refusal text: what was found, that the server refuses to start, the command, and the 17.x way out. */ +export function organizationOwnershipRefusalMessage(tables: readonly string[], marker: 'absent' | 'unverified' | 'unreadable'): string { + const state = marker === 'absent' + ? 'the ceremony has not run on it' + : marker === 'unverified' + ? 'the ceremony has not finished on it (its marker is not verified)' + : 'its marker could not be read'; + return ( + `This database is still in the v17 organization-ownership shape: ${tables.join(', ')} still carr` + + `${tables.length === 1 ? 'ies' : 'y'} a physical organization_id column, and ${state}. The server refuses to ` + + `start rather than serve v17 data with v18 semantics. Run '${CEREMONY}' (review its plan), then ` + + `'${CEREMONY} --apply', and start again. To keep 17.x semantics, run a 17.x runtime.` + ); +} + +export class OrganizationOwnershipBootGuardPlugin implements Plugin { + readonly name = 'com.objectstack.organization-ownership-boot-guard'; + readonly type = 'standard'; + readonly version = '1.0.0'; + readonly dependencies: string[] = []; + /** Needs the engine to read columns and the marker; still loads (inert) without it. */ + readonly optionalDependencies: string[] = ['com.objectstack.engine.objectql']; + + async init(_ctx: PluginContext): Promise {} + + async start(ctx: PluginContext): Promise { + (ctx as any)?.hook?.('kernel:ready', async () => { + let engine: GuardEngine | undefined; + try { + engine = (ctx as any).getService?.('objectql'); + } catch { + return; // no engine — no database to judge + } + if (!engine || typeof engine.getDriverForObject !== 'function') return; + await assertOrganizationOwnershipMigrated(engine, (ctx as any)?.logger); + }); + } +} + +/** + * The check itself, for the plugin and for a host that composes its own boot: + * resolves when the database may be served, throws + * {@link OrganizationOwnershipBootRefusal} when it may not. + */ +export async function assertOrganizationOwnershipMigrated( + engine: GuardEngine, + logger?: { info?: (msg: string) => void }, +): Promise { + let tables: string[]; + try { + tables = await findV17ShapeTables(engine); + } catch (error) { + throw new OrganizationOwnershipBootRefusal( + `The server refuses to start: it could not read whether this database is still in the v17 organization-ownership ` + + `shape (${error instanceof Error ? error.message : String(error)}), and an unread answer is never taken for a ` + + `migrated database. Fix the read, or run '${CEREMONY} --apply', then start again.`, + [], + ); + } + if (tables.length === 0) return; + let marker: 'absent' | 'unverified' | 'unreadable' = 'absent'; + try { + const row = typeof engine.findOne === 'function' + ? ((await engine.findOne(DATA_MIGRATION_FLAG_OBJECT, { + where: { id: ORGANIZATION_OWNERSHIP_MIGRATION_ID }, + context: { isSystem: true }, + })) as DataMigrationFlag | null) + : null; + if (row) { + const flag = { ...row, blocking: Number((row as { blocking?: unknown }).blocking) } as DataMigrationFlag; + if (isDataMigrationFlagVerified(flag)) { + logger?.info?.( + `[organization-ownership] ${tables.join(', ')} still carry a physical organization_id column, and the ` + + `ceremony's marker is verified: the server starts.`, + ); + return; + } + marker = 'unverified'; + } + } catch { + marker = 'unreadable'; + } + throw new OrganizationOwnershipBootRefusal(organizationOwnershipRefusalMessage(tables, marker), tables); +} diff --git a/packages/runtime/src/package-list-commits-org-scope.integration.test.ts b/packages/runtime/src/package-list-commits-org-scope.integration.test.ts index 12923e586d0..7b2be125a51 100644 --- a/packages/runtime/src/package-list-commits-org-scope.integration.test.ts +++ b/packages/runtime/src/package-list-commits-org-scope.integration.test.ts @@ -94,7 +94,6 @@ const PKG = 'com.repro.commits'; const OTHER_PKG = 'com.other.commits'; const PLATFORM_PKG = '@objectstack/platform-objects'; const ACTIVE_ORG = 'org_active'; -const OTHER_ORG = 'org_other'; /** * [commit 13a6cb4ad] Every publish this fixture makes runs the metadata-protocol build @@ -186,13 +185,13 @@ const viewBody = (name: string, label: string) => ({ /** * Author one draft and publish it, which is what records ONE commit row, - * env-wide. [ADR-0131 D6] The protocol refuses an organization-scoped publish, - * so an `organizationId` here plants a LEGACY organization commit: the - * env-wide commit row is re-stamped at rest, the way an older release left it. + * env-wide. [ADR-0131 D6/D7] The protocol refuses an organization-scoped + * publish, and the commit ledger declares no organization column, so every + * commit is the environment's. */ async function publishOne( protocol: any, - args: { view: string; packageId: string; organizationId?: string; message: string }, + args: { view: string; packageId: string; message: string }, ): Promise { await protocol.saveMetaItem({ type: 'view', @@ -207,14 +206,6 @@ async function publishOne( }); expect(res.success).toBe(true); expect(res.commitId).toBeTruthy(); - if (args.organizationId) { - // `isSystem`: the column is `readonly`, and only a system write may set it. - await protocol.engine.update( - 'sys_metadata_commit', - { id: res.commitId, organization_id: args.organizationId }, - { context: { isSystem: true } }, - ); - } return res.commitId as string; } @@ -232,21 +223,17 @@ async function seed(protocol: any) { view: 'commits_env', packageId: PKG, message: 'env-wide publish', }); await tick(); - // The caller's OWN org — the only rows the strict equality ever found. - const own = await publishOne(protocol, { - view: 'commits_own', packageId: PKG, organizationId: ACTIVE_ORG, message: 'own-org publish', + // A second publish of the same package — published by an org-active + // session, which lands env-wide like every write. + const second = await publishOne(protocol, { + view: 'commits_second', packageId: PKG, message: 'second publish', }); await tick(); - // Negative 1 — same package, ANOTHER organization. Must stay invisible. - const foreign = await publishOne(protocol, { - view: 'commits_foreign', packageId: PKG, organizationId: OTHER_ORG, message: 'other-org publish', - }); - await tick(); - // Negative 2 — ANOTHER package, env-wide. Must never appear. + // The negative — ANOTHER package, env-wide. Must never appear. const otherPkg = await publishOne(protocol, { view: 'commits_other_pkg', packageId: OTHER_PKG, message: 'other package publish', }); - return { envWide, own, foreign, otherPkg }; + return { envWide, second, otherPkg }; } const idsOf = (commits: any[]): string[] => commits.map((c) => c.id).sort(); @@ -258,44 +245,25 @@ describe('#7779 — org-scoped listCommits must not hide env-wide commit rows', view: 'commits_env', packageId: PKG, message: 'env-wide publish', }); - // Straight out of SQLite: the column really is NULL, so the strict - // equality below really has nothing to match. + // Straight out of SQLite: the ledger carries no organization column at + // all (ADR-0131 D7), so no equality on one has anything to match. const rows = (await engine.find('sys_metadata_commit', { where: {} })) as any[]; - expect(rows.map((r) => ({ id: r.id, org: r.organization_id ?? null }))).toEqual([ - { id, org: null }, + expect(rows.map((r) => ({ id: r.id, org: 'organization_id' in r }))).toEqual([ + { id, org: false }, ]); }); - it('[ADR-0131 D6] returns the env-wide commits to a caller naming an organization — and not its legacy organization commit', async () => { + it('[ADR-0131 D6] returns the environment\'s commits to a caller naming an organization', async () => { const { protocol } = await boot(); - const { envWide, own } = await seed(protocol as any); + const { envWide, second } = await seed(protocol as any); const commits = await (protocol as any).listCommits({ packageId: PKG, organizationId: ACTIVE_ORG, }); - // The env-wide commit is the timeline (#7779 closed its absence). The - // caller's legacy organization commit is reported at boot, never served: - // the timeline holds exactly the commits whose changes the reads serve. - expect(idsOf(commits)).toEqual([envWide]); - expect(idsOf(commits)).not.toContain(own); - expect(commits.map((c: any) => c.message)).toEqual(['env-wide publish']); - }); - - it('does NOT surface another organization’s commits', async () => { - const { protocol } = await boot(); - const { foreign } = await seed(protocol as any); - - const commits = await (protocol as any).listCommits({ - packageId: PKG, - organizationId: ACTIVE_ORG, - }); - - // The direction that must not widen. A commit timeline that leaked - // another tenant's authoring history would be a worse defect than the - // under-reporting this closes. - expect(idsOf(commits)).not.toContain(foreign); + // The env-wide commits are the timeline (#7779 closed their absence). + expect(idsOf(commits)).toEqual([envWide, second].sort()); }); it('does NOT surface another package’s commits', async () => { @@ -310,19 +278,13 @@ describe('#7779 — org-scoped listCommits must not hide env-wide commit rows', expect(idsOf(commits)).not.toContain(otherPkg); }); - it('[ADR-0131 D6] a caller with NO org sees the environment\'s timeline — no organization\'s legacy commits', async () => { + it('[ADR-0131 D6] a caller with NO org sees the same timeline', async () => { const { protocol } = await boot(); - const { envWide, own, foreign } = await seed(protocol as any); + const { envWide, second } = await seed(protocol as any); - // The no-org branch used to be left package-wide, so an org-active - // operator on the packages door saw every organization's legacy commits. - // The ledger is deployment-level (ADR-0131 D7) and the legacy rows are - // reported at boot, not served — the same rule as the metadata rows. const commits = await (protocol as any).listCommits({ packageId: PKG }); - expect(idsOf(commits)).toEqual([envWide]); - expect(idsOf(commits)).not.toContain(own); - expect(idsOf(commits)).not.toContain(foreign); + expect(idsOf(commits)).toEqual([envWide, second].sort()); }); it('the timeline the rollback planner reads now contains the env-wide commit', async () => { @@ -330,14 +292,9 @@ describe('#7779 — org-scoped listCommits must not hide env-wide commit rows', const p = protocol as any; // [ADR-0131 D6] C1 and C2, both env-wide (every publish is), C2 strictly - // newer; plus a legacy organization commit between them, which the - // timeline does not show and the planner therefore never reverts. + // newer. const c1 = await publishOne(p, { view: 'roll_a', packageId: PKG, message: 'c1' }); await tick(); - const legacy = await publishOne(p, { - view: 'roll_legacy', packageId: PKG, organizationId: ACTIVE_ORG, message: 'legacy', - }); - await tick(); const c2 = await publishOne(p, { view: 'roll_b', packageId: PKG, message: 'c2' }); // `rollbackToPackageCommit` filters THIS list to decide what to undo, so @@ -347,7 +304,6 @@ describe('#7779 — org-scoped listCommits must not hide env-wide commit rows', // performed, with C2's changes still live. const commits = await p.listCommits({ packageId: PKG }); expect(idsOf(commits)).toEqual([c1, c2].sort()); - expect(idsOf(commits)).not.toContain(legacy); // [#7819 tier 1] ⭐ THE GAP THIS SUITE HANDED ON IS NOW CLOSED — these // lines are the handoff, and they changed exactly as it predicted. diff --git a/packages/runtime/src/package-revert-stored-members.integration.test.ts b/packages/runtime/src/package-revert-stored-members.integration.test.ts index 4fa6cd71adf..7183c12d4cc 100644 --- a/packages/runtime/src/package-revert-stored-members.integration.test.ts +++ b/packages/runtime/src/package-revert-stored-members.integration.test.ts @@ -75,7 +75,6 @@ const SHOWCASE_CAPABILITIES = [ ]; const PLATFORM_PKG = '@objectstack/platform-objects'; const ACTIVE_ORG = 'org_active'; -const OTHER_ORG = 'org_other'; /** * Every publish this fixture makes runs the metadata-protocol build probes, and @@ -258,30 +257,6 @@ describe('#22090 POST /packages/:id/revert on a Studio-authored package', () => expect(answer.status).toBe(200); expect(await draftNames(protocol, STUDIO_PKG)).toEqual([]); }); - - it('another organization’s draft of the package is neither read nor discarded', async () => { - const { engine, protocol, revert } = await boot(); - await draftSave(protocol, { view: 'repairs_board', label: 'Board v1', packageId: STUDIO_PKG }); - await publish(protocol, STUDIO_PKG); - // [ADR-0131 D6] The protocol refuses an organization-scoped save, so the - // other organization's draft is a LEGACY row, planted at rest. - await engine.insert('sys_metadata', { - type: 'view', name: 'repairs_board', organization_id: OTHER_ORG, package_id: STUDIO_PKG, state: 'draft', - metadata: JSON.stringify(viewBody('repairs_board', 'Other org overlay (draft)')), checksum: 'sha256:legacy', version: 1, - }); - - const answer = await revert(STUDIO_PKG, ACTIVE_ORG); - - expect(answer.status).toBe(200); - // [ADR-0131 D6] The draft listing is the environment's, so the legacy row - // is read at rest: still there, untouched. - const legacy = (await engine.find('sys_metadata', { - where: { organization_id: OTHER_ORG, package_id: STUDIO_PKG, state: 'draft' }, - context: { isSystem: true }, - })) as any[]; - expect(legacy.map((r) => `${r.type}/${r.name}`)).toEqual(['view/repairs_board']); - expect(await draftNames(protocol, STUDIO_PKG)).toEqual([]); - }); }); describe('#22090 controls — a package with no stored row is not the protocol’s', () => { diff --git a/packages/runtime/src/package-uninstall-org-scope.integration.test.ts b/packages/runtime/src/package-uninstall-org-scope.integration.test.ts index 63597c9dd61..645b398c51b 100644 --- a/packages/runtime/src/package-uninstall-org-scope.integration.test.ts +++ b/packages/runtime/src/package-uninstall-org-scope.integration.test.ts @@ -94,7 +94,6 @@ const PKG = 'com.repro.b'; const OTHER_PKG = 'com.other'; const PLATFORM_PKG = '@objectstack/platform-objects'; const ACTIVE_ORG = 'org_active'; -const OTHER_ORG = 'org_other'; let cleanup: Array<() => void> = []; afterEach(() => { @@ -150,11 +149,11 @@ const viewBody = (name: string) => ({ * stay on row survival rather than on physical-table teardown, which * `deleteMetaItem` handles separately and which #7705 is not about. * - * [ADR-0131 D6] The protocol refuses every organization-scoped write, so the - * legacy organization rows are planted AT REST, the way an older release left - * them. + * [ADR-0131 D6/D7] The protocol refuses every organization-scoped write, and + * the table declares no organization column, so every row is the + * environment's: the uninstall's reach is the package, nothing narrower. */ -async function seed(protocol: any, engine: any) { +async function seed(protocol: any, _engine: any) { const save = (name: string, packageId: string) => protocol.saveMetaItem({ type: 'view', @@ -162,19 +161,10 @@ async function seed(protocol: any, engine: any) { item: viewBody(name), packageId, }); - const plantLegacy = (name: string, organizationId: string) => - engine.insert('sys_metadata', { - type: 'view', name, organization_id: organizationId, package_id: PKG, state: 'active', - metadata: JSON.stringify(viewBody(name)), checksum: 'sha256:legacy', version: 1, - }); - - // The suspected — and confirmed — miss: env-wide rows, `organization_id IS NULL`. + // The suspected — and confirmed — miss: env-wide rows. await save('reprob_a', PKG); await save('reprob_b', PKG); await save('reprob_v', PKG); - // Same package, legacy organization rows. - await plantLegacy('reprob_own', ACTIVE_ORG); - await plantLegacy('reprob_foreign', OTHER_ORG); // Negative — ANOTHER package, env-wide. Must survive. await save('other_a', OTHER_PKG); } @@ -182,7 +172,7 @@ async function seed(protocol: any, engine: any) { /** Every surviving row, as `name[pkg,org]`, read straight back out of SQLite. */ async function survivors(engine: any): Promise { const rows = (await engine.find('sys_metadata', { where: {} })) as any[]; - return rows.map((r) => `${r.name}[${r.package_id},${r.organization_id ?? 'ENV'}]`).sort(); + return rows.map((r) => `${r.name}[${r.package_id},${'organization_id' in r ? 'ORG-COLUMN' : 'ENV'}]`).sort(); } const namesFor = async (engine: any, packageId: string): Promise => { @@ -194,13 +184,11 @@ const SEEDED = [ `other_a[${OTHER_PKG},ENV]`, `reprob_a[${PKG},ENV]`, `reprob_b[${PKG},ENV]`, - `reprob_foreign[${PKG},${OTHER_ORG}]`, - `reprob_own[${PKG},${ACTIVE_ORG}]`, `reprob_v[${PKG},ENV]`, ]; describe('#7705 · ADR-0131 D6 — an uninstall is package-wide and orphans no sys_metadata row', () => { - it('removes the env-wide rows AND the legacy organization rows, counts them, and leaves another package alone', async () => { + it('removes every row of the package, counts them, and leaves another package alone', async () => { const { engine, protocol } = await boot(); await seed(protocol, engine); @@ -211,11 +199,11 @@ describe('#7705 · ADR-0131 D6 — an uninstall is package-wide and orphans no s const res: any = await (protocol as any).deletePackage({ packageId: PKG }); expect(await namesFor(engine, PKG)).toEqual([]); - expect(res.deletedCount).toBe(5); + expect(res.deletedCount).toBe(3); expect(res.failedCount).toBe(0); expect(res.success).toBe(true); expect(res.deleted.map((d: any) => d.name).sort()).toEqual( - ['reprob_a', 'reprob_b', 'reprob_foreign', 'reprob_own', 'reprob_v'], + ['reprob_a', 'reprob_b', 'reprob_v'], ); expect(await survivors(engine)).toEqual([`other_a[${OTHER_PKG},ENV]`]); }); diff --git a/packages/services/service-automation/src/flow-credential-migration.test.ts b/packages/services/service-automation/src/flow-credential-migration.test.ts index 102e717aa6f..f59b6ca00b0 100644 --- a/packages/services/service-automation/src/flow-credential-migration.test.ts +++ b/packages/services/service-automation/src/flow-credential-migration.test.ts @@ -44,9 +44,27 @@ function fakes(rows: Array>, refuse?: (name: string) => const saves: Array> = []; const receipts: Array> = []; const logs: Array<{ level: string; msg: string }> = []; + // [ADR-0131 D7] Each row gets an id; the engine shapes a read to the + // declared columns (no `organization_id`), and a legacy organization's row + // is told apart through the driver's physical column. + rows.forEach((row, i) => { row.id ??= `row_${i}`; }); const engine = { async find(object: string) { - return object === 'sys_metadata' ? rows : []; + return object === 'sys_metadata' + ? rows.map(({ organization_id: _physical, ...declared }) => declared) + : []; + }, + getDriverForObject(object: string) { + if (object !== 'sys_metadata') return undefined; + return { + dialectName: 'sqlite', + async execute(sql: string) { + return /column_name/.test(sql) + ? [{ column_name: 'id' }, { column_name: 'organization_id' }] + : rows.filter((r) => r.organization_id != null && r.organization_id !== '') + .map((r) => ({ id: r.id, organization_id: r.organization_id })); + }, + }; }, async findOne(object: string, query?: Record) { assertEngineFindOnePredicate(object, query as never); diff --git a/packages/services/service-automation/src/flow-credential-migration.ts b/packages/services/service-automation/src/flow-credential-migration.ts index 2e324517812..b3a842c4c32 100644 --- a/packages/services/service-automation/src/flow-credential-migration.ts +++ b/packages/services/service-automation/src/flow-credential-migration.ts @@ -34,6 +34,7 @@ * `blocking: 0` (it gates nothing). Its details name flows, never values. */ +import { readLegacyOrganizationRows } from '@objectstack/metadata-core'; import { DATA_MIGRATION_FLAG_OBJECT, type DataMigrationFlag } from '@objectstack/spec/system'; import { explicitFlowCredentials, FLOW_CREDENTIAL_UNAVAILABLE_CODE } from './flow-credential-channel.js'; import { FLOW_METADATA_TYPE, flowCredentialClassList } from './flow-credential-projection.js'; @@ -53,6 +54,8 @@ export interface FlowCredentialMigrationEngine { insert(object: string, data: Record, options?: Record): Promise; update(object: string, data: Record, options?: Record): Promise; getObject?(name: string): unknown; + /** [ADR-0131 D7] The table's driver, through which a legacy organization's rows are told apart. */ + getDriverForObject?(name: string): unknown; } /** The slice of the metadata protocol the migration re-saves through. */ @@ -168,10 +171,17 @@ export async function migrateFlowCredentialsIntoChannel(deps: { const result: FlowCredentialMigrationResult = { status: 'nothing-to-move', found: 0, migrated: [], failed: [] }; let rows: Record[]; + // [ADR-0131 D6/D7] The family declares no organization column, so the + // engine row never carries one: a legacy organization's row is told apart + // off the physical column, on a database the ceremony has not migrated. + let legacy: ReadonlyMap; try { rows = rowsOf( await engine.find(STORED_ROW_OBJECT, { where: { type: FLOW_METADATA_TYPE }, context: SYSTEM_CONTEXT }), ); + legacy = rows.length === 0 + ? new Map() + : await readLegacyOrganizationRows(engine.getDriverForObject?.(STORED_ROW_OBJECT), STORED_ROW_OBJECT); } catch (err) { logger.warn( '[Automation] the stored flow credential move could not read the stored flow rows; it runs again at the ' + @@ -194,11 +204,12 @@ export async function migrateFlowCredentialsIntoChannel(deps: { // the protocol writes no organization-scoped row, and re-saving it // environment-wide would move it — the promotion ceremony's job // (ADR-0131 C7). It is reported as not moved, every run, until then. - if (typeof row.organization_id === 'string' && row.organization_id !== '') { + const organization = legacy.get(String(row.id)); + if (organization !== undefined) { result.failed.push({ flow: name, state, code: 'NOT_OVERRIDABLE' }); logger.error( `[Automation] flow '${name}' (${state}): its stored row is organization-scoped (organization ` + - `'${row.organization_id}'), and no metadata write lands organization-scoped any more, so its ` + + `'${organization}'), and no metadata write lands organization-scoped any more, so its ` + 'credential could not be moved into the write-only flow credential store: that row STILL CARRIES ' + 'IT IN CLEARTEXT. The promotion ceremony (ADR-0131 C7) carries the row to the environment layer, ' + 'where the next run moves it. Rotate the credential now.', diff --git a/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts b/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts index d71ac9bd261..87524f7bf20 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts @@ -361,9 +361,24 @@ describe('DatasourceAdminServicePlugin: runtime datasource durability', () => { const w = q.where ?? {}; return rows.find((r) => Object.entries(w).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return r[k] === v; })); }, + // [ADR-0131 D7] The engine shapes a read to the declared columns, and the + // family declares no `organization_id`: the key never comes back. A row + // stored with one (a database the ceremony has not migrated) is told + // apart through the driver below, off the physical column. find: async (_o: string, q: { where?: Record }) => { const w = q.where ?? {}; - return rows.filter((r) => Object.entries(w).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return r[k] === v; })); + return rows + .filter((r) => Object.entries(w).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return r[k] === v; })) + .map(({ organization_id: _physical, ...declared }) => declared); + }, + getDriverForObject: (object: string) => object !== 'sys_metadata' ? undefined : { + dialectName: 'sqlite', + async execute(sql: string) { + return /column_name/.test(sql) + ? [{ column_name: 'id' }, { column_name: 'organization_id' }] + : rows.filter((r) => r.organization_id != null && r.organization_id !== '') + .map((r) => ({ id: r.id, organization_id: r.organization_id })); + }, }, insert: async (_o: string, row: Record) => { rows.push({ ...row }); }, update: async (_o: string, row: Record, opts: { where: Record }) => { diff --git a/packages/services/service-datasource/src/datasource-admin-plugin.ts b/packages/services/service-datasource/src/datasource-admin-plugin.ts index ed525af25cf..f884a0d0d19 100644 --- a/packages/services/service-datasource/src/datasource-admin-plugin.ts +++ b/packages/services/service-datasource/src/datasource-admin-plugin.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { Plugin, PluginContext } from '@objectstack/core'; -import { hashSpec, isLegacyOrganizationRow } from '@objectstack/metadata-core'; +import { hashSpec, readLegacyOrganizationRows } from '@objectstack/metadata-core'; import { applyConversionsToStoredItem } from '@objectstack/spec'; import { registerMetadataTypeActions } from '@objectstack/spec/kernel'; import type { @@ -68,7 +68,7 @@ interface MetadataServiceLike { * dropped rather than moved so there is one declaration of them, not two. */ type DataEngineLike = Partial< - Pick + Pick >; /** @@ -116,8 +116,8 @@ function newMetaId(): string { * one, or one name's. EVERY read below of a stored datasource row goes through * here: the boot restore, the by-name read behind convergence and the `/meta` * projection, and the existence probes behind this plugin's own writes. A row - * stored in a legacy organization's layer ({@link isLegacyOrganizationRow}, - * the one test the metadata reads apply) is therefore never restored, served, + * stored in a legacy organization's layer (its physical `organization_id`, + * {@link readLegacyOrganizationRows}) is therefore never restored, served, * pooled, updated or deleted here; the boot report names it, and it stays at * rest. A `find` filtered here, never a `findOne`: a single row picked by the * store could be the legacy one while the environment's row exists. @@ -129,7 +129,13 @@ async function findEnvironmentRows( if (!engine?.find) return []; const where = { type: DS_META_TYPE, ...(name === undefined ? {} : { name }), state: 'active' }; const rows = await engine.find(SYS_METADATA, { where }, { context: SYSTEM_CTX }); - return (rows ?? []).filter((row) => !isLegacyOrganizationRow(row)); + if (!rows || rows.length === 0) return []; + // [ADR-0131 D7] The family declares no organization column, so the engine + // row never carries one: a legacy organization's row is told apart by the + // physical column, on a database the ceremony has not migrated. A read that + // cannot answer throws — never "every row is the environment's". + const legacy = await readLegacyOrganizationRows(engine.getDriverForObject?.(SYS_METADATA)); + return rows.filter((row) => !legacy.has(String(row.id))); } /** diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 9bae0980883..fc0407b6735 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -446,6 +446,7 @@ "NotificationChannelSchema (const)", "OPERATION_MESSAGE_FALLBACK_LOCALE (const)", "OPERATION_MESSAGE_KEY_PREFIX (const)", + "ORGANIZATION_OWNERSHIP_MIGRATION_ID (const)", "ORSet (type)", "ORSetElement (type)", "ORSetElementParsed (type)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index b6f920e6b93..63ea593a0ac 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -427,6 +427,7 @@ "NotificationChannelSchema": "src/system/notification.zod.ts#NotificationChannelSchema (const)", "OPERATION_MESSAGE_FALLBACK_LOCALE": "src/system/operation-message.ts#OPERATION_MESSAGE_FALLBACK_LOCALE (const)", "OPERATION_MESSAGE_KEY_PREFIX": "src/system/operation-message.ts#OPERATION_MESSAGE_KEY_PREFIX (const)", + "ORGANIZATION_OWNERSHIP_MIGRATION_ID": "src/system/migration.zod.ts#ORGANIZATION_OWNERSHIP_MIGRATION_ID (const)", "ORSet": "src/system/collaboration.zod.ts#ORSet (type)", "ORSetElement": "src/system/collaboration.zod.ts#ORSetElement (type)", "ORSetElementParsed": "src/system/collaboration.zod.ts#ORSetElementParsed (type)", diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 810ecbf9cbc..a108a1607e3 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -476,6 +476,17 @@ export const ERROR_CODE_LEDGER = { // rows for the addressed object. Provenance, not identity (see above). 'OBJECT_API_DISABLED', 'OBJECT_API_METHOD_NOT_ALLOWED', + // [ADR-0131 D10 item 5, ruling 6163114098 Q3 A] The v18 boot refusal + // (`OrganizationOwnershipBootGuardPlugin`, `organization-ownership-boot- + // guard.ts`): `os serve` refuses to start against a database still in the + // v17 shape — a table whose object declares no organization column still + // carries a physical `organization_id` — while the organization-ownership + // ceremony's `sys_migration` marker is not verified. Thrown from the + // plugin's `kernel:ready` hook, before `kernel:listening` opens the socket, + // so no request is ever served (`door: 'none'`, the + // WALLED_MEMBERSHIP_POLICY_UNDECLARED precedent); the `code` lets a host + // discriminate the refusal structurally. No escape hatch exists (D10). + 'ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED', // [#7557] `DELETE /packages/:id` on the DISPATCHER door — per-item failures // used to ride inside a 200 with a hardcoded `success: true`. Second // EMITTER of the code `@objectstack/rest` already registers for the @@ -692,6 +703,17 @@ export const ERROR_CODE_LEDGER = { 'WRITABLE_PACKAGE_REQUIRED', ], '@objectstack/metadata-core': [ + // [ADR-0131 D6/D7, #23429] `readLegacyOrganizationRows` + // (`legacy-organization-row.ts`, `LegacyOrganizationRowsUnreadable`) could + // not tell a `sys_metadata`-family table's legacy organization rows from + // the environment's: the driver's raw seam returned no result set, its SQL + // dialect resolved to none, or the column list came back empty for a table + // that exists. Refused, never read as "none" — that answer would serve, + // list or delete one organization's rows as the environment's. It reaches a + // door only on a database the organization-ownership ceremony has not + // migrated (which `os serve` refuses), so the remedy it names is the + // ceremony. + 'LEGACY_ORGANIZATION_ROWS_UNREADABLE', 'METADATA_BRANCH', 'METADATA_CONFLICT', 'METADATA_NOT_FOUND', diff --git a/packages/spec/src/migrations/entries/semantic/18.sys-metadata-audit-organization-column-retired.ts b/packages/spec/src/migrations/entries/semantic/18.sys-metadata-audit-organization-column-retired.ts new file mode 100644 index 00000000000..0bd460c1986 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.sys-metadata-audit-organization-column-retired.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #15211 (ADR-0131 D6/D7, C7b) — one D3 entry per removed column, one per +// object of the sys_metadata family. A platform-object COLUMN retirement, not +// a spec-key retirement: no authorable spec key moves, so nothing lands in +// RETIRED_KEYS_BY_MAJOR and no D2 conversion exists to pair with (the +// sys-presence-organization-column-retired shape). +export const entry: SemanticMigration = { + id: 'sys-metadata-audit-organization-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_metadata_audit.organization_id — the declared organization column left the metadata write audit log ' + + '(packages/metadata-core/src/objects/sys-metadata-audit.object.ts, which now declares systemFields.tenant false). ' + + 'Its timeline index is re-keyed from (organization_id, occurred_at) to (occurred_at).', + replacement: + 'nothing on this table — metadata is the environment\'s (ADR-0131 D6), and no organization owns ' + + 'a row. Delete any authored filter, list-view column, report grouping, formula or seed key that ' + + 'names `organization_id` on `sys_metadata_audit`. Existing databases: run the v18 ceremony, ' + + '`os migrate organization-ownership --apply`, which promotes or reports the legacy ' + + 'organization rows and then drops the physical column', + reason: + 'ADR-0131 D6 retired the per-organization overlay axis: every metadata write now lands ' + + 'environment-wide with a NULL organization, every read is environment then code, and a legacy ' + + 'organization row is served by no read, and the v18 runtime refuses to start on a database still holding the column until the ceremony has run. The column therefore only holds ' + + 'the legacy rows the 17.x organization layer wrote, and the v18 ceremony owns those: under ' + + 'single it promotes the presentational customizations to environment rows and carries every ' + + 'public-form withdrawal onto the environment, fail-closed (the maintainer rulings on organization customizations and form withdrawals), and ' + + 'it reports the rest. Only then does its column-drops step drop the column, through the ' + + 'schema-drift machinery the drop needs: schema sync is additive, so an undeclared column stays ' + + 'and the boot drift report names it orphaned. The organization leaves every index key in the ' + + 'same change, because an index naming a dropped column is skipped on rebuild. Removing it ' + + 'TIGHTENS uniqueness where it was a key part (two organizations, or an organization and the ' + + 'environment, can share a key): a legacy database holds rows the re-keyed unique indexes ' + + 'refuse until the ceremony has run, and the ADR-0120 D4 index ceremony keeps the previous ' + + 'index and names them rather than drop a row.', + acceptanceCriteria: + 'No authored metadata names `organization_id` on `sys_metadata_audit`: the field resolver (lint and the data ' + + 'door) answers it as an unknown field. A fresh database creates `sys_metadata_audit` with no organization ' + + 'column. On a legacy database, after `os migrate organization-ownership --apply` the column is ' + + 'gone and the boot no longer reports it orphaned.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.sys-metadata-commit-organization-column-retired.ts b/packages/spec/src/migrations/entries/semantic/18.sys-metadata-commit-organization-column-retired.ts new file mode 100644 index 00000000000..771d93ae327 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.sys-metadata-commit-organization-column-retired.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #15211 (ADR-0131 D6/D7, C7b) — one D3 entry per removed column, one per +// object of the sys_metadata family. A platform-object COLUMN retirement, not +// a spec-key retirement: no authorable spec key moves, so nothing lands in +// RETIRED_KEYS_BY_MAJOR and no D2 conversion exists to pair with (the +// sys-presence-organization-column-retired shape). +export const entry: SemanticMigration = { + id: 'sys-metadata-commit-organization-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_metadata_commit.organization_id — the declared organization column left the package commit log ' + + '(packages/metadata-core/src/objects/sys-metadata-commit.object.ts, which now declares systemFields.tenant false). ' + + 'Its indexes are re-keyed from (organization_id, package_id, created_at) and (organization_id, created_at) to (package_id, created_at) and (created_at).', + replacement: + 'nothing on this table — metadata is the environment\'s (ADR-0131 D6), and no organization owns ' + + 'a row. Delete any authored filter, list-view column, report grouping, formula or seed key that ' + + 'names `organization_id` on `sys_metadata_commit`. Existing databases: run the v18 ceremony, ' + + '`os migrate organization-ownership --apply`, which promotes or reports the legacy ' + + 'organization rows and then drops the physical column', + reason: + 'ADR-0131 D6 retired the per-organization overlay axis: every metadata write now lands ' + + 'environment-wide with a NULL organization, every read is environment then code, and a legacy ' + + 'organization row is served by no read, and the v18 runtime refuses to start on a database still holding the column until the ceremony has run. The column therefore only holds ' + + 'the legacy rows the 17.x organization layer wrote, and the v18 ceremony owns those: under ' + + 'single it promotes the presentational customizations to environment rows and carries every ' + + 'public-form withdrawal onto the environment, fail-closed (the maintainer rulings on organization customizations and form withdrawals), and ' + + 'it reports the rest. Only then does its column-drops step drop the column, through the ' + + 'schema-drift machinery the drop needs: schema sync is additive, so an undeclared column stays ' + + 'and the boot drift report names it orphaned. The organization leaves every index key in the ' + + 'same change, because an index naming a dropped column is skipped on rebuild. Removing it ' + + 'TIGHTENS uniqueness where it was a key part (two organizations, or an organization and the ' + + 'environment, can share a key): a legacy database holds rows the re-keyed unique indexes ' + + 'refuse until the ceremony has run, and the ADR-0120 D4 index ceremony keeps the previous ' + + 'index and names them rather than drop a row.', + acceptanceCriteria: + 'No authored metadata names `organization_id` on `sys_metadata_commit`: the field resolver (lint and the data ' + + 'door) answers it as an unknown field. A fresh database creates `sys_metadata_commit` with no organization ' + + 'column. On a legacy database, after `os migrate organization-ownership --apply` the column is ' + + 'gone and the boot no longer reports it orphaned.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.sys-metadata-history-organization-column-retired.ts b/packages/spec/src/migrations/entries/semantic/18.sys-metadata-history-organization-column-retired.ts new file mode 100644 index 00000000000..238fe4451ad --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.sys-metadata-history-organization-column-retired.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #15211 (ADR-0131 D6/D7, C7b) — one D3 entry per removed column, one per +// object of the sys_metadata family. A platform-object COLUMN retirement, not +// a spec-key retirement: no authorable spec key moves, so nothing lands in +// RETIRED_KEYS_BY_MAJOR and no D2 conversion exists to pair with (the +// sys-presence-organization-column-retired shape). +export const entry: SemanticMigration = { + id: 'sys-metadata-history-organization-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_metadata_history.organization_id — the declared organization column left the metadata event log ' + + '(packages/metadata-core/src/objects/sys-metadata-history.object.ts, which now declares systemFields.tenant false). ' + + 'Its uniqueness is re-keyed to the environment: event_seq is unique environment-wide, and so is (type, name, version); its other organization-prefixed indexes drop the prefix.', + replacement: + 'nothing on this table — metadata is the environment\'s (ADR-0131 D6), and no organization owns ' + + 'a row. Delete any authored filter, list-view column, report grouping, formula or seed key that ' + + 'names `organization_id` on `sys_metadata_history`. Existing databases: run the v18 ceremony, ' + + '`os migrate organization-ownership --apply`, which promotes or reports the legacy ' + + 'organization rows and then drops the physical column', + reason: + 'ADR-0131 D6 retired the per-organization overlay axis: every metadata write now lands ' + + 'environment-wide with a NULL organization, every read is environment then code, and a legacy ' + + 'organization row is served by no read, and the v18 runtime refuses to start on a database still holding the column until the ceremony has run. The column therefore only holds ' + + 'the legacy rows the 17.x organization layer wrote, and the v18 ceremony owns those: under ' + + 'single it promotes the presentational customizations to environment rows and carries every ' + + 'public-form withdrawal onto the environment, fail-closed (the maintainer rulings on organization customizations and form withdrawals), and ' + + 'it reports the rest. Only then does its column-drops step drop the column, through the ' + + 'schema-drift machinery the drop needs: schema sync is additive, so an undeclared column stays ' + + 'and the boot drift report names it orphaned. The organization leaves every index key in the ' + + 'same change, because an index naming a dropped column is skipped on rebuild. Removing it ' + + 'TIGHTENS uniqueness where it was a key part (two organizations, or an organization and the ' + + 'environment, can share a key): a legacy database holds rows the re-keyed unique indexes ' + + 'refuse until the ceremony has run, and the ADR-0120 D4 index ceremony keeps the previous ' + + 'index and names them rather than drop a row.', + acceptanceCriteria: + 'No authored metadata names `organization_id` on `sys_metadata_history`: the field resolver (lint and the data ' + + 'door) answers it as an unknown field. A fresh database creates `sys_metadata_history` with no organization ' + + 'column. On a legacy database, after `os migrate organization-ownership --apply` the column is ' + + 'gone and the boot no longer reports it orphaned.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.sys-metadata-organization-column-retired.ts b/packages/spec/src/migrations/entries/semantic/18.sys-metadata-organization-column-retired.ts new file mode 100644 index 00000000000..cdd6efd734d --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.sys-metadata-organization-column-retired.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #15211 (ADR-0131 D6/D7, C7b) — one D3 entry per removed column, one per +// object of the sys_metadata family. A platform-object COLUMN retirement, not +// a spec-key retirement: no authorable spec key moves, so nothing lands in +// RETIRED_KEYS_BY_MAJOR and no D2 conversion exists to pair with (the +// sys-presence-organization-column-retired shape). +export const entry: SemanticMigration = { + id: 'sys-metadata-organization-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_metadata.organization_id — the declared organization column left the metadata overlay table ' + + '(packages/metadata-core/src/objects/sys-metadata.object.ts, which now declares systemFields.tenant false). ' + + 'Its overlay uniqueness is re-keyed to (type, name, package_id, bundle_member): the declared index idx_sys_metadata_overlay_active and the runtime partial index of the same name, and idx_sys_metadata_org_type is gone.', + replacement: + 'nothing on this table — metadata is the environment\'s (ADR-0131 D6), and no organization owns ' + + 'a row. Delete any authored filter, list-view column, report grouping, formula or seed key that ' + + 'names `organization_id` on `sys_metadata`. Existing databases: run the v18 ceremony, ' + + '`os migrate organization-ownership --apply`, which promotes or reports the legacy ' + + 'organization rows and then drops the physical column', + reason: + 'ADR-0131 D6 retired the per-organization overlay axis: every metadata write now lands ' + + 'environment-wide with a NULL organization, every read is environment then code, and a legacy ' + + 'organization row is served by no read, and the v18 runtime refuses to start on a database still holding the column until the ceremony has run. The column therefore only holds ' + + 'the legacy rows the 17.x organization layer wrote, and the v18 ceremony owns those: under ' + + 'single it promotes the presentational customizations to environment rows and carries every ' + + 'public-form withdrawal onto the environment, fail-closed (the maintainer rulings on organization customizations and form withdrawals), and ' + + 'it reports the rest. Only then does its column-drops step drop the column, through the ' + + 'schema-drift machinery the drop needs: schema sync is additive, so an undeclared column stays ' + + 'and the boot drift report names it orphaned. The organization leaves every index key in the ' + + 'same change, because an index naming a dropped column is skipped on rebuild. Removing it ' + + 'TIGHTENS uniqueness where it was a key part (two organizations, or an organization and the ' + + 'environment, can share a key): a legacy database holds rows the re-keyed unique indexes ' + + 'refuse until the ceremony has run, and the ADR-0120 D4 index ceremony keeps the previous ' + + 'index and names them rather than drop a row.', + acceptanceCriteria: + 'No authored metadata names `organization_id` on `sys_metadata`: the field resolver (lint and the data ' + + 'door) answers it as an unknown field. A fresh database creates `sys_metadata` with no organization ' + + 'column. On a legacy database, after `os migrate organization-ownership --apply` the column is ' + + 'gone and the boot no longer reports it orphaned.', +}; diff --git a/packages/spec/src/system/migration.zod.ts b/packages/spec/src/system/migration.zod.ts index 8f4b654b923..5f91853fd18 100644 --- a/packages/spec/src/system/migration.zod.ts +++ b/packages/spec/src/system/migration.zod.ts @@ -169,6 +169,22 @@ export const FILE_REFERENCES_MIGRATION_ID = 'adr-0104-file-references'; */ export const VALUE_SHAPES_MIGRATION_ID = 'adr-0104-value-shapes'; +/** + * Well-known migration id: ADR-0131 D10, the v18 organization-ownership + * ceremony (`os migrate organization-ownership --apply`). Its row in + * `sys_migration` is the ceremony's checkpoint and, once verified, its + * completion marker: `verified_at` is set only by a run that finished every + * step and whose post-check found zero discrepancies. The v18 runtime refuses + * to start against a database still in the v17 shape while this marker is not + * verified (D10 item 5). + * + * ⛔ Not a member of {@link CREATION_ATTESTED_MIGRATION_IDS}: a fresh store is + * not in the v17 shape (no table it creates carries a retired organization + * column), so the boot needs no attestation to start it, and a row claiming a + * ceremony that never ran would read as one that did. + */ +export const ORGANIZATION_OWNERSHIP_MIGRATION_ID = 'adr-0131-organization-ownership'; + /** * ⚰️ RETIRED migration id: ADR-0030 notification convergence. * diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c51e1be4840..ef069e88bab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2122,6 +2122,9 @@ importers: '@objectstack/plugin-webhooks': specifier: workspace:* version: link:../../plugins/plugin-webhooks + '@objectstack/runtime': + specifier: workspace:* + version: link:../../runtime '@objectstack/service-analytics': specifier: workspace:* version: link:../../services/service-analytics diff --git a/scripts/check-tenant-chokepoint.mjs b/scripts/check-tenant-chokepoint.mjs index b2448f4f49a..af2a6f8c6e9 100644 --- a/scripts/check-tenant-chokepoint.mjs +++ b/scripts/check-tenant-chokepoint.mjs @@ -83,9 +83,11 @@ * and if a helper is ever wanted the answer is an EXEMPT entry with a * reason, not a looser rule. * - Raw `this.knex(...)` construction that bypasses `getBuilder` entirely. - * Out of reach of this criterion by construction. Nothing in the family - * does it today on a read path; if that changes, this gate cannot see it, - * and the honest place to record that limit is here. + * Out of reach of this criterion by construction. One read path does it, + * and it returns no row: `SqlDriver.listPhysicalColumns` (#23429) reads a + * table's CATALOG (column names) on Knex directly, so it has no rows to + * scope. Any other such read path is invisible to this gate, and the + * honest place to record that limit is here. * - **WHERE the call sits.** This is the important one, and it is measured * rather than assumed. The gate asserts the call EXISTS on that binding, * never its position — so a call moved below `builder.toSQL()`, or below diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 918e52dac27..6b3fc46cd86 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1066,21 +1066,6 @@ "verb": "update", "pinned": 1 }, - { - "file": "packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts", - "verb": "delete", - "pinned": 1 - }, - { - "file": "packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts", - "verb": "findOne", - "pinned": 1 - }, - { - "file": "packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts", - "verb": "update", - "pinned": 1 - }, { "file": "packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts", "verb": "delete", @@ -3331,6 +3316,11 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/plugins/plugin-security/src/metadata-family-data-door-gate.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/plugins/plugin-security/src/ownership-floor-alternates.test.ts", "verb": "findOne", diff --git a/scripts/objectql-double-limit.baseline.json b/scripts/objectql-double-limit.baseline.json index 08ff34574f5..f527efbccde 100644 --- a/scripts/objectql-double-limit.baseline.json +++ b/scripts/objectql-double-limit.baseline.json @@ -582,9 +582,6 @@ "packages/plugins/plugin-security/src/explain-engine.test.ts": { "blind": 1 }, - "packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts": { - "unjudged": 1 - }, "packages/plugins/plugin-security/src/permission-denied-user-copy.test.ts": { "blind": 1 }, diff --git a/scripts/platform-object-tenancy-census.json b/scripts/platform-object-tenancy-census.json index cccca4aa004..dd9392b32e4 100644 --- a/scripts/platform-object-tenancy-census.json +++ b/scripts/platform-object-tenancy-census.json @@ -25,12 +25,12 @@ "population": "every object registered by a tracked packages/**/*.object.ts module whose name carries a platform prefix (sys_ / cloud_ / ai_). The cloud repository's own cloud_ objects are not in this tree and so not in this census.", "totals": { "registered": 83, - "inReach": 48, - "outOfReach": 35 + "inReach": 44, + "outOfReach": 39 }, "reasonTotals": { "managedBy: 'better-auth'": 25, - "systemFields.tenant: false": 10, + "systemFields.tenant: false": 14, "tenancy.enabled: false": 2 }, "unexplained": [], @@ -299,9 +299,11 @@ { "name": "sys_metadata", "file": "packages/metadata-core/src/objects/sys-metadata.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] + "reach": "out", + "tenantField": null, + "reasons": [ + "systemFields.tenant: false" + ] }, { "name": "sys_metadata_activation", @@ -315,23 +317,29 @@ { "name": "sys_metadata_audit", "file": "packages/metadata-core/src/objects/sys-metadata-audit.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] + "reach": "out", + "tenantField": null, + "reasons": [ + "systemFields.tenant: false" + ] }, { "name": "sys_metadata_commit", "file": "packages/metadata-core/src/objects/sys-metadata-commit.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] + "reach": "out", + "tenantField": null, + "reasons": [ + "systemFields.tenant: false" + ] }, { "name": "sys_metadata_history", "file": "packages/metadata-core/src/objects/sys-metadata-history.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] + "reach": "out", + "tenantField": null, + "reasons": [ + "systemFields.tenant: false" + ] }, { "name": "sys_migration",