diff --git a/.changeset/15206-reads-environment-only.md b/.changeset/15206-reads-environment-only.md new file mode 100644 index 00000000000..7e409114fa6 --- /dev/null +++ b/.changeset/15206-reads-environment-only.md @@ -0,0 +1,45 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/metadata-core': minor +'@objectstack/objectql': minor +'@objectstack/rest': minor +'@objectstack/plugin-security': minor +'@objectstack/spec': minor +--- + +feat(metadata-protocol,metadata-core,objectql,rest,plugin-security,spec)!: every metadata read is environment → code; legacy organization rows and overlays of sealed managed content are reported at boot, not served (ADR-0131 D6) + +Clause-②: no (narrowing) + + + +**BREAKING (narrowing).** No read that is refused today is admitted. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + +ADR-0131 D6 retires the per-organization overlay axis. The doors carry no organization and the protocol refuses every organization-scoped write; now every protocol read is environment → code as well. + +**What changes.** + +- The item, list, layered, cached (ETag), history, diff, audit, drafts and commit-timeline reads, the `_lock` gate's overlay layer, search's page sweep, diagnostics and references serve the environment's stored row, else the code packages' definition — whatever organization a caller names. A row stored organization-scoped by an earlier release is served by no read and loaded by no boot. +- `overlayScope` on the layered read is `'env'` or `null`; `'org'` is gone. +- `listDrafts` lists the environment's drafts only, and its rows carry no `organizationId`. +- `listCommits` lists the environment's commits only, for every caller: a legacy organization commit is not shown, so an operator on the packages door no longer sees every organization's legacy commits, and a rollback plan never contains one. +- [Triage ruling Q1 → C] An environment row that overlays an item a managed package ships, on a type sealed against overlays (any type whose registry entry admits no overlay — a managed flow, action, hook, object or datasource, for example — written through the `OS_METADATA_WRITABLE` hatch before the seal), is not served: the package's definition is. An `object` such row is not loaded at boot either, so the engine keeps the packaged schema. A stored fork of a code-declared permission set keeps its own ruling (detection reading and Discard Overlay) and is still served. +- `@objectstack/objectql` binds the environment's authored hook and action rows only; a legacy organization-scoped hook or action row is no longer bound. +- Boot names both populations, once each: `[metadata_org_scoped_unserved]` lists every legacy organization row per type (active and draft), plus the legacy rows of `sys_metadata_commit`, `sys_metadata_history` and `sys_metadata_audit`, and names the v18 migration ceremony (`os migrate`, ADR-0131 D10) that carries them; `[metadata_sealed_overlay_unserved]` lists the sealed overlays per type with the two remedies. Nothing is deleted or rewritten. +- The anonymous public-form doors keep reading the Default Organization's legacy `view` layer, fail-closed, until that ceremony (triage ruling Q3 A) — through the protocol-internal `legacyFormOrganizationId` key, which no spec request declares and which the protocol honours for `view` alone. +- The seed loader's refusal of an unowned record now prescribes `organization_id` on the record first: a seed published through a package carries no caller organization. + +**What moves for consumers.** + +| From | To | +|:--|:--| +| `organizationId` on a `GetMetaItems` / `GetMetaItem` / `GetMetaItemLayered` / `AuditMetaItem` / `HistoryMetaItem` / `GetMetaItemCached` request (`@objectstack/spec`) | drop it: the read is the environment's. The key is stripped at a spec parse (the schemas are not strict) and no longer type-checks | +| `organizationId` on a `listDrafts` / `listCommits` / `diffMetaItem` / `getMetaDiagnostics` / `findReferencesToMeta` request (`@objectstack/metadata-protocol`) | drop it | +| `ListDraftsResponse` draft `organizationId` | gone: every listed draft is the environment's | +| `overlayScope: 'org'` (`GetMetaItemLayeredResponse`) | only `'env'` or `null` | +| `declaresOrgOverride`, `organizationIdForMetaRead` (`@objectstack/metadata-core`) | removed — no read takes an organization. Read `allowOrgOverride` off `DEFAULT_METADATA_TYPE_REGISTRY` where a type's overlay channel is the question | +| `MetadataAuthoringGateContext.organizationId` (`@objectstack/metadata-protocol`) | removed — no write is organization-scoped, and no gate read it | +| a legacy organization-scoped `sys_metadata` row a deployment relied on | re-save the item in Studio to make it environment-wide now, or wait for the v18 migration ceremony, which promotes it | +| an environment overlay of a managed item on a sealed type (flow, action, hook, object, datasource …) | re-express the change as a new item under a new name (a linkage-free clone), then delete the stored row; or delete the row | + +**What does not change.** No stored row is deleted, rewritten or migrated. The anonymous form doors' read of organization-layer withdrawals is unchanged. diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index cdac13d1c9b..cb4d1bca4ab 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -45,8 +45,8 @@ Reads walk top-to-bottom: the first non-null layer wins. Writes always route to Today this precedence is implemented **inside the protocol layer**, not by `LayeredRepository`. `ObjectStackProtocolImplementation` lazily builds one -`SysMetadataRepository` per org (`getOverlayRepo()`) and merges it against the -in-memory code registry — `getMetaItemLayered()` returns `code` / `overlay` / +`SysMetadataRepository` for the environment (`getOverlayRepo()`) and merges it +against the in-memory code registry — `getMetaItemLayered()` returns `code` / `overlay` / `effective` separately. `LayeredRepository` ships in `@objectstack/metadata-core` and implements exactly the semantics above, but it is **not yet composed** into any production path: every `new LayeredRepository(...)` call site is a test @@ -106,7 +106,7 @@ later ships. See [ADR-0070](https://github.com/objectstack-ai/objectstack/blob/m In shared-database multi-tenancy, **most metadata types must not be per-org customizable** — overriding them would break the physical schema. The whitelist lives in **one** place: `MetadataTypeRegistryEntry.allowOrgOverride` in `packages/spec/src/kernel/metadata-plugin.zod.ts`. -> **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`). 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 not served by any `/meta` read until the promotion ceremony (ADR-0131 C7) carries it to the environment layer; re-save the item in Studio to make the edit live on the `/meta` doors 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** (`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. | Type | `allowOrgOverride` | Rationale | | :--- | :---: | :--- | @@ -142,6 +142,8 @@ See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/ The switches are operator-only where one install serves several organizations. The refusal names the same path whichever layer answers it: `403 NOT_OVERRIDABLE` for a write that names no base, and `403 ITEM_LOCKED` for one that names the read-only package. Which writes are refused, and with which code, is unchanged. +An environment row stored over a managed item of a type sealed against overlays (a flow, action, hook, object or datasource, for example — any type whose registry entry admits no overlay) before the seal — written through the `OS_METADATA_WRITABLE` hatch — is **not served** (ADR-0131 D6): every read, and boot, takes the package's definition, and the row stays at rest, untouched. Boot names each such row, per type (`[metadata_sealed_overlay_unserved]`), with the two remedies: re-express the change as a new item under a new name, or delete the stored row. A stored fork of a code-declared permission set is the one exception: it keeps the detection reading and the operator's Discard Overlay action it already had. + --- ## Conflict handling @@ -176,13 +178,13 @@ The hash is `sha256:` + 64-hex of a canonical JSON serialization of the body, wi | :--- | :--- | | `InMemoryRepository`, `LayeredRepository` | ✅ Shipped (`@objectstack/metadata-core`) | | `FileSystemRepository` | ✅ Shipped (`@objectstack/metadata-fs`) | -| Change log + `seq` (per-org, monotonic) | ✅ Shipped | +| Change log + `seq` (per environment, monotonic) | ✅ Shipped | | SSE bridge (`/api/v1/dev/metadata-events`, `event: metadata-change`) | ✅ Shipped | | Granular client-side HMR hook + seq status badge | ❌ Not implemented — no such hook or badge exists in the console today | | Console dev-mode HMR reloader (`MetadataHmrReloader`) | ✅ Shipped (console app, mounted app-wide, dev-only) | | `SysMetadataRepository` (overlay over `sys_metadata`) | ✅ Shipped (`@objectstack/metadata-protocol`) | | `LayeredRepository(SysMeta + artifact)` composition | ⏳ Not wired — the class ships, but nothing composes it outside tests | -| `protocol.ts:saveMetaItem` routed through `SysMetadataRepository.put` | ✅ Shipped (via the per-org `getOverlayRepo()` cache) | +| `protocol.ts:saveMetaItem` routed through `SysMetadataRepository.put` | ✅ Shipped (via the environment's `getOverlayRepo()` repository) | | `sys_metadata_history` table (durable, org-keyed change log) | ✅ Shipped (object definition in `@objectstack/metadata-core`; written by `SysMetadataRepository`) | | Cache + Registry refactor against `MetadataRepository` | ⏳ Post-M0 | @@ -236,9 +238,9 @@ The hash is `sha256:` + 64-hex of a canonical JSON serialization of the body, wi ### Why artifact never enters the database 1. **Single source of truth.** Code → artifact → memory. If artifact also wrote to `sys_metadata`, every deploy would need a reconciliation step ("the row says v3.0.0 but the code says v3.0.1") that is impossible to get right under concurrent edits. -2. **Overlay stays small and auditable.** Per-org overlay = "what this organization deliberately changed at runtime". A clean table that's safe to dump, diff, or reset to factory. +2. **Overlay stays small and auditable.** The overlay = "what this environment deliberately changed at runtime" (ADR-0131 D6: one environment layer, no per-organization one). A clean table that's safe to dump, diff, or reset to factory. 3. **Immutable infrastructure.** Rolling back a deploy = switching image tags. No DB migration, no reverse `put`, no history replay required. -4. **Per-org multi-tenant economics.** If artifacts were materialised into `sys_metadata`, every org would carry tens of thousands of baseline rows. Overlay-only keeps each org's row count proportional to its actual customisation. +4. **Storage economics.** If artifacts were materialised into `sys_metadata`, every environment would carry tens of thousands of baseline rows. Overlay-only keeps its row count proportional to its actual customisation. ### What if I want auditable history of deployed artifact changes? diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index 6f4c2102a6a..bd22e11b997 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -306,7 +306,7 @@ that bypassed write hooks, `rebuildSearchCompanion` (from | `OS_MARKETPLACE_CACHE` | enum | `on` | `off` disables the in-memory marketplace listing cache. | | `OS_MARKETPLACE_PUBLIC_BASE_URL` | url | — | Public base URL of the marketplace registry (proxied from this runtime when set). | | `OS_ALLOW_UNMASKED_OBJECT_METADATA` | boolean | `false` | Escape hatch for the metadata-plane field-level security mask ([ADR-0106](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0106-metadata-plane-fls-object-schema-masking.md) D8). By default every object schema served by `/meta` and `/metadata` is projected onto the fields the **calling user** may read, so a field they cannot read does not appear at all — not its name, label, type, picklist options, formula, `visibleWhen` predicate, `defaultValue`, or the `requiredPermissions` capability guarding it. Set to `1` to serve the full schema to every authenticated caller, as releases before this one did. This changes **disclosure only**: the data plane still masks values and refuses forbidden writes either way, and the console reads field affordances from `/auth/me/permissions`, so toggling it never changes UI correctness. The REST layer also honours a per-server `metadata.maskObjectFields: false`; this variable is the deployment-wide knob and covers the runtime `/metadata` dispatcher, which has no REST config to read. | -| `OS_METADATA_WRITABLE` | csv | — (none) | Comma-separated metadata type names (e.g. `hook,job`) granted a runtime escape hatch that treats them as `allowOrgOverride: true` for items **no managed package ships**: it opens runtime creation of a type whose registry entry allows none. It never opens an organization-scoped write: since [ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6 the metadata protocol refuses every organization-scoped write with `403 NOT_OVERRIDABLE`. It **never opens an item a managed package ships**: managed content is sealed ([ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6), so an overlay or a removal of a shipped flow, object, field, permission set, position or any other type without an environment overlay is refused with `403 NOT_OVERRIDABLE` whether the hatch is set or not. Customize managed content through its type's own route instead: an environment overlay for `view`, `dashboard`, `report`, `translation` and `email_template`; disable, or clone under a new name, for a flow; clone for a permission set. Overlay rows this hatch wrote before are still served, and a row of a type that merges overlays at read (`permission`, `position`, `page`, `app`, `dataset`, `book`, `tool`, `skill`) can still be removed to restore the package's definition. See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0005-metadata-customization-overlay.md). | +| `OS_METADATA_WRITABLE` | csv | — (none) | Comma-separated metadata type names (e.g. `hook,job`) granted a runtime escape hatch that treats them as `allowOrgOverride: true` for items **no managed package ships**: it opens runtime creation of a type whose registry entry allows none. It never opens an organization-scoped write: since [ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6 the metadata protocol refuses every organization-scoped write with `403 NOT_OVERRIDABLE`. It **never opens an item a managed package ships**: managed content is sealed ([ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6), so an overlay or a removal of a shipped flow, object, field, permission set, position or any other type without an environment overlay is refused with `403 NOT_OVERRIDABLE` whether the hatch is set or not. Customize managed content through its type's own route instead: an environment overlay for `view`, `dashboard`, `report`, `translation` and `email_template`; disable, or clone under a new name, for a flow; clone for a permission set. An overlay row this hatch wrote before over a managed item is kept at rest but **not served**: every read takes the package's definition, and boot names each such row, per type (`[metadata_sealed_overlay_unserved]`), with the two remedies — re-express the change as a new item under a new name, or delete the row. A stored fork of a packaged permission set is the one exception: it is still served, with its detection reading and Discard Overlay. Removing that permission-set row restores the package's definition. A stored row over a managed item of the other types that merge overlays at read (`position`, `page`, `app`, `dataset`, `book`, `tool`, `skill`) can still be removed, but removing it changes nothing that is served: no read serves it, so the package's definition is already served. See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0005-metadata-customization-overlay.md). | --- diff --git a/content/docs/kernel/contracts/metadata-service.mdx b/content/docs/kernel/contracts/metadata-service.mdx index 200875c5892..9a7785a903b 100644 --- a/content/docs/kernel/contracts/metadata-service.mdx +++ b/content/docs/kernel/contracts/metadata-service.mdx @@ -160,7 +160,7 @@ silently. Reach for the keyed read wherever the set you are assembling is addressed by key. -Not to be confused with the **org customization overlay**, which is ADR-0005's +Not to be confused with the **customization overlay**, which is ADR-0005's `sys_metadata` mechanism described under "Overlay Management" below — a different thing entirely. @@ -409,10 +409,11 @@ View customization rides ADR-0005's metadata overlay (opt-in per type via `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 -of the deployment, whatever the author's active organization. An overlay row an -earlier release stored under an organization is not served by any `/meta` read -until the promotion ceremony (ADR-0131 C7) carries it to the environment layer. -Public forms are the exception: until that ceremony the anonymous form doors +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: diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 4c84cdf0246..fef5485c7c0 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -291,7 +291,6 @@ const result = AiAgentCapabilitiesSchema.parse(data); | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name | -| **organizationId** | `string \| null` | optional | Organization (tenant) scope for the read. With an organization, the trail includes that org's rows AND the env-wide (`organization_id IS NULL`) rows — the env-wide limb is load-bearing, because env-level writes are stamped org-less. `null` and absent are equivalent and both mean the env-wide rows only — the fail-closed direction: an unresolved organization reads env-wide rows, never every tenant's. | | **limit** | `number` | optional | Maximum events to return, newest first. The implementation clamps to [1, 500] and defaults to 100 — out-of-range values are clamped, never refused. | @@ -1223,7 +1222,6 @@ Enable package response | **name** | `string` | ✅ | Item name | | **cacheRequest** | `{ ifNoneMatch?: string; ifModifiedSince?: string; cacheControl?: object }` | optional | Cache validation parameters | | **locale** | `string` | optional | Resolved response locale. Folded into the ETag so a language switch never returns a stale-locale 304 — metadata is translated *after* the cache validator check (issue). | -| **organizationId** | `string` | optional | Organization (tenant) scope for the read. When an org partition applies, this selects it in the ADR-0005 overlay read order — org overlay wins over env-wide overlay wins over packaged artifact — exactly as on the uncached read. Also folded into the ETag, so a scope switch never returns a stale 304 from another scope's cached representation. Supplying a value does not by itself guarantee an org partition is consulted; where none applies, and whenever it is absent, the read is environment-wide. | ### Nested Shape: `GetMetaItemCachedRequest.cacheRequest` @@ -1277,7 +1275,6 @@ Enable package response | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name | | **packageId** | `string` | optional | Optional package ID — scopes the `code` layer so a same-name collision resolves to the requested package's artifact (ADR-0048). | -| **organizationId** | `string` | optional | Organization (tenant) scope for the read. When an org partition applies, this selects it in the ADR-0005 overlay read order, so it decides which tenant's customization row is reported as the `overlay` layer (and merged into `effective`). Supplying a value does not by itself guarantee an org partition is consulted; where none applies, and whenever it is absent, the read is environment-wide: `overlay` reports the env-level row only. | --- @@ -1291,8 +1288,8 @@ Enable package response | **type** | `string` | ✅ | Metadata type name (canonical singular) | | **name** | `string` | ✅ | Item name | | **code** | `any` | ✅ | LAYER 1 — the packaged artifact baseline exactly as shipped, before any tenant customization. `null` when no artifact ships this item (it exists only as an overlay). | -| **overlay** | `any` | ✅ | LAYER 2 — the stored customization row ALONE, not merged with `code`. `null` when this tenant has not customized the item. | -| **overlayScope** | `Enum<'org' \| 'env'> \| null` | ✅ | Which scope the `overlay` row was read from — `org` for a tenant overlay, `env` for an environment-level one. `null` exactly when `overlay` is null. | +| **overlay** | `any` | ✅ | LAYER 2 — the stored customization row ALONE, not merged with `code`. `null` when the environment has not customized the item. | +| **overlayScope** | `Enum<'env'> \| null` | ✅ | Which scope the `overlay` row was read from: `env`, the environment layer — the only layer a read serves (ADR-0131 D6). `null` exactly when `overlay` is null. | | **effective** | `any` | ✅ | LAYER 3 — the merged result, i.e. the value an ordinary `GET /meta/:type/:name` would return under `item`. `null` when the item resolves to nothing at all. | | **_diagnostics** | `{ valid: boolean; errors?: object[]; warnings?: object[] }` | optional | Load-time spec-validation verdict for `effective`, so the Studio edit page can raise invalid-metadata banners and inline field errors without a second round trip. ABSENT for metadata types that register no Zod schema (function / service / router) — absence means "no opinion", never "valid". | | **lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | ✅ | Resolved lock verdict (ADR-0010 §3.3), artifact winning over overlay. Always present on this path. | @@ -1326,7 +1323,6 @@ Enable package response | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name (snake_case identifier) | | **packageId** | `string` | optional | Optional package ID to filter items by | -| **organizationId** | `string` | optional | Organization (tenant) scope for the read. When an org partition applies, this selects it in the ADR-0005 overlay read order — org overlay wins over env-wide overlay wins over packaged artifact — so it decides which tenant's customization row is served as the item. Supplying a value does not by itself guarantee an org partition is consulted; where none applies, and whenever it is absent, the read is environment-wide and only env-level overlays apply. | | **state** | `Enum<'active' \| 'draft'>` | optional | Draft-visibility switch — which lifecycle row to read (strict mode): `'draft'` opens the pending draft buffer (Studio's editor read) and fails when no draft exists; absent or `'active'` reads the live published row. Distinct from `previewDrafts`, which FALLS BACK to the active row when no draft exists. Declaration ≠ authorization: this member only selects which stored row is read — ADR-0106 masking is unaffected, and draft access is gated upstream, not by this schema. | | **previewDrafts** | `boolean` | optional | Draft-visibility switch (ADR-0033 draft-overlay preview, non-strict): when true and `state` is not `'draft'`, a pending draft row is preferred if one exists, else the read falls back to the active row — the render path degrades to the published value instead of erroring. A served draft is tagged `_draft: true` so UIs can badge it. Declaration ≠ authorization: this member only switches which row is read, and ADR-0106 masking is unaffected — draft preview is admin-gated upstream, not by this schema. | @@ -1343,7 +1339,7 @@ Enable package response | **name** | `string` | ✅ | Item name | | **item** | `any` | ✅ | Metadata item definition | | **sortability** | `{ fields: Record }` | optional | Per-column sortability projection — present exactly when `type` is `object`, on every serving branch. Computed at serve time from the served document via the spec's own storage predicates; consumers render sort affordances from this signal and never re-derive it from field `type`. See `ObjectSortabilitySchema` for the closed category set. | -| **lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Resolved lock verdict for this item (ADR-0010 §3.3). `none` means unlocked; `no-overlay` / `no-delete` / `full` mean the write doors refuse the corresponding write with 403. Joins two refusals: the document's own `_lock` (`ITEM_LOCKED`; the packaged artifact wins over any org overlay), and the locked packaged base — an item a code package ships, on a type with no per-org overlay channel (`NOT_OVERRIDABLE`, or `ITEM_LOCKED` when the write names the read-only package). | +| **lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Resolved lock verdict for this item (ADR-0010 §3.3). `none` means unlocked; `no-overlay` / `no-delete` / `full` mean the write doors refuse the corresponding write with 403. Joins two refusals: the document's own `_lock` (`ITEM_LOCKED`; the packaged artifact wins over any overlay), and the locked packaged base — an item a code package ships, on a type with no per-org overlay channel (`NOT_OVERRIDABLE`, or `ITEM_LOCKED` when the write names the read-only package). | | **lockReason** | `string` | optional | Human-readable explanation shown next to a refused write. Present only when the resolved item declares `_lockReason`. | | **lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Which layer asserted the lock. Present only when the resolved item declares `_lockSource`. | | **lockDocsUrl** | `string` | optional | Documentation link surfaced beside `lockReason`. Present only when the resolved item declares `_lockDocsUrl`. | @@ -1353,7 +1349,7 @@ Enable package response | **editable** | `boolean` | optional | Whether an overlay write is permitted — false iff `lock` is `no-overlay` or `full`. A derived verdict: do not recompute it from `lock` client-side. | | **deletable** | `boolean` | optional | Whether deleting the overlay is permitted — false iff `lock` is `no-delete` or `full`. | | **resettable** | `boolean` | optional | Whether the item can be reset to its packaged default — true iff it is artifact-backed, i.e. there is a baseline to reset TO. | -| **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the caller's organization partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (the environment-wide row an organization falls back to, a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | +| **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | ### Nested Shape: `GetMetaItemResponse.sortability` @@ -1372,7 +1368,6 @@ Enable package response | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name (e.g., "object", "plugin") | | **packageId** | `string` | optional | Optional package ID to filter items by | -| **organizationId** | `string` | optional | Organization (tenant) scope for the read. When an org partition applies, this selects it in the ADR-0005 overlay read order — org overlay wins over env-wide overlay wins over packaged artifact — so it decides which tenant's customization rows are merged into the list. Supplying a value does not by itself guarantee an org partition is consulted; where none applies, and whenever it is absent, the read is environment-wide and only env-level overlays apply. | | **previewDrafts** | `boolean` | optional | Draft-visibility switch (ADR-0033 draft-overlay preview): when true, pending `state='draft'` rows are overlaid on the active list — draft wins on name collision, draft-only items appear, and each overlaid item is tagged `_draft: true` so UIs can badge the preview. Absent/false = published world only. Declaration ≠ authorization: this member only switches which rows are read, and ADR-0106 masking is unaffected — callers without draft-preview authorization are refused upstream (admin-gated), not by this schema. | @@ -1854,7 +1849,6 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name | -| **organizationId** | `string` | optional | Organization (tenant) partition the change log is read from. Absent means the env-wide partition (the implementation resolves the overlay repository as `organizationId ?? null`, keyed `org: 'env'`). Declared because the implementation declares and reads it; plain `string`, not nullable, because that is the implementation's parameter type — unlike the audit twin, whose door always sends `ctx?.tenantId ?? null` and whose implementation declares `string \| null`. The REST history door currently sends no organization at all (whether it should is a tenant-scoping question measured separately for that door — declaring the member records the implementation contract, it does not answer that question). | | **sinceSeq** | `number` | optional | Exclusive lower bound on `seq` for pagination: only events with `seq > sinceSeq` are returned. Absent means "from the beginning". | | **limit** | `number` | optional | Maximum events to return, oldest first. Forwarded to the repository unclamped and with NO default — absent means the full remaining change log. (Deliberately no declared bounds: unlike the audit twin's [1, 500] clamp, nothing on this path clamps or refuses, so declaring `.min()`/`.max()` here would refuse values the shipped verb accepts.) | @@ -2086,7 +2080,7 @@ Install package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **drafts** | `{ type: string; name: string; label: string \| Record \| null; organizationId: string \| null; … }[]` | ✅ | Every pending draft visible to the caller, one row per item. | +| **drafts** | `{ type: string; name: string; label: string \| Record \| null; packageId: string \| null; … }[]` | ✅ | Every pending draft visible to the caller, one row per item. | ### Nested Shape: `ListDraftsResponse.drafts[number]` @@ -2095,7 +2089,6 @@ Install package response | **type** | `string` | ✅ | Metadata type name (canonical singular). | | **name** | `string` | ✅ | Item name. | | **label** | `string \| Record \| null` | ✅ | The draft body's own top-level `label`, as authored: a plain string or an inline locale map (`I18nLabel`), resolved by the reader. `null` when the body declares none, or none this shape admits — never the item name standing in for it. | -| **organizationId** | `string \| null` | ✅ | Owning organization of the draft row, `null` for an environment-wide draft. | | **packageId** | `string \| null` | ✅ | Package the draft is bound to, `null` for a package-less draft. | | **updatedAt** | `string \| null` | ✅ | Last-touch timestamp of the draft row (ISO-8601 string), `null` when the row recorded none. | | **updatedBy** | `string \| null` | ✅ | Who last touched the draft, `null` when the row recorded none. | diff --git a/packages/metadata-core/src/index.ts b/packages/metadata-core/src/index.ts index 8328313261b..4b2f072d15b 100644 --- a/packages/metadata-core/src/index.ts +++ b/packages/metadata-core/src/index.ts @@ -95,13 +95,6 @@ export * from './object-schema-fls.js'; // `ITEM_KEY_DISCRIMINATORS` from `registry.ts`, so its surface is unchanged. export * from './item-key-discriminators.js'; -// [#9454 · ADR-0131 D6] The registry-derived per-organization overlay -// predicate (`declaresOrgOverride`) and the organization a protocol READ -// carries (`organizationIdForMetaRead`). The write-side twin retired with the -// per-organization overlay axis: the `/meta` doors carry no organization into -// a metadata write. See the module header for what still reads through it. -export * from './meta-write-org-scope.js'; - // [#12702 · ADR-0131 D6] Which CALLERS a `/meta` item write door admits: // `manage_metadata` (or `isSystem`), on both transports. The org-scoped // `manage_org_presentation` arm retired with the per-organization overlay diff --git a/packages/metadata-core/src/meta-write-org-scope.test.ts b/packages/metadata-core/src/meta-write-org-scope.test.ts deleted file mode 100644 index f9f676e73c9..00000000000 --- a/packages/metadata-core/src/meta-write-org-scope.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// Commit 26f3588fb — `declaresOrgOverride` and the boundary fold, pinned as ONE -// composed contract. -// -// The predicate tolerates the MANIFEST-collection spellings only; the URL -// spelling contract is larger (`META_URL_TO_SINGULAR` also carries every -// registry-derived spelling). The two disagreed for `translations` / -// `email_templates`, and the REST doors — which held raw URL segments — -// consulted the predicate directly, so those two spellings read and wrote -// env-wide while their singular twins were org-scoped. -// -// The correction is at the boundary: callers fold through -// `canonicalMetaUrlType` BEFORE asking. These cases pin BOTH halves: -// -// 1. the COMPOSED contract (fold → predicate) answers the registry flag -// for every spelling in the URL map — the property the doors rely on; -// 2. the predicate itself does NOT absorb the URL map — the repair -// `metadata-url-spelling.ts` forbids ("nothing here should ever be -// consulted by a predicate one layer down"). If a future change widens -// the predicate, this pin turns red so the widening is argued against -// commit 26f3588fb / #7894 rather than slipped in as a convenience. - -import { describe, it, expect } from 'vitest'; -import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; -import { META_URL_TO_SINGULAR, canonicalMetaUrlType } from '@objectstack/spec/meta-spelling'; -import * as metadataCore from './index.js'; -import { - declaresOrgOverride, - organizationIdForMetaRead, -} from './meta-write-org-scope.js'; - -const ORG = 'org_alpha'; - -const REGISTRY_FLAG = new Map( - DEFAULT_METADATA_TYPE_REGISTRY.map((e) => [e.type, e.allowOrgOverride === true]), -); - -describe('#10340 org scope composed with the boundary fold', () => { - it('fold → predicate answers the registry flag for EVERY spelling in the URL map', () => { - // The contract every REST door relies on after commit 26f3588fb: whatever the - // caller spelled, folding first yields the canonical type's own - // declaration. Quantified over the whole map so a new spelling limb - // arrives already covered. - for (const [spelling, folded] of Object.entries(META_URL_TO_SINGULAR)) { - const expected = REGISTRY_FLAG.get(folded) === true ? ORG : undefined; - expect( - organizationIdForMetaRead(canonicalMetaUrlType(spelling), ORG), - `composed read scope for '${spelling}' (folds to '${folded}')`, - ).toBe(expected); - } - }); - - it('the predicate alone still answers the canonical singular correctly', () => { - for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { - expect( - declaresOrgOverride(entry.type), - `declaresOrgOverride('${entry.type}')`, - ).toBe(entry.allowOrgOverride === true); - } - }); - - it('⛔ the predicate does NOT absorb the URL map — URL-only spellings answer false raw', () => { - // The documented limit, pinned so it cannot erode silently. These are - // registry-derived URL spellings with no manifest key; the BOUNDARY - // folds them, the predicate must not learn them (#7894's forbidden - // repair, one layer down). A deliberate future widening flips this - // pin consciously, against the recorded rationale — never as a - // side effect. - expect(declaresOrgOverride('translations')).toBe(false); - expect(declaresOrgOverride('email_templates')).toBe(false); - // The manifest tolerance it DOES carry, kept for dispatcher-era - // callers, stays: manifest spellings of overridable types hold. - expect(declaresOrgOverride('views')).toBe(true); - expect(declaresOrgOverride('emailTemplates')).toBe(true); - }); -}); - -describe('ADR-0131 D6 — the write-side twin retired', () => { - it('`organizationIdForMetaWrite` is no longer exported: no `/meta` door threads an organization into a write', () => { - expect('organizationIdForMetaWrite' in metadataCore).toBe(false); - // Control: the read gate the protocol still runs is exported from the same barrel. - expect('organizationIdForMetaRead' in metadataCore).toBe(true); - }); -}); diff --git a/packages/metadata-core/src/meta-write-org-scope.ts b/packages/metadata-core/src/meta-write-org-scope.ts deleted file mode 100644 index 17a23e3b87c..00000000000 --- a/packages/metadata-core/src/meta-write-org-scope.ts +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * The registry-derived "does this type declare a per-organization overlay?" - * predicate, and the organization a metadata READ inside the protocol carries. - * - * ── ADR-0131 D6: the doors carry no organization (C5 stage S3) ──────────── - * - * The per-organization overlay axis is retired. Neither transport's `/meta` - * doors (`@objectstack/rest`, and the runtime dispatcher's `/meta` branch) - * carry an organization into a metadata write or read any more: every - * Studio-authored write lands environment-wide (`organization_id` NULL), and - * every door read resolves environment → code. The write-side twin that used - * to decide which writes carried the session's organization, - * `organizationIdForMetaWrite`, therefore had no caller left and was deleted. - * - * What stays, and why. `@objectstack/metadata-protocol` still gates the reads - * an in-process caller hands an organization (`getMetaItem`, `getMetaItems`, - * the layered read, history and diff) through {@link organizationIdForMetaRead}. - * The one door that still names an organization is the anonymous form door, - * whose read of the Default Organization's withdrawals stays, fail-closed, - * until the promotion ceremony carries those rows to the environment layer - * (ADR-0131 C7). The protocol's own read narrowing (environment → code - * everywhere) is a later stage of the same retirement, and deletes this - * module with it. - * - * ⛔ Registry-derived, never a hand-written list (Prime Directive #8): the set - * below is computed from `DEFAULT_METADATA_TYPE_REGISTRY`, so a registry entry - * flipping `allowOrgOverride` moves this predicate with it. - */ - -import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; -import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; - -/** - * Metadata types whose registry entry declares `allowOrgOverride: true`, - * augmented with each one's MANIFEST plural spelling (`SINGULAR_TO_PLURAL`). - * - * ⚠️ [commit 26f3588fb] That augmentation is NOT the protocol's URL fold, and the doc - * that used to stand here — "judged identically to the singular form — the - * same normalization the protocol's own allow-list does" — was measured - * false. The protocol folds through `META_URL_TO_SINGULAR`, the COMPLETE - * spelling map; `SINGULAR_TO_PLURAL` is the manifest-collection map, which is - * incomplete by design (`translation` and `email_template` have no manifest - * key, so `translations` / `email_templates` are not in this set). Handed a - * raw URL segment this predicate therefore answered env-wide for those two - * spellings while storage folded them into an org-scoped type — one item, - * two partitions, addressed by spelling. - * - * The correction landed at the boundary, not here: a caller folds the - * segment through `canonicalMetaUrlType` BEFORE the scope decision, - * exactly as `metadata-url-spelling.ts` mandates ("folding happens at the - * boundary and only there; the layers below keep reading the single - * canonical singular"). ⛔ Do not "complete" this set with the URL map — a - * predicate below the boundary consuming the URL spelling contract is the - * repair that module's header forbids, and #7894 already refused once. - */ -const ORG_OVERRIDABLE_TYPES: ReadonlySet = (() => { - const out = new Set(); - for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { - if (!entry.allowOrgOverride) continue; - out.add(entry.type); - const plural = SINGULAR_TO_PLURAL[entry.type]; - if (plural) out.add(plural); - } - return out; -})(); - -/** - * Does the registry declare this metadata type per-org overridable? - * - * Expects the CANONICAL singular type. It additionally tolerates the - * manifest-collection spellings (`views`, `emailTemplates`, …) — kept for the - * dispatcher-era callers — but ⚠️ [commit 26f3588fb] that tolerance is NOT the URL - * fold: URL-only spellings (`translations`, `email_templates`) answer - * `false` here. A caller holding a raw `/meta/:type` segment must fold it - * through `canonicalMetaUrlType` BEFORE asking; see - * `ORG_OVERRIDABLE_TYPES` above for the measurement and for why this - * predicate must not grow the URL map itself. - * - * A type with no registry entry at all — runtime-registered plugin types — - * answers `false`: boot hydration has no per-org channel for them either, so - * an org-scoped row would be the same phantom. - */ -export function declaresOrgOverride(type: string): boolean { - const singular = PLURAL_TO_SINGULAR[type] ?? type; - return ORG_OVERRIDABLE_TYPES.has(singular) || ORG_OVERRIDABLE_TYPES.has(type); -} - -/** - * [#9454] The `organizationId` a metadata READ of `type` should carry, given - * the caller's organization. - * - * ── Why a read door has to ask this at all ──────────────────────────────── - * - * The (since retired) write-side twin stopped the runtime MINTING org-scoped - * rows for types that have no per-org read channel. It says nothing about serving - * the rows that types WITH such a channel legitimately produce — and the REST - * `/meta` read doors were never told. A `PUT` of an org-overridable type - * (`view`, `dashboard`, `report`, `translation`, `email_template`) landed an - * org-scoped row, answered `200 state:'active'`, and then every REST read door - * asked for the row WITHOUT naming an organization. `getMetaItem` resolves - * `(orgId ? findOverlay(orgId) : undefined) ?? findOverlay(null)`, so an - * org-less read resolves the env-wide row only: the author's work was - * persisted, receipted as live, and served by nothing. That is #9454. - * - * ── Why it is registry-derived and NOT a bare `ctx?.tenantId` ───────────── - * - * ⛔ The tempting shorter fix — pass the active org at every read site — is - * wrong in a way that only shows on databases with history. Deployments that - * ran before the #6190 ruling contain PHANTOM org-scoped rows for types the - * registry declares non-overridable (`object`, `flow`, … — the runtime used to - * stamp `organization_id` on every type; `reportUnhydratableOrgScopedRows` is - * the audit that warns about the survivors). Boot hydration walks past those - * rows deliberately, so they are dead. A read door that named the org for - * EVERY type would resolve them again — resurrecting, on the read side, exactly - * the phantom writes #6190 stopped minting, and serving a document that - * vanishes at the next restart. Gating the read on the same static registry - * flag keeps the two sides answering one question. - * - * Since ADR-0131 D6 the `/meta` doors hand the protocol no organization, so - * this gate answers `undefined` for every door read; see the module header for - * the in-process callers that still name one. - * - * Returns the active org for a type the registry declares per-org overridable, - * and `undefined` — env-wide, today's behaviour for every read — otherwise. - * An anonymous or org-less caller reads exactly what it reads today. - */ -export function organizationIdForMetaRead( - type: string, - activeOrganizationId: string | undefined, -): string | undefined { - if (activeOrganizationId === undefined) return undefined; - return declaresOrgOverride(type) ? activeOrganizationId : undefined; -} diff --git a/packages/metadata-protocol/src/get-meta-item-cached-etag-scope.test.ts b/packages/metadata-protocol/src/get-meta-item-cached-etag-scope.test.ts deleted file mode 100644 index 57237a4f51a..00000000000 --- a/packages/metadata-protocol/src/get-meta-item-cached-etag-scope.test.ts +++ /dev/null @@ -1,347 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#16525] WHICH organization value `getMetaItemCached` folds into the ETag — - * the SUPPLIED request member, or the EFFECTIVE scope the registry read gate - * returns — and why the answer is not the defect the card feared. - * - * ── The question, and the measurement ───────────────────────────────────── - * - * The card put two facts side by side. `organizationIdForMetaRead(type, org)` - * answers `undefined` for a supplied organization whenever the registry - * declares `allowOrgOverride: false`, and `getMetaItem` then resolves - * `(orgId ? findOverlay(orgId) : undefined) ?? findOverlay(null)` — so a - * gated-away organization is served the env-wide record. `getMetaItemCached` - * delegates to that. ⇒ If the ETag names the SUPPLIED organization while the - * body is the env-wide representation, the validator states a scope that was - * never served. - * - * MEASURED, and the answer is the SUPPLIED member: - * - * const scope = [ - * request.organizationId ? `org:${request.organizationId}` : undefined, - * request.locale || undefined, - * ].filter(...); - * - * §1 reproduces the divergence end to end — `object` is `allowOrgOverride: - * false`, a supplied organization is gated away, the env-wide record is - * served, and the validator still names the organization. - * - * ── ⭐ Why that is a COST and not a correctness fault ────────────────────── - * - * Because `content` — the serialized document actually being sent — is inside - * the same hash. The validator is therefore a function of the bytes, not only - * of the declared scope, so the harm the card names ("a validator claiming to - * describe an org-scoped representation that was never served") cannot reach - * a caller: a `304` is answered only on an exact match of a hash that covers - * those bytes, so a caller is only ever pinned to the representation IT - * previously received. §3 pins that, and it is the half a future change is - * most likely to break silently — hashing a cheap version marker instead of - * the document would leave §1 and §2 green and destroy the whole argument. - * - * What is left is validator FRAGMENTATION: N organizations reading one - * env-wide document through a non-overridable type hold N validators for - * byte-identical content. Wasteful, not wrong. - * - * ── ⚠️ §1 is a CHARACTERIZATION pin, not a prohibition ──────────────────── - * - * Folding the EFFECTIVE value instead would merge those validators and make - * the declared scope true. It is deliberately NOT done here: it changes every - * published ETag that carries an organization (one forced miss per caller per - * deploy) and buys nothing at the only production door, which already supplies - * the effective value (pinned in `@objectstack/rest`, - * `rest-server-meta-cached-etag-door-scope.test.ts`). ⇒ If §1 reddens, read - * #16525 before making it green: it means the fold moved, which is a decision, - * not a regression. - * - * ── Why the observation channel is a BODY-vs-VALIDATOR pair ─────────────── - * - * Asserting an ETag literal would pin the hash function, which is not what - * this card is about. Every assertion below compares two reads that differ in - * exactly one input, and asserts the served BODIES alongside the validators — - * so an ETag difference can never be attributed to a body difference nobody - * checked, and a body difference can never hide behind a validator nobody - * read. - */ - -import { describe, expect, it } from 'vitest'; -import { assertEngineFindOnePredicate, organizationIdForMetaRead } from '@objectstack/metadata-core'; -import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; -import { ObjectStackProtocolImplementation } from './protocol.js'; - -const ORG = 'org_acme'; -const OTHER_ORG = 'org_globex'; - -/** `allowOrgOverride: false` — a supplied organization is gated away. */ -const NON_OVERRIDABLE = 'object'; - -/** `allowOrgOverride: true` — a supplied organization survives the gate. */ -const OVERRIDABLE = 'view'; - -interface StoredRow { - id: string; - type: string; - name: string; - organization_id: string | null; - package_id: string | null; - state: string; - metadata: string; -} - -/** - * A row whose `label` names the partition it came from, so a served document - * says which row answered without the assertion having to guess. - */ -const storedRow = ( - type: string, - name: string, - extra: Partial = {}, -): StoredRow => ({ - id: `r_${type}_${name}_${extra.organization_id ?? 'env'}`, - type, - name, - organization_id: null, - package_id: null, - state: 'active', - metadata: JSON.stringify({ name, label: `${extra.organization_id ?? 'env'} ${name}` }), - ...extra, -}); - -/** - * The engine double: `findOne` and `find` over a row table, plus the registry - * surface the single-item read path touches on its way past the overlay. - * - * ⛔ No `insert` / `update` / `delete`, deliberately — the read path under test - * issues two read verbs (`findOne` for the served row, and since #21761 `find` - * for the rows its lock is selected from), and a double declaring verbs no - * case exercises would owe `check:engine-double-contract` a dispatch contract - * that protects nothing. Same shape the sibling org-read-gate pin drives. - */ -function makeHarness(rows: StoredRow[]) { - const matching = (table: string, opts?: { where?: Record }): StoredRow[] => { - if (table !== 'sys_metadata') return []; - const where = opts?.where ?? {}; - // `check:where-matcher` — a hand-written matcher with no combinator - // branch reads `$and` as a field name and answers the wrong - // question rather than failing. Refuse the shape this double does - // not implement, matching the sibling doubles' convention. - for (const k of Object.keys(where)) { - if (k.startsWith('$')) { - throw new Error(`[test double] unsupported WHERE combinator '${k}'`); - } - } - return rows.filter((r) => - Object.entries(where).every(([k, v]) => { - if (v === undefined) return true; - return (r as unknown as Record)[k] === v; - }), - ); - }; - const engine: any = { - async findOne(table: string, opts?: { where?: Record }) { - // `check:engine-double-contract` — refuses what the real engine refuses. - assertEngineFindOnePredicate(table, opts); - if (table !== 'sys_metadata') return undefined; - return matching(table, opts)[0]; - }, - async find(table: string, opts?: { where?: Record }) { - return matching(table, opts); - }, - registry: { - registerItem: () => undefined, - registerObject: () => undefined, - listItems: () => [], - getItem: () => undefined, - getObject: () => undefined, - getPackage: () => undefined, - getArtifactItem: () => undefined, - isPackageDisabled: () => false, - applyNavContributions: (app: unknown) => app, - }, - }; - return new ObjectStackProtocolImplementation(engine, () => new Map()) as any; -} - -/** The label the served document carries — `'env …'` or `'org_acme …'`. */ -const servedLabel = (res: any): string => res?.data?.label; - -// ═══════════════════════════════════════════════════════════════════════════ -// §0 — the registry facts every section below rests on, read not assumed -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§0 the fixture types really are on opposite sides of the read gate', () => { - it(`${NON_OVERRIDABLE} is declared allowOrgOverride: false`, () => { - const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === NON_OVERRIDABLE); - expect(entry, `no registry entry for '${NON_OVERRIDABLE}'`).toBeDefined(); - expect(entry!.allowOrgOverride).toBe(false); - expect(organizationIdForMetaRead(NON_OVERRIDABLE, ORG)).toBeUndefined(); - }); - - it(`${OVERRIDABLE} is declared allowOrgOverride: true`, () => { - const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === OVERRIDABLE); - expect(entry, `no registry entry for '${OVERRIDABLE}'`).toBeDefined(); - expect(entry!.allowOrgOverride).toBe(true); - expect(organizationIdForMetaRead(OVERRIDABLE, ORG)).toBe(ORG); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §1 — (a) WHICH VALUE. The ETag folds the SUPPLIED member. -// ⚠️ Characterization. If this reddens, read #16525 before greening it. -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§1 the ETag names the SUPPLIED organization, not the effective scope', () => { - it('a gated-away organization is absent from the BODY and present in the VALIDATOR', async () => { - // A pre-#6190 phantom org row sits beside the env-wide one. The gate is - // what keeps it out of the response; without it this case would prove - // nothing about the ETag, because the two reads would differ in body. - const protocol = makeHarness([ - storedRow(NON_OVERRIDABLE, 'customer'), - storedRow(NON_OVERRIDABLE, 'customer', { organization_id: ORG, id: 'phantom' }), - ]); - - const supplied = await protocol.getMetaItemCached({ - type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, - }); - const orgless = await protocol.getMetaItemCached({ - type: NON_OVERRIDABLE, name: 'customer', - }); - - // ⭐ The control that makes the validator assertion mean anything: the - // gate held, so BOTH reads were answered by the env-wide row and the - // phantom was not served. - expect(servedLabel(supplied), 'the phantom org row was served').toBe('env customer'); - expect(servedLabel(orgless)).toBe('env customer'); - expect(JSON.stringify(supplied.data)).toBe(JSON.stringify(orgless.data)); - - // ⇒ Byte-identical representations, different validators. The scope - // component is the request member, unreduced by the read gate. - expect( - supplied.etag.value, - 'the ETag no longer distinguishes a gated-away organization — see #16525', - ).not.toBe(orgless.etag.value); - }); - - it('two organizations gated away from the same document hold two validators', async () => { - const protocol = makeHarness([storedRow(NON_OVERRIDABLE, 'customer')]); - - const a = await protocol.getMetaItemCached({ - type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, - }); - const b = await protocol.getMetaItemCached({ - type: NON_OVERRIDABLE, name: 'customer', organizationId: OTHER_ORG, - }); - - expect(JSON.stringify(a.data)).toBe(JSON.stringify(b.data)); - // The fragmentation the card names. Wasteful, and harmless for the - // reason §3 pins. - expect(a.etag.value).not.toBe(b.etag.value); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §2 — (b) REACHABILITY of the fallback the fold was written against -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§2 the org-resolved-then-env-wide fallback is present, not future', () => { - /** - * The fold's own comment argued from "any FUTURE path that resolves an org - * row but falls back to the env-wide body". That path is the `??` in - * `getMetaItem` and it runs today on an OVERRIDABLE type whose - * organization simply has no row — reachable through the production - * cached door, which forwards the organization for exactly these types. - */ - it('an overridable type with no org row serves env-wide under an org-named validator', async () => { - const protocol = makeHarness([storedRow(OVERRIDABLE, 'account_list')]); - - const supplied = await protocol.getMetaItemCached({ - type: OVERRIDABLE, name: 'account_list', organizationId: ORG, - }); - const orgless = await protocol.getMetaItemCached({ - type: OVERRIDABLE, name: 'account_list', - }); - - // The organization survived the gate here — and still resolved nothing, - // so the env-wide row answered. - expect(organizationIdForMetaRead(OVERRIDABLE, ORG)).toBe(ORG); - expect(servedLabel(supplied)).toBe('env account_list'); - expect(JSON.stringify(supplied.data)).toBe(JSON.stringify(orgless.data)); - expect(supplied.etag.value).not.toBe(orgless.etag.value); - }); - - it('the same type WITH an org row serves it, so the fallback above was a real fallback', async () => { - // ⭐ Without this control the case above is indistinguishable from a - // harness that can never resolve an org row at all. - const protocol = makeHarness([ - storedRow(OVERRIDABLE, 'account_list'), - storedRow(OVERRIDABLE, 'account_list', { organization_id: ORG, id: 'org_row' }), - ]); - - const supplied = await protocol.getMetaItemCached({ - type: OVERRIDABLE, name: 'account_list', organizationId: ORG, - }); - expect(servedLabel(supplied)).toBe(`${ORG} account_list`); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §3 — ⭐ WHY §1 IS A COST AND NOT A FAULT: the served bytes are in the hash -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§3 the validator is a function of the served bytes, not of the scope alone', () => { - it('an unchanged document answers 304 to the validator it issued', async () => { - // ⭐ The control that must FIRE. Every assertion below reads "no 304"; - // if the 304 arm were unreachable in this harness they would all pass - // for the wrong reason. - const rows = [storedRow(NON_OVERRIDABLE, 'customer')]; - const protocol = makeHarness(rows); - - const first = await protocol.getMetaItemCached({ - type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, - }); - const again = await protocol.getMetaItemCached({ - type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, - cacheRequest: { ifNoneMatch: `"${first.etag.value}"` }, - }); - - expect(again.notModified, 'the 304 arm is unreachable in this harness').toBe(true); - }); - - it('a changed document never answers 304, though the scope is unchanged', async () => { - // This is the invariant the whole "harmless" argument rests on. A hash - // over the scope and a cheap version marker — a plausible optimization — - // leaves §1 and §2 green and turns this red. - const rows = [storedRow(NON_OVERRIDABLE, 'customer')]; - const protocol = makeHarness(rows); - - const first = await protocol.getMetaItemCached({ - type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, - }); - - rows[0].metadata = JSON.stringify({ name: 'customer', label: 'env customer REVISED' }); - - const after = await protocol.getMetaItemCached({ - type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, - cacheRequest: { ifNoneMatch: `"${first.etag.value}"` }, - }); - - expect(after.notModified, 'a stale validator was honoured across a body change').toBe(false); - expect(servedLabel(after)).toBe('env customer REVISED'); - expect(after.etag.value).not.toBe(first.etag.value); - }); - - it("one organization's validator is never honoured for another's request", async () => { - const protocol = makeHarness([storedRow(NON_OVERRIDABLE, 'customer')]); - - const mine = await protocol.getMetaItemCached({ - type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, - }); - const theirs = await protocol.getMetaItemCached({ - type: NON_OVERRIDABLE, name: 'customer', organizationId: OTHER_ORG, - cacheRequest: { ifNoneMatch: `"${mine.etag.value}"` }, - }); - - expect(theirs.notModified).toBe(false); - expect(servedLabel(theirs)).toBe('env customer'); - }); -}); diff --git a/packages/metadata-protocol/src/get-meta-item-layered-org-read-gate.test.ts b/packages/metadata-protocol/src/get-meta-item-layered-org-read-gate.test.ts deleted file mode 100644 index 22caf11e94f..00000000000 --- a/packages/metadata-protocol/src/get-meta-item-layered-org-read-gate.test.ts +++ /dev/null @@ -1,511 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [commit e1d4f9e3f] `getMetaItemLayered` — the THIRD `/meta` read verb — applies the - * registry read gate ITSELF, so a caller cannot spend a raw active - * organization on a type that has no per-org read channel. - * - * ── The defect, and why this verb was graded on its own harm ────────────── - * - * The series is #9454 → commit 96326040f (plural `getMetaItems`) → commit d5cbb44f3 (singular - * `getMetaItem`) → this one. On the plural verb an ungated organization can - * only ADD a row; on the singular verb it SUBSTITUTES the served document. - * Here the affected value is the `overlay` LAYER of a three-layer diagnostic - * whose entire purpose is to answer "what did this tenant customize" — so a - * pre-#6190 phantom org-scoped row of an `allowOrgOverride: false` type is - * reported as `overlay` + `overlayScope: 'org'` and rendered by the Studio - * "Code default vs Overlay vs Effective" diff tab as evidence of a - * customization that does not exist. §1 is that case. - * - * ⚠️ It is not merely displayed. TWO doors return that layer AS the response - * when it is non-null — `runtime/src/domains/meta.ts` and `rest-server.ts`'s - * `/meta/:type/:name/published` — so on those paths the phantom is SERVED. - * §6 pins the predicate those two branches read. - * - * ── ⭐ §3 is the section that does not exist on either twin ─────────────── - * - * In both twins the fix is "replace the `orgId` binding with the gated call", - * and it is correct there because the binding already sat AFTER - * `canonicalizeMetaRequestType`. Here the binding sat BEFORE the fold, so the - * one-liner does NOT port: dropping the same expression in place would gate on - * the RAW type. Commit 26f3588fb measured what that costs — `declaresOrgOverride` - * tolerates the MANIFEST plurals but not the URL-only ones (`translations` / - * `email_templates` have no manifest key), so a raw segment splits one item - * across two partitions. The fix is therefore a REORDER, and §3 is what fails - * if a later author moves the binding back above the fold: it asserts that a - * URL-only spelling of an OVERRIDABLE type still reaches its org partition. - * - * ── §4–§5 are the idempotence proof the ruling behind commit 96326040f made this conditional - * on, discharged over THIS door's caller population ────────────────────── - * - * That ruling makes a callee-side gate conditional on proving no already-gating - * caller is double-scoped or wrongly denied, discharged PER DOOR over that - * door's own callers. Commit d5cbb44f3's proof covers none of this verb's population, so - * it is re-discharged here: §4 covers `f(t, undefined) === undefined` (the four - * `plugin-security` invocations that name no organization) and §5 covers - * `f(t, f(t, o)) === f(t, o)` over the COMPLETE accepted-spelling population - * (the `/layers` door, which gates on the folded type and passes the raw - * segment), including the direction that idempotence must NOT be achieved by - * denying everyone. - * - * ── Why the observation channel is the WHERE multiset AND the layer ─────── - * - * `partitions()` reads the `organization_id` partitions the engine was asked - * for — the whole of what this change moves — so it is body-independent and - * covers every declared type. The layer assertions pay for that by naming the - * real `overlay` / `overlayScope` on both sides of the gate, so neither half - * rests on a query nobody proved returns a row. - */ - -import { describe, expect, it } from 'vitest'; -import { declaresOrgOverride, organizationIdForMetaRead } from '@objectstack/metadata-core'; -import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; -import { META_URL_TO_SINGULAR, canonicalMetaUrlType } from '@objectstack/spec/shared'; -import { ObjectStackProtocolImplementation } from './protocol.js'; - -const ORG = 'org_acme'; - -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, - extra: Partial = {}, -): StoredRow => ({ - id: `r_${type}_${name}_${extra.organization_id ?? 'env'}_${extra.state ?? 'active'}`, - type, - name, - organization_id: null, - package_id: null, - state: 'active', - // `label` is the observation channel for WHICH row became the layer — the - // two rows of a pair differ only by scope, so a served label names its - // origin. - metadata: JSON.stringify({ name, label: `${extra.organization_id ?? 'env'} ${name}` }), - ...extra, -}); - -/** - * The engine double: `findOne` over a row table, plus the registry surface the - * layered read touches on its way past the overlay. - * - * `find` is the second verb the read path issues, and only for a `view` name - * with no stored row of its own: the read then selects the stored view - * containers that might expand that name, through the list read's own row - * selection (#21442). It records into `finds` — the same partitions question - * asked of that read. ⛔ No `insert` / `update` / `delete`, deliberately — a - * double declaring verbs no case exercises would owe - * `check:engine-double-contract` a dispatch contract that protects nothing. Same shape the two sibling read-gate pins drive. - */ -function makeHarness(rows: StoredRow[]) { - const findOnes: Array> = []; - const finds: Array> = []; - const matching = (where: Record) => { - // `check:where-matcher` — a hand-written matcher with no combinator - // branch reads `$and` as a field name and answers the wrong question - // rather than failing. Refuse the shape this double does not - // implement, matching the sibling doubles' convention. - for (const k of Object.keys(where)) { - if (k.startsWith('$')) { - throw new Error(`[test double] unsupported WHERE combinator '${k}'`); - } - } - return rows.filter((r) => - Object.entries(where).every(([k, v]) => { - if (v === undefined) return true; - return (r as unknown as Record)[k] === v; - }), - ); - }; - const engine: any = { - async findOne(table: string, opts?: { where?: Record }) { - if (table !== 'sys_metadata') return undefined; - const where = opts?.where ?? {}; - findOnes.push({ ...where }); - return matching(where)[0]; - }, - registry: { - registerItem: () => undefined, - registerObject: () => undefined, - listItems: () => [], - getItem: () => undefined, - getObject: () => undefined, - getPackage: () => undefined, - getArtifactItem: () => undefined, - isPackageDisabled: () => false, - applyNavContributions: (app: unknown) => app, - }, - }; - engine.find = async (table: string, opts?: { where?: Record }) => { - if (table !== 'sys_metadata') return []; - const where = opts?.where ?? {}; - finds.push({ ...where }); - return matching(where); - }; - const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; - return { protocol, findOnes, finds }; -} - -/** Every `organization_id` partition the engine was asked for, deduplicated. */ -const partitions = (finds: Array>): Array => - [...new Set(finds.map((f) => (f.organization_id ?? null) as string | null))].sort( - (a, b) => String(a).localeCompare(String(b)), - ); - -/** The same, folded per canonical type. */ -function partitionsByType(finds: Array>): Map> { - const out = new Map>(); - for (const f of finds) { - const t = canonicalMetaUrlType(String(f.type)); - if (!out.has(t)) out.set(t, new Set()); - out.get(t)!.add((f.organization_id ?? null) as string | null); - } - return out; -} - -/** The label the `overlay` LAYER carries — `'env …'` or `'org_acme …'`. */ -const overlayLabel = (res: any): string | undefined => res?.overlay?.label; - -/** - * The COMPLETE accepted-spelling population: every URL spelling the `/meta` - * doors fold, unioned with every registry singular. Derived, never listed — - * a newly declared type arrives in this sweep on its own. - */ -const ALL_SPELLINGS: string[] = [ - ...new Set([ - ...Object.keys(META_URL_TO_SINGULAR), - ...DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type), - ]), -].sort(); - -/** The org-overridable canonical types, derived. */ -const OVERRIDABLE: readonly string[] = DEFAULT_METADATA_TYPE_REGISTRY - .filter((e) => e.allowOrgOverride) - .map((e) => e.type); - -// ═══════════════════════════════════════════════════════════════════════════ -// §0 — the population this rests on, pinned so a registry change is visible -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§0 the org-overridable set', () => { - it('is exactly the ADR-0005 tier-A five', () => { - expect([...OVERRIDABLE].sort()).toEqual( - ['dashboard', 'email_template', 'report', 'translation', 'view'], - ); - }); - - it("`object` — the type the reachable ungated callers can carry — is NOT overridable", () => { - expect(OVERRIDABLE).not.toContain('object'); - expect(organizationIdForMetaRead('object', ORG)).toBeUndefined(); - }); - - it("`permission` — the type every `plugin-security` call site hard-codes — is NOT overridable", () => { - // The premise of the third moving caller named in the gate's comment: - // `permission-set-projection.ts` forwards `evt.organizationId` into - // this verb on `type: 'permission'`. If that flag ever flips, that - // caller stops moving and the comment must be re-derived. - expect(OVERRIDABLE).not.toContain('permission'); - expect(organizationIdForMetaRead('permission', ORG)).toBeUndefined(); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §1 — ⭐ THE CARD'S CASE: a phantom must not become the `overlay` layer -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§1 a pre-#6190 phantom is not reported as a customization', () => { - const rows = () => [ - // The live env-wide document. - storedRow('object', 'showcase_task'), - // The phantom: an org-scoped row of an `allowOrgOverride: false` type. - // `loadMetaFromDb` walks past it at boot, so it is dead — it exists - // only because the runtime used to stamp `organization_id` on every - // type before #6190. - storedRow('object', 'showcase_task', { organization_id: ORG }), - ]; - - it('reports the env-wide row to a caller that passes a RAW active organization', async () => { - const { protocol, findOnes } = makeHarness(rows()); - const res = await protocol.getMetaItemLayered({ - type: 'object', - name: 'showcase_task', - organizationId: ORG, - }); - expect(overlayLabel(res)).toBe('env showcase_task'); - // ⭐ The field the Studio diff tab renders as "this tenant customized - // it". A phantom must never reach it. - expect(res.overlayScope).toBe('env'); - // And the phantom's partition was never even read — the gate resolves - // to `undefined`, so the `if (orgId)` arm is skipped whole. - expect(partitions(findOnes)).toEqual([null]); - }); - - it('answers identically whether or not the caller names the organization', async () => { - const withOrg = makeHarness(rows()); - const withoutOrg = makeHarness(rows()); - const a = await withOrg.protocol.getMetaItemLayered({ - type: 'object', name: 'showcase_task', organizationId: ORG, - }); - const b = await withoutOrg.protocol.getMetaItemLayered({ - type: 'object', name: 'showcase_task', - }); - expect(overlayLabel(a)).toBe(overlayLabel(b)); - expect(a.overlayScope).toBe(b.overlayScope); - expect(partitions(withOrg.findOnes)).toEqual(partitions(withoutOrg.findOnes)); - }); - - it('holds for every non-overridable declared type, not just `object`', async () => { - const nonOverridable = DEFAULT_METADATA_TYPE_REGISTRY - .filter((e) => !e.allowOrgOverride) - .map((e) => e.type); - expect(nonOverridable.length).toBeGreaterThan(5); - for (const type of nonOverridable) { - const { protocol, findOnes } = makeHarness([ - storedRow(type, 'probe'), - storedRow(type, 'probe', { organization_id: ORG }), - ]); - const res = await protocol.getMetaItemLayered({ - type, name: 'probe', organizationId: ORG, - }); - expect(overlayLabel(res), `${type} reported the phantom`).toBe('env probe'); - expect(res.overlayScope, `${type} claimed an org customization`).toBe('env'); - expect(partitions(findOnes), `${type} read the org partition`).toEqual([null]); - } - }); - - it('reports NO overlay at all when the phantom is the only row', async () => { - // The sharpest shape: nothing env-wide exists, so before this gate the - // diagnostic answered `overlay: , overlayScope: 'org'` — a - // customization claim with no live document behind it at all. - const { protocol, findOnes } = makeHarness([ - storedRow('object', 'ghost', { organization_id: ORG }), - ]); - const res = await protocol.getMetaItemLayered({ - type: 'object', name: 'ghost', organizationId: ORG, - }); - expect(res.overlay).toBeNull(); - expect(res.overlayScope).toBeNull(); - expect(partitions(findOnes)).toEqual([null]); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §2 — the gate must not be achieved by denying everyone -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§2 an overridable type still reports its org-scoped customization', () => { - it('prefers the org row and says `overlayScope: org`', async () => { - const { protocol } = makeHarness([ - storedRow('view', 'task_list'), - storedRow('view', 'task_list', { organization_id: ORG }), - ]); - const res = await protocol.getMetaItemLayered({ - type: 'view', name: 'task_list', organizationId: ORG, - }); - expect(overlayLabel(res)).toBe(`${ORG} task_list`); - expect(res.overlayScope).toBe('org'); - }); - - it('falls back to the env-wide row when the org has no overlay of its own', async () => { - const { protocol, findOnes } = makeHarness([storedRow('view', 'task_list')]); - const res = await protocol.getMetaItemLayered({ - type: 'view', name: 'task_list', organizationId: ORG, - }); - expect(overlayLabel(res)).toBe('env task_list'); - expect(res.overlayScope).toBe('env'); - // BOTH partitions read — the org one first, the env-wide one as the - // fallback. (`partitions` returns a SORTED set, so this asserts - // membership, not read order.) - expect(partitions(findOnes)).toEqual([null, ORG]); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §3 — ⭐ THE REORDER: the gate reads the FOLDED type, never the raw segment -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§3 the gate resolves AFTER canonicalizeMetaRequestType', () => { - /** - * The URL-only spellings — accepted at `/meta/:type` and folded by this - * method's first statement, but absent from the manifest map - * `declaresOrgOverride` tolerates. Derived from the predicate itself, so - * this cannot go stale against a changed map: a spelling qualifies when it - * folds to an OVERRIDABLE canonical type and yet answers `false` raw. - */ - const URL_ONLY_OVERRIDABLE = ALL_SPELLINGS.filter( - (s) => declaresOrgOverride(canonicalMetaUrlType(s)) && !declaresOrgOverride(s), - ); - - it('the #10340 hazard is real on this population — it is not an empty sweep', () => { - // ⛔ Positive control. Without this the three assertions below could - // pass over zero spellings and read as proof of nothing. The card names - // `translations` and `email_templates`; the derivation must find them. - expect(URL_ONLY_OVERRIDABLE.length).toBeGreaterThan(0); - expect(URL_ONLY_OVERRIDABLE).toContain('translations'); - expect(URL_ONLY_OVERRIDABLE).toContain('email_templates'); - }); - - it('a URL-only spelling of an overridable type still reaches its org partition', async () => { - // ⭐ THE PIN THAT FAILS IF THE BINDING MOVES BACK ABOVE THE FOLD. - // Gated raw, `organizationIdForMetaRead('translations', ORG)` is - // `undefined` and this read never asks for the org partition — one item - // split across two partitions, addressed by spelling. Gated folded, it - // is `ORG` and the org row wins. - for (const spelling of URL_ONLY_OVERRIDABLE) { - const canonical = canonicalMetaUrlType(spelling); - const { protocol, findOnes } = makeHarness([ - storedRow(canonical, 'greeting'), - storedRow(canonical, 'greeting', { organization_id: ORG }), - ]); - const res = await protocol.getMetaItemLayered({ - type: spelling, name: 'greeting', organizationId: ORG, - }); - expect(overlayLabel(res), spelling).toBe(`${ORG} greeting`); - expect(res.overlayScope, spelling).toBe('org'); - // ONLY the org partition: the org row wins, so the `overlay === - // null` env fallback never runs. Gated on the raw segment this - // list is `[null]` instead — the partition split commit 26f3588fb measured. - expect(partitions(findOnes), spelling).toEqual([ORG]); - } - }); - - it('answers the URL spelling and the canonical spelling identically', async () => { - // The statement of commit 26f3588fb restated as an equality: one item, ONE - // partition, whichever accepted spelling addresses it. - for (const spelling of URL_ONLY_OVERRIDABLE) { - const canonical = canonicalMetaUrlType(spelling); - const rows = () => [ - storedRow(canonical, 'greeting'), - storedRow(canonical, 'greeting', { organization_id: ORG }), - ]; - const viaUrl = makeHarness(rows()); - const viaCanonical = makeHarness(rows()); - const a = await viaUrl.protocol.getMetaItemLayered({ - type: spelling, name: 'greeting', organizationId: ORG, - }); - const b = await viaCanonical.protocol.getMetaItemLayered({ - type: canonical, name: 'greeting', organizationId: ORG, - }); - expect(overlayLabel(a), spelling).toBe(overlayLabel(b)); - expect(a.overlayScope, spelling).toBe(b.overlayScope); - expect(partitions(viaUrl.findOnes), spelling) - .toEqual(partitions(viaCanonical.findOnes)); - } - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §4 — idempotence, leg 1: `f(t, undefined) === undefined` -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§4 a caller that names no organization is untouched', () => { - it('reads only the env-wide partition, for every accepted spelling', async () => { - // The population that covers the four `plugin-security` invocations - // naming no organization — `packaged-permission-set-lock-gate.ts`'s - // authoring gate and three of `permission-set-projection.ts`'s reads: - // the predicate short-circuits on `undefined` before it ever consults - // the registry flag. - for (const spelling of ALL_SPELLINGS) { - const { protocol, findOnes } = makeHarness([]); - await protocol.getMetaItemLayered({ type: spelling, name: 'probe' }) - .catch(() => undefined); - for (const [type, parts] of partitionsByType(findOnes)) { - expect([...parts], `${spelling} → ${type}`).toEqual([null]); - } - } - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §5 — idempotence, leg 2: `f(t, f(t, o)) === f(t, o)` over the whole -// accepted-spelling population -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§5 an already-gating caller receives the same scope it did before', () => { - it('the predicate is idempotent for every accepted spelling', () => { - for (const spelling of ALL_SPELLINGS) { - const once = organizationIdForMetaRead(spelling, ORG); - expect(organizationIdForMetaRead(spelling, once), spelling).toBe(once); - } - }); - - it('the REST `/layers` door gates on the string this method folds to', () => { - // The door computes `organizationIdForMetaRead(canonicalMetaUrlType( - // req.params.type), layeredCtx?.tenantId)` and then passes `type: - // req.params.type` — the RAW segment. This method's first statement - // folds that segment through `canonicalizeMetaRequestType`, which IS - // `canonicalMetaUrlType`. So the gate inside reads the identical STRING - // the door gated on, and the second application is the algebraic no-op. - for (const spelling of ALL_SPELLINGS) { - const doorGate = organizationIdForMetaRead(canonicalMetaUrlType(spelling), ORG); - const innerGate = organizationIdForMetaRead(canonicalMetaUrlType(spelling), doorGate); - expect(innerGate, spelling).toBe(doorGate); - } - }); - - it('a gated door reading an overridable type still reaches the org partition', async () => { - // The other direction of the no-op: a door that already resolved `ORG` - // for `view` gets the org partition read, exactly as before. - const gated = organizationIdForMetaRead('view', ORG); - expect(gated).toBe(ORG); - const { protocol, findOnes, finds } = makeHarness([]); - await protocol.getMetaItemLayered({ - type: 'view', name: 'probe', organizationId: gated, - }); - expect(partitions(findOnes)).toEqual([null, ORG]); - // [#21442] `probe` has no row of its own, so the read also selects the - // stored containers that might expand it — from the same partitions. - expect(partitions(finds)).toEqual([null, ORG]); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §6 — the two doors that return the `overlay` layer AS the response -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§6 the branch both serving doors read does not fire on a phantom', () => { - /** - * `runtime/src/domains/meta.ts` and `rest-server.ts`'s - * `/meta/:type/:name/published` both spell the same test: - * - * if (layered?.overlay !== undefined && layered?.overlay !== null) - * - * and then return `layered.overlay` as the response body. Both hand this - * method a RAW active organization on a `type` taken off the URL, so - * neither is confined to the overridable five. This asserts the predicate - * they read, on the input that used to make it true wrongly. - */ - const serveBranchFires = (layered: any): boolean => - layered?.overlay !== undefined && layered?.overlay !== null; - - it('does not fire for a non-overridable type whose ONLY row is an org phantom', async () => { - const { protocol } = makeHarness([ - storedRow('object', 'ghost', { organization_id: ORG }), - ]); - const layered = await protocol.getMetaItemLayered({ - type: 'object', name: 'ghost', organizationId: ORG, - }); - expect(serveBranchFires(layered)).toBe(false); - }); - - it('still fires for an overridable type with a real org-scoped publish', async () => { - // #8805's case, which the `/published` door exists to serve: a `view` - // published into the caller's organization must still be served. - const { protocol } = makeHarness([ - storedRow('view', 'task_list', { organization_id: ORG }), - ]); - const layered = await protocol.getMetaItemLayered({ - type: 'view', name: 'task_list', organizationId: ORG, - }); - expect(serveBranchFires(layered)).toBe(true); - expect(overlayLabel(layered)).toBe(`${ORG} task_list`); - }); -}); diff --git a/packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts b/packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts deleted file mode 100644 index 36b0647d9c7..00000000000 --- a/packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts +++ /dev/null @@ -1,451 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [commit d5cbb44f3] `getMetaItem` — the SINGULAR verb — applies the registry read gate - * ITSELF, so a caller cannot spend a raw active organization on a type that has - * no per-org read channel. - * - * ── The defect, and why the singular verb is the sharper half ───────────── - * - * Commit 96326040f moved {@link organizationIdForMetaRead} INSIDE the PLURAL - * verb, `getMetaItems`, and deliberately did not carry to this one. There, the - * two `queryByOrg` reads are UNIONed, so an ungated organization can only ADD - * rows — the resurrection that card is about. Here the two `findOverlay` reads - * combine with `??`, which is PRECEDENCE, so an ungated organization can - * SUBSTITUTE: - * - * const record = (orgId ? await findOverlay(orgId) : undefined) - * ?? await findOverlay(null); - * - * On a type the registry declares `allowOrgOverride: false`, a pre-#6190 - * phantom org-scoped row — the kind `loadMetaFromDb` walks past and - * `reportUnhydratableOrgScopedRows` exists to warn about — was served INSTEAD - * OF the live env-wide document, to a caller that asked for the live one. Not - * an extra row in a list: the served document. §1 is that case, and it is the - * case this whole card exists for. - * - * ── ⛔ §2 is the half that must NOT move ────────────────────────────────── - * - * The card's title names the `??` as precedence-not-union, and that is the - * DEFECT STATEMENT, not a licence to convert the combinator. - * - * ⚠️ Each citation at its real scope. ADR-0005's decision block DOES name this - * method — `RUNTIME READ getMetaItem(type, name)` → `1. sys_metadata … ← - * overlay (wins)`, `2. SchemaRegistry / MetadataService ← artifact default` — - * but the pair it ranks is overlay-vs-artifact-default, not - * org-row-vs-env-wide-row. It settles that an overlay WINS rather than merges. - * The inner precedence rests on two other things: ADR-0005 design principle 3 - * stores the ENTIRE item document per overlay row (so a layering has nothing - * to layer), and the field-level patch model that would have given "layering" - * any meaning was retired and deleted whole under ADR-0049 (ADR-0005 principle 3's correction, commit 9e0ba21a1, - * maintainer ruling 2026-08-29), with ADR-0126 §6 ruling out the phase it was - * held for; and `organizationIdForMetaRead`'s own docblock quotes this very - * expression as the intended shape while defining #9454. ADR-0029 D9 reaches - * the same shape one type over — quoted, not paraphrased: `resolveObject` - * "selects its base layer as `overlay ?? owner` instead of `owner`" — though - * its status line reads "Design only — nothing is implemented yet", so it - * corroborates rather than rules. ⇒ §2 pins precedence for a type that - * legitimately HAS a per-org channel, so a future reading of the title as - * "make it a union" fails a test instead of landing. - * - * ── §3–§5 are the idempotence proof the ruling made this conditional on ─── - * - * Direction "callee-side" was ruled conditional on demonstrating that moving - * the predicate INSIDE does not change the scope any already-gating call site - * receives. §3 discharges `f(t, undefined) === undefined`; §4 discharges - * `f(t, f(t, o)) === f(t, o)` over the COMPLETE population of accepted URL - * spellings rather than a hand-listed sample, so a newly declared type or a - * changed fold cannot slip past it; §5 pins that ONE binding serves BOTH arms - * (the ADR-0033 `previewDrafts` read and the active-overlay read), which is - * the half-fix shape #9454's hoist comment refuses one door over. - * - * ── Why the observation channel is the WHERE multiset ───────────────────── - * - * §4 seeds NO rows and reads the `organization_id` partitions the engine was - * asked for. That is the whole of what this change moves — which partitions - * are read — and observing it directly keeps the assertion body-independent, - * so it covers every declared type instead of the handful with a - * hand-written schema-valid body. §1/§2 pay for that by asserting the real - * SERVED DOCUMENT on both sides of the gate, so neither half rests on a query - * nobody proved returns a row. - */ - -import { describe, expect, it } from 'vitest'; -import { organizationIdForMetaRead } from '@objectstack/metadata-core'; -import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; -import { META_URL_TO_SINGULAR, canonicalMetaUrlType } from '@objectstack/spec/shared'; -import { ObjectStackProtocolImplementation } from './protocol.js'; - -const ORG = 'org_acme'; - -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, - extra: Partial = {}, -): StoredRow => ({ - id: `r_${type}_${name}_${extra.organization_id ?? 'env'}_${extra.state ?? 'active'}`, - type, - name, - organization_id: null, - package_id: null, - state: 'active', - // `label` is the observation channel for WHICH row was served — the two - // rows of a pair differ only by scope, so a served label names its origin. - metadata: JSON.stringify({ name, label: `${extra.organization_id ?? 'env'} ${name}` }), - ...extra, -}); - -/** - * The engine double: `findOne` over a row table, plus the registry surface the - * single-item read path touches on its way past the overlay. - * - * `find` is the second verb the read path issues, and only for a `view` name - * with no stored row of its own: the read then selects the stored view - * containers that might expand that name, through the list read's own row - * selection (#21442). It records into `finds` — the same partitions question - * asked of that read. ⛔ No `insert` / `update` / `delete`, deliberately — a - * double declaring verbs no case exercises would owe - * `check:engine-double-contract` a dispatch contract that protects nothing. Same shape the sibling plural-door pin drives. - */ -function makeHarness(rows: StoredRow[]) { - const findOnes: Array> = []; - const finds: Array> = []; - const matching = (where: Record) => { - // `check:where-matcher` — a hand-written matcher with no combinator - // branch reads `$and` as a field name and answers the wrong question - // rather than failing. Refuse the shape this double does not - // implement, matching the sibling doubles' convention. - for (const k of Object.keys(where)) { - if (k.startsWith('$')) { - throw new Error(`[test double] unsupported WHERE combinator '${k}'`); - } - } - return rows.filter((r) => - Object.entries(where).every(([k, v]) => { - if (v === undefined) return true; - return (r as unknown as Record)[k] === v; - }), - ); - }; - const engine: any = { - async findOne(table: string, opts?: { where?: Record }) { - if (table !== 'sys_metadata') return undefined; - const where = opts?.where ?? {}; - findOnes.push({ ...where }); - return matching(where)[0]; - }, - registry: { - registerItem: () => undefined, - registerObject: () => undefined, - listItems: () => [], - getItem: () => undefined, - getObject: () => undefined, - getPackage: () => undefined, - getArtifactItem: () => undefined, - isPackageDisabled: () => false, - applyNavContributions: (app: unknown) => app, - }, - }; - engine.find = async (table: string, opts?: { where?: Record }) => { - if (table !== 'sys_metadata') return []; - const where = opts?.where ?? {}; - finds.push({ ...where }); - return matching(where); - }; - const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; - return { protocol, findOnes, finds }; -} - -/** Every `organization_id` partition the engine was asked for, deduplicated. */ -const partitions = (finds: Array>): Array => - [...new Set(finds.map((f) => (f.organization_id ?? null) as string | null))].sort( - (a, b) => String(a).localeCompare(String(b)), - ); - -/** The same, folded per canonical type. */ -function partitionsByType(finds: Array>): Map> { - const out = new Map>(); - for (const f of finds) { - const t = canonicalMetaUrlType(String(f.type)); - if (!out.has(t)) out.set(t, new Set()); - out.get(t)!.add((f.organization_id ?? null) as string | null); - } - return out; -} - -/** The label the served document carries — `'env …'` or `'org_acme …'`. */ -const servedLabel = (res: any): string => res?.item?.label; - -/** - * The COMPLETE accepted-spelling population: every URL spelling the `/meta` - * doors fold, unioned with every registry singular. Derived, never listed — - * a newly declared type arrives in this sweep on its own. - */ -const ALL_SPELLINGS: string[] = [ - ...new Set([ - ...Object.keys(META_URL_TO_SINGULAR), - ...DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type), - ]), -].sort(); - -/** - * The org-overridable canonical types, derived. Widened to `string[]` - * deliberately: the registry's `type` is a literal union, and the uses below - * ask the question of a type read back OUT of a WHERE clause or a fold — a - * plain `string` by construction. - */ -const OVERRIDABLE: readonly string[] = DEFAULT_METADATA_TYPE_REGISTRY - .filter((e) => e.allowOrgOverride) - .map((e) => e.type); - -// ═══════════════════════════════════════════════════════════════════════════ -// §0 — the population this rests on, pinned so a registry change is visible -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§0 the org-overridable set', () => { - it('is exactly the ADR-0005 tier-A five', () => { - expect([...OVERRIDABLE].sort()).toEqual( - ['dashboard', 'email_template', 'report', 'translation', 'view'], - ); - }); - - it("`object` — the type the ungated runtime callers hard-code — is NOT overridable", () => { - // The premise of §1. `runtime/src/domains/meta.ts` reaches this verb - // three times, twice hard-coded to `'object'`; a FOURTH raw-org caller - // lives in `runtime/src/domains/packages.ts` (`applyPublishedSeeds`, - // `type: 'seed'`, equally non-overridable). If either type ever flips, - // §1 is measuring something else and must be re-derived. - expect(OVERRIDABLE).not.toContain('object'); - expect(organizationIdForMetaRead('object', ORG)).toBeUndefined(); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §1 — ⭐ THE CARD'S CASE: a phantom must not shadow the live env-wide row -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§1 a pre-#6190 phantom does not become the served document', () => { - const rows = () => [ - // The live env-wide document — what the caller asked for. - storedRow('object', 'showcase_task'), - // The phantom: an org-scoped row of an `allowOrgOverride: false` type. - // `loadMetaFromDb` walks past it at boot, so it is dead — it exists - // only because the runtime used to stamp `organization_id` on every - // type before #6190. - storedRow('object', 'showcase_task', { organization_id: ORG }), - ]; - - it('serves the env-wide row to a caller that passes a RAW active organization', async () => { - const { protocol, findOnes } = makeHarness(rows()); - const res = await protocol.getMetaItem({ - type: 'object', - name: 'showcase_task', - organizationId: ORG, - }); - expect(servedLabel(res)).toBe('env showcase_task'); - // And the phantom's partition was never even read — the gate resolves - // to `undefined`, so the `orgId ? … : undefined` arm is skipped whole. - expect(partitions(findOnes)).toEqual([null]); - }); - - it('answers identically whether or not the caller names the organization', async () => { - const withOrg = makeHarness(rows()); - const withoutOrg = makeHarness(rows()); - const a = await withOrg.protocol.getMetaItem({ - type: 'object', name: 'showcase_task', organizationId: ORG, - }); - const b = await withoutOrg.protocol.getMetaItem({ - type: 'object', name: 'showcase_task', - }); - expect(servedLabel(a)).toBe(servedLabel(b)); - expect(partitions(withOrg.findOnes)).toEqual(partitions(withoutOrg.findOnes)); - }); - - it('holds for every non-overridable declared type, not just `object`', async () => { - const nonOverridable = DEFAULT_METADATA_TYPE_REGISTRY - .filter((e) => !e.allowOrgOverride) - .map((e) => e.type); - expect(nonOverridable.length).toBeGreaterThan(5); - for (const type of nonOverridable) { - const { protocol, findOnes } = makeHarness([ - storedRow(type, 'probe'), - storedRow(type, 'probe', { organization_id: ORG }), - ]); - const res = await protocol.getMetaItem({ type, name: 'probe', organizationId: ORG }); - expect(servedLabel(res), `${type} served the phantom`).toBe('env probe'); - expect(partitions(findOnes), `${type} read the org partition`).toEqual([null]); - } - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §2 — ⛔ precedence is the RULING: the `??` must survive this fix -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§2 org-scoped row still WINS where the registry declares a per-org channel', () => { - it('serves the org row, not the env-wide one, for an overridable type', async () => { - // ADR-0005: `1. sys_metadata … ← overlay (wins)`. This is the - // behaviour #9454 exists to make reachable — the author's PUT landed an - // org-scoped row, and a read that names the organization must see it. - const { protocol } = makeHarness([ - storedRow('view', 'task_list'), - storedRow('view', 'task_list', { organization_id: ORG }), - ]); - const res = await protocol.getMetaItem({ - type: 'view', name: 'task_list', organizationId: ORG, - }); - expect(servedLabel(res)).toBe(`${ORG} task_list`); - }); - - it('is REPLACEMENT, not a merge of the two rows', async () => { - // ⛔ The pin that forbids reading the card's title as a licence to turn - // `??` into a union. The env-wide row carries a key the org row does - // not; a layering would surface it, and ADR-0005 principle 3 (whole - // document per row, patch model retired under ADR-0049) says it must - // not. `getMetaItem` serves ONE document, whole. - const env = storedRow('view', 'task_list'); - env.metadata = JSON.stringify({ name: 'task_list', label: 'env task_list', envOnlyKey: 1 }); - const { protocol } = makeHarness([ - env, - storedRow('view', 'task_list', { organization_id: ORG }), - ]); - const res = await protocol.getMetaItem({ - type: 'view', name: 'task_list', organizationId: ORG, - }); - expect(servedLabel(res)).toBe(`${ORG} task_list`); - expect(res.item.envOnlyKey).toBeUndefined(); - }); - - it('falls back to the env-wide row when the org has no overlay of its own', async () => { - const { protocol, findOnes } = makeHarness([storedRow('view', 'task_list')]); - const res = await protocol.getMetaItem({ - type: 'view', name: 'task_list', organizationId: ORG, - }); - expect(servedLabel(res)).toBe('env task_list'); - // BOTH partitions read — the org one first, the env-wide one as the - // `??` fallback. That is the precedence chain, intact. (`partitions` - // returns a SORTED set, so this asserts membership, not read order.) - expect(partitions(findOnes)).toEqual([null, ORG]); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §3 — idempotence, leg 1: `f(t, undefined) === undefined` -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§3 a caller that names no organization is untouched', () => { - it('reads only the env-wide partition, for every accepted spelling', async () => { - // The population that covers `import-mapping.ts`, `import-prepare.ts`, - // plugin-email's template read, service-analytics' draft probe and - // plugin-auth's `metaReader` in one statement: none of them passes an - // `organizationId`, and the predicate short-circuits on `undefined` - // before it ever consults the registry flag. - for (const spelling of ALL_SPELLINGS) { - const { protocol, findOnes } = makeHarness([]); - await protocol.getMetaItem({ type: spelling, name: 'probe' }).catch(() => undefined); - for (const [type, parts] of partitionsByType(findOnes)) { - expect([...parts], `${spelling} → ${type}`).toEqual([null]); - } - } - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §4 — idempotence, leg 2: `f(t, f(t, o)) === f(t, o)`, over the whole -// accepted-spelling population -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§4 an already-gating caller receives the same scope it did before', () => { - it('the predicate is idempotent for every accepted spelling', () => { - // The algebra, stated directly. `f` answers `o` when the registry - // declares the type per-org overridable and `undefined` otherwise, so - // a second application cannot move the answer. - for (const spelling of ALL_SPELLINGS) { - const once = organizationIdForMetaRead(spelling, ORG); - expect(organizationIdForMetaRead(spelling, once), spelling).toBe(once); - } - }); - - it('the REST by-name door gates on the string this method folds to', () => { - // The door computes `organizationIdForMetaRead(canonicalMetaUrlType( - // req.params.type), ctx?.tenantId)` and then passes `type: - // req.params.type` — the RAW segment. The first statement of - // `getMetaItem` folds that segment through `canonicalizeMetaRequestType`, - // which IS `canonicalMetaUrlType`. So the gate inside reads the - // identical STRING the door gated on, and the second application is - // the algebraic no-op above. - // - // ⛔ This is why the gate sits AFTER the fold. `declaresOrgOverride` - // tolerates the MANIFEST plurals and not the URL-only ones - // (`translations` / `email_templates` have no manifest key) — commit 26f3588fb - // measured what that costs when a raw segment reaches the predicate. - for (const spelling of ALL_SPELLINGS) { - const doorGate = organizationIdForMetaRead(canonicalMetaUrlType(spelling), ORG); - const innerGate = organizationIdForMetaRead(canonicalMetaUrlType(spelling), doorGate); - expect(innerGate, spelling).toBe(doorGate); - } - }); - - it('a gated door reading an overridable type still reaches the org partition', async () => { - // The other direction of the no-op: idempotence must not be achieved by - // denying everyone. A door that already resolved `ORG` for `view` gets - // the org partition read, exactly as before this change. - const gated = organizationIdForMetaRead('view', ORG); - expect(gated).toBe(ORG); - const { protocol, findOnes, finds } = makeHarness([]); - await protocol.getMetaItem({ type: 'view', name: 'probe', organizationId: gated }); - expect(partitions(findOnes)).toEqual([null, ORG]); - // [#21442] `probe` has no row of its own, so the read also selects the - // stored containers that might expand it — from the same partitions. - expect(partitions(finds)).toEqual([null, ORG]); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §5 — ONE binding, BOTH arms -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§5 the draft-preview arm gets the same scope as the active read', () => { - it('does not resurrect a phantom DRAFT row for a non-overridable type', async () => { - // ADR-0033 `previewDrafts` runs its own `findDraft` pair off the SAME - // `orgId` binding. A gate threaded into only the active arm would leave - // this one serving exactly the phantom the active read had just stopped - // serving — the half-fix #9454's hoist comment refuses. - const { protocol, findOnes } = makeHarness([ - storedRow('object', 'showcase_task', { state: 'draft' }), - storedRow('object', 'showcase_task', { state: 'draft', organization_id: ORG }), - ]); - const res = await protocol.getMetaItem({ - type: 'object', - name: 'showcase_task', - organizationId: ORG, - previewDrafts: true, - }); - expect(servedLabel(res)).toBe('env showcase_task'); - expect(partitions(findOnes)).toEqual([null]); - }); - - it('still prefers the org DRAFT row for an overridable type', async () => { - const { protocol } = makeHarness([ - storedRow('view', 'task_list', { state: 'draft' }), - storedRow('view', 'task_list', { state: 'draft', organization_id: ORG }), - ]); - const res = await protocol.getMetaItem({ - type: 'view', - name: 'task_list', - organizationId: ORG, - previewDrafts: true, - }); - expect(servedLabel(res)).toBe(`${ORG} task_list`); - }); -}); diff --git a/packages/metadata-protocol/src/get-meta-items-org-read-gate.test.ts b/packages/metadata-protocol/src/get-meta-items-org-read-gate.test.ts deleted file mode 100644 index c038a9dad90..00000000000 --- a/packages/metadata-protocol/src/get-meta-items-org-read-gate.test.ts +++ /dev/null @@ -1,431 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [commit 96326040f] `getMetaItems` applies the registry read gate ITSELF, so a sweep - * that reads MORE THAN ONE type per request is scoped per type instead of per - * request. - * - * ── The defect ──────────────────────────────────────────────────────────── - * - * `getMetaItems` used to apply no gate at all: whatever `organizationId` - * arrived was spent on whatever `type` arrived. The scope of a metadata sweep - * was therefore decided per type BY THE CALLER — which a request carrying ONE - * organization can only get right when it sweeps ONE type. Three live callers - * sweep more: - * - * • `getMetaDiagnostics` with no `type` — `targetTypes` is the whole - * registry, five `allowOrgOverride: true` types and every other declared - * type together, under one request-level organization. - * • `findReferencesToMeta` — `request.type` is the TARGET; the organization - * is spent on `matcher.fromType`, the SOURCES, so the target's own flag - * says nothing about the types actually read. - * • the runtime's package export sweep (`runtime/src/domains/packages.ts`, - * `assemblePackageManifest`) — every plural key of `PLURAL_TO_SINGULAR`, - * one raw active organization. - * - * ── ⭐ The harm class is RESURRECTION, not concealment ──────────────────── - * - * Stated because the sibling card was filed against the opposite premise and a - * fix aimed at concealment would be aiming at the wrong failure. - * `SysMetadataRepository.history()` filters `organization_id` by strict - * equality, so naming the tenant THERE hides an `allowOrgOverride: false` - * type's rows. On THIS path the two `queryByOrg` reads are UNIONed, so naming - * it can only ADD rows — and the rows it adds are the pre-#6190 phantoms: - * org-scoped rows of types with no per-org read channel, which `loadMetaFromDb` - * walks past and `reportUnhydratableOrgScopedRows` exists to warn about. They - * come back inside the admin "Used by" panel and the Studio governance - * directory — a clearance rendered before a destructive action, where a - * resurrected row is worse than an omission because it reads as evidence. - * - * ── ⭐ §3 is the IDEMPOTENCE PROOF the ruling made this change conditional on - * - * Direction A was ruled conditional on demonstrating that moving the predicate - * INSIDE does not CHANGE the scope any already-gating call site receives. §3 - * discharges that mechanically rather than by argument, over the COMPLETE - * population of accepted URL spellings (`META_URL_TO_SINGULAR`, unioned with - * every registry singular) rather than over a hand-listed sample — a new type - * or a changed fold cannot slip past it. - * - * ── Why the observation channel is the WHERE multiset ───────────────────── - * - * §3–§5 seed NO rows and read the `organization_id` partitions the engine was - * asked for. That is the whole of what this change moves — which partitions - * are read — and observing it directly keeps the sweep body-independent, so - * the pin covers every declared type instead of the handful with a - * hand-written schema-valid body. §1/§2 pay for that by asserting real merged - * items on both sides of the gate, so neither half rests on a query nobody - * proved returns rows. - */ - -import { describe, expect, it } from 'vitest'; -import { organizationIdForMetaRead } from '@objectstack/metadata-core'; -import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; -import { META_URL_TO_SINGULAR, canonicalMetaUrlType } from '@objectstack/spec/shared'; -import { ObjectStackProtocolImplementation } from './protocol.js'; - -const ORG = 'org_acme'; - -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, - extra: Partial = {}, -): StoredRow => ({ - id: `r_${type}_${name}_${extra.organization_id ?? 'env'}_${extra.state ?? 'active'}`, - type, - name, - organization_id: null, - package_id: null, - state: 'active', - metadata: JSON.stringify({ name, label: `${extra.organization_id ?? 'env'} ${name}` }), - ...extra, -}); - -/** - * The engine double: `find` over a row table, plus the registry surface the - * overlay path touches. - * - * ⛔ No `findOne` / `insert` / `update` / `delete`, deliberately — the read - * path under test issues exactly one verb, and a double declaring verbs no - * case exercises would owe `check:engine-double-contract` a dispatch contract - * that protects nothing. Same shape `meta-overlay-cache.test.ts` drives. - */ -function makeHarness(rows: StoredRow[]) { - const finds: Array> = []; - const engine: any = { - async find(table: string, opts?: { where?: Record; limit?: number }) { - if (table !== 'sys_metadata') return []; - const where = opts?.where ?? {}; - finds.push({ ...where }); - // `check:where-matcher` — a hand-written matcher with no combinator - // branch reads `$and` as a field name and answers the wrong - // question rather than failing. Refuse the shape this double does - // not implement, matching the sibling doubles' convention. - for (const k of Object.keys(where)) { - if (k.startsWith('$')) { - throw new Error(`[test double] unsupported WHERE combinator '${k}'`); - } - } - const matched = rows.filter((r) => - Object.entries(where).every(([k, v]) => { - if (v === undefined) return true; - return (r as unknown as Record)[k] === v; - }), - ); - // `check:objectql-double-limit` (commit 4c9780c7a) — hold the caller's bound, - // applied AFTER the filter and BY PRESENCE. - return opts?.limit === undefined ? matched : matched.slice(0, opts.limit); - }, - registry: { - registerItem: () => undefined, - registerObject: () => undefined, - listItems: () => [], - getItem: () => undefined, - getObject: () => undefined, - getPackage: () => undefined, - getArtifactItem: () => undefined, - isPackageDisabled: () => false, - applyNavContributions: (app: unknown) => app, - }, - }; - const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; - return { protocol, finds }; -} - -/** Every `organization_id` partition the engine was asked for, deduplicated. */ -const partitions = (finds: Array>): Array => - [...new Set(finds.map((f) => (f.organization_id ?? null) as string | null))].sort( - (a, b) => String(a).localeCompare(String(b)), - ); - -/** The same, folded per canonical type — for the multi-type sweeps. */ -function partitionsByType(finds: Array>): Map> { - const out = new Map>(); - for (const f of finds) { - const t = canonicalMetaUrlType(String(f.type)); - if (!out.has(t)) out.set(t, new Set()); - out.get(t)!.add((f.organization_id ?? null) as string | null); - } - return out; -} - -const names = (res: any): string[] => - (Array.isArray(res) ? res : res.items).map((i: any) => i.name).sort(); - -const labels = (res: any): Record => - Object.fromEntries( - (Array.isArray(res) ? res : res.items).map((i: any) => [i.name, i.label]), - ); - -/** - * The COMPLETE accepted-spelling population: every URL spelling the `/meta` - * doors fold, unioned with every registry singular. Derived, never listed — - * a newly declared type arrives in this sweep on its own. - */ -const ALL_SPELLINGS: string[] = [ - ...new Set([ - ...Object.keys(META_URL_TO_SINGULAR), - ...DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type), - ]), -].sort(); - -/** - * The org-overridable canonical types, derived. Widened to `string[]` - * deliberately: the registry's `type` is a literal union, and every use below - * asks the question of a type read back OUT of a WHERE clause or a fold — - * a plain `string` by construction. Narrowing the probe to the union would - * make `includes` a compile error against exactly the values this file exists - * to judge. - */ -const OVERRIDABLE: readonly string[] = DEFAULT_METADATA_TYPE_REGISTRY - .filter((e) => e.allowOrgOverride) - .map((e) => e.type); - -// ═══════════════════════════════════════════════════════════════════════════ -// §0 — the population this rests on, pinned so a registry change is visible -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§0 the org-overridable set', () => { - it('is exactly the ADR-0005 tier-A five', () => { - expect([...OVERRIDABLE].sort()).toEqual( - ['dashboard', 'email_template', 'report', 'translation', 'view'], - ); - }); - - it('covers every accepted spelling — the sweep below is not a sample', () => { - expect(ALL_SPELLINGS.length).toBeGreaterThan(DEFAULT_METADATA_TYPE_REGISTRY.length); - for (const s of ALL_SPELLINGS) expect(typeof canonicalMetaUrlType(s)).toBe('string'); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §1 — the gate, in BOTH directions, on real merged items -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§1 a raw active organization is gated per type', () => { - it('a NON-overridable type does not resurrect its org-scoped phantom rows', async () => { - const h = makeHarness([ - storedRow('object', 'task'), - storedRow('object', 'task', { organization_id: ORG }), - storedRow('object', 'phantom_only', { organization_id: ORG }), - ]); - - const res = await h.protocol.getMetaItems({ type: 'object', organizationId: ORG }); - - // The org partition is never asked for at all — not asked and - // discarded, which would still pay for the read and still put the row - // one refactor away from the answer. - expect(partitions(h.finds)).toEqual([null]); - expect(names(res)).toEqual(['task']); - expect(labels(res).task).toBe('env task'); - }); - - it('an OVERRIDABLE type still unions both partitions, org winning on collision', async () => { - const h = makeHarness([ - storedRow('view', 'tasks_list'), - storedRow('view', 'tasks_list', { organization_id: ORG }), - storedRow('view', 'org_only', { organization_id: ORG }), - ]); - - const res = await h.protocol.getMetaItems({ type: 'view', organizationId: ORG }); - - expect(partitions(h.finds)).toEqual([null, ORG]); - expect(names(res)).toEqual(['org_only', 'tasks_list']); - expect(labels(res).tasks_list).toBe(`${ORG} tasks_list`); - }); - - it('an org-less caller reads exactly what it read before, either way', async () => { - const h = makeHarness([ - storedRow('view', 'tasks_list'), - storedRow('view', 'org_only', { organization_id: ORG }), - ]); - - const res = await h.protocol.getMetaItems({ type: 'view' }); - - expect(partitions(h.finds)).toEqual([null]); - expect(names(res)).toEqual(['tasks_list']); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §2 — the `previewDrafts` arm spends the SAME resolution -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§2 the draft-preview arm carries the same gate', () => { - it('does not preview an org-scoped DRAFT of a non-overridable type', async () => { - const h = makeHarness([ - storedRow('object', 'task'), - storedRow('object', 'task', { organization_id: ORG, state: 'draft' }), - storedRow('object', 'draft_phantom', { organization_id: ORG, state: 'draft' }), - ]); - - const res = await h.protocol.getMetaItems({ - type: 'object', - organizationId: ORG, - previewDrafts: true, - }); - - expect(partitions(h.finds)).toEqual([null]); - expect(names(res)).toEqual(['task']); - }); - - it('still previews an org-scoped DRAFT of an overridable type', async () => { - const h = makeHarness([ - storedRow('view', 'tasks_list'), - storedRow('view', 'org_draft', { organization_id: ORG, state: 'draft' }), - ]); - - const res = await h.protocol.getMetaItems({ - type: 'view', - organizationId: ORG, - previewDrafts: true, - }); - - expect(partitions(h.finds)).toEqual([null, ORG]); - expect(names(res)).toContain('org_draft'); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §3 — ⭐ THE IDEMPOTENCE PROOF, over the complete spelling population -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§3 an already-gating call site receives unchanged read scope', () => { - /** - * The algebra, stated first because §3's behavioural half only measures - * that the implementation obeys it: `organizationIdForMetaRead` answers - * either its argument or `undefined`, so a second application over the - * SAME type is a no-op. The load-bearing half is "the same type" — which - * §3b measures. - */ - it.each(ALL_SPELLINGS)('f(t, f(t, org)) === f(t, org) for spelling %s', (spelling) => { - const t = canonicalMetaUrlType(spelling); - const once = organizationIdForMetaRead(t, ORG); - expect(organizationIdForMetaRead(t, once)).toBe(once); - }); - - /** - * §3b — the type this method gates on is the type the door gated on. - * - * The REST `GET /meta/:type` list door computes - * `organizationIdForMetaRead(canonicalMetaUrlType(req.params.type), …)` - * and then passes the RAW segment as `type`. Reproduced here for every - * accepted spelling: the partitions read must equal the partitions the - * pre-change implementation read for that same argument — which it spent - * verbatim, so `expected` below IS the old behaviour, written out. - */ - it.each(ALL_SPELLINGS)('gated caller: read scope unchanged for spelling %s', async (spelling) => { - const gated = organizationIdForMetaRead(canonicalMetaUrlType(spelling), ORG); - const h = makeHarness([]); - - await h.protocol.getMetaItems({ - type: spelling, - ...(gated ? { organizationId: gated } : {}), - }); - - // Pre-change semantics, verbatim: `queryByOrg(null)` always, plus - // `queryByOrg(orgId)` iff the caller named one. - const expected: Array = gated ? [null, gated] : [null]; - expect(partitions(h.finds)).toEqual(expected.sort((a, b) => String(a).localeCompare(String(b)))); - }); - - /** - * §3c — the complement: an UNGATED caller IS narrowed, which is the - * repair. Without this the suite would pass on an implementation that - * changed nothing at all. - */ - it.each(ALL_SPELLINGS)('ungated caller: raw org is gated for spelling %s', async (spelling) => { - const canonical = canonicalMetaUrlType(spelling); - const overridable = OVERRIDABLE.includes(canonical); - const h = makeHarness([]); - - await h.protocol.getMetaItems({ type: spelling, organizationId: ORG }); - - expect(partitions(h.finds)).toEqual(overridable ? [null, ORG] : [null]); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §4 — `getMetaDiagnostics`, the whole-registry sweep (door 1 on the card) -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§4 GET /meta/diagnostics with no ?type= is scoped per swept type', () => { - it('names the organization only for the types whose registry entry allows it', async () => { - const h = makeHarness([]); - - await h.protocol.getMetaDiagnostics({ organizationId: ORG }); - - const byType = partitionsByType(h.finds); - expect(byType.size).toBeGreaterThan(1); // it really did sweep more than one type - - for (const [type, parts] of byType) { - expect(parts.has(null)).toBe(true); - expect(parts.has(ORG)).toBe(OVERRIDABLE.includes(type)); - } - // …and the sweep genuinely reached at least one of each kind, so the - // loop above is not vacuously true on a single-kind sweep. - expect([...byType.keys()].some((t) => OVERRIDABLE.includes(t))).toBe(true); - expect([...byType.keys()].some((t) => !OVERRIDABLE.includes(t))).toBe(true); - }); - - it('the ?type= arm is unchanged — one type, one organization, still correct', async () => { - for (const type of ['view', 'object']) { - const h = makeHarness([]); - // What the REST door passes once it gates: the predicate's answer. - const gated = organizationIdForMetaRead(type, ORG); - await h.protocol.getMetaDiagnostics({ type, ...(gated ? { organizationId: gated } : {}) }); - expect(partitions(h.finds)).toEqual(gated ? [null, ORG] : [null]); - } - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// §5 — `findReferencesToMeta`, where the org is spent on the SOURCES -// ═══════════════════════════════════════════════════════════════════════════ - -describe('§5 GET /meta/:type/:name/references is scoped per SOURCE type', () => { - it('gates each matcher.fromType on its own flag, not on the target’s', async () => { - const h = makeHarness([]); - - // Target `object` is NON-overridable; its sources include `view`, - // which IS. Gating on the target would have answered env-wide for the - // org-scoped views this panel exists to find. - await h.protocol.findReferencesToMeta({ type: 'object', name: 'task', organizationId: ORG }); - - const byType = partitionsByType(h.finds); - expect(byType.size).toBeGreaterThan(1); - for (const [type, parts] of byType) { - expect(parts.has(null)).toBe(true); - expect(parts.has(ORG)).toBe(OVERRIDABLE.includes(type)); - } - // The load-bearing source: an org-overridable type really is among the - // sources of `object`, so the `true` branch above is exercised. - expect([...byType.keys()].some((t) => OVERRIDABLE.includes(t))).toBe(true); - }); - - it('an org-scoped view referencing the target is FOUND — the false clearance closes', async () => { - const h = makeHarness([ - storedRow('view', 'org_tasks', { - organization_id: ORG, - metadata: JSON.stringify({ name: 'org_tasks', label: 'Org tasks', object: 'task' }), - }), - ]); - - const res = await h.protocol.findReferencesToMeta({ - type: 'object', - name: 'task', - organizationId: ORG, - }); - - expect(res.references.map((r: any) => `${r.type}/${r.name}`)).toContain('view/org_tasks'); - }); -}); diff --git a/packages/metadata-protocol/src/item-lock.ts b/packages/metadata-protocol/src/item-lock.ts index 2e06934a9c1..1721444e6ba 100644 --- a/packages/metadata-protocol/src/item-lock.ts +++ b/packages/metadata-protocol/src/item-lock.ts @@ -58,10 +58,9 @@ * reported that package's row while the `_lock` gate bound whichever row * `findOne` returned first. * - * - **The rows in scope.** ADR-0005 precedence, as the reads resolve it: the - * organization's rows when it holds any row of the item, else the env-wide - * rows. Within that scope every row of the item, whichever package it is - * bound to: the address's own package row, the package-less row, and any + * - **The rows in scope.** The environment's rows of the item (ADR-0131 D6: + * a legacy organization-scoped row is reported at boot, never read), every + * one of them, whichever package it is bound to: the address's own package row, the package-less row, and any * other package's row. Those are every row the doors could bind before this * rule (they asked with no package, so `findOne` could return any of them). * A row stored under the type's other spelling is in scope only for a @@ -143,7 +142,7 @@ import { * §3.3, "an overlay cannot loosen a packaged lock"), one per installed * package that ships the name, as {@link resolveArtifactLockLayer} selects * them from the item's address; - * - `overlay`: the stored `sys_metadata` rows in the item's scope (ADR-0005), + * - `overlay`: the item's stored environment `sys_metadata` rows, * as {@link resolveOverlayLockLayer} selects them from the item's address. */ export const ITEM_LOCK_LAYERS = Object.freeze(['artifact', 'overlay'] as const); @@ -251,7 +250,7 @@ export async function resolveItemLockLazily(read: { * axis, so a field added here without one turns that pin's completeness check * red, naming the field. */ -export const ITEM_ADDRESS_FIELDS = Object.freeze(['type', 'name', 'organizationId', 'packageId'] as const); +export const ITEM_ADDRESS_FIELDS = Object.freeze(['type', 'name', 'packageId'] as const); /** One of {@link ITEM_ADDRESS_FIELDS}. */ export type ItemAddressField = (typeof ITEM_ADDRESS_FIELDS)[number]; @@ -261,9 +260,6 @@ export type ItemAddressField = (typeof ITEM_ADDRESS_FIELDS)[number]; * * - `type`: the canonical metadata type (#4432); * - `name`: the item's name; - * - `organizationId`: the organization the request is scoped to, already - * gated by `organizationIdForMetaRead` (ADR-0005), or `undefined` for the - * env-wide scope alone; * - `packageId`: the package the request names (ADR-0048 `?package=`), or * `undefined`. It decides which row's prose the lock carries when several * rows in scope declare the strictest lock, never which rows are in scope. @@ -271,7 +267,6 @@ export type ItemAddressField = (typeof ITEM_ADDRESS_FIELDS)[number]; export type ItemAddress = { readonly type: string; readonly name: string; - readonly organizationId: string | undefined; readonly packageId: string | undefined; } & { readonly [F in ItemAddressField]: unknown }; @@ -283,14 +278,13 @@ export interface StoredOverlayRow { } /** - * The stored rows of the addressed item in one scope (`organizationId`, or - * `null` for env-wide) under one spelling of its type: `'canonical'`, or - * `'other'` (the type's singular/plural twin, pre-#4432 residue). Every row of - * the item there, whichever package it is bound to. A failed read throws: each - * caller owns its #5532 / #5706 discrimination. + * The stored environment rows of the addressed item (ADR-0131 D6) under one + * spelling of its type: `'canonical'`, or `'other'` (the type's + * singular/plural twin, pre-#4432 residue). Every row of the item there, + * whichever package it is bound to. A failed read throws: each caller owns its + * #5532 / #5706 discrimination. */ export type StoredOverlayRowReader = ( - organizationId: string | null, spelling: 'canonical' | 'other', ) => readonly StoredOverlayRow[] | Promise; @@ -343,22 +337,16 @@ const LOCK_FAMILY_KEYS = Object.freeze(['_lock', '_lockReason', '_lockDocsUrl', * * Every caller that enforces or reports an item's lock passes its address * here: the `_lock` gate (`otherSpelling: false`, #4432), both item reads and - * the list (`otherSpelling: true`). Rows are read scope by scope and stop at - * the first scope that holds one, so the gate reads no more than it did when - * it read one row. + * the list (`otherSpelling: true`). */ export async function resolveOverlayLockLayer( address: ItemAddress, rowsIn: StoredOverlayRowReader, options: { readonly otherSpelling: boolean }, ): Promise { - const scopes: Array = address.organizationId ? [address.organizationId, null] : [null]; - for (const scope of scopes) { - let rows = await rowsIn(scope, 'canonical'); - if (rows.length === 0 && options.otherSpelling) rows = await rowsIn(scope, 'other'); - if (rows.length > 0) return strictestRowLockDocument(rows, address.packageId); - } - return undefined; + let rows = await rowsIn('canonical'); + if (rows.length === 0 && options.otherSpelling) rows = await rowsIn('other'); + return rows.length > 0 ? strictestRowLockDocument(rows, address.packageId) : undefined; } function strictestRowLockDocument(rows: readonly StoredOverlayRow[], packageId: string | undefined): unknown { @@ -426,8 +414,7 @@ export function resolveArtifactLockLayer(address: ItemAddress, artifactsOf: Ship * and no `_lock*` key it does not. So a served body states exactly the lock the * envelope reports, whichever document it was served from: a row the address * prefers for content while other rows in scope bind ([#21761]), the address's - * own package's artifact while another package's lock binds ([#21803]), or a - * row served by content precedence from a scope the lock is not read from (a + * own package's artifact while another package's lock binds ([#21803]) (a * family the answer does not carry is removed). Returns `document` itself when * nothing changes, and a copy otherwise. */ diff --git a/packages/metadata-protocol/src/meta-overlay-cache.test.ts b/packages/metadata-protocol/src/meta-overlay-cache.test.ts index 8e9cb007754..b9da8787201 100644 --- a/packages/metadata-protocol/src/meta-overlay-cache.test.ts +++ b/packages/metadata-protocol/src/meta-overlay-cache.test.ts @@ -578,25 +578,13 @@ describe('[#11967] §7 distinct reads never share an entry', () => { expect((scoped.items as any[]).map((i) => i.name)).toEqual(['beta']); }); - // ⚠️ [commit 96326040f] `view`, NOT `object`, and the type is LOAD-BEARING here in a - // way it is not in this section's three siblings. `getMetaItems` now - // resolves its own read scope through `organizationIdForMetaRead`, so a - // type the registry declares NON-overridable has exactly one partition to - // read: an `organizationId` handed in for `object` is gated to `undefined` - // before it ever reaches the cache key, and the two reads below would - // legitimately share one entry — this case would then be asserting that a - // key separation exists where the platform deliberately has none. - // - // The INVARIANT is unchanged and is what this case still pins: two reads - // at genuinely different org scopes must not answer from each other. It - // just has to be exercised on a type that HAS two org scopes, which since - // ADR-0005 tier A means one of `view` / `dashboard` / `report` / - // `translation` / `email_template`. See - // `get-meta-items-org-read-gate.test.ts` for the gate itself and for the - // `object` side of this — that an org-scoped read of a non-overridable - // type reads the env partition alone is pinned there, as behaviour rather - // than as a cache-key fact. - it('an org-scoped read does not answer from the env-wide entry', async () => { + // [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' }), @@ -605,14 +593,17 @@ describe('[#11967] §7 distinct reads never share an entry', () => { const envWide = await h.protocol.getMetaItems({ type: 'view' }); const findsAfterEnvWide = h.finds.length; - const orgScoped = await h.protocol.getMetaItems({ type: 'view', organizationId: 'org_1' }); + 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((orgScoped.items as any[]).map((i) => i.name).sort()) - .toEqual(['alpha', 'org_only']); + 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']); }); + it('two engines never share a bucket', async () => { const a = makeHarness([storedRow('object', 'from_engine_a')]); const b = makeHarness([storedRow('object', 'from_engine_b')]); diff --git a/packages/metadata-protocol/src/meta-overlay-cache.ts b/packages/metadata-protocol/src/meta-overlay-cache.ts index 213f1acf2ed..2128e61ab35 100644 --- a/packages/metadata-protocol/src/meta-overlay-cache.ts +++ b/packages/metadata-protocol/src/meta-overlay-cache.ts @@ -12,7 +12,8 @@ * is the shape #11633 §1 calls out: *"most of leg D's win is negative caching"*. * * This module caches the OUTCOME OF THAT READ — the raw `sys_metadata` rows — - * per `(engine, type, packageId, organizationId)`, and retires the entry the + * per `(engine, type, packageId)` — the environment's rows; ADR-0131 D6 reads + * no organization's — and retires the entry the * moment the engine's write epoch moves. * * ## ⭐ Where this cache sits, and why that IS the resolution of the @@ -131,7 +132,6 @@ export interface MetaOverlayCacheKey { /** Canonical metadata type — already folded by `canonicalizeMetaRequestType`. */ type: string; packageId?: string; - organizationId?: string; } /** One cached row set, with everything needed to decide it is still the answer. */ @@ -272,11 +272,11 @@ export function bumpWriteEpoch(engine: unknown, reason: string): number | undefi /** * The cache key. `JSON.stringify` over the tuple rather than a delimiter join: - * a package id or organization id containing the delimiter would otherwise let - * two different reads collide on one key. + * a package id containing the delimiter would otherwise let two different + * reads collide on one key. */ function cacheKeyOf(key: MetaOverlayCacheKey): string { - return JSON.stringify([key.type, key.packageId ?? null, key.organizationId ?? null]); + return JSON.stringify([key.type, key.packageId ?? null]); } /** @@ -359,8 +359,8 @@ export function writeMetaOverlayCache( // ⭐ Every entry read at an older epoch is already dead by the read-side // rule, so keeping it costs memory and buys nothing. Without this a // long-lived process accumulates one never-evicted entry per distinct - // `(type, packageId, organizationId)` ever requested — bounded in principle - // by the tenant count, which is not a bound worth shipping. + // `(type, packageId)` ever requested — bounded in principle by the + // package count, which is not a bound worth shipping. bucket.entries.clear(); bucket.epoch = epoch; } 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 053a722e5ed..57230b6f3b7 100644 --- a/packages/metadata-protocol/src/protocol.audit-org-scope.test.ts +++ b/packages/metadata-protocol/src/protocol.audit-org-scope.test.ts @@ -1,32 +1,20 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// #8747 — `auditMetaItem` declared `organizationId?: string | null`, never read -// it, and carried a comment describing the org filter it would have built: +// #8747 — `auditMetaItem` once declared an organization, never read it, and +// returned every organization's rows. The scope is BUILT in the method, +// unconditionally: nothing below it scopes the read. // -// "Org-scoped lookup: include rows for the specific org AND env-wide -// (organization_id IS NULL) rows so the editor sees both tenant overlays -// and env-level package writes." -// -// The `where` underneath was exactly `{ type, name }`. Measured consequence: one -// read returned three organizations' rows, disclosing another tenant's `actor`, -// `note` and `lock_state` through a GA endpoint that carries no capability gate. +// [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. // // This file pins the QUERY SHAPE, next to the code that builds it. The -// behavioural half — that the shape actually SELECTS the right rows through a -// real SQL driver, in both directions — is pinned by -// `packages/runtime/src/audit-meta-item-org-scope.integration.test.ts`, which -// needs a real driver this package does not depend on. Neither half is -// sufficient alone: a shape assertion cannot tell a correct filter from one -// that hides everything, and a row assertion cannot tell which spelling -// produced it. -// -// The test names below restate the comment's two claims deliberately. That is -// the "comment now describes behaviour that exists" pin the ruling asks for: -// each claim is an assertion, so the comment cannot drift back into -// over-claiming without a red test. +// behavioural half — that the shape SELECTS the right rows through a real SQL +// driver — is pinned by +// `packages/runtime/src/audit-meta-item-org-scope.integration.test.ts`. import { describe, it, expect, vi } from 'vitest'; -import { readFileSync } from 'node:fs'; import { ObjectStackProtocolImplementation } from './protocol.js'; /** A `find` that records its options and returns nothing. */ @@ -39,97 +27,21 @@ function makeProtocol() { /** The options the protocol handed to `engine.find` on its first call. */ const whereFrom = (find: any) => find.mock.calls[0][1].where; -const ORG = 'org_alpha'; - -describe('#8747 auditMetaItem builds the org scope its comment describes', () => { - it('claim 1 + 2: rows for the specific org AND env-wide (organization_id IS NULL) rows', async () => { - const { p, find } = makeProtocol(); - await p.auditMetaItem({ type: 'views', name: 'shared_grid', organizationId: ORG }); - - const where = whereFrom(find); - // Both limbs, in one `$or`. The env-wide limb is not optional: the REST - // `PUT /meta` door writes rows with `organization_id: null`, so an - // equality-only filter would blank the audit tab on those deployments. - expect(where.$or).toEqual([ - { organization_id: ORG }, - { organization_id: null }, - ]); - }); - - it('does not ALSO constrain organization_id at the top level (which would AND away the env-wide limb)', async () => { - const { p, find } = makeProtocol(); - await p.auditMetaItem({ type: 'views', name: 'shared_grid', organizationId: ORG }); - - // A top-level `organization_id` alongside the `$or` would re-narrow the - // query to the equality and silently undo the env-wide half — the exact - // "looks scoped, hides everything" shape this card warns about. - expect(whereFrom(find)).not.toHaveProperty('organization_id'); - }); - - it('still keys on (type, name), with the plural folded to singular', async () => { +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 () => { const { p, find } = makeProtocol(); - await p.auditMetaItem({ type: 'views', name: 'shared_grid', organizationId: ORG }); - - const where = whereFrom(find); - expect(where.type).toBe('view'); - expect(where.name).toBe('shared_grid'); - }); - - it('an OMITTED organizationId is fail-closed: env-wide rows only, never unscoped', async () => { - const { p, find } = makeProtocol(); - // This is the exact call the production route made before the fix. await p.auditMetaItem({ type: 'views', name: 'shared_grid' }); - const where = whereFrom(find); - expect(where.organization_id).toBe(null); - // The absence of `$or` here is the point: there is no organization to - // widen to, so the read must not widen at all. - expect(where).not.toHaveProperty('$or'); + expect(whereFrom(find)).toEqual({ type: 'view', name: 'shared_grid', organization_id: null }); }); - it('an explicit null organizationId reads env-wide, identically to omitting it', async () => { - const { p, find } = makeProtocol(); - await p.auditMetaItem({ type: 'views', name: 'shared_grid', organizationId: null }); - - const where = whereFrom(find); - expect(where.organization_id).toBe(null); - expect(where).not.toHaveProperty('$or'); - }); - - it('the parameter is READ — no call shape leaves the query without an organization term', async () => { - // The defect in one assertion: `organizationId` was inert, so every - // spelling produced the same unscoped `where`. Each spelling must now - // constrain `organization_id` one way or the other. - for (const request of [ - { type: 'views', name: 'shared_grid' }, - { type: 'views', name: 'shared_grid', organizationId: null }, - { type: 'views', name: 'shared_grid', organizationId: ORG }, - { type: 'view', name: 'shared_grid', organizationId: ORG, limit: 5 }, - ] as any[]) { + it('a caller still naming an organization changes nothing: the retired key is not read', async () => { + for (const organizationId of ['org_alpha', null]) { const { p, find } = makeProtocol(); - await p.auditMetaItem(request); + await p.auditMetaItem({ type: 'views', name: 'shared_grid', organizationId } as any); const where = whereFrom(find); - const scoped = where.$or !== undefined || 'organization_id' in where; - expect(scoped, `unscoped where for ${JSON.stringify(request)}`).toBe(true); + expect(where.organization_id, String(organizationId)).toBe(null); + expect(where, String(organizationId)).not.toHaveProperty('$or'); } }); }); - -describe('#8747 the comment and the code cannot drift apart again', () => { - it('the method that claims an org-scoped lookup is the method that builds one', () => { - const source = readFileSync(new URL('./protocol.ts', import.meta.url), 'utf8'); - const start = source.indexOf('async auditMetaItem('); - expect(start, 'auditMetaItem not found').toBeGreaterThan(-1); - // Slice to the end of the method — the next sibling member declaration. - const rest = source.slice(start); - const end = rest.indexOf('\n async ', 1); - const body = end === -1 ? rest : rest.slice(0, end); - - // The comment makes two claims. Both must be backed by code IN THE SAME - // METHOD. This is what went wrong: the prose survived, the query did - // not, and nothing failed. - expect(body).toContain('organization_id IS NULL'); - expect(body).toContain('$or'); - expect(body).toContain('request.organizationId'); - }); -}); 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 8ff5830e78b..13d89bbf2cd 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 @@ -265,12 +265,10 @@ describe('[#7774] the unscoped /meta list keeps every i18n bundle member', () => expect(byPkg['com.acme.hr'].locale).toBe('zh-CN'); }); - it('keys an env-wide row and an org row of DIFFERENT locales as different slots', async () => { - // The store's unique index is `(type, name, organization_id, - // package_id)`, so an org cannot hold two rows differing only by - // body locale — but the env-wide tier and the org tier can, and - // keying them together made the org's row displace the env-wide - // one. Precedence within a member is unchanged; see the next case. + it('[ADR-0131 D6] a legacy organization row of a bundle member is served by no list read, whatever organization the caller names', async () => { + // The env-wide row customizes en-US; a legacy organization row + // customizes zh-CN. The organization row is not read: zh-CN is the + // registry's member, en-US the env-wide row. const { engine } = makeEngine({ items: [ { __type: 'email_template', ...tpl('auth.welcome', 'en-US'), _packageId: PKG }, @@ -291,36 +289,13 @@ describe('[#7774] the unscoped /meta list keeps every i18n bundle member', () => }); const protocol = new ObjectStackProtocolImplementation(engine); - const res = await protocol.getMetaItems({ type: 'email_template', organizationId: 'org_1' }); - const byLocale = Object.fromEntries((res.items as any[]).map((i) => [i.locale, i])); - expect(Object.keys(byLocale).sort()).toEqual(['en-US', 'zh-CN']); - expect(byLocale['en-US'].subject).toBe('ENV en-US'); - expect(byLocale['zh-CN'].subject).toBe('ORG zh-CN'); - }); - - it('still lets an org row override the env-wide row of the SAME locale', async () => { - // ADR-0005 org-over-env precedence, unchanged where it was ever - // meaningful: two rows of one member still resolve to one row. - const { engine } = makeEngine({ - items: [{ __type: 'email_template', ...tpl('auth.welcome', 'en-US'), _packageId: PKG }], - rows: [ - row({ - type: 'email_template', name: 'auth.welcome', package_id: PKG, - organization_id: null, - metadata: tpl('auth.welcome', 'en-US', { subject: 'ENV en-US' }), - }), - row({ - type: 'email_template', name: 'auth.welcome', package_id: PKG, - organization_id: 'org_1', - metadata: tpl('auth.welcome', 'en-US', { subject: 'ORG en-US' }), - }), - ], - }); - const protocol = new ObjectStackProtocolImplementation(engine); - - const res = await protocol.getMetaItems({ type: 'email_template', organizationId: 'org_1' }); - expect((res.items as any[]).length).toBe(1); - expect((res.items as any[])[0].subject).toBe('ORG en-US'); + for (const request of [{ type: 'email_template' }, { type: 'email_template', organizationId: 'org_1' }]) { + const res = await protocol.getMetaItems(request as { type: string }); + const byLocale = Object.fromEntries((res.items as any[]).map((i) => [i.locale, i])); + expect(Object.keys(byLocale).sort()).toEqual(['en-US', 'zh-CN']); + expect(byLocale['en-US'].subject).toBe('ENV en-US'); + expect(byLocale['zh-CN'].subject).toBe('Subject zh-CN'); + } }); it('treats a member that declares no locale as the canonical member', async () => { 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 ff1b3528659..6a31ca7ecbb 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 @@ -15,8 +15,9 @@ * the by-name read (organization first, ADR-0005) never does. * * Both now take one order, `servedOverlayRowCandidates` in `protocol.ts`: - * scope (the organization's rows, then env-wide), then spelling, then the - * package's own row before the package-less row. `findServedOverlayRow` reads + * scope (the environment's rows — since ADR-0131 D6 no read reaches a legacy + * organization row), then spelling, then the package's own row before the + * 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 @@ -27,9 +28,9 @@ * 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, organization scope: the organization's package-less row beats an - * env-wide row of A; the organization's row of A beats the env-wide - * package-less row. Both orders. + * 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. * 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 @@ -163,13 +164,13 @@ function subsets(xs: readonly T[]): T[][] { } /** - * The oracle, written from the rule: the scopes the request reaches - * (ADR-0005: the organization's, then env-wide), and within one scope the - * package's own row before the package-less row (ADR-0048), never another - * package's. The first row found is the served row. + * The oracle, written from the rule: the environment's rows (ADR-0131 D6 — + * the request's organization reaches no row), the package's own row before + * the package-less row (ADR-0048), never another package's. The first row + * found is the served row. */ -function servedLabel(rows: readonly RowKey[], packageId: string, organizationId: string | undefined): string | undefined { - const scopes: Scope[] = organizationId ? ['org-scoped', 'env-wide'] : ['env-wide']; +function servedLabel(rows: readonly RowKey[], packageId: string, _organizationId: string | undefined): string | undefined { + const scopes: Scope[] = ['env-wide']; for (const scope of scopes) { for (const candidate of [packageId, null]) { const hit = rows.find((key) => ROWS[key].scope === scope && ROWS[key].packageId === candidate); @@ -227,7 +228,7 @@ describe('pin 1 (generated) — every package slot in the list serves the row ge for (const packageId of [PKG_A, PKG_B]) { const expected = servedLabel(order, packageId, organizationId); const inScope = order.some((key) => ROWS[key].packageId === packageId - && (ROWS[key].scope === 'env-wide' || organizationId !== undefined)); + && ROWS[key].scope === 'env-wide'); const slots = await listSlots(protocol, packageId, scope); if (!inScope) { // No row of the package in scope and no artifact: the @@ -265,21 +266,21 @@ describe('pin 2 — the package\'s row beside the package-less row (one scope, e } }); -describe('pin 3 — organization scope (ADR-0005): the organization\'s rows before the env-wide rows, then the package\'s own row', () => { +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 package-less row over an env-wide row of A · row order ${order.join(', ')}`, async () => { + it(`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 listSlots(protocol, PKG_A, { organizationId: ORG }); - expect(slots.map((s) => s.label)).toEqual([ROWS.orgPackageless.label]); - expect((await byName(protocol, PKG_A, { organizationId: ORG }))?.label).toBe(ROWS.orgPackageless.label); + 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 row of A over the env-wide package-less row · row order ${order.join(', ')}`, async () => { + 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 () => { 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.orgA.label]); - expect((await byName(protocol, PKG_A, { organizationId: ORG }))?.label).toBe(ROWS.orgA.label); + expect(slots).toEqual([]); + expect((await byName(protocol, PKG_A, { organizationId: ORG }))?.label).toBe(ROWS.envPackageless.label); }); } }); 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 d8edadf3546..c0b6f85492d 100644 --- a/packages/metadata-protocol/src/protocol.lock-one-resolution.test.ts +++ b/packages/metadata-protocol/src/protocol.lock-one-resolution.test.ts @@ -240,7 +240,6 @@ const POSITIONS: ReadonlyArray<{ const ADDRESS_AXES: { readonly [F in ItemAddressField]: AxisName | 'one item' } = { type: 'requestSpelling', name: 'one item', - organizationId: 'requestScope', packageId: 'requestPackage', }; @@ -386,8 +385,8 @@ const declaredLockOf = (a: ArtifactAxis): Lock => (a.artifact === 'declared' ? a * - `artifact`: [#21803] one lock per installed package that ships the name * (the package's own, another package's), `'none'` for an artifact that * declares no lock; no entry for a package that ships nothing. - * - `overlay`: the strictest lock among the rows in scope (ADR-0005: the - * organization's rows when it holds any, else the env-wide rows; any + * - `overlay`: the strictest lock among the rows in scope (ADR-0131 D6: the + * environment's rows, whatever organization the request comes from; any * package). The door differs on exactly one input: it does not see a row * stored under the other spelling, which the reads see only when no * canonical row is in that scope. The request's package selects nothing. @@ -398,7 +397,7 @@ const DECLARED: { } = { artifact: (row) => [row.artifact, row.otherArtifact].filter(present).map(declaredLockOf), overlay: (row, side) => { - const scopes: Array = row.requestScope === ORG ? ['org-scoped', 'env-wide'] : ['env-wide']; + const scopes: Array = ['env-wide']; for (const scope of scopes) { const inScope = oracleRows(row).filter((r) => r.scope === scope); let visible = inScope.filter((r) => r.spelling === 'canonical'); @@ -625,9 +624,11 @@ async function door( /** Both reads' envelopes for one request; they must agree with each other first. */ async function envelope( - protocol: ObjectStackProtocolImplementation, type: string, organizationId?: string, name = NAME, packageId?: string, + // [ADR-0131 D6] The caller's organization reaches no read: the reads take + // none, so the request's organization changes nothing here. + protocol: ObjectStackProtocolImplementation, type: string, _callerOrganization?: string, name = NAME, packageId?: string, ) { - const scope = { ...(organizationId ? { organizationId } : {}), ...(packageId ? { packageId } : {}) }; + const scope = { ...(packageId ? { packageId } : {}) }; const byName: any = await protocol.getMetaItem({ type, name, ...scope }); const layered: any = await protocol.getMetaItemLayered({ type, name, ...scope }); const pick = (r: any) => ({ lock: r.lock, editable: r.editable, deletable: r.deletable }); @@ -841,12 +842,13 @@ 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] The folded position: the organization's only row is another - // package's, and the request names the package, whose env-wide row is - // what it is served, with a lock the organization's rows do not declare. + // [#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. 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 === 'none')).toBe(true); + && 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 () => { @@ -1081,14 +1083,14 @@ describe('[#21761] pin 7 — a third package\'s row is in scope: no write the ga }); } - it('an organization holding only another package\'s row: its rows are the reads\' scope, and its writes are refused', async () => { + 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: 'full', editable: false, deletable: false }); + .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'); @@ -1211,27 +1213,28 @@ describe('[#21803] pin 10 — a disabled package is still installed: its package } }); -describe('[#21803] pin 11 — the folded position: a body served from outside the lock\'s scope states no lock the envelope does not report', () => { +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'. A request naming A in the organization is - // served A's env-wide row (content: the organization holds no row of A and - // no package-less row), while the lock's scope is the organization's rows. + // 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 says 'none', and the served body carries no _lock`, async () => { + 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: 'none', editable: true, deletable: true }); + .toEqual({ lock: 'full', editable: false, deletable: false }); expect(read.byName.item?.label).toBe('env-wide package row'); - expect(Object.keys(read.byName.item ?? {}).filter((k) => k.startsWith('_lock'))).toEqual([]); - expect(Object.keys(read.layered.effective ?? {}).filter((k) => k.startsWith('_lock'))).toEqual([]); - // The layered read still reports the stored layer as stored. + 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); 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 9787fbc9c44..40fcafc9896 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,8 +25,9 @@ * 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 the read serves the org-scoped row, - * and the door binds THAT row's `_lock`, whatever the env-wide row says. + * 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. * @@ -216,7 +217,7 @@ describe('[#21716] pin 2 — an env-wide _lock: full row binds an organization w } }); -describe('[#21716] pin 3 — both rows present: the door binds the lock of the row the read serves', () => { +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' }, @@ -229,13 +230,12 @@ describe('[#21716] pin 3 — both rows present: the door binds the lock of the r 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); - // The read serves the org-scoped row to its organization, the - // env-wide row otherwise (ADR-0005 precedence, never a merge)… - const servedLock = q.organizationId ? c.org : c.env; + // [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: servedLock, - served: q.organizationId ? 'org row' : 'env-wide row', - overlayScope: q.organizationId ? 'org' : 'env', + 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. @@ -252,7 +252,7 @@ describe('[#21716] pin 3 — both rows present: the door binds the lock of the r describe('[#21716] pin 4 — a type with no per-org channel: neither the read nor the door serves an org-scoped row', () => { // `page` declares `allowOrgOverride: false`, so an org-scoped row of it is // pre-#6190 residue boot hydration walks past. The reads gate the - // organization away (`organizationIdForMetaRead`) and serve the env-wide + // 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 }> = [ 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 a6102b9ef5d..f5fd1fc9648 100644 --- a/packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts +++ b/packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts @@ -355,33 +355,6 @@ describe('[#5707] the layered read stops painting an outage as "nothing was cust expect([caught.status, caught.code]).toEqual([503, 'SERVICE_UNAVAILABLE']); }); - it('a failed ORG-scope read does not silently demote to the env row it never got to', async () => { - // The org lookup runs first and the env lookup is inside the same - // `try`, so the swallow hid BOTH: an env-wide overlay row that was - // perfectly readable was reported as "no overlay" because the org read - // failed ahead of it. - // - // ⚠️ [commit e1d4f9e3f] `view`, not `object` — and the type is now load-bearing - // rather than incidental. This verb gates its organization through - // `organizationIdForMetaRead`, so an `allowOrgOverride: false` type - // (`object`, which the rest of this file uses as its generic subject) - // resolves `orgId` to `undefined` and never issues an org-scope read at - // all. There would be no failing read to report, and this case would - // pass vacuously against a method that had stopped doing the thing it - // is about. `view` is tier-A, so the org arm is really taken. - const err = connectionRefused(); - const engine = engineWithRows([]); - engine.findOne = vi.fn(async (_o: string, opts: any) => { - if (opts?.where?.organization_id === 'org_acme') throw err; - return { type: 'view', name: 'acct', state: 'active', metadata: JSON.stringify({ name: 'acct', label: 'Env overlay' }) }; - }); - - const caught = await rejection( - () => p_layered(engine, { type: 'view', name: 'acct', organizationId: 'org_acme' }), - ); - expectStoreUnavailable(caught, err); - }); - it('an unprovisioned sys_metadata still renders the code layer (benign, unchanged)', async () => { // First boot: no overlay row EXISTS, so `overlay: null`, // `overlayScope: null` and `effective === code` are the truth — and the diff --git a/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit-live-registry.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit-live-registry.test.ts deleted file mode 100644 index c74d7996d47..00000000000 --- a/packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit-live-registry.test.ts +++ /dev/null @@ -1,351 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * #6992 — the cold-boot audit scans the LIVE registry, not the static one. - * - * --------------------------------------------------------------------------- - * The gap this file pins - * --------------------------------------------------------------------------- - * A metadata type with no entry in `DEFAULT_METADATA_TYPE_REGISTRY` is - * registered at runtime by a plugin (`theme`, `connector`, `webhook`, - * `sharing_rule`, `analytics_cube`, …). Before this change that family fell - * through BOTH halves of the org-scoped-row protection: - * - * - `orgScopedWriteRefusal` (#6190) requires a static registry entry, so an - * org-scoped write of such a type is accepted; - * - `reportUnhydratableOrgScopedRows` (PR #6600) built its scanned-type list - * by walking `DEFAULT_METADATA_TYPE_REGISTRY`, so such a type was absent - * from the scan. - * - * …while `loadMetaFromDb`'s filter is type-BLIND (`organization_id: null`) and - * skips its rows exactly like a `flow`'s. Neither the gate nor the warning. - * - * Triage on #6992 scoped the fix to the DIAGNOSTIC half only; the refusal - * stayed static-registry-keyed. [ADR-0131 D6] has since widened the refusal to - * every organization-scoped write of every type, so the two sets agree; the - * last case in this file records that. - * - * --------------------------------------------------------------------------- - * Boot order — measured, because it is how this widening could have been inert - * --------------------------------------------------------------------------- - * The audit fires inside `loadMetaFromDb`, i.e. in `ObjectQLPlugin.start()` - * Phase 2 — after EVERY plugin's `init` (the kernel runs all inits, then all - * starts). Plugin metadata reaches the SchemaRegistry during Phase 1, via the - * `manifest` service's `ql.registerApp`. Probed on a real `app-showcase` boot, - * at the instant the audit runs: - * - * engine.registry.getRegisteredTypes() = ["action","analytics_cube","api", - * "app","book","capability","connector","dashboard","data","dataset", - * "doc","email_template","flow","hook","job","mapping","object","package", - * "page","permission","report","sharing_rule","theme","view","webhook"] - * metadataService.getRegisteredTypes() = - * live \ DEFAULT_METADATA_TYPE_REGISTRY = ["analytics_cube","connector", - * "data","package","sharing_rule","theme","webhook"] - * - * So the SchemaRegistry is the source that actually carries the family, it is - * already populated when the audit runs, and the widening is live rather than - * inert. The metadata service contributes nothing beyond the declared set at - * boot (its `typeRegistry` is seeded with `DEFAULT_METADATA_TYPE_REGISTRY` in - * the manager's constructor) — it is read anyway, because it is the other half - * of what `getMetaTypes()` lists and the two must not diverge. - * - * --------------------------------------------------------------------------- - * Reverse verification, direction predicted BEFORE running - * --------------------------------------------------------------------------- - * Ordinary red for the coverage cases, deliberate green for the guards. - * Reverting `reportUnhydratableOrgScopedRows`' scan to the static registry - * alone (the `origin/main` body) must turn the four "reports" cases red — - * they assert a line that names a plugin-registered type, which the static - * scan cannot produce — and must leave the guards green, because those assert - * an ABSENCE (`view` silence, env-wide silence) or read the registry directly - * (the premise pin, the divergence pin). Measured below in the PR body. - */ -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 { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; -import { ObjectStackProtocolImplementation } from './protocol.js'; - -interface Row { - id: string; - type: string; - name: string; - organization_id: string | null; - state: string; - metadata: string; -} - -/** Honours the two predicates the audit query relies on, like the #6190 file. */ -function matchesWhere(r: Row, where: Record): boolean { - for (const [k, v] of Object.entries(where)) { - 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; - } - continue; - } - if (actual !== v) return false; - } - return true; -} - -/** - * The engine stub differs from the #6190 file's in exactly one way that - * matters: its registry ANSWERS `getRegisteredTypes()`. That method is the - * accessor this change reads, and the #6190 stub omits it — which is why every - * case in that file keeps passing unchanged (the audit degrades to the declared - * scan when the accessor is absent, and those cases only use declared types). - */ -function makeEngine( - rows: Row[], - opts: { liveTypes?: string[]; serviceTypes?: string[]; registryThrows?: boolean } = {}, -) { - const registered: Array<{ type: string; name: string }> = []; - const engine: any = { - async find(_table: string, q: { where: Record }) { - return rows.filter((r) => matchesWhere(r, q.where)); - }, - 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: { - getRegisteredTypes: () => { - if (opts.registryThrows) throw new Error('registry exploded'); - return opts.liveTypes ?? []; - }, - 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, - }, - }; - const services = new Map(); - if (opts.serviceTypes) { - services.set('metadata', { getRegisteredTypes: async () => opts.serviceTypes }); - } - return { engine, registered, getServicesRegistry: () => services }; -} - -/** A body every type parses as "a named item" — these rows are never hydrated. */ -const body = (name: string) => JSON.stringify({ 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: body(over.name), - ...over, -}); - -async function bootAndCapture( - engine: any, - getServicesRegistry?: () => Map, -): 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, getServicesRegistry) as any; - const result = await protocol.loadMetaFromDb(); - return { result, warns }; - } finally { - spy.mockRestore(); - } -} - -const AUDIT = '[metadata_org_scoped_unhydrated]'; - -describe('#6992 — the cold-boot audit scans the live registry, not just the declared one', () => { - // ── the premise, read from the registry rather than restated ────────── - - it('the specimen types genuinely have NO entry in DEFAULT_METADATA_TYPE_REGISTRY', () => { - // If a later PR declares one of these, this case goes red and the - // corresponding case below should be re-pointed at a type that is - // still plugin-registered — not "repaired" by deleting the assertion. - for (const type of ['webhook', 'theme', 'sharing_rule', 'connector']) { - expect( - DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === type), - `${type} gained a registry entry — re-read #6992 before touching this file`, - ).toBeUndefined(); - } - }); - - // ── the acceptance criterion: the new family is reported ────────────── - - it('reports an org-scoped row of a PLUGIN-REGISTERED type the engine registry knows', async () => { - const { engine, getServicesRegistry } = makeEngine( - [ - row({ type: 'webhook', name: 'org_hook', organization_id: 'org_a' }), - row({ type: 'webhook', name: 'platform_hook' }), - ], - { liveTypes: ['object', 'view', 'webhook'] }, - ); - - const { result, warns } = await bootAndCapture(engine, getServicesRegistry); - - // Hydration is UNCHANGED — #6992 widens the diagnostic, nothing else. - // The env-wide row loads; the org-scoped one still does not. - expect(result).toMatchObject({ loaded: 1, 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('webhook×1'); - expect(line).toContain('org_hook@org_a'); - // The row that DID load is not accused. - expect(line).not.toContain('platform_hook'); - }); - - it('marks the plugin-registered family, because its remediation differs', async () => { - // A DECLARED type's org-scoped write is refused from now on (#6190), so - // its rows are historical residue that cannot grow. An UNDECLARED type's - // write is NOT refused, so the same names return after every restart - // until the author stops writing them org-scoped. The line has to say - // which of the two an operator is looking at. - const { engine, getServicesRegistry } = makeEngine( - [ - row({ type: 'flow', name: 'org_sweep', organization_id: 'org_a' }), - row({ type: 'theme', name: 'org_theme', organization_id: 'org_a' }), - ], - { liveTypes: ['flow', 'theme'] }, - ); - - const { warns } = await bootAndCapture(engine, getServicesRegistry); - - const line = warns.find((w) => w.includes(AUDIT))!; - expect(line).toContain('theme×1 [plugin-registered]'); - // The declared specimen in the SAME line carries no marker. - expect(line).toContain('flow×1 ('); - expect(line).not.toContain('flow×1 [plugin-registered]'); - expect(line).toContain('does NOT cover them'); - }); - - it('reads the metadata SERVICE as well as the engine registry', async () => { - // `getMetaTypes()` unions both sources; the audit must scan the same - // union or it accuses a type the listing does not admit exists, or - // stays silent about one it does. Here the type is known ONLY to the - // service. - const { engine, getServicesRegistry } = makeEngine( - [row({ type: 'sharing_rule', name: 'org_rule', organization_id: 'org_a' })], - { liveTypes: [], serviceTypes: ['sharing_rule'] }, - ); - - const { warns } = await bootAndCapture(engine, getServicesRegistry); - - const line = warns.find((w) => w.includes(AUDIT)); - expect(line, `no ${AUDIT} line in: ${JSON.stringify(warns)}`).toBeDefined(); - expect(line).toContain('sharing_rule×1 [plugin-registered]'); - expect(line).toContain('org_rule@org_a'); - }); - - it('stays ONE aggregated line across declared and plugin-registered types alike', async () => { - // The shape #6600 chose is unchanged by the widening: a tenant with - // many such rows costs one line, with a capped name sample per type. - const rows: Row[] = []; - for (let i = 0; i < 7; i++) { - rows.push(row({ type: 'webhook', name: `hook_${i}`, organization_id: 'org_a' })); - } - rows.push(row({ type: 'flow', name: 'org_sweep', organization_id: 'org_a' })); - const { engine, getServicesRegistry } = makeEngine(rows, { liveTypes: ['flow', 'webhook'] }); - - const { warns } = await bootAndCapture(engine, getServicesRegistry); - - const audit = warns.filter((w) => w.includes(AUDIT)); - expect(audit).toHaveLength(1); - expect(audit[0]).toContain('8 active sys_metadata row(s)'); - expect(audit[0]).toContain('webhook×7'); - expect(audit[0]).toContain('+2 more'); - expect(audit[0]).toContain('flow×1'); - }); - - // ── the silence that is the design, not a miss ──────────────────────── - - it('says NOTHING about an ENV-WIDE row of a plugin-registered type', async () => { - const { engine, getServicesRegistry } = makeEngine( - [ - row({ type: 'webhook', name: 'platform_hook' }), - row({ type: 'theme', name: 'platform_theme' }), - ], - { liveTypes: ['webhook', 'theme'] }, - ); - - const { result, warns } = await bootAndCapture(engine, getServicesRegistry); - - expect(result.loaded).toBe(2); - expect(warns.filter((w) => w.includes(AUDIT))).toEqual([]); - }); - - it('still says NOTHING about an org-scoped VIEW — widening must not touch that silence', async () => { - // `view` is `allowOrgOverride: true`. Its org row is a per-org overlay - // served on demand — the ADR-0005 design, and the case that would break - // first if the widening were written as "scan every live type". - const { engine, getServicesRegistry } = makeEngine( - [row({ type: 'view', name: 'org_grid', organization_id: 'org_a' })], - { liveTypes: ['view', 'webhook', 'theme'] }, - ); - - const { warns } = await bootAndCapture(engine, getServicesRegistry); - - expect(warns.filter((w) => w.includes(AUDIT))).toEqual([]); - }); - - // ── the diagnostic can never become the outage ──────────────────────── - - it('degrades to the declared scan when the live accessor throws', async () => { - // A best-effort probe must not be able to cost a boot its report, let - // alone its verdict: an engine whose registry throws still gets the - // declared-type half of the audit, and a healthy `storeUnavailable`. - const { engine, getServicesRegistry } = makeEngine( - [ - row({ type: 'flow', name: 'org_sweep', organization_id: 'org_a' }), - row({ type: 'webhook', name: 'org_hook', organization_id: 'org_a' }), - ], - { registryThrows: true }, - ); - - const { result, warns } = await bootAndCapture(engine, getServicesRegistry); - - 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'); - expect(line).not.toContain('webhook'); - }); - - // ── the divergence from the refusal is deliberate, and pinned ───────── - - it('the write refusal now covers the family it reports too — recorded, not deleted (ADR-0131 D6)', async () => { - // #6992's scope was the diagnostic half ONLY, and this case used to pin - // the divergence: `orgScopedWriteRefusal` refused statically-declared - // types only, so a plugin-registered type's org-scoped write was - // accepted. ADR-0131 D6 is the new ruling that widened the refusal — - // every organization-scoped write is refused, for every type — so the - // two sets now agree, and this case records it. - const refuse = (type: string) => (ObjectStackProtocolImplementation as any).organizationScopedWriteRefusal( - `Metadata item '${type}/x'`, 'org_a', - ); - expect(refuse('flow')).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - for (const type of ['webhook', 'theme', 'sharing_rule', 'connector']) { - expect(refuse(type), `${type} write refusal changed`).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - } - }); -}); 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 index 141c72675a1..54a5249e3cf 100644 --- 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 @@ -1,67 +1,29 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * #6190 — cold boot must SAY which org-scoped rows it walked past. + * [#6190, ADR-0131 D6] Cold boot SAYS which stored rows sit in a legacy + * organization's layer — rows no read serves any more. * - * --------------------------------------------------------------------------- - * What survived the upstream ruling, measured against `origin/main` - * --------------------------------------------------------------------------- - * #6155 Q1=B → #6283 → commit 474f131cf rolled `flow`'s `allowOrgOverride` back to - * `false` and proved declared=enforced on the write side: overlaying a - * PACKAGED flow per org is now a 403 `NOT_OVERRIDABLE` before persistence. + * #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. * - * That closed one of the two write tiers. The other is still open BY DESIGN — - * `flow` keeps `allowRuntimeCreate: true`, which is ADR-0005's "a deployment, - * not an overlay" — and it is the tier the tenant scenario in #6190 actually - * uses: authoring a BRAND-NEW flow in Studio. Measured on current main, that - * write still lands `sys_metadata.organization_id = ''`, because - * `SysMetadataRepository.put` stamps `organization_id: this.organizationId` - * for every type and the runtime `PUT /metadata/:type/:name` threads - * `resolveActiveOrganizationId` into `saveMetaItem`: - * - * PROBE rows = [{"name":"org_sweep","org":"org_a"}, - * {"name":"platform_sweep","org":null}] - * PROBE loadMetaFromDb = {"loaded":1,...} // only platform_sweep - * PROBE logs during cold boot = [] // ← the defect - * PROBE getMetaItems({type:flow}) no org = ["platform_sweep"] - * - * So the symptom #6190 filed — an org-scoped flow that fires all day and never - * fires again after a restart — is still reachable, and the third line is why - * nobody can tell: the skip was completely silent. `kernel:bootstrapped`'s - * unbound audit cannot report it either, because the flow was never registered. - * - * This file pins the loud half. It does NOT change what boot loads — whether - * such a row should exist at all (refuse the write / force it env-wide / teach - * the binder to read per-org) is a contract ruling recorded on the issue. - * - * --------------------------------------------------------------------------- - * Reverse verification, direction predicted BEFORE running - * --------------------------------------------------------------------------- - * Ordinary red, with a deliberately green control. Deleting the - * `reportUnhydratableOrgScopedRows()` call from `loadMetaFromDb` turns the - * three "warns" cases red AND the probe-failure case with them — that one - * asserts the second `find` happened at all, so it goes red counting calls - * rather than reading a message. The two silence cases and the registry - * premise pin stay green: they assert an ABSENCE of output, which a deleted - * producer trivially satisfies. Predicted 4 red / 3 green; measured 4 red / - * 3 green, and the reds fail in the shape that names the defect: - * - * AssertionError: no [metadata_org_scoped_unhydrated] line in: [] - * AssertionError: expected 1 to be greater than 1 - * - * — the silent cold boot of the PROBE output above, reproduced on demand. - * - * The silence cases are not slack: a "fix" that warned about every skipped - * org-scoped row would pass the red half and fail there, and that shape is - * wrong — for `view` and friends (`allowOrgOverride: true`) the skip IS the - * ADR-0005 design, loaded on demand by `getMetaItem`/`getMetaItems`. + * 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 { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { ObjectStackProtocolImplementation } from './protocol.js'; interface Row { @@ -82,6 +44,11 @@ interface Row { */ 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') { @@ -93,6 +60,7 @@ function matchesWhere(r: Row, where: Record): boolean { 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; @@ -100,15 +68,26 @@ function matchesWhere(r: Row, where: Record): boolean { return true; } -function makeEngine(rows: Row[], opts: { dropPredicates?: boolean } = {}) { +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 }) { + 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.filter((r) => r.state === q.where.state); + 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' }; }, @@ -171,50 +150,46 @@ async function bootAndCapture(engine: any): Promise<{ result: any; warns: string } } -const AUDIT = '[metadata_org_scoped_unhydrated]'; +const AUDIT = '[metadata_org_scoped_unserved]'; -describe('#6190 — cold boot names the org-scoped rows it cannot hydrate', () => { - // ── the premise, read from the registry rather than restated ────────── - - it('flow is the specimen: not per-org overridable, still runtime-creatable', () => { - // Both halves matter. `allowOrgOverride: false` (#6283 / commit 474f131cf) is - // why an org-scoped flow row can never be read back as an overlay; - // `allowRuntimeCreate: true` is why one can still be WRITTEN. If a - // later ruling closes the second flag, this case goes red and the - // whole file should be re-read, not repaired. - expect(DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'flow')).toMatchObject({ - allowOrgOverride: false, - allowRuntimeCreate: true, - }); - // The control specimen's flag, likewise read and not assumed. - expect(DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'view')).toMatchObject({ - allowOrgOverride: true, - }); - }); - - // ── the acceptance criterion: the absence is loud ───────────────────── - - it('warns, naming type/name/org, when an org-scoped FLOW row is skipped', async () => { +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 itself is UNCHANGED — this issue's fix is the log, not a - // load. The org row stays out of the process-wide registry. - expect(result).toMatchObject({ loaded: 1, errors: 0, invalid: 0, storeUnavailable: false }); + // 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'); - expect(line).toContain('org_sweep@org_a'); - // The consequence, not just the fact — an operator reading this must - // learn why an automation stopped firing after a restart. + 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'); - // And it must not name the row that DID load. - expect(line).not.toContain('platform_sweep'); + 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 () => { @@ -232,45 +207,20 @@ describe('#6190 — cold boot names the org-scoped rows it cannot hydrate', () = expect(audit[0]).toContain('+4 more'); }); - it('still warns when the driver drops the predicates and returns a superset', async () => { - // `driver-memory` historically dropped `is_null` outright (see its - // `memory-filter-ast-vocabulary.test.ts`), so the audit re-checks both - // predicates in JS. A superset must produce the SAME line — not a - // false accusation against the env-wide and view rows in it. + 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' }), - row({ type: 'view', name: 'org_grid', organization_id: 'org_a' }), ], { dropPredicates: true }); const { warns } = await bootAndCapture(engine); const line = warns.find((w) => w.includes(AUDIT)); - expect(line).toBeDefined(); expect(line).toContain('org_sweep@org_a'); expect(line).not.toContain('platform_sweep'); - expect(line).not.toContain('org_grid'); - }); - - // ── the silence that is the design, not a miss ──────────────────────── - - it('says NOTHING about an org-scoped VIEW — that skip is ADR-0005 working', async () => { - // `view` is `allowOrgOverride: true`: the row is a per-org overlay, - // deliberately not hydrated process-wide and served on demand by - // `getMetaItem`/`getMetaItems({ organizationId })`. Warning here would - // print a line at every boot of every healthy tenant. - const { engine } = makeEngine([ - row({ type: 'view', name: 'org_grid', organization_id: 'org_a' }), - row({ type: 'view', name: 'platform_grid' }), - ]); - - const { result, warns } = await bootAndCapture(engine); - - expect(result.loaded).toBe(1); - expect(warns.filter((w) => w.includes(AUDIT))).toEqual([]); }); - it('says nothing at all on a store with no org-scoped rows', async () => { + 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' }), @@ -282,13 +232,9 @@ describe('#6190 — cold boot names the org-scoped rows it cannot hydrate', () = expect(warns.filter((w) => w.includes(AUDIT))).toEqual([]); }); - // ── the diagnostic can never become the outage ──────────────────────── - it('a failing audit probe does not change the boot verdict', async () => { - // #5897 draws a hard line between "the store had no rows" and "the - // store could not be read". A best-effort extra probe must not be able - // to cross it: the first `find` succeeds, so this boot is HEALTHY, and - // `storeUnavailable` must stay false even though the probe threw. + // #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; 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 a670d7a317f..695b88b89d8 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 @@ -198,7 +198,7 @@ async function seedLegacyOrgDraft( args: { type: string; name: string; body: unknown; organizationId: string; packageId?: string | null }, ): Promise { await protocol.ensureOverlayIndex(); - const repo = protocol.getOverlayRepo(args.organizationId); + const repo = protocol.legacyOrganizationRepo(args.organizationId); await repo.put( { type: args.type, name: args.name, org: args.organizationId }, args.body, @@ -437,7 +437,7 @@ describe('a package-less organization overlay, two packages shipping its view na /** The organization read's copies of the view, where the doors find the form. */ async function servedCopies(protocol: any): Promise>> { - const org: any = await protocol.getMetaItems({ type: 'view', organizationId: 'org_a' }); + const org: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_a' }); return (org.items as any[]).filter((v) => v?.name === NAME); } @@ -459,7 +459,7 @@ describe('a package-less organization overlay, two packages shipping its view na */ async function storeOpenOverlay(protocol: any, rows: Map) { await protocol.ensureOverlayIndex(); - await protocol.getOverlayRepo('org_a').put( + 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 }, @@ -734,7 +734,7 @@ describe('each package\'s copy of a view container expands into its own package\ /** 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.getOverlayRepo('org_a').put( + 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 }, @@ -762,7 +762,7 @@ describe('each package\'s copy of a view container expands into its own package\ * judged over the env-wide view list beneath it. */ async function doorsServe(protocol: any): Promise { - const org: any = await protocol.getMetaItems({ type: 'view', organizationId: 'org_a' }); + 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) .some((c) => c.slug === SLUG && !anonymousFormIntakeWithdrawnIn(envWide.items, view, c))); @@ -1012,7 +1012,7 @@ describe('each package\'s copy of a view container expands into its own package\ /** 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.getOverlayRepo('org_a').put( + 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 }, diff --git a/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts b/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts new file mode 100644 index 00000000000..cbd537c2d78 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts @@ -0,0 +1,382 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [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'`. + * 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. + * + * 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. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + assertEngineDeleteDispatch, + assertEngineFindOnePredicate, + assertEngineUpdateDispatch, +} from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const ORG = 'org_a'; +const PKG = 'app.pkg'; +const TIER_A = ['view', 'dashboard', 'report', 'translation', 'email_template'] as const; + +type Row = Record; + +/** Equality (NULL included), `$or`, `$and`, `$null`, `$in` and `$ne` — the subset these reads emit. */ +function matches(row: Row, where: Record = {}): boolean { + for (const [k, v] of Object.entries(where)) { + if (k === '$or') { + if (!(v as Row[]).some((clause) => matches(row, clause))) return false; + continue; + } + if (k === '$and') { + if (!(v as Row[]).every((clause) => matches(row, clause))) return false; + continue; + } + // Any other combinator is refused, never read as a field name. + if (k.startsWith('$')) throw new Error(`matches: unsupported combinator ${k}`); + if (v === undefined) continue; + const actual = row[k] ?? null; + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + const ops = v as Record; + for (const op of Object.keys(ops)) { + if (op !== '$null' && op !== '$in' && op !== '$ne') throw new Error(`matches: unsupported operator ${op}`); + } + if ('$null' in ops && (actual === null) !== ops.$null) return false; + if ('$in' in ops && !(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; +} + +interface Artifact { type: string; name: string; body: Row } + +function harness(seed: Record, artifacts: Artifact[] = []) { + const tables = new Map(Object.entries(seed).map(([t, rows]) => [t, rows.map((r) => ({ ...r }))])); + const table = (t: string) => { + if (!tables.has(t)) tables.set(t, []); + return tables.get(t)!; + }; + let nextId = 0; + const artifactOf = (type: string, name: string) => + artifacts.find((a) => a.type === type && a.name === name)?.body; + /** Every `find` the protocol made: the object, its predicate, and the columns it asked for. */ + const finds: Array<{ object: string; where?: Row; fields?: string[] }> = []; + const engine: any = { + async find(t: string, q?: { where?: Row; limit?: number; fields?: string[] }) { + finds.push({ object: t, where: q?.where, fields: q?.fields }); + const rows = table(t).filter((r) => matches(r, q?.where)); + const page = typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + // A projection hands back only the columns asked for, as a driver does. + const fields = q?.fields; + return fields ? page.map((r) => Object.fromEntries(fields.map((f) => [f, r[f]]))) : page; + }, + async count(t: string, q?: { where?: Row }) { + return table(t).filter((r) => matches(r, q?.where)).length; + }, + async findOne(t: string, q: { where: Row }) { + assertEngineFindOnePredicate(t, q); + return table(t).find((r) => matches(r, q.where)) ?? null; + }, + async insert(t: string, data: Row) { + nextId += 1; + const row = { ...data, id: data.id ?? `i_${nextId}` }; + table(t).push(row); + return { id: row.id }; + }, + async update(_t: string, data: Row, o: { where: Row }) { + assertEngineUpdateDispatch(data, o); + return { id: null }; + }, + async delete(_t: string, o: { where: Row }) { + assertEngineDeleteDispatch(o); + return { deleted: 0 }; + }, + async transaction(cb: (ctx: unknown, info: { owned: boolean }) => Promise): Promise { + return cb(undefined, { owned: true }); + }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + getObject: () => undefined, + getPackage: () => undefined, + isPackageDisabled: () => false, + listItems: (type: string) => artifacts.filter((a) => a.type === type).map((a) => ({ ...a.body })), + getItem: (type: string, name: string) => artifactOf(type, name), + getArtifactItem: (type: string, name: string) => artifactOf(type, name), + }, + }; + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_prod') as any; + 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')}`, + type, + name, + organization_id: org, + package_id: null, + state: 'active', + version: 1, + metadata: JSON.stringify({ name, ...body }), + ...over, +}); + +/** The two spellings a caller may still send: none, and a retired organization key. */ +const CALLERS = [{}, { organizationId: ORG }] as const; +const asCaller = (request: R, caller: object) => ({ ...request, ...caller }) as R; + +afterEach(() => vi.restoreAllMocks()); + +describe('§1 [ADR-0131 D6] a legacy organization row is invisible to every read, 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_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' }, + ], + 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 }, + ], + }); + + 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 () => { + const { protocol } = harness(seed()); + + // item: the environment's row, its lock; the org-only name answers nothing + 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' + 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)); + expect(audit.events.map((e: Row) => e.id)).toEqual(['a_env']); + + // drafts + const drafts = await protocol.listDrafts(asCaller({ type }, caller)); + expect(drafts.drafts).toEqual([]); + + // commits + const commits = await protocol.listCommits(asCaller({ packageId: PKG }, caller)); + expect(commits.map((c: Row) => c.id)).toEqual(['c_env']); + + // ETag: one validator, whoever asks + const cached = await protocol.getMetaItemCached(asCaller({ type, name: 'shared' }, caller)); + const unscoped = await protocol.getMetaItemCached({ type, name: 'shared' }); + expect(cached.etag.value).toBe(unscoped.etag.value); + expect(cached.data?.label).toBe('env'); + }); + } + } +}); + +/** 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 }, + }); + + 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' })] }, + [packaged(type, 'managed')], + ); + expect(protocol.declinesStoredRow(type, 'managed')).toBe(true); + + const item = await protocol.getMetaItem({ type, name: 'managed' }); + expect(item.item?.label).toBe('packaged'); + + const list = await protocol.getMetaItems({ type }); + expect((list.items as Row[]).filter((i) => i.name === 'managed').map((i) => i.label)).toEqual(['packaged']); + + const layered = await protocol.getMetaItemLayered({ type, name: 'managed' }); + expect(layered.effective?.label).toBe('packaged'); + }); + + 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' })] }); + expect(protocol.declinesStoredRow(type, 'authored')).toBe(false); + expect((await protocol.getMetaItem({ type, name: 'authored' })).item?.label).toBe('authored'); + }); + } + + 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' })] }, + [packaged('view', 'managed')], + ); + expect(protocol.declinesStoredRow('view', 'managed')).toBe(false); + expect((await protocol.getMetaItem({ type: 'view', name: 'managed' })).item?.label).toBe('overlay'); + }); + + 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: {} })] }, + [packaged('permission', 'managed')], + ); + expect(protocol.declinesStoredRow('permission', 'managed')).toBe(false); + }); +}); + +describe('§3 the boot report names both populations, 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(' ')); }); + const { protocol, finds } = harness(seed, artifacts); + const result = await protocol.loadMetaFromDb(); + return { result, warns, finds }; + } + + it('one line per population: the legacy organization rows (the ceremony), 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' }), + ], + }, + [ + { type: 'action', name: 'managed', body: { name: 'managed', _packageId: PKG } }, + { type: 'hook', name: 'managed', body: { name: 'managed', _packageId: PKG } }, + ], + ); + + 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'); + + const sealed = warns.filter((w) => w.includes('[metadata_sealed_overlay_unserved]')); + expect(sealed).toHaveLength(1); + expect(sealed[0]).toContain('action×1 (managed)'); + expect(sealed[0]).toContain('hook×1 (managed)'); + expect(sealed[0]).toContain('linkage-free clone'); + expect(sealed[0]).toContain('delete the stored row'); + 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' })] }); + expect(warns.filter((w) => w.includes('_unserved]'))).toEqual([]); + }); +}); + +describe('§4 [triage ruling Q3 A] the anonymous form doors\' legacy layer is the one read left that reaches a legacy organization', () => { + 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' }), + ], + }); + + 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([]); + }); +}); 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 6c3ce3fbf7a..16b480c1178 100644 --- a/packages/metadata-protocol/src/protocol.scoped-list-fallback.test.ts +++ b/packages/metadata-protocol/src/protocol.scoped-list-fallback.test.ts @@ -4,8 +4,9 @@ * [#21817, ADR-0048, ADR-0005] A list scoped to one package * (`getMetaItems({ type, packageId })`, `GET /api/v1/meta/:type?package=`) * serves, in each slot the package ships, the row `getMetaItem` naming that - * package serves: the package's own row, else the package-less row, the - * organization's rows before the env-wide ones. + * package serves: the package's own row, else the package-less row — the + * environment's rows only (ADR-0131 D6: a legacy organization-scoped row is + * served by no read, whatever organization the request comes from). * * The scoped list read its stored rows with the package only, so a * package-less row never reached its merge. With the package's artifact and a @@ -184,13 +185,13 @@ function subsets(xs: readonly T[]): T[][] { } /** - * The oracle, written from the rule: the scopes the request reaches - * (ADR-0005: the organization's, then env-wide), and within one scope the - * package's own row before the package-less row (ADR-0048), never another - * package's. With no row, the package's artifact. + * The oracle, written from the rule: the environment's rows (ADR-0131 D6 — + * the request's organization reaches no row), the package's own row before + * the package-less row (ADR-0048), never another package's. With no row, the + * package's artifact. */ -function servedLabel(rows: readonly RowKey[], packageId: string, organizationId: string | undefined, artifact: boolean): string | undefined { - const scopes: Scope[] = organizationId ? ['org-scoped', 'env-wide'] : ['env-wide']; +function servedLabel(rows: readonly RowKey[], packageId: string, _organizationId: string | undefined, artifact: boolean): string | undefined { + const scopes: Scope[] = ['env-wide']; for (const scope of scopes) { for (const candidate of [packageId, null]) { const hit = rows.find((key) => ROWS[key].scope === scope && ROWS[key].packageId === candidate); @@ -201,10 +202,9 @@ function servedLabel(rows: readonly RowKey[], packageId: string, organizationId: } /** Whether the package ships the name: its artifact, or a row of its own in scope. */ -function ships(rows: readonly RowKey[], packageId: string, organizationId: string | undefined, artifact: boolean): boolean { +function ships(rows: readonly RowKey[], packageId: string, _organizationId: string | undefined, artifact: boolean): boolean { if (artifact && packageId === PKG_A) return true; - return rows.some((key) => ROWS[key].packageId === packageId - && (ROWS[key].scope === 'env-wide' || organizationId !== undefined)); + return rows.some((key) => ROWS[key].packageId === packageId && ROWS[key].scope === 'env-wide'); } /** The items of the scoped list, by name. */ @@ -299,19 +299,19 @@ describe('pin 2 — named arrangements, both row orders: the scoped slot is getM } } for (const order of orders(['orgPackageless', 'envA'])) { - it(`organization ${ORG} · the organization's package-less row over an env-wide row of A · row order ${order.join(', ')}`, async () => { + 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.orgPackageless.label]); - expect((await byName(protocol, PKG_A, { organizationId: ORG })).item?.label).toBe(ROWS.orgPackageless.label); + 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(`organization ${ORG} · the organization's row of A over the env-wide package-less row · row order ${order.join(', ')}`, async () => { + 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.map((s) => s.label)).toEqual([ROWS.orgA.label]); - expect((await byName(protocol, PKG_A, { organizationId: ORG })).item?.label).toBe(ROWS.orgA.label); + expect(slots).toEqual([]); + expect((await byName(protocol, PKG_A, { organizationId: ORG })).item?.label).toBe(ROWS.envPackageless.label); }); } }); @@ -333,10 +333,12 @@ describe('pin 3 — membership: a package-less row of a name the package does no const listedWithout = names(await scopedList(without, PKG_A, scope)); expect(listedWithout).toEqual([`d_rowed@${PKG_A}`, `${NAME}@${PKG_A}`]); expect(names(await scopedList(withRow, PKG_A, scope))).toEqual(listedWithout); - // The row is still served where it belongs: the unscoped list. + // 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. const unscoped: any = await withRow.getMetaItems({ type: TYPE, ...scope }); expect((unscoped.items as any[]).filter((i) => i.name === OTHER).map((i) => i.label)) - .toEqual([ROWS[scopeKey].label]); + .toEqual(scopeKey === 'orgPackageless' ? [] : [ROWS[scopeKey].label]); }); } } @@ -347,13 +349,16 @@ 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) { - it(`a ${ROWS[scopeKey].label} of the name: the scoped slot serves it, as getMetaItem naming A does`, async () => { + // [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; + 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 }; const slots = await scopedSlots(protocol, PKG_A, scope); expect(slots.map((s) => ({ label: s.label, packageId: s._packageId }))) - .toEqual([{ label: ROWS[scopeKey].label, packageId: PKG_A }]); - expect((await byName(protocol, PKG_A, scope)).item?.label).toBe(ROWS[scopeKey].label); + .toEqual([{ label: served, packageId: PKG_A }]); + expect((await byName(protocol, PKG_A, scope)).item?.label).toBe(served); }); } it('control: no runtime item of A, the package-less row adds no slot; lit control: the runtime item alone is served', async () => { @@ -432,15 +437,15 @@ 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(`organization ${ORG} · the organization's package-less row served over env A · ${ROWS[declaredOn].label} declares ${lock}`, async () => { + 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) => stored(key, { body: key === declaredOn ? { _lock: lock, _lockReason: `declared on ${key}` } : {} })); const protocol = harness(rows, { items: [ARTIFACT_A] }); const scope = { organizationId: ORG }; const [slot] = await scopedSlots(protocol, PKG_A, scope); const read = await byName(protocol, PKG_A, scope); - expect(slot?.label).toBe(ROWS.orgPackageless.label); - expect(read.item?.label).toBe(ROWS.orgPackageless.label); + expect(slot?.label).toBe(ROWS.envA.label); + expect(read.item?.label).toBe(ROWS.envA.label); expect({ lock: slot?._lock ?? 'none', reason: slot?._lockReason }) .toEqual({ lock: read.lock, reason: read.item?._lockReason }); }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 239dd6270f9..65aa95bfb0f 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -85,11 +85,6 @@ import { ITEM_KEY_DISCRIMINATORS, itemDiscriminator, type MetadataItem, - // [#13216] The search sweep's page read carries the SAME org scope the - // REST `/meta` read doors derive — registry-gated, never a bare tenant id - // (#9454's read-side rule). One predicate on both doors means the swept - // page set and the served page set cannot drift. - organizationIdForMetaRead, // [#16319] The field-`type` admission vocabulary. `loadMetaFromDb` asks the // registration door's OWN question — never a second opinion about which // declarations the registry will take, which is the defect class the @@ -1979,8 +1974,9 @@ function storedRowDiscriminator(type: string, record: unknown): string | undefin /** * One place a stored `sys_metadata` row of an item can sit, as - * {@link servedOverlayRowCandidates} orders them: a scope (the organization's, - * or env-wide with `organizationId: null`), a spelling of the type + * {@link servedOverlayRowCandidates} orders them: a scope (the environment's, + * `organizationId: null`, or — for the anonymous form doors' legacy layer + * alone — a legacy organization's), a spelling of the type * (`'canonical'`, or `'other'`, the type's singular/plural twin, pre-#4432 * residue), and the package the row is bound to: the address's package, `null` * for the package-less row, or `undefined` for any row (an address that names @@ -2004,12 +2000,19 @@ interface StoredRowPlace { } /** - * [#21804, ADR-0005, ADR-0048] THE served-row resolution: the order in which - * the stored rows of one item are tried for the CONTENT a read serves. The - * first candidate that holds a row is the served row. - * - * - Scope first (ADR-0005): the organization's rows, then the env-wide rows. - * Precedence, never a merge. + * [#21804, ADR-0131 D6, ADR-0048] THE served-row resolution: the order in + * which the stored rows of one item are tried for the CONTENT a read serves. + * 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. * - 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 @@ -2030,11 +2033,11 @@ interface StoredRowPlace { * scope (`resolveOverlayLockLayer` in `item-lock.ts`, #21761). */ function servedOverlayRowCandidates(address: { - readonly organizationId: string | undefined; + readonly legacyOrganizationId?: string; readonly packageId: string | undefined; }): ServedOverlayRowCandidate[] { - const scopes: Array> = address.organizationId - ? [{ scope: 'org', organizationId: address.organizationId }, { scope: 'env', organizationId: null }] + const scopes: Array> = address.legacyOrganizationId + ? [{ scope: 'org', organizationId: address.legacyOrganizationId }, { scope: 'env', organizationId: null }] : [{ scope: 'env', organizationId: null }]; const packages: Array = address.packageId ? [address.packageId, null] : [undefined]; return scopes.flatMap((scope) => (['canonical', 'other'] as const).flatMap((spelling) => @@ -2048,7 +2051,7 @@ function servedOverlayRowCandidates(address: { * `undefined`, which matches a row of any package. */ function addressPackages(packageId: string | undefined): ReadonlyArray { - return [...new Set(servedOverlayRowCandidates({ organizationId: undefined, packageId }) + return [...new Set(servedOverlayRowCandidates({ packageId }) .map((candidate) => candidate.packageId))]; } @@ -2113,8 +2116,9 @@ function servedViewExpansion( * draft winning). * • [#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`: the organization's row - * before the env-wide one, then `P`'s own before the package-less one. It + * {@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 * 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), @@ -2162,7 +2166,7 @@ function mergePackageAwareOverlay( baseItems: unknown[], records: Array<{ data: unknown; packageId: string | undefined; stored?: StoredRowPlace; standIn?: boolean }>, transform?: (data: any, prev: any) => any, - rowsRead?: { readonly organizationId: string | undefined }, + rowsRead?: { readonly legacyOrganizationId?: string }, unseated?: WeakSet, ): unknown[] { // Per-SLOT, layer-ordered contributions; `pkg: undefined` = package-less. @@ -2186,7 +2190,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({ organizationId: rowsRead?.organizationId, packageId: real })) { + for (const candidate of servedOverlayRowCandidates({ legacyOrganizationId: rowsRead?.legacyOrganizationId, packageId: real })) { const hit = list.find((c) => c.stored !== undefined && c.stored.organizationId === candidate.organizationId && (c.stored.type === type ? 'canonical' : 'other') === candidate.spelling @@ -5284,7 +5288,6 @@ export interface MetadataAuthoringGateContext { name: string; /** Lifecycle the body is being saved into. */ state: 'draft' | 'active'; - organizationId?: string; /** The body being persisted. */ body: unknown; /** True when a packaged artifact backs this name — the write is an env overlay of shipped metadata. */ @@ -6005,7 +6008,7 @@ export class ObjectStackProtocolImplementation implements * declaration body (the baseline an overlay customizes) for the gate. */ private async runAuthoringGate(evt: { - type: string; name: string; state: 'draft' | 'active'; organizationId?: string; body: unknown; + type: string; name: string; state: 'draft' | 'active'; body: unknown; }): Promise { const singular = PLURAL_TO_SINGULAR[evt.type] ?? evt.type; const gate = this.authoringGates.get(singular); @@ -6021,7 +6024,6 @@ export class ObjectStackProtocolImplementation implements type: singular, name: evt.name, state: evt.state, - ...(evt.organizationId ? { organizationId: evt.organizationId } : {}), body: evt.body, isArtifactBacked: artifactBacked, ...(declaredBody !== undefined ? { declaredBody } : {}), @@ -6070,12 +6072,6 @@ export class ObjectStackProtocolImplementation implements */ private async assertRuntimeAuthoringRules(evt: { type: string; name: string; state: 'draft' | 'active'; body: unknown; source?: string; - /** - * The organization partition of this write (`saveMetaItem`'s - * `organizationId`). Absent = a platform-level / environment write, - * which is one limb of the #6285 refusal combination. - */ - organizationId?: string | null; /** * [#9612] The package this write belongs to (`saveMetaItem`'s * `packageId`, or the promoted draft row's). It decides the CLOSURE the @@ -6217,7 +6213,6 @@ export class ObjectStackProtocolImplementation implements listRegisteredCollection(singularType, pluralType), singularType, pluralType, - evt.organizationId ?? null, ); // Resolved together rather than in sequence: each is one indexed // `sys_metadata` read and they do not depend on one another, so the @@ -6286,7 +6281,6 @@ export class ObjectStackProtocolImplementation implements // collections above. Absent on every non-batch door. ...(evt.pending !== undefined ? { pending: evt.pending } : {}), ...(packageScope !== undefined ? { packageScope } : {}), - ...(evt.organizationId !== undefined ? { organizationId: evt.organizationId } : {}), orgWallEnforced: this.orgWallEnforced(), // [#21476] The posture IN FORCE, for the public-form intake // advisory — a separate input from the requested one above, on @@ -6411,29 +6405,26 @@ export class ObjectStackProtocolImplementation implements registered: unknown[], singularType: string, pluralType: string, - organizationId: string | null, ): Promise { if (typeof this.engine?.find !== 'function') return registered; let rows: Record[]; try { - const scopes: (string | null)[] = organizationId ? [null, organizationId] : [null]; - const read = async (type: string, oid: string | null): Promise[]> => { + // [ADR-0131 D6] The environment's rows only: a legacy + // organization-scoped row is reported at boot, never read + // ({@link reportUnhydratableOrgScopedRows}). + 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: oid }, + where: { type, state: 'active', organization_id: null }, context: { isSystem: true }, }); return (rs ?? []) as Record[]; }; - rows = []; - for (const oid of scopes) { - // The same singular/plural retry the registry half and - // `getMetaItems` both perform: rows written through a plural - // URL spelling are stored under it. - let rs = await read(singularType, oid); - if (rs.length === 0) rs = await read(pluralType, oid); - rows.push(...rs); - } + // The same singular/plural retry the registry half and + // `getMetaItems` both perform: rows written through a plural + // URL spelling are stored under it. + rows = await read(singularType); + if (rows.length === 0) rows = await read(pluralType); } catch (error) { if (!isMissingTableError(error, 'sys_metadata')) { console.warn( @@ -6929,7 +6920,7 @@ export class ObjectStackProtocolImplementation implements // registry (#6602). So an event a peer stamped with an organization // converges on the environment row — the state the registry holds. const orgId = null; - const repo = this.getOverlayRepo(orgId); + const repo = this.getOverlayRepo(); const ref = { type, name: evt.name, @@ -7051,11 +7042,27 @@ export class ObjectStackProtocolImplementation implements } /** - * Lazily obtain a SysMetadataRepository for the given organization. - * Env-wide overlays (organizationId == null) share a singleton under - * the `__env__` key. + * [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. */ - private getOverlayRepo(organizationId: string | null): SysMetadataRepository { + 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__'; let repo = this.overlayRepos.get(key); if (!repo) { @@ -7833,7 +7840,6 @@ export class ObjectStackProtocolImplementation implements async getMetaDiagnostics(request: { type?: string; severity?: 'error' | 'warning'; - organizationId?: string; packageId?: string; } = {}): Promise<{ entries: Array<{ type: string; name: string; diagnostics: MetadataDiagnostics }>; @@ -7866,7 +7872,6 @@ export class ObjectStackProtocolImplementation implements try { listed = await this.getMetaItems({ type: t, - organizationId: request.organizationId, packageId: request.packageId, } as any); } catch (error) { @@ -7995,7 +8000,6 @@ export class ObjectStackProtocolImplementation implements artifact: this.artifactLockLayerAt({ type: t, name: itemName, - organizationId: request.organizationId, packageId: request.packageId ?? (item?._packageId as string | undefined), }, shipping), overlay: item, @@ -8532,17 +8536,16 @@ export class ObjectStackProtocolImplementation implements */ private async restoredCredentialPathsFor(args: { type: string; - organizationId: string | null; name: string; packageId: string | null; item: unknown; }): Promise { if (!hasMetadataRedactor(args.type)) return []; - const repo = this.getOverlayRepo(args.organizationId); + const repo = this.getOverlayRepo(); const ref = { type: args.type, name: args.name, - org: args.organizationId ?? 'env', + org: 'env', } as Parameters[0]; const body = await this.storedBodyForCarryForward({ type: args.type, @@ -8683,7 +8686,13 @@ export class ObjectStackProtocolImplementation implements ); } - async getMetaItems(request: { type: string; packageId?: string; organizationId?: string; previewDrafts?: boolean }) { + /** + * `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 }) { // #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 @@ -8720,7 +8729,7 @@ export class ObjectStackProtocolImplementation implements * export, a log line. Every such exit serves {@link getMetaItems}, whose * redaction is the whole reason this method has to exist separately. */ - async getMetaItemsForExecution(request: { type: string; packageId?: string; organizationId?: string; previewDrafts?: boolean }) { + async getMetaItemsForExecution(request: { type: string; packageId?: string; previewDrafts?: boolean }) { // The same #4432 fold {@link getMetaItems} applies, for the same reason. return this.readFlattenedMetaItems(canonicalizeMetaRequestType(request), 'execution'); } @@ -8735,112 +8744,24 @@ export class ObjectStackProtocolImplementation implements * `request` arrives already folded by the calling face (#4432). */ private async readFlattenedMetaItems( - request: { type: string; packageId?: string; organizationId?: string; previewDrafts?: boolean }, + request: { type: string; packageId?: string; previewDrafts?: boolean; legacyFormOrganizationId?: string }, audience: 'served' | 'execution', ) { const { packageId } = request; - // ── [commit 96326040f] The registry read gate, resolved ONCE, HERE ────────────────── - // - // {@link organizationIdForMetaRead} — the predicate the REST `/meta` - // read doors applied from #9454 until the doors stopped carrying an - // organization at all (ADR-0131 D6). Until this line - // `getMetaItems` applied NO gate of its own: whatever organization - // arrived was used for whatever type arrived, so the scope of a - // metadata sweep was decided per type, BY THE CALLER — and a request - // carrying ONE `organizationId` can only be correct when it sweeps ONE - // type. Three live callers sweep more than one: `getMetaDiagnostics`' - // untyped arm walks the whole registry; `findReferencesToMeta` spends - // the request's organization on `matcher.fromType` — the SOURCES — - // while `request.type` is the TARGET, so the target's own flag says - // nothing about the types actually read; and the runtime's package - // export sweep (`domains/packages.ts`) walks every plural key with one - // raw active organization. - // - // ⭐ THE HARM IS RESURRECTION, NOT CONCEALMENT, and which one it is - // decides that the registry-gated predicate is the right instrument. - // `SysMetadataRepository.history()` filters `organization_id` by strict - // equality, so naming the tenant THERE hides an `allowOrgOverride: - // false` type's rows. Here the two `queryByOrg` reads are UNIONed, so - // naming it can only ADD — and what it adds are the pre-#6190 - // phantoms: org-scoped rows of types with no per-org read channel, - // which `loadMetaFromDb` walks past and - // {@link reportUnhydratableOrgScopedRows} exists to say out loud. Read - // back, they surface in the admin "Used by" panel and the Studio - // governance directory — inside a clearance shown before a - // destructive action, where a resurrected row is worse than an - // omission because it reads as evidence. - // - // ── Why this is a repair, not a tenancy read-scope change ─────────── - // - // Let `f(t, o) = organizationIdForMetaRead(t, o)`. `f` answers `o` when - // the registry declares `t` per-org overridable and `undefined` - // otherwise, so `f(t, f(t, o)) === f(t, o)` for EVERY `t` and `o`. A - // caller that already gates therefore sees no change, provided it gated - // on the same type this line gates on. Every such caller does: - // - // • `packages/rest`'s `GET /meta/:type` list door — one of TWO doors - // that both gate and reach this method — computes - // `organizationIdForMetaRead(canonicalMetaUrlType(req.params.type), - // ctx?.tenantId)` and then passes `type: req.params.type`, the RAW - // segment. The first statement of this method folds that segment - // through {@link canonicalizeMetaRequestType}, which IS - // `canonicalMetaUrlType` — so `request.type` here is the identical - // STRING the door gated on, and the second application is the - // algebraic no-op above. - // • `GET /meta/diagnostics?type=` — the SECOND such door, and the one - // a caller-side grep cannot see. It gates the same way - // (`organizationIdForMetaRead(canonicalMetaUrlType(diagnosticsType), - // ctx?.tenantId)`) and then passes its own RAW segment on — but it - // reaches this method TRANSITIVELY, through - // {@link getMetaDiagnostics}, whose `?type=` arm sets `targetTypes = - // [request.type]` and loops `getMetaItems({ type: t, - // organizationId, … })` over it. So the door's segment still arrives - // here as `request.type` and is still folded by the same first - // statement: the identical STRING, the same algebraic no-op, one hop - // further out. ⚠️ That hop is UNDECLARED — `getMetaDiagnostics` is - // not a member of `MetadataProtocol`, neither required nor optional, - // so the door reaches it through a `(p as any)` cast behind a 501 - // feature-detect. Real at runtime, invisible to the type system, and - // therefore something a caller census must be TOLD rather than left - // to derive. - // • the search sweep's page read below gates on `'page'` and passes - // `'page'`; `page` is non-overridable, so both readings are - // `undefined` whatever the session holds. - // • the four remaining `organizationIdForMetaRead` call sites in - // `rest-server.ts` (`/layers`, the by-name read, `/history`, - // `/diff`) reach `getMetaItemLayered` / `getMetaItem` / - // `historyMetaItem` / `diffMetaItem` — never this method, at any - // depth — so this line cannot move them at all. Two doors named - // above plus these four IS that file's whole set of SIX; the - // enumeration that named one door and "four remaining" described - // five, and the door it dropped was the one that reaches here. - // - // ⭐ Read this list from the CALLEE side, which is how it is now built. - // A grep for doors that invoke `getMetaItems` answers only its own - // question: it cannot see a door that arrives through something else, - // and that is exactly how the diagnostics door went unlisted. The - // closed form is the other direction — `this.getMetaItems(` has THREE - // callers in this file: {@link getMetaDiagnostics}, - // {@link searchAll} and {@link findReferencesToMeta}. The third gates - // nothing, deliberately: its door spends the organization on the - // reference SOURCES while `req.params.type` is the TARGET, so it hands - // the tenant over RAW and is not a caller this paragraph is about. - // - // ⛔ Gate AFTER the fold, never before it. `declaresOrgOverride` - // tolerates the MANIFEST plurals and not the URL-only ones - // (`translations` / `email_templates` have no manifest key), and - // commit 26f3588fb measured what that costs when the raw segment reaches the - // predicate: one item in two partitions, addressed by spelling. Folding - // happens at the boundary and only there; this line reads what the - // boundary produced. - // - // ⚠️ ONE resolution for BOTH arms, deliberately — the active-overlay - // read below and the `previewDrafts` read further down both spend it. - // A gate threaded into only one arm would leave the draft preview - // resurrecting exactly the phantoms the active list had just stopped - // serving, which is the half-fix shape #9454's own hoist comment - // refuses one package over. - const orgId = organizationIdForMetaRead(request.type, request.organizationId); + // ── [ADR-0131 D6] The environment → code read ────────────────────── + // + // 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; let items: unknown[] = []; // Unscoped kernels (control plane): read everything from SchemaRegistry. @@ -8884,10 +8805,9 @@ export class ObjectStackProtocolImplementation implements // was never surfaced whenever system-bridged items populated the // registry). Deduplicate against whatever the registry returned. // - // ADR-0005 (revised 2026-05): isolation is now per-organization, since - // each env has its own physical DB. We surface both org-scoped overlays - // (when an active org is provided) and env-wide (organization_id IS NULL) - // overlays; org-scoped rows win on name collision. + // [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). // [#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 @@ -8970,7 +8890,7 @@ export class ObjectStackProtocolImplementation implements return this.foldObjectExtendersFromRegistry( request.type, (data as { name?: unknown } | null)?.name, data, ); - }, { organizationId: orgId }, unseated); + }, { legacyOrganizationId: orgId }, unseated); // [#13407] Expand any aggregated `defineView` container this // READ just merged in, INLINE into this response's own `items` @@ -9149,10 +9069,19 @@ export class ObjectStackProtocolImplementation implements // process reads from — one listing call was enough, and it also // undid the write-side gate for anything already saved. The // per-row verdict now lives in the shared hydrator, which each - // row's own `organizationId` answers to; the merged LIST above - // is unchanged, so org readers still get their overlays. + // row's own `organizationId` answers to. + // + // [ADR-0131 D6] An `object` overlay of a sealed managed item is + // not served ({@link isUnservedSealedOverlay}), so it is not + // registered either: an object row registers as a LAYER the + // engine resolves the object through, not as a bare copy the + // reads decline, so the registry keeps the package's + // definition. Every other type's row is hydrated as before + // (#20913: the bare copy is the tenant row, which the reads + // decline through {@link isStoredEntryOfDeclinedName}). if (this.environmentId === undefined) { - for (const { data, packageId: recPkg, organizationId: recOrg } of overlays) { + for (const { name, data, packageId: recPkg, organizationId: recOrg } of overlays) { + if (request.type === 'object' && this.isUnservedSealedOverlay(request.type, name)) continue; this.hydrateOverlayIntoRegistry(request.type, data, { packageId: recPkg, organizationId: recOrg, @@ -9194,15 +9123,15 @@ export class ObjectStackProtocolImplementation implements } return rs ?? []; }; - const draftRecords = [...(await queryDrafts(null, packageId)), ...(orgId ? await queryDrafts(orgId, packageId) : [])]; + // [ADR-0131 D6] The environment's drafts only, whoever asks. + const draftRecords = await queryDrafts(null, 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)), ...(orgId ? await queryDrafts(orgId, undefined) : [])] - .filter((record) => (record?.package_id ?? null) === null) + ? (await queryDrafts(null, undefined)).filter((record) => (record?.package_id ?? null) === null) : []; if (draftRecords.length > 0 || standInDraftRecords.length > 0) { // ADR-0048 (#1828) — package-aware draft overlay (parity with @@ -9237,7 +9166,7 @@ export class ObjectStackProtocolImplementation implements items = mergePackageAwareOverlay(request.type, items, drafts, (data) => { if (data && typeof data === 'object') (data as any)._draft = true; return data; - }, { organizationId: orgId }, unseated); + }, {}, unseated); } } catch (error) { // [#5532] Same rule as the active-overlay read above. Serving @@ -9456,15 +9385,14 @@ export class ObjectStackProtocolImplementation implements const address: ItemAddress = { type: request.type, name: itemName, - organizationId: orgId, packageId: itemPackageId, }; shipping ??= this.shippingPackagesOf(request.type); itemLock = resolveItemLock({ artifact: this.artifactLockLayerAt(address, shipping), - overlay: await resolveOverlayLockLayer(address, (organizationId, spelling) => + overlay: await resolveOverlayLockLayer(address, (spelling) => (lockRowsByName.get(itemName) ?? []).filter((row) => - (row.organization_id ?? null) === organizationId + (row.organization_id ?? null) === null && row.type === (spelling === 'canonical' ? request.type : otherType)), { otherSpelling: true }), }); } @@ -9483,22 +9411,23 @@ export class ObjectStackProtocolImplementation implements } /** - * [#21442] The active `sys_metadata` rows a read of `request.type` consults - * for one caller: the environment-wide rows, plus that organization's rows - * when `orgId` names one (an organization's row wins its slot), restricted - * to `request.packageId` when one is given. Moved here unchanged from + * [#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 * {@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 * be a second expansion rule. * - * `orgId` arrives already gated ({@link organizationIdForMetaRead}). A read - * failure is thrown as the engine threw it; each caller applies the #5532 - * rule ({@link rethrowUnlessMetadataStoreUnprovisioned}). + * A read failure is thrown as the engine threw it; each caller applies the + * #5532 rule ({@link rethrowUnlessMetadataStoreUnprovisioned}). */ private async readActiveOverlayRows( request: { type: string; packageId?: string }, - orgId: string | undefined, + legacyOrganizationId?: string, ): Promise { const { packageId } = request; const queryByOrg = async (oid: string | null): Promise => { @@ -9524,7 +9453,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 two `queryByOrg` calls produce — + // the ROW SET — the value the environment `queryByOrg` 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 @@ -9550,7 +9479,6 @@ export class ObjectStackProtocolImplementation implements const overlayCacheKey: MetaOverlayCacheKey = { type: request.type, packageId, - organizationId: orgId, }; const overlayCacheEpoch = readWriteEpoch(this.engine); const overlayCacheTtlMs = metaOverlayCacheTtlMs(); @@ -9563,30 +9491,7 @@ export class ObjectStackProtocolImplementation implements if (cachedRecords !== undefined) { records = cachedRecords as any[]; } else { - const envWideRecords = await queryByOrg(null); - const orgRecords = orgId ? await queryByOrg(orgId) : []; - // org-specific rows override env-wide rows 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; org-over-env precedence still holds within each slot. - // - // [#7774] …and for a bundled type the slot is `(package, name, - // locale)`. Within ONE org this changes nothing — the store's own - // unique index is `(type, name, organization_id, package_id)`, so - // an org cannot hold two rows that differ only by body locale. - // Across the two tiers it can: an env-wide row and this org's row - // may customize DIFFERENT members of one bundle, and keying them - // together made the org's zh-CN row silently displace the - // env-wide en-US one. Precedence is unchanged where it was ever - // meaningful — an org row still overrides the env-wide row of the - // same member — and an undiscriminated type keeps a - // byte-identical key. - const mergedMap = new Map(); - const rowKey = (r: any): string => - metaItemKey(r.package_id, r.name, storedRowDiscriminator(request.type, r)); - for (const r of envWideRecords) mergedMap.set(rowKey(r), r); - for (const r of orgRecords) mergedMap.set(rowKey(r), r); - records = Array.from(mergedMap.values()); + records = await queryByOrg(null); // ⭐ 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 @@ -9597,7 +9502,21 @@ export class ObjectStackProtocolImplementation implements this.engine, overlayCacheKey, overlayCacheEpoch, records, overlayCacheTtlMs, overlayCacheNow, ); } - return records; + 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()); } /** @@ -9628,9 +9547,9 @@ 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 - // merged row set is env-wide rows PLUS this org's rows, and - // the two are only distinguishable here, at the row. + // [#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; return { name: String(record.name), data, packageId: recPkg, organizationId: recOrg, @@ -9715,7 +9634,7 @@ export class ObjectStackProtocolImplementation implements * instead of the one the list serves. * * The answer is the list read's own, by construction: the same rows - * ({@link readActiveOverlayRows}, same `packageId`, same gated `orgId`), + * ({@link readActiveOverlayRows}, same `packageId`, the environment's), * the same parse ({@link storedOverlayEntries}) and the same expansion * ({@link expandStoredViewContainers} over * {@link expandRuntimeViewContainer}), selected for the address by the @@ -9738,7 +9657,6 @@ export class ObjectStackProtocolImplementation implements */ private async resolveRowlessExpandedView( request: { type: string; name: string; packageId?: string }, - orgId: string | undefined, ): Promise { if ((PLURAL_TO_SINGULAR[request.type] ?? request.type) !== 'view') return undefined; let records: any[] = []; @@ -9749,10 +9667,9 @@ export class ObjectStackProtocolImplementation implements try { records = await this.readActiveOverlayRows( { type: request.type, ...(request.packageId ? { packageId: request.packageId } : {}) }, - orgId, ); if (request.packageId) { - standInRows = (await this.readActiveOverlayRows({ type: request.type }, orgId)) + standInRows = (await this.readActiveOverlayRows({ type: request.type })) .filter((row) => (row?.package_id ?? null) === null); } } catch (error) { @@ -9772,8 +9689,7 @@ export class ObjectStackProtocolImplementation implements /** * [#21716, ADR-0005] The stored `sys_metadata` row a by-name read SERVES - * for `(type, name)` in `orgId`'s scope, and the scope it was read from — - * the ONE content resolution {@link getMetaItem} (both its draft-preview + * for `(type, name)` — the ONE content resolution {@link getMetaItem} (both its draft-preview * arm and its row read) and {@link getMetaItemLayered} call. * * [#21761] It decides the served CONTENT only, never the lock. The lock's @@ -9784,12 +9700,10 @@ export class ObjectStackProtocolImplementation implements * reported that package's row while the gate bound whichever row * `findOne` returned first. * - * Precedence is ADR-0005's, and it is precedence, never a merge (the - * ruling is recorded in {@link getMetaItem}): the org-scoped row wins, - * and the env-wide row (`organization_id` null) is the fallback. Within - * one scope, 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. + * The environment's rows only (`organization_id` null, ADR-0131 D6): a + * legacy organization-scoped row is reported at boot 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. * * [#21804] That order is {@link servedOverlayRowCandidates}, and this * method is its store reader: one `findOne` per candidate, first hit @@ -9797,32 +9711,26 @@ export class ObjectStackProtocolImplementation implements * reader, over the rows the list already read, so a package's list slot * serves the row this method serves for that package. * - * `orgId` arrives already gated ({@link organizationIdForMetaRead}): an - * organization only selects a row on a type the registry declares per-org - * overridable, so a pre-#6190 phantom org row is never the served one. - * * The reads keep the at-rest tolerance they have always had: a row stored - * under the type's other spelling (pre-#4432 residue) is the last resort - * in each scope. + * under the type's other spelling (pre-#4432 residue) is the last resort. * - * Returns `undefined` when neither scope holds a row. A failed read - * propagates: each caller owns its #5532 discrimination. + * Returns `undefined` when no row is found. A failed read propagates: each + * caller owns its #5532 discrimination. */ private async findServedOverlayRow(args: { type: string; name: string; - orgId: string | undefined; state: 'active' | 'draft'; packageId?: string; - }): Promise<{ row: any; scope: 'org' | 'env' } | undefined> { + }): Promise<{ row: any } | undefined> { // [#21804] The order is {@link servedOverlayRowCandidates}, the one - // the list's merge takes over the rows it read: per scope, the - // canonical spelling then the other, and within each, the package's + // the list's merge takes over the rows it read: the canonical + // spelling then the other, and within each, the package's // own row then the package-less row (never another package's, or a // collision would serve package B's customization for a package A // read); with no package context (legacy/runtime reader), any row. const other = PLURAL_TO_SINGULAR[args.type] ?? SINGULAR_TO_PLURAL[args.type]; - for (const candidate of servedOverlayRowCandidates({ organizationId: args.orgId, packageId: args.packageId })) { + for (const candidate of servedOverlayRowCandidates({ packageId: args.packageId })) { const type = candidate.spelling === 'canonical' ? args.type : other; if (type === undefined) continue; const where: Record = { @@ -9835,7 +9743,7 @@ export class ObjectStackProtocolImplementation implements // 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 } }); - if (row) return { row, scope: candidate.scope }; + if (row) return { row }; } return undefined; } @@ -9866,136 +9774,26 @@ export class ObjectStackProtocolImplementation implements */ private overlayLockLayerAt(address: ItemAddress, options: { readonly otherSpelling: boolean }): Promise { const other = PLURAL_TO_SINGULAR[address.type] ?? SINGULAR_TO_PLURAL[address.type]; - return resolveOverlayLockLayer(address, async (organizationId, spelling) => { + return resolveOverlayLockLayer(address, async (spelling) => { 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: organizationId }, + where: { type, name: address.name, state: 'active', organization_id: null }, context: { isSystem: true }, }); return rows ?? []; }, options); } - async getMetaItem(request: { type: string, name: string, packageId?: string, organizationId?: string, state?: 'active' | 'draft', previewDrafts?: boolean }) { + async getMetaItem(request: { type: string, name: string, packageId?: string, state?: 'active' | 'draft', previewDrafts?: boolean }) { // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. request = canonicalizeMetaRequestType(request); let item: unknown; - // ── [commit d5cbb44f3] The registry read gate, resolved ONCE, HERE ──────────────────── - // - // {@link organizationIdForMetaRead} — the predicate the REST `/meta` - // read doors applied from #9454, and which commit 96326040f moved INSIDE the - // plural verb, `getMetaItems` above. Until this line the SINGULAR verb - // applied no gate of its own: whatever organization arrived was spent on - // whatever type arrived. - // - // ⭐ THE SHARPER HALF, and why the plural verb's fix did not cover it. - // `getMetaItems` UNIONs its two `queryByOrg` reads, so an ungated - // organization can only ADD rows — the resurrection commit 96326040f closed. - // The two scope reads below ({@link findServedOverlayRow}) combine as - // PRECEDENCE: an ungated organization can SUBSTITUTE. On a type the - // registry declares `allowOrgOverride: false`, a pre-#6190 phantom - // org-scoped row — the kind `loadMetaFromDb` walks past and - // {@link reportUnhydratableOrgScopedRows} exists to warn about — was - // served INSTEAD OF the live env-wide document, to a caller that asked - // for the live one. Not an extra row in a list: the served document. - // - // ── ⛔ The `??` is the RULING, not the defect ──────────────────────── - // - // Do not "repair" this by turning the precedence read into a layering or - // union of the org overlay OVER the env-wide row. - // - // ⚠️ Read the citations at their real scope. ADR-0005's decision block - // names THIS method — `RUNTIME READ getMetaItem(type, name)` → `1. - // sys_metadata … ← overlay (wins)`, `2. SchemaRegistry / - // MetadataService ← artifact default` — but the pair it RANKS is - // overlay-vs-artifact-default, not org-row-vs-env-wide-row. It settles - // that an overlay WINS rather than merges; it does not by itself settle - // this method's inner `??`. - // - // What settles the inner one: - // • ADR-0005 design principle 3 — "Customizations are full-JSON - // deltas, not field-level patches" — stores the ENTIRE item - // document per overlay row, so a layering would have nothing to - // layer. The field-level patch model that would have given - // "layering" a meaning was retired and deleted whole under ADR-0049 - // (commit 9e0ba21a1, maintainer ruling 2026-08-29), recorded as a - // correction inside principle 3 itself, with ADR-0126 §6 ruling out - // the phase it was held for. - // • {@link organizationIdForMetaRead}'s own docblock quotes THIS - // expression — `(orgId ? findOverlay(orgId) : undefined) ?? - // findOverlay(null)` — as the intended shape while defining #9454. - // Precedence is the behaviour that card was written against, not the - // thing it reported. - // • ADR-0029 D9 reaches the same shape one type over. ⚠️ Quoted, not - // paraphrased: `resolveObject` "selects its base layer as `overlay ?? - // owner` instead of `owner`, then folds `extend` contributions". Its - // status line reads "Design only — nothing is implemented yet", so - // it is corroboration, not an authority this method rests on. - // ⇒ The defect is which ROW `orgId` selects, never how the two rows - // combine. - // - // ── The idempotence proof this change was made conditional on ─────── - // - // Let `f(t, o) = organizationIdForMetaRead(t, o)`. - // - // • `f(t, undefined) === undefined` for every `t`, so every caller that - // names no organization — `import-mapping.ts`, `import-prepare.ts`, - // plugin-email's template read, service-analytics' draft probe, - // plugin-auth's `metaReader` — reads exactly what it reads today. - // • `f(t, f(t, o)) === f(t, o)`, so a caller that already gated on the - // same type sees no change. The REST by-name door computes - // `organizationIdForMetaRead(canonicalMetaUrlType(req.params.type), - // ctx?.tenantId)` and then passes `type: req.params.type`, the RAW - // segment; the first statement of this method folds that segment - // through {@link canonicalizeMetaRequestType}, which IS - // `canonicalMetaUrlType` — so `request.type` here is the identical - // STRING the door gated on, and this is the algebraic no-op. - // • That door's CACHED arm reaches here through `getMetaItemCached`, - // which folds first and forwards the same hoisted `readOrganizationId` - // — the same no-op, one hop later. - // • [ADR-0131 D6] Every write lands environment-wide (an - // organization-scoped one is refused, {@link - // organizationScopedWriteRefusal}), and the write-side pre-reads - // (`saveMetaItem`'s destructive-change probe, `publishMetaItem`'s - // seed-loader adapter, `publishPackageDrafts`' build probes) carry - // no organization, so they read the partition their write LANDS in. - // - // ⇒ What is left to move is the runtime callers that hand this method a - // RAW active organization. THREE, all in one file — the population is - // stated with the method that establishes it, because the first - // enumeration of it named only the first file and was wrong: grep every - // `getMetaItem(` / `getMetaItemCached(` invocation in the repo, then - // trace each `organizationId` argument to its source. - // • `runtime/src/domains/meta.ts:703` and `:745` — hard-coded `type: - // 'object'`, which is `allowOrgOverride: false`: callers that cannot - // be right about scope, by construction. - // • `runtime/src/domains/meta.ts:768` — `singularType` off the URL, so - // it moves only for the non-overridable half of what it serves. - // A FOURTH used to sit here: `applyPublishedSeeds` in - // `runtime/src/domains/packages.ts`, `type: 'seed'`, equally - // non-overridable. It hand-rolled an org-then-env ladder that this gate - // had turned into a byte-identical repeat — both rungs asking the - // engine the same predicates and serving the same answer. Commit 8744de9e9 - // measured that (ablation: neutering the second rung reddened nothing - // on a pinned publish-then-read path) and collapsed it to a single read - // naming no organization at all, so it now belongs to the bucket below. - // Every other invocation either names no organization at all or is a - // REST door that already computed `organizationIdForMetaRead` — the - // idempotence legs above are what make those two cases no-ops. - // - // ⚠️ ONE resolution for BOTH arms, deliberately — the ADR-0033 - // `previewDrafts` read below and the active-overlay read under it both - // spend this binding. A gate threaded into only one would leave the - // draft preview serving exactly the phantom the active read had just - // stopped serving, the half-fix shape #9454's hoist comment refuses. - // - // ⛔ Gate AFTER the fold, never before it. `declaresOrgOverride` - // tolerates the MANIFEST plurals and not the URL-only ones - // (`translations` / `email_templates` have no manifest key); commit 26f3588fb - // measured what that costs when a raw segment reaches the predicate. - const orgId = organizationIdForMetaRead(request.type, request.organizationId); + // [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. // 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'; @@ -10021,9 +9819,8 @@ export class ObjectStackProtocolImplementation implements // Scoped to the ACTIVE read. A draft is answered as a draft (the strict // `state: 'draft'` read and the `previewDrafts` arm), never under the // artifact's envelope, and the list's own preview arm is equally - // unfiltered. Organization-scoped flow rows never reach this read: - // `orgId` is `undefined` for `flow`, which declares no org override. - // What becomes of the stored rows themselves (keep, refuse, migrate) is + // unfiltered. Organization-scoped rows never reach this read + // (ADR-0131 D6). What becomes of the stored rows themselves (keep, refuse, migrate) is // not decided here. // // ── [#21922, ADR-0062 D4, ADR-0126 §3] A code-defined DATASOURCE name ── @@ -10056,7 +9853,6 @@ export class ObjectStackProtocolImplementation implements const draftRec = (await this.findServedOverlayRow({ type: request.type, name: request.name, - orgId, state: 'draft', ...(request.packageId ? { packageId: request.packageId } : {}), }))?.row; @@ -10086,11 +9882,10 @@ export class ObjectStackProtocolImplementation implements } } - // 1. Customization overlay lookup (sys_metadata). - // Per ADR-0005 (revised), org-scoped row wins; env-wide - // (organization_id IS NULL) row is the fallback before falling + // 1. Customization overlay lookup (sys_metadata): the environment's + // row (organization_id IS NULL, ADR-0131 D6) before falling // through to the in-memory registry / MetadataService. - // ADR-0048 prefer-local within each scope (a package id prefers that + // ADR-0048 prefer-local (a package id prefers that // package's row, then the package-less one, mirroring // `SchemaRegistry.getItem(type, name, pkg)`). [#21716] The ONE // served-row resolution — see {@link findServedOverlayRow}. It @@ -10103,7 +9898,6 @@ export class ObjectStackProtocolImplementation implements const record = (await this.findServedOverlayRow({ type: request.type, name: request.name, - orgId, state: readState, ...(request.packageId ? { packageId: request.packageId } : {}), }))?.row; @@ -10152,7 +9946,7 @@ export class ObjectStackProtocolImplementation implements item: decorateMetadataItem(request.type, this.governServedObject(request.type, item)), // [#22114] The draft row's version token — the one a draft save // (`?mode=draft`) at this address accepts as `If-Match`. - version: await this.readVersionToken(request, orgId, 'draft'), + version: await this.readVersionToken(request, 'draft'), }; } @@ -10169,7 +9963,6 @@ export class ObjectStackProtocolImplementation implements overlayLockLayer = await this.overlayLockLayerAt({ type: request.type, name: request.name, - organizationId: orgId, packageId: request.packageId, }, { otherSpelling: true }); } catch (error) { @@ -10215,7 +10008,7 @@ export class ObjectStackProtocolImplementation implements // answered only for an environment-wide container. ⛔ No // kernel-specific branch: every kernel answers here. if (item === undefined) { - const expanded = await this.resolveRowlessExpandedView(request, orgId); + const expanded = await this.resolveRowlessExpandedView(request); if (expanded !== undefined) item = expanded.item; } @@ -10340,7 +10133,6 @@ export class ObjectStackProtocolImplementation implements artifact: this.artifactLockLayerAt({ type: request.type, name: request.name, - organizationId: orgId, packageId: request.packageId, }), overlay: overlayLockLayer, @@ -10420,7 +10212,7 @@ export class ObjectStackProtocolImplementation implements // editor reads `state: 'draft'` or the plain read. ...(request.previewDrafts ? {} - : { version: storedRowServed ? await this.readVersionToken(request, orgId, 'active') : null }), + : { version: storedRowServed ? await this.readVersionToken(request, 'active') : null }), }; } @@ -10475,13 +10267,12 @@ export class ObjectStackProtocolImplementation implements type: string; name: string; packageId?: string; - organizationId?: string; }): Promise<{ type: string; name: string; code: unknown | null; overlay: unknown | null; - overlayScope: 'org' | 'env' | null; + overlayScope: 'env' | null; effective: unknown | null; /** * Load-time validation result for the effective payload — same @@ -10517,99 +10308,8 @@ export class ObjectStackProtocolImplementation implements // `overlay` can be read from two. request = canonicalizeMetaRequestType(request); - // ── [commit e1d4f9e3f] The registry read gate, resolved AFTER the fold ───────── - // - // {@link organizationIdForMetaRead} — the predicate the REST `/meta` - // read doors applied from #9454, and the same gate - // `getMetaItems` (commit 96326040f) and `getMetaItem` (commit d5cbb44f3) now carry. Until - // this line `getMetaItemLayered` — the third `/meta` read verb — - // applied NO gate of its own: whatever organization arrived was spent - // on whatever type arrived. - // - // ⛔ THE BINDING MOVED, and that reorder IS the fix. It used to sit - // ABOVE the fold, so dropping the sibling verbs' one-liner in place - // would have gated on the RAW type. Commit 26f3588fb measured what that costs: - // `declaresOrgOverride` tolerates the MANIFEST plurals but not the - // URL-only ones (`translations` / `email_templates` have no manifest - // key), so a raw segment splits one item across two partitions. Both - // REST callers below hand this method an unfolded `/meta/:type` - // segment, so gating before the fold would have been wrong on exactly - // the types the gate exists to admit. - // - // ⭐ THE HARM IS A FALSE POSITIVE CUSTOMIZATION CLAIM — which is why - // this verb was graded on its own rather than inheriting either twin's. - // On the plural verb a phantom can only ADD a row; on the singular verb - // it REPLACES the served document. Here the affected value is the - // `overlay` layer of a three-layer diagnostic whose entire purpose is - // to answer "what did this tenant customize", so a pre-#6190 phantom - // org-scoped row of an `allowOrgOverride: false` type is rendered by - // the Studio "Code default vs Overlay vs Effective" diff tab as - // evidence of a customization that does not exist. It compounds at the - // two doors that return the `overlay` layer AS the response: there the - // phantom is not merely displayed, it is served. - // - // ── The idempotence proof, discharged over THIS door's callers ────── - // - // Let `f(t, o) = organizationIdForMetaRead(t, o)`. `f` answers `o` when - // the registry declares `t` per-org overridable and `undefined` - // otherwise, so `f(t, undefined) === undefined` and - // `f(t, f(t, o)) === f(t, o)` for every `t` and `o`. Commit 96326040f's and - // commit d5cbb44f3's proofs do NOT carry: the ruling behind commit 96326040f discharges this per - // door over that door's OWN caller population, and this verb's is a - // different set. Enumerated by grepping every `getMetaItemLayered(` - // invocation in the repo and tracing each `organizationId` argument to - // its source — no caller is denied a partition it can legitimately - // read: - // - // • `rest-server.ts`'s `/meta/:type/:name/layers` door (and the - // deprecated `?layers=` flag, which delegates to the same helper) - // computes `organizationIdForMetaRead(canonicalMetaUrlType( - // req.params.type), ctx?.tenantId)` and then passes `type: - // req.params.type`, the RAW segment. The fold above IS - // `canonicalMetaUrlType`, so `request.type` here is the identical - // STRING the door gated on — the algebraic no-op. - // • Four of the five `plugin-security` invocations name no - // organization at all (`packaged-permission-set-lock-gate.ts`'s - // authoring gate, and three of `permission-set-projection.ts`'s - // reads), so `f(t, undefined) === undefined` leaves them reading - // exactly what they read today. `permission-set-overlay-discard.ts` - // only feature-detects this method and delegates to - // `projectPermissionMutation`; it is not a call site. - // - // ⇒ THREE callers move, and each one is the defect. ⚠️ Only the first - // was named by the enumeration this repair was dispatched with, which - // asserted that the runtime dispatcher was the ONLY reachable site, - // that BOTH REST doors already gated, and that every `plugin-security` - // site passed no organization. The last two were measured false here — - // in the direction that adds callers to this gate, never one that - // removes them: - // - // • `runtime/src/domains/meta.ts` — `resolveActiveOrganizationId` - // straight into the request, on a `type` taken off the URL, so it is - // not confined to the overridable five. Its `if (layered?.overlay != - // null)` branch returns the overlay layer AS the response. - // • `rest-server.ts`'s `/meta/:type/:name/published` door — the REST - // twin of that dispatcher branch, and the door recorded as already - // gating. It passes `publishedCtx.tenantId` RAW and then - // `res.json(layered.overlay)`. Its own comment argues the raw tenant - // "is right for a READ" because the overlay lookup is - // org-first-then-env, so "nothing that resolves today stops - // resolving" — precisely the argument {@link - // organizationIdForMetaRead} was written to refute: failing open in - // that direction is what RESURRECTS the phantoms, which is why - // #6190 stopped minting them and why boot hydration walks past the - // survivors. - // • `permission-set-projection.ts`'s post-mutation read forwards - // `evt.organizationId` — a raw active organization on - // `type: 'permission'`, which is `allowOrgOverride: false`. Moving - // it makes the projection read the partition boot hydration - // actually serves: the same correction, one caller further out. - // - // ⚠️ ONE resolution for BOTH overlay arms, deliberately — the - // org-scoped read and the env-wide fallback below both spend this - // binding, and gating only the first would leave `overlayScope: 'org'` - // reachable for a type with no per-org read channel. - const orgId = organizationIdForMetaRead(request.type, request.organizationId); + // [ADR-0131 D6] Environment → code: the overlay layer is the + // environment's stored row; no organization's row is read. // ── code layer: MetadataService.get + registry, BYPASSING overlay ── let code: unknown | null = null; @@ -10704,29 +10404,26 @@ export class ObjectStackProtocolImplementation implements this.throwMetadataServiceUnavailable(codeDegraded.errors); } - // ── overlay layer: sys_metadata row (org-scoped wins, then env-wide) ── + // ── overlay layer: the environment's sys_metadata row (ADR-0131 D6) ── let overlay: unknown | null = null; - let overlayScope: 'org' | 'env' | null = null; + let overlayScope: 'env' | null = null; // [#21761] The `overlay` layer of the one item-lock resolution below, // selected from this read's address as in {@link getMetaItem}: the // strictest lock among the item's stored rows in scope, never only the // row this layer reports. let overlayLockLayer: unknown; try { - // ADR-0048 prefer-local within each scope. [#21716] The ONE - // served-row resolution {@link getMetaItem} calls too — see - // {@link findServedOverlayRow}. + // ADR-0048 prefer-local. [#21716] The ONE served-row resolution + // {@link getMetaItem} calls too — see {@link findServedOverlayRow}. const served = await this.findServedOverlayRow({ type: request.type, name: request.name, - orgId, state: 'active', ...(request.packageId ? { packageId: request.packageId } : {}), }); overlayLockLayer = await this.overlayLockLayerAt({ type: request.type, name: request.name, - organizationId: orgId, packageId: request.packageId, }, { otherSpelling: true }); if (served) { @@ -10735,7 +10432,7 @@ export class ObjectStackProtocolImplementation implements String(rec.type ?? request.type), storedRowDocument(rec), ); - overlayScope = served.scope; + overlayScope = 'env'; } } catch (error) { // [#5707] The same rule as the four overlay reads in @@ -10744,8 +10441,8 @@ export class ObjectStackProtocolImplementation implements // // Swallowing here does not answer 404 — it answers something this // method states positively in THREE fields at once: `overlay: null` - // ("nothing was ever customised"), `overlayScope: null` ("no scope - // holds a row"), and `effective = code` ("what runs today is the + // ("nothing was ever customised"), `overlayScope: null` ("no row + // is stored"), and `effective = code` ("what runs today is the // packaged artifact, verbatim"). The whole point of the layered // read is to show an author what they changed; during an outage it // told them they had changed nothing, which is the #5532 error — @@ -10769,16 +10466,15 @@ export class ObjectStackProtocolImplementation implements // the layers say where it came from in the fields that already say it: // `overlay` is the container's own stored row, the one stored layer // behind this name (its `name` is the container's, its `_packageId` the - // package its row is bound to), and `overlayScope` the scope that row - // was read from. `code` keeps its own read: the item a package ships + // package its row is bound to), and `overlayScope` `'env'`. `code` keeps its own read: the item a package ships // under this name (a tenant overlay of that package's container), else // `null`. let expandedFrom: RowlessExpandedView | undefined; if (overlay === null) { - expandedFrom = await this.resolveRowlessExpandedView(request, orgId); + expandedFrom = await this.resolveRowlessExpandedView(request); if (expandedFrom !== undefined) { overlay = expandedFrom.container.data; - overlayScope = expandedFrom.container.organizationId === null ? 'env' : 'org'; + overlayScope = 'env'; } } @@ -10855,7 +10551,6 @@ export class ObjectStackProtocolImplementation implements artifact: this.artifactLockLayerAt({ type: request.type, name: request.name, - organizationId: orgId, packageId: request.packageId, }), overlay: overlayLockLayer, @@ -10970,16 +10665,12 @@ export class ObjectStackProtocolImplementation implements * unprovisioned table. The `/audit` route's existing * `handleRouteError` turns it into an honest 5xx. * - * `organizationId` SCOPES the read and is enforced in the query below: - * rows for that organization plus env-wide (`organization_id IS NULL`) - * rows, and nothing else. Omitted (or `null`) reads the env-wide rows - * only. It is never a hint — a caller that does not supply it does not - * get another tenant's rows. + * [ADR-0131 D6] The environment's rows (`organization_id IS NULL`) only, + * enforced in the query below. */ async auditMetaItem(request: { type: string; name: string; - organizationId?: string | null; limit?: number; }): Promise<{ events: Array<{ @@ -11045,36 +10736,10 @@ export class ObjectStackProtocolImplementation implements Math.max(1, request.limit ?? 100), 500, ); - // [#8747] `request.organizationId` is READ here. It was declared and - // never used, while the comment below described the filter it would - // have built — a live cross-tenant disclosure, measured rather than - // argued: three saves of one view name under `org_alpha`, `org_beta` - // and env-wide, then one `auditMetaItem({ type, name })`, returned all - // three orgs' rows (`actor`, `note`, `lock_state` with them). - // - // ⚠️ Nothing below this method compensates, and all three candidates - // were eliminated by measurement, not by reading: - // - the driver's tenant wall never engages — `buildDriverOptions` - // sets `DriverOptions.tenantId` only from `execCtx.tenantId` - // (`objectql/engine.ts`), and this read's context carries no - // `tenantId` (only the system opt-in below); - // - plugin-security's Layer 0 never engages — the middleware - // short-circuits on that `isSystem` opt-in (it used to take its - // principal-less `return next()`, refused since ADR-0096 D5) - // thousands of lines before the `objectFields.has('organization_id')` - // gate that would have carried it; - // - no posture would save it anyway: `computeTenantLayer0Filter` - // yields `null` under `single` and the deny sentinel under - // `isolated` with no tenantId. - // So the scope has to be BUILT here. It is unconditional — it does not - // depend on a posture, a principal, or a layer below choosing to act. - // - // `?? null` is the same normalization the sibling `/published` door - // applies (`request.organizationId ?? null`, mirroring what an org-less - // `publishPackageDrafts` WRITES): a caller that resolves no - // organization reads exactly the env-wide rows an org-less write - // produces. Fail-closed, and symmetric with the write path. - const organizationId = request.organizationId ?? null; + // [ADR-0131 D6] The environment's audit trail only — the scope is + // built here, unconditionally (#8747: nothing below this method + // scopes the read). A legacy organization-scoped audit row is not + // read; the v18 migration ceremony carries its organization's rows. // [#9638] The FIRST of the two benign causes the catch below used to // name, asked as a PRECONDITION rather than as an error shape. // @@ -11105,29 +10770,12 @@ export class ObjectStackProtocolImplementation implements return { events: [] }; } try { - // Org-scoped lookup: include rows for the specific org AND - // env-wide (organization_id IS NULL) rows so the editor - // sees both tenant overlays and env-level package writes. - // - // The env-wide limb is LOAD-BEARING, not defensive garnish: the - // REST `PUT /meta/:type/:name` door passes no `organizationId` at - // all, so every row that door writes is stamped - // `organization_id: null` (`recordMetadataAudit` persists - // `entry.organizationId ?? null`). Drop the limb and this read - // returns nothing on a REST-authored deployment — "correctly - // scoped" and "hides everything" are different behaviours and the - // tests pin them apart. + // The environment's rows: every write stamps + // `organization_id: null` (ADR-0131 D6). const where: Record = { type: singular, name: request.name, - ...(organizationId === null - ? { organization_id: null } - : { - $or: [ - { organization_id: organizationId }, - { organization_id: null }, - ], - }), + organization_id: null, }; // `order`, NOT `direction`: the QueryAST sort shape is // `SortNodeSchema` = `{ field, order }`, and both drivers normalize @@ -14368,11 +14016,8 @@ export class ObjectStackProtocolImplementation implements // - PUBLISHED only: `getMetaItems` reads `state: 'active'` overlay // rows plus code-registered pages; drafts surface only under // `previewDrafts`, which this sweep never passes. - // - Org scope: threaded through `organizationIdForMetaRead('page', - // context.tenantId)` — the registry-gated predicate every REST meta - // read door uses (#9454), so the sweep and the read door move - // together if `page` ever declares org override (today it does - // not, and the predicate answers env-wide). + // - Scope: the environment's pages, as every `/meta` read door + // answers (ADR-0131 D6: no metadata read is organization-scoped). // - Disabled-package and conversion handling ride along for free — // whatever `getMetaItems` withholds, this sweep never saw. // @@ -14407,11 +14052,7 @@ export class ObjectStackProtocolImplementation implements // whole one's answer. const pageHits: Array<{ kind: 'page'; name: string; title: string; snippet?: string; pageType?: string }> = []; if (!objectsFilter) { - const pageOrgId = organizationIdForMetaRead('page', request.context?.tenantId); - const served = await this.getMetaItems({ - type: 'page', - ...(pageOrgId !== undefined ? { organizationId: pageOrgId } : {}), - }); + const served = await this.getMetaItems({ type: 'page' }); const pageItems: unknown[] = Array.isArray(served) ? served : served.items; const localeTexts = (v: unknown): string[] => { if (typeof v === 'string') return v ? [v] : []; @@ -14468,17 +14109,11 @@ export class ObjectStackProtocolImplementation implements // ========================================== /** - * [#9454] `organizationId` — the sole meta read verb that could not express - * an org, which is why the CACHED door (`view` and every org-overridable - * type that is not `dashboard`) served nothing back after a runtime `PUT`. - * Its siblings `getMetaItem` / `getMetaItems` / `getMetaItemLayered` have - * carried the member all along; this one hard-coded a two-key delegation - * and dropped whatever the caller knew. Threaded into `getMetaItem` below, - * so the ADR-0005 read order (`sys_metadata` org row → env-wide row → - * registry → MetadataService) is honoured at the same scope the caller - * named — the whole reason this method delegates rather than re-reading. + * The cached read delegates to {@link getMetaItem}, so it serves the same + * environment → code read order (ADR-0131 D6) — the whole reason this + * method delegates rather than re-reading. */ - async getMetaItemCached(request: { type: string, name: string, cacheRequest?: MetadataCacheRequest, locale?: string, organizationId?: string }): Promise { + async getMetaItemCached(request: { type: string, name: string, cacheRequest?: MetadataCacheRequest, locale?: string }): Promise { // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. The ETag // and the cache entry are keyed by type, so two spellings would cache // the same item twice and invalidate only one of them. @@ -14490,14 +14125,6 @@ export class ObjectStackProtocolImplementation implements const result = await this.getMetaItem({ type: request.type, name: request.name, - // [#9454] Spread, not an unconditional member: `getMetaItem` - // branches on `organizationId !== undefined`, so passing an - // explicit `undefined` is not the same statement as passing - // nothing. Mirrors the conditional-spread idiom the REST read - // doors use to reach here. - ...(request.organizationId !== undefined - ? { organizationId: request.organizationId } - : {}), }); const item = (result as any)?.item; @@ -14530,59 +14157,19 @@ export class ObjectStackProtocolImplementation implements // language until a hard refresh (issue #1319). Folding the resolved // locale into the hash gives each locale a distinct validator. // - // [#9454] The ETag MUST also state the ORGANIZATION scope, and the - // mechanism differs from `locale` above in a way worth stating - // rather than glossing. `locale` is INVISIBLE to the hash (the body - // is translated AFTER this runs), so folding it in was the only way - // it could vary the validator at all. `organizationId` is VISIBLE — - // the org-resolved document is the very thing hashed — so two orgs - // whose overlays differ already get different validators, and no - // leak is claimed here: `Cache-Control` is `private, no-cache` and - // there is no server-side cache ENTRY keyed by type+name. - // - // It is folded in anyway because that makes the scope a DECLARED - // property of the validator instead of an emergent property of the - // body. Two orgs whose documents are byte-identical today share a - // validator by coincidence, not by statement. Prepended, and ONLY - // when present, so an org-less caller's validator stays byte-for- - // byte the one it is issued today. - // - // [#16525] ⚠️ The paragraph above used to argue from "any FUTURE - // path that resolves an org row but falls back to the env-wide - // body". THAT PATH IS PRESENT, and reading it as future is how a - // later author concludes the risk has not arrived yet: - // `getMetaItem` resolves `(orgId ? findOverlay(orgId) : undefined) - // ?? findOverlay(null)`, so an organization with no row of its own - // is served the env-wide document under an org-named validator. - // - // ⭐ AND `request.organizationId` IS THE SUPPLIED MEMBER, not the - // effective scope: {@link organizationIdForMetaRead} reduces it to - // `undefined` for a type declaring `allowOrgOverride: false`, and - // that reduction happens BELOW this line, inside `getMetaItem`. So - // a caller that hands this verb a raw organization gets a validator - // naming a scope its body was never resolved under. - // - // ⛔ Neither is a correctness fault, and the reason is the ONE - // invariant this block depends on: `content` — the bytes actually - // being sent — is inside the hash below. A 304 is therefore - // answered only on an exact match over those bytes, so a caller is - // only ever pinned to the representation IT received; the cost is - // validator FRAGMENTATION (N orgs, one env-wide document, N - // validators), which is waste, not error. ⇒ Hashing anything - // cheaper than the document — a version marker, the scope alone — - // destroys that argument silently. `get-meta-item-cached-etag- - // scope.test.ts` §3 is the pin; measured, removing `content` here - // reddens exactly one assertion and leaves the rest green. + // [ADR-0131 D6] No organization is part of the validator: the + // read is the environment's for every caller, so the document + // hashed below is the whole scope. An org-less caller's validator + // is byte-for-byte the one it was issued before. // - // ⛔ Do NOT "repair" this by folding the effective value without - // reading #16525: it changes every published ETag that carries an - // organization, and buys nothing at the only production door — - // `@objectstack/rest` computes `organizationIdForMetaRead` BEFORE - // it calls (pinned by `rest-server-meta-cached-etag-door-scope. - // test.ts`), so supplied and effective already agree there. + // ⛔ The ONE invariant this block depends on: `content` — the bytes + // actually being sent — is inside the hash below. A 304 is answered + // only on an exact match over those bytes, so a caller is only ever + // pinned to the representation IT received. Hashing anything + // cheaper than the document — a version marker — destroys that + // silently. const content = JSON.stringify(item); const scope = [ - request.organizationId ? `org:${request.organizationId}` : undefined, request.locale || undefined, ].filter((part): part is string => part !== undefined); const hash = simpleHash( @@ -16548,7 +16135,7 @@ export class ObjectStackProtocolImplementation implements * path of its own: the row is skipped, and each read falls through to the * code layer it already reads next. * - * Exactly two answers, each its own type's, neither re-derived here: + * Exactly three answers, none re-derived here: * * - a FLOW name the loader's set holds ({@link isShippedFlowName}, * #20913 / #20946 / #21002) — Regime C, "never an overlay read path"; @@ -16562,6 +16149,14 @@ export class ObjectStackProtocolImplementation implements * a layer of it, only residue ({@link originGatedRemovalRefusal}): it * stays at rest, the boot restore names it in a warning, and the `/meta` * DELETE removes it as repair. + * - [ADR-0131 D6, triage ruling Q1 → C] a SEALED managed item + * ({@link isUnservedSealedOverlay}): an environment overlay row on an + * item a managed package ships, of a type whose registry entry opens no + * overlay channel (a managed flow, action, hook or object), is in no + * regime D6 recognises — the package definition wins. Such rows were + * written through the `OS_METADATA_WRITABLE` hatch before the seal; they + * stay at rest, untouched, and the boot report names them per type with + * the two remedies ({@link reportSealedOverlayRows}). * * ⛔ Never a row's, slot's or body's `origin` — the caller sets it. * @@ -16586,7 +16181,28 @@ export class ObjectStackProtocolImplementation implements */ declinesStoredRow(type: string, name: unknown): boolean { if (this.isShippedFlowName(type, name)) return true; - return typeof name === 'string' && name !== '' && this.isDeclaredCodeDatasource(type, name); + if (typeof name !== 'string' || name === '') return false; + return this.isDeclaredCodeDatasource(type, name) || this.isUnservedSealedOverlay(type, name); + } + + /** + * [ADR-0131 D6, triage ruling Q1 → C] Is a stored environment row of + * `(type, name)` an overlay of SEALED managed content, which no read + * serves? {@link isSealedManagedItem}, the predicate the save and delete + * doors refuse by, so the write refusal and the read narrowing cannot + * disagree about which items are sealed. + * + * ⛔ Except `permission`. A permission set a code package ships is guarded + * by its own lock (ADR-0086 / ADR-0094, `packaged-permission-set-lock`), + * and the forks stored before that lock existed have a maintainer ruling + * of their own: a detection reading and an operator's Discard Overlay + * action, "no automatic reap/merge … not a silent migration". Not serving + * such a fork would change the grants the deployment enforces without an + * operator's act, so it keeps that ruling's disposition. + */ + private isUnservedSealedOverlay(type: string, name: string): boolean { + if (canonicalMetaType(type) === 'permission') return false; + return this.isSealedManagedItem(type, name); } /** @@ -17228,13 +16844,10 @@ export class ObjectStackProtocolImplementation implements * writes rejected" were admitted. The door was looser than the read on the * organization axis, as it had been on the topology axis (#21694). * - * So the limb now asks {@link findServedOverlayRow} — the resolution both - * reads call — with the organization gated by the same - * {@link organizationIdForMetaRead} the reads apply. When the env-wide row - * is the one served, its `_lock` binds the organization's writes; when the - * organization's own row is served, that row's `_lock` does, whatever the - * env-wide row declares (the read reports that same row). ⛔ No second - * predicate: a change to the read's precedence moves this limb with it. + * So the limb asked {@link findServedOverlayRow} — the resolution both + * reads call. [ADR-0131 D6] Both now read the environment's rows alone, + * and so does this limb. ⛔ No second predicate: a change to the read's + * precedence moves this limb with it. * * ## [#21761] The limb and the reads select the rows from one ADDRESS * @@ -17333,7 +16946,6 @@ export class ObjectStackProtocolImplementation implements private async getEffectiveLock( type: string, name: string, - organizationId: string | null | undefined, packageId?: string, ): Promise<{ lock: MetadataLock; @@ -17354,10 +16966,9 @@ export class ObjectStackProtocolImplementation implements artifact: () => this.artifactLockLayerAt({ type: canonicalType, name, - organizationId: organizationId ?? undefined, packageId, }), - overlay: () => this.readLockGateOverlayLayer(canonicalType, name, organizationId, packageId), + overlay: () => this.readLockGateOverlayLayer(canonicalType, name, packageId), }); return { lock: resolved.lock, lockReason: resolved.lockReason, lockSource: resolved.layer }; } @@ -17372,14 +16983,13 @@ export class ObjectStackProtocolImplementation implements private async readLockGateOverlayLayer( canonicalType: string, name: string, - organizationId: string | null | undefined, packageId: string | undefined, ): Promise { // 2. Overlay rows — addressed by the SAME canonical key the repository // stores them under (`SysMetadataRepository.whereFor`), which is // what makes this limb read the rows the artifact limb already - // folded to. [#21716] …behind the reads' own organization gate, and - // [#21761] through the reads' own selection, from the same address — + // folded to. [#21761] Through the reads' own selection, from the + // same address — // see {@link getEffectiveLock}'s header. Canonical spelling only // (#4432), the one declared difference — see // {@link overlayLockLayerAt}. @@ -17387,7 +16997,6 @@ export class ObjectStackProtocolImplementation implements return await this.overlayLockLayerAt({ type: canonicalType, name, - organizationId: organizationIdForMetaRead(canonicalType, organizationId ?? undefined), packageId, }, { otherSpelling: false }); } catch (error) { @@ -17575,7 +17184,7 @@ export class ObjectStackProtocolImplementation implements source?: string; requestId?: string; }): Promise<{ err: Error; audit: MetadataAuditEntry } | null> { - const state = await this.getEffectiveLock(args.type, args.name, null, args.packageId); + const state = await this.getEffectiveLock(args.type, args.name, args.packageId); const refusal = evaluateLockForWrite(state.lock); if (!refusal) return null; const reason = state.lockReason ?? refusal.reason; @@ -17645,7 +17254,7 @@ export class ObjectStackProtocolImplementation implements source?: string; requestId?: string; }): Promise { - const state = await this.getEffectiveLock(args.type, args.name, null); + const state = await this.getEffectiveLock(args.type, args.name); const refusal = evaluateLockForDelete(state.lock); if (!refusal) return null; const reason = state.lockReason ?? refusal.reason; @@ -17784,16 +17393,15 @@ export class ObjectStackProtocolImplementation implements /** * [#22114] The item read's `version`: the keyed form * ({@link receiptVersion}) of the stored head a save to this item compares - * against ({@link storedHeadAt}), at the read's own scope — its - * organization partition and `packageId` — and its lifecycle, or `null` + * against ({@link storedHeadAt}), at the read's own scope — the + * environment's partition and `packageId` — and its lifecycle, or `null` * when no stored row is there. The `/meta` save door builds its address * from the same facts (no organization on either since ADR-0131 D6, and * `?package=` names the binding on both), so a read followed by a save with the served token is accepted, and a * `null` says that save is a create: the state `If-None-Match: *` asserts. * * ⚠️ The address of the SAVE, not of the served content. A read falls back - * from the caller's organization to the environment-wide row (ADR-0005) - * and from a package's own row to the package-less one (ADR-0048); a save + * from a package's own row to the package-less one (ADR-0048); a save * does not, it writes its own partition. A token of a row the save would * not overwrite would be refused by that save every time, so such a read * serves `null` rather than the served row's token. [#22128] And the @@ -17804,15 +17412,13 @@ export class ObjectStackProtocolImplementation implements */ private async readVersionToken( request: { type: string; name: string; packageId?: string }, - orgId: string | undefined, state: 'active' | 'draft', ): Promise { - const organizationId = orgId ?? null; - const repo = this.getOverlayRepo(organizationId); + const repo = this.getOverlayRepo(); const ref = { type: PLURAL_TO_SINGULAR[request.type] ?? request.type, name: request.name, - org: organizationId ?? 'env', + org: 'env', } as Parameters[0]; const stored = await this.storedHeadAt(repo, ref, state, request.packageId ?? null); return stored === null ? null : this.receiptVersion(stored); @@ -19506,8 +19112,8 @@ export class ObjectStackProtocolImplementation implements * never a copy of them: * - another stored container's expansion, in the caller's selection, * whatever that container's object: the rows - * {@link readActiveOverlayRows} selects through the readers' gate - * ({@link organizationIdForMetaRead}) with no package filter, parsed by + * {@link readActiveOverlayRows} selects (the environment's) with no + * package filter, parsed by * {@link storedOverlayEntries} and expanded by * {@link expandStoredViewContainers} with each row's own package * binding. The row stored under the save name is left out: it is the @@ -20494,10 +20100,6 @@ export class ObjectStackProtocolImplementation implements // Stored, and handed to the credential walk below, as written. body: flowGateVerdictBody ?? gatedItem, source: writeSource, - // [#6285] The write's organization partition. It was always here; - // it simply never travelled to the gate, which is the whole reason - // the "platform-level flow" limb could not be judged before. - organizationId: null, // [#9612] Which package this write belongs to — the unit the gate // now judges it against. Same story as the line above: the request // has carried it all along, it just never reached the gate. Null @@ -20514,7 +20116,6 @@ export class ObjectStackProtocolImplementation implements // is absent and not stored as missing. The body judged is unchanged. restoredCredentialPaths: () => this.restoredCredentialPathsFor({ type: singularType, - organizationId: null, name: request.name, packageId: request.packageId ?? null, item: gatedItem, @@ -20696,7 +20297,7 @@ export class ObjectStackProtocolImplementation implements } // [ADR-0131 D6] Environment-wide, always: an organization-scoped // request was refused at the top of this method. - const repo = this.getOverlayRepo(null); + const repo = this.getOverlayRepo(); const ref = { type: singularTypeForRepo, name: request.name, @@ -21474,7 +21075,6 @@ export class ObjectStackProtocolImplementation implements async historyMetaItem(request: { type: string; name: string; - organizationId?: string; sinceSeq?: number; limit?: number; }): Promise<{ events: import('@objectstack/metadata-core').MetadataEvent[] }> { @@ -21525,17 +21125,17 @@ export class ObjectStackProtocolImplementation implements // every event's `ref.name` names the container. const expandedFrom = await this.resolveRowlessExpandedView( { type: singularType, name: request.name }, - organizationIdForMetaRead(singularType, request.organizationId), ); if (expandedFrom !== undefined) { return this.historyMetaItem({ ...request, name: expandedFrom.container.name }); } - const orgId = request.organizationId ?? null; - const repo = this.getOverlayRepo(orgId); + // [ADR-0131 D6] The environment's change log; a legacy + // organization-scoped history row is not read. + const repo = this.getOverlayRepo(); const ref = { type: singularType, name: request.name, - org: orgId ?? 'env', + org: 'env', } as Parameters[0]; const events: import('@objectstack/metadata-core').MetadataEvent[] = []; @@ -21930,7 +21530,7 @@ export class ObjectStackProtocolImplementation implements await this.ensureOverlayIndex(); // [ADR-0131 D6] Environment-wide: every caller refused an // organization-scoped request before reaching here. - const repo = this.getOverlayRepo(null); + const repo = this.getOverlayRepo(); // #4463 D1 — the OTHER way a body reaches `active`. `saveMetaItem` // gates a direct active save and deliberately lets every draft through; @@ -22018,9 +21618,6 @@ export class ObjectStackProtocolImplementation implements name: request.name, state: 'active', body: draftForGate.body, - // [#6285] Same partition the draft is being promoted in — the - // environment's, since ADR-0131 D6. - organizationId: null, // [#9612] The package binding the CALLER stated for this // promotion — the same value threaded into `repo.promoteDraft` // below, so the gate and the write resolve the draft under one @@ -22386,22 +21983,20 @@ export class ObjectStackProtocolImplementation implements async listDrafts(request?: { packageId?: string; type?: string; - organizationId?: string; }): Promise<{ drafts: Array<{ type: string; name: string; /** The draft body's own top-level `label`, as authored; `null` when it declares none. */ label: I18nLabel | null; - organizationId: string | null; packageId: string | null; updatedAt: string | null; updatedBy: string | null; }>; }> { await this.ensureOverlayIndex(); - const orgId = request?.organizationId ?? null; - const repo = this.getOverlayRepo(orgId); + // [ADR-0131 D6] The environment's pending drafts. + const repo = this.getOverlayRepo(); const drafts = await repo.listDrafts({ ...(request?.type ? { type: PLURAL_TO_SINGULAR[request.type] ?? request.type } : {}), ...(request?.packageId ? { packageId: request.packageId } : {}), @@ -22459,8 +22054,8 @@ export class ObjectStackProtocolImplementation implements * ## Why the batch's existing enumeration cannot supply the bodies * * `listDrafts` — the read that DEFINES this batch — is a declared header - * projection: it maps rows to `(type, name, label, organizationId, - * packageId, updatedAt, updatedBy)` and drops `metadata` on purpose — of the + * projection: it maps rows to `(type, name, label, packageId, updatedAt, + * updatedBy)` and drops `metadata` on purpose — of the * body only its own top-level `label` leaves ([#22200]) — because its other * caller is the console's "pending changes" list. Widening it would put * every draft BODY on that listing, and it would not even remove the guard @@ -22470,7 +22065,7 @@ export class ObjectStackProtocolImplementation implements * and the absence of the member it needs is stated rather than assumed. */ private async collectBatchPendingDeclarations( - drafts: ReadonlyArray<{ type: string; name: string; organizationId: string | null }>, + drafts: ReadonlyArray<{ type: string; name: string }>, ): Promise { // [#13216] Typed as a mapped type over `RuntimePendingDeclarations` // with `-?`, not as a hand-listed literal. The literal it replaces was @@ -22496,8 +22091,7 @@ export class ObjectStackProtocolImplementation implements const singular = canonicalMetaType(d.type); const key = (CLOSURE_CONTEXT_KEY_BY_TYPE as Record)[singular]; if (!key) continue; - const draftOrgId = d.organizationId ?? null; - const repo = this.getOverlayRepo(draftOrgId); + const repo = this.getOverlayRepo(); // ── The repository-shape guard, and why it degrades ALL-OR-NOTHING // // This method introduced the batch door's first dependency on @@ -22530,7 +22124,7 @@ export class ObjectStackProtocolImplementation implements return undefined; } const ref = { - type: singular, name: d.name, org: draftOrgId ?? 'env', + type: singular, name: d.name, org: 'env', } as unknown as Parameters[0]; const draft = await repo.get(ref, { state: 'draft' }); if (draft?.body === undefined || draft.body === null) continue; @@ -22677,7 +22271,7 @@ export class ObjectStackProtocolImplementation implements // `organization_id IS NULL` rows, so a legacy organization-scoped draft // is never promoted here (it waits for the promotion ceremony, // ADR-0131 C7). - const repo = this.getOverlayRepo(null); + const repo = this.getOverlayRepo(); const drafts = await repo.listDrafts({ packageId: request.packageId }); // Runtime enforcement of the package namespace-prefix rule (ADR-0028 @@ -22970,13 +22564,12 @@ export class ObjectStackProtocolImplementation implements const commitItems: Array<{ type: string; name: string; existedBefore: boolean; prevVersion: number | null }> = []; for (const d of ordered) { try { - // Read the pre-publish active row in the draft's OWN scope - // (env-wide drafts have env-wide active rows). Using the - // request's active org here would miss an env-wide edit and - // mis-record it as a create in the revert plan (#3115). + // Read the pre-publish active row in the draft's scope, the + // environment's (ADR-0131 D6), so an env-wide edit is not + // mis-recorded as a create in the revert plan (#3115). // [#21911] The explicit system opt-in — see findServedOverlayRow. const activeRow = (await this.engine.findOne('sys_metadata', { - where: { organization_id: d.organizationId ?? null, type: d.type, name: d.name, state: 'active' }, + where: { organization_id: null, type: d.type, name: d.name, state: 'active' }, context: { isSystem: true }, })) as { version?: number } | null; commitItems.push({ @@ -23515,7 +23108,7 @@ export class ObjectStackProtocolImplementation implements `Package '${request.packageId}'`, request, ); await this.ensureOverlayIndex(); - const repo = this.getOverlayRepo(null); + const repo = this.getOverlayRepo(); const drafts = await repo.listDrafts({ packageId: request.packageId }); const discarded: Array<{ type: string; name: string }> = []; @@ -23632,10 +23225,10 @@ export class ObjectStackProtocolImplementation implements let drafts: Awaited>; let publishedRows: unknown[]; try { - drafts = await this.getOverlayRepo(null).listDrafts({ packageId: request.packageId }); + drafts = await this.getOverlayRepo().listDrafts({ packageId: request.packageId }); // [#21911] The explicit system opt-in — see findServedOverlayRow. publishedRows = (await this.engine.find('sys_metadata', { - where: packageScopedRowWhere(null, 'active', { packageId: request.packageId }), + where: packageScopedRowWhere('active', { packageId: request.packageId }), limit: 1, context: { isSystem: true }, })) as unknown[]; @@ -23946,7 +23539,7 @@ export class ObjectStackProtocolImplementation implements actor: string | undefined, ): Promise { const type = PLURAL_TO_SINGULAR[row.type] ?? row.type; - const repo = this.getOverlayRepo(row.organization_id); + 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; @@ -24613,7 +24206,6 @@ export class ObjectStackProtocolImplementation implements */ async listCommits(request: { packageId: string; - organizationId?: string; limit?: number; }): Promise> { try { - const where: Record = { package_id: request.packageId }; - // [#7779] Surface BOTH org-scoped and env-wide (`organization_id IS - // NULL`) commit rows to an org-scoped caller — the same defect and - // the same remedy as the sibling {@link deletePackage} read one - // function above (#7705), and as {@link - // SysMetadataRepository.listDrafts} (#3115) in this package. - // - // Env-wide commit rows are not hypothetical: {@link - // recordPackageCommit} stores `organization_id: request. - // organizationId ?? null`, and the ONLY door into a publish — the - // dispatcher's `POST /packages/:id/publish-drafts` — forwards an - // org only when `resolveActiveOrganizationId` yields one. That - // resolver answers `undefined` for a session with no active - // organization AND for every failure on the auth seam (it is - // `catch`-wrapped). So a publish made before an org was selected — - // or during a transient auth blip — lands its commit env-wide, - // permanently, and the strict equality then hid it from every - // org-scoped read of that package's timeline. - // - // This is NOT merely an observability miss. {@link - // rollbackToPackageCommit} derives the set of commits to undo from - // this list, so an invisible commit was silently never reverted: - // measured pre-fix, a rollback past an env-wide commit answered - // `{success: true, revertedCommits: []}` while that commit's - // changes stayed live. - // - // The no-org branch is deliberately NOT narrowed to - // `organization_id IS NULL`, exactly as #7705 left its own: a - // caller with no active org reads the package's whole timeline, - // and restricting it to env-wide rows would hide every org-scoped - // commit instead — the same bug pointed the other way. - if (request.organizationId) { - where.$or = [ - { organization_id: request.organizationId }, - { organization_id: null }, - ]; - } + // [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 + // 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 }; // [#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. @@ -24912,7 +24477,7 @@ export class ObjectStackProtocolImplementation implements // [ADR-0131 D6] Every item reverts environment-wide: the commit row // is environment-wide (a legacy organization's was refused above). const itemOrgId = null; - const repo = this.getOverlayRepo(itemOrgId); + const repo = this.getOverlayRepo(); const ref = { type: it.type, name: it.name, org: itemOrgId ?? 'env' } as unknown as Parameters[0]; try { const current = await repo.get(ref, { state: 'active' }); @@ -25419,7 +24984,7 @@ export class ObjectStackProtocolImplementation implements // [ADR-0131 D6] The environment's lineage: an organization-scoped // request was refused above. const orgId = null; - const repo = this.getOverlayRepo(orgId); + const repo = this.getOverlayRepo(); const artifactBacked = this.isArtifactBacked(singularType, request.name); const intent: 'override-artifact' | 'runtime-only' = artifactBacked ? 'override-artifact' : 'runtime-only'; @@ -25591,7 +25156,6 @@ export class ObjectStackProtocolImplementation implements name: string; fromVersion?: number; toVersion?: number; - organizationId?: string; }): Promise<{ type: string; name: string; @@ -25649,12 +25213,12 @@ export class ObjectStackProtocolImplementation implements // `type` follows above. const expandedFrom = await this.resolveRowlessExpandedView( { type: singularType, name: request.name }, - organizationIdForMetaRead(singularType, request.organizationId), ); if (expandedFrom !== undefined) { return this.diffMetaItem({ ...request, name: expandedFrom.container.name }); } - const orgId = request.organizationId ?? null; + // [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 @@ -26056,7 +25620,7 @@ export class ObjectStackProtocolImplementation implements if (useRepoPath) { // [ADR-0131 D6] Environment-wide: refused above otherwise. const orgId = null; - const repo = this.getOverlayRepo(orgId); + const repo = this.getOverlayRepo(); const ref = { type: singularTypeForRepo, name: request.name, @@ -26420,11 +25984,19 @@ 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 }`: env-wide rows only, with - * per-org overlays left to `getMetaItem`'s on-demand read so one org's - * customization never lands in the process-wide SchemaRegistry. There is no + * 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 * `environment_id` constraint — ADR-0005 (revised 2026-05) gave each - * environment its own database and `organization_id` is the isolation key. + * environment its own database. + * + * [ADR-0131 D6, triage ruling Q1 → C] An environment row that overlays + * SEALED managed content ({@link isUnservedSealedOverlay}) is named by + * {@link reportSealedOverlayRows} with its remedies. An `object` such row + * is not loaded: it would register as a layer the engine resolves the + * object through, so the package's definition stays the registered one. + * Every other type's row still registers its bare, tenant-marked copy, + * which the reads decline (#20913, {@link isStoredEntryOfDeclinedName}). * * #3903 — two contract duties run per row, and their split is deliberate: * @@ -26487,10 +26059,8 @@ export class ObjectStackProtocolImplementation implements /** #5897 — see the TSDoc: set only on the non-benign outer-catch branch. */ let storeUnavailable = false; try { - // ADR-0005 (revised 2026-05): hydrate only env-wide rows - // (organization_id IS NULL). Per-org overlays are loaded on - // demand by getMetaItem to avoid cross-org leakage into the - // process-wide SchemaRegistry. + // [ADR-0131 D6] Hydrate only the environment's rows + // (organization_id IS NULL); no read serves any other. const where: Record = { state: 'active', organization_id: null, @@ -26508,16 +26078,22 @@ export class ObjectStackProtocolImplementation implements const sduiManifest = (records as Array<{ type?: unknown }>).some( (r) => (PLURAL_TO_SINGULAR[String(r.type)] ?? r.type) === 'page', ) ? this.resolveSduiManifest() : undefined; + /** [ADR-0131 D6] Overlays of sealed managed content, reported. */ + const sealedOverlays: Array<{ type: string; name: string }> = []; for (const record of records) { try { + // Normalize DB type to singular (DB may store legacy plural forms) + const normalizedType = PLURAL_TO_SINGULAR[record.type] ?? record.type; + if (this.isUnservedSealedOverlay(normalizedType, String(record.name))) { + sealedOverlays.push({ type: normalizedType, name: String(record.name) }); + if (normalizedType === 'object') continue; + } const data = this.convertStoredItem( String(record.type), typeof record.metadata === 'string' ? JSON.parse(record.metadata) : record.metadata, ); - // Normalize DB type to singular (DB may store legacy plural forms) - const normalizedType = PLURAL_TO_SINGULAR[record.type] ?? record.type; const verdict = computeMetadataDiagnostics(normalizedType, data); if (verdict && !verdict.valid) { invalid++; @@ -26683,8 +26259,10 @@ export class ObjectStackProtocolImplementation implements } } } - // #6190 — say out loud which org-scoped rows this filter just - // walked past. See {@link reportUnhydratableOrgScopedRows}. + // [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. + this.reportSealedOverlayRows(sealedOverlays); await this.reportUnhydratableOrgScopedRows(); } catch (e: unknown) { // #5841 — the ONE benign reason this whole read can fail is @@ -26770,80 +26348,36 @@ export class ObjectStackProtocolImplementation implements } /** - * [#6190] Cold boot walks past every `organization_id IS NOT NULL` row. - * For the types the registry declares per-org overridable that is the - * design (ADR-0005 revised 2026-05 — those overlays are loaded on demand - * by `getMetaItem`/`getMetaItems`, which is why the filter above exists). - * For every OTHER type it is a stored row the platform has no per-org - * channel for, and until this method the skip was **completely silent**. - * - * The measured specimen is `flow`. `flow` is `allowOrgOverride: false` - * (rolled back in #6283 / commit 474f131cf, matching ADR-0005:57) but - * `allowRuntimeCreate: true`, so a tenant authoring a BRAND-NEW flow in - * Studio still writes `sys_metadata.organization_id = ''` — the - * runtime `PUT /metadata/:type/:name` threads `resolveActiveOrganizationId` - * into `saveMetaItem`, and `SysMetadataRepository.put` stamps - * `organization_id: this.organizationId` whatever the type is. That flow - * binds its triggers for the rest of the process's life (the publish-time - * write-through puts it in the process-wide registry) and then, on the - * next restart, this filter drops it and the `kernel:ready` binder — - * `getMetaItems({ type: 'flow' })`, no `organizationId`, so - * `orgRecords = []` — never sees it. It stops firing, and nothing said so: - * the `kernel:bootstrapped` unbound audit cannot report a flow that was - * never registered. - * - * This method does not change what boot loads. It makes the absence - * LOUD — AGENTS.md's rule, and the half of #6190 that is implementable - * without a contract ruling. Whether such a row should exist at all - * (refuse the write / force it env-wide / teach the binder to read per-org) - * is the maintainer decision recorded on the issue; the operator-visible - * consequence is the same either way and it is what an operator needs - * TODAY to explain an automation that stopped after a restart. + * [#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: * - * - **Which rows.** Registry-derived, never a hand-written list - * (Prime Directive #7): the complement of - * {@link OVERLAY_ALLOWED_TYPES}'s source flag. Derived from - * `DEFAULT_METADATA_TYPE_REGISTRY` and NOT from - * {@link isOverlayAllowed}, because the `OS_METADATA_WRITABLE` escape - * hatch only unlocks the WRITE — an env-unlocked type's org rows are - * hydrated no more than any other's, so silencing the line on that - * flag would hide exactly the deployment most likely to have these rows. - * - **[#6992] …plus every LIVE type the registry does not declare at - * all.** {@link listLiveMetadataTypes} — the same accessor - * {@link getMetaTypes} lists from. A plugin-registered type (`theme`, - * `connector`, `webhook`, `sharing_rule`, `analytics_cube`, …) has no - * registry entry, so the loop above could not reach it, yet - * `loadMetaFromDb`'s filter is type-BLIND and skips its org-scoped rows - * exactly like a `flow`'s. That family was the one getting neither the - * refusal nor the warning. It has no declaration to consult, and - * `getMetaTypes()` synthesises `allowOrgOverride: false` for it, so - * "not per-org overridable" is its correct reading here. - * - * ── THE REFUSAL HAS SINCE WIDENED TO MEET IT ── - * - * The #6190 refusal keyed off the STATIC registry and let exactly this - * family through, while this audit keyed off the LIVE set (ruled on - * #6992, scoped to the diagnostic). Since ADR-0131 D6 the refusal - * ({@link organizationScopedWriteRefusal}) admits no organization-scoped - * write of ANY type, plugin-registered ones included, hatch or no hatch, - * so the only rows this audit can find are residue. - * - * Measured, not assumed (#6992): at the instant this method runs — in - * `ObjectQLPlugin.start()` Phase 2, after every plugin's `init` — a - * real `app-showcase` boot has 7 live types with no registry entry - * (`analytics_cube`, `connector`, `data`, `package`, `sharing_rule`, - * `theme`, `webhook`), all of them from the SchemaRegistry. The - * widening is therefore live and not defeated by boot order. - * - **Two predicates, both narrowing.** `organization_id IS NOT NULL` - * plus the type list keeps the query empty-by-default: a healthy - * deployment reads nothing and prints nothing. A driver that drops - * either predicate degrades to reading more rows, never to a false - * line — the JS filter re-checks both. + * - **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 rather - * than a thousand. + * names, so a tenant with a thousand such rows costs one line. * - **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 @@ -26853,118 +26387,76 @@ export class ObjectStackProtocolImplementation implements /** Names printed per type before the line collapses to a count. */ const SAMPLE_PER_TYPE = 5; try { - const orgOverridable = new Set(); - /** Types `DEFAULT_METADATA_TYPE_REGISTRY` declares, whatever their flags. */ - const declaredTypes = new Set(); - /** [#6992] Live types with NO registry entry — reported, never refused. */ - const undeclaredTypes = new Set(); - const scannedTypes: string[] = []; - const scan = (singular: string): void => { - scannedTypes.push(singular); - // Both spellings: `sys_metadata.type` may hold the legacy - // plural, exactly as the hydration loop above assumes. - const plural = SINGULAR_TO_PLURAL[singular]; - if (plural) scannedTypes.push(plural); - }; - for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { - declaredTypes.add(entry.type); - if (entry.allowOrgOverride) { - orgOverridable.add(entry.type); - continue; + // 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 stays here. + const orNoTable = async (object: string, read: () => Promise, empty: T): Promise => { + try { + return await read(); + } catch (error) { + if (isMissingTableError(error, object)) return empty; + throw error; } - scan(entry.type); - } - // [#6992] Widen to the live registry — see the TSDoc's "…plus every - // LIVE type the registry does not declare at all" bullet. Best - // effort like the rest of this method: a kernel whose accessors - // throw degrades to the declared scan it had before, never to a - // failed boot. - let liveTypes: string[] = []; - try { - liveTypes = await this.listLiveMetadataTypes(); - } catch { - liveTypes = []; - } - for (const liveType of liveTypes) { - const singular = PLURAL_TO_SINGULAR[liveType] ?? liveType; - if (declaredTypes.has(singular) || undeclaredTypes.has(singular)) continue; - undeclaredTypes.add(singular); - scan(singular); - } - if (scannedTypes.length === 0) return; - - const rows = await this.engine.find('sys_metadata', { - where: { - state: 'active', - organization_id: { $null: false }, - type: { $in: scannedTypes }, - }, - }); - if (!rows || rows.length === 0) return; + }; - // Re-check both predicates in JS: a driver that cannot lower one - // of them hands back a superset, and a superset must not become a - // false accusation. + // 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. + const 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(); - const scannedSingulars = new Set( - scannedTypes.map((t) => PLURAL_TO_SINGULAR[t] ?? t), - ); - let total = 0; for (const row of rows) { - const org = (row as { organization_id?: string | null }).organization_id; - if (org === null || org === undefined || org === '') continue; const singular = PLURAL_TO_SINGULAR[String(row.type)] ?? String(row.type); - if (orgOverridable.has(singular)) continue; - // [#6992] Re-check the TYPE predicate too, which is what the - // TSDoc above has always promised ("the JS filter re-checks - // both"). Before the live widening, `orgOverridable` was that - // re-check: within the declared registry, "not org-overridable" - // and "in the scanned list" were the same statement. They are - // not any more — a row of a type that is neither declared NOR - // live (a plugin uninstalled since the row was written) is - // absent from the list, so only this line keeps a driver that - // cannot lower `$in` from turning a superset into a line about - // a type this kernel never scanned. - if (!scannedSingulars.has(singular)) continue; - total++; counts.set(singular, (counts.get(singular) ?? 0) + 1); const names = samples.get(singular) ?? []; - if (names.length < SAMPLE_PER_TYPE) names.push(`${String(row.name)}@${String(org)}`); + 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); } - if (total === 0) return; + const ledgers: string[] = []; + for (const ledger of ['sys_metadata_commit', 'sys_metadata_history', 'sys_metadata_audit']) { + 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) return; - let reportedUndeclared = false; const detail = Array.from(counts.entries()) .map(([type, count]) => { const names = samples.get(type) ?? []; const more = count > names.length ? `, +${count - names.length} more` : ''; - // [#6992] Mark the plugin-registered family. Not decoration: - // the operator's next step differs between the two: a - // DECLARED type's org-scoped write is refused from now on - // (#6190), so the listed rows are historical residue and - // cannot grow; an UNDECLARED type's write is not refused, - // so the same names come back after every restart until the - // author stops writing them org-scoped. - const mark = undeclaredTypes.has(type) ? ' [plugin-registered]' : ''; - if (mark) reportedUndeclared = true; - return `${type}×${count}${mark} (${names.join(', ')}${more})`; + return `${type}×${count} (${names.join(', ')}${more})`; }) .join('; '); console.warn( - `[Protocol] [metadata_org_scoped_unhydrated] ${total} active sys_metadata row(s) are ` + - `org-scoped on types with NO per-org channel (the registry declares allowOrgOverride=false, ` + - `or does not declare the type at all), so boot hydration skipped them and they are absent ` + - `from the process-wide registry: ${detail}. ` + - `A 'flow' listed here will NOT bind its triggers in this process (the kernel:ready binder ` + - `reads flows env-wide) — it fired until the last restart and stops now. ` + - (reportedUndeclared - ? `Types marked [plugin-registered] have no metadata-type registry entry, so the declared-types-only ` - + `org-scope write refusal does NOT cover them: rows of those types can still be written ` - + `org-scoped, and will be listed here again after every restart until the author stops. ` + `[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(', ')}. ` : '') + - `Re-save the item env-wide (no active organization), or delete the row. See ADR-0005.`, + `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 @@ -26973,6 +26465,52 @@ export class ObjectStackProtocolImplementation implements } } + /** + * [ADR-0131 D6, triage ruling Q1 → C] Say out loud which stored + * ENVIRONMENT rows boot did not load because they overlay sealed managed + * content ({@link isUnservedSealedOverlay}): an overlay of an item a + * managed package ships, on a type whose registry entry opens no overlay + * channel — a managed flow, action, hook or object, written through the + * `OS_METADATA_WRITABLE` hatch before D6 sealed it. No read serves such a + * row: the package's definition wins. + * + * Per type, a capped sample of names, and the two remedies the ruling + * names. ⛔ Nothing here deletes or rewrites the row — its fate is the v18 + * migration ceremony's to name, as a reported population. + * + * `warn`: a functional degradation an operator can see (the customised + * behaviour is gone, the package's is served), not a durability one. + * Judged against the packages registered when boot hydrates; a package + * registered later (a marketplace install) is not judged here, and its + * sealed overlays are still not served. + */ + private reportSealedOverlayRows(rows: ReadonlyArray<{ type: string; name: string }>): void { + if (rows.length === 0) return; + const SAMPLE_PER_TYPE = 5; + const byType = new Map(); + for (const { type, name } of rows) { + const names = byType.get(type) ?? []; + names.push(name); + byType.set(type, names); + } + const detail = Array.from(byType.entries()) + .map(([type, names]) => { + const shown = names.slice(0, SAMPLE_PER_TYPE); + const more = names.length > shown.length ? `, +${names.length - shown.length} more` : ''; + return `${type}×${names.length} (${shown.join(', ')}${more})`; + }) + .join('; '); + console.warn( + `[Protocol] [metadata_sealed_overlay_unserved] ${rows.length} active environment sys_metadata row(s) ` + + `overlay an item a managed package ships, on a type that is sealed against overlays (ADR-0131 D6): ` + + `${detail}. No read serves them — the package's own definition is served. ` + + `Each row is kept at rest, untouched (an object's table and its columns stay as they are). To keep ` + + `the change, re-express it as a new item under a new ` + + `name (a linkage-free clone of the managed item), then delete the stored row; otherwise delete the ` + + `stored row.`, + ); + } + // ========================================== // Metadata References (Phase 3a-references) // ========================================== @@ -27023,7 +26561,6 @@ export class ObjectStackProtocolImplementation implements async findReferencesToMeta(request: { type: string; name: string; - organizationId?: string; }): Promise<{ references: Array<{ type: string; @@ -27224,7 +26761,6 @@ export class ObjectStackProtocolImplementation implements // is what surfaces. const result = await this.getMetaItems({ type: matcher.fromType, - ...(request.organizationId ? { organizationId: request.organizationId } : {}), }); const items = (result?.items ?? []) as unknown[]; for (const raw of items) { diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index 097d4adee50..c559be1b27d 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -1321,8 +1321,10 @@ export class SeedLoaderService implements ISeedLoaderService { `column, this seed load names no organization (config.organizationId) and none can be derived — ` + `the install holds ${count === 0 ? 'no organization at all' : 'several organizations'}. Every seed ` + `row of an organization-owned object is stamped with its organization or refused; writing it would ` + - `store a row no organization owns. Fix it by passing the organization the seed populates as ` + - `config.organizationId, or by setting organization_id on the record. Under the 'single' tenancy ` + + `store a row no organization owns. Fix it by setting organization_id on the record — a seed ` + + `published through a package carries no caller organization (ADR-0131 D12), so the record is ` + + `where its owner is named; a direct SeedLoader caller may instead pass config.organizationId. ` + + `Under the 'single' tenancy ` + `posture the Default Organization is created at boot, before seeds load, so a single-tenant ` + `deployment that reaches this has lost that organization.`, }; 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 15531c6f8d3..093f134891d 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 @@ -112,7 +112,6 @@ const CONFORMS: { [K in keyof DraftHeader]: (value: DraftHeader[K]) => boolean } name: (v) => typeof v === 'string', // [#22200] The draft body's own label: an `I18nLabel` as authored, or `null`. label: (v) => v === null || I18nLabelSchema.safeParse(v).success, - organizationId: (v) => v === null || typeof v === 'string', packageId: (v) => v === null || typeof v === 'string', updatedAt: (v) => v === null || typeof v === 'string', updatedBy: (v) => v === null || typeof v === 'string', @@ -236,9 +235,7 @@ describe('#14938 — listDrafts emits canonical ISO text for updatedAt, whatever expectConformsToDeclaration(drafts); }); - it('reaches the same canonicalisation through the org-scoped $or read', async () => { - // A non-null-org caller sees BOTH its own overlay drafts and the env-wide - // ones (#3115); the canonicalisation must not depend on which arm matched. + 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 }), @@ -248,7 +245,7 @@ describe('#14938 — listDrafts emits canonical ISO text for updatedAt, whatever expect(engine.rows[0]!.updated_at).toBeInstanceOf(Date); const drafts = await repo.listDrafts(); - expect(drafts).toHaveLength(2); + expect(drafts.map((d) => d.name)).toEqual(['lead_grid']); for (const draft of drafts) expect(draft.updatedAt).toBe(PG_INSTANT.toISOString()); expectConformsToDeclaration(drafts); }); @@ -367,7 +364,7 @@ describe('#14938 — listDrafts emits canonical ISO text for updatedAt, whatever * table above now covers `label` on every case in the file. */ describe('#22200 — each listDrafts row carries the draft body\'s own label, or null', () => { - const HEADER_KEYS = ['label', 'name', 'organizationId', 'packageId', 'type', 'updatedAt', 'updatedBy']; + const HEADER_KEYS = ['label', 'name', 'packageId', 'type', 'updatedAt', 'updatedBy']; describe('§A drafts saved through the real write path', () => { it('a draft whose body declares a label returns it', async () => { diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index 0843e9a99a5..0d13161d099 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -436,44 +436,30 @@ export function resetEnvWritableMetadataTypes(): void { } /** - * [#22090] The `sys_metadata` predicate for "the rows of one state that a - * caller in `organizationId` sees, optionally narrowed to a `type` and a - * package" — the ONE statement of that rule, which + * [#22090] The `sys_metadata` predicate for "the environment's rows of one + * state, optionally narrowed to a `type` and a package" — the ONE statement + * of that rule, which * {@link SysMetadataRepository.listDrafts} asks for `'draft'` and the * protocol's package revert asks for `'active'`. * * A package's membership in storage is its rows' `package_id` column, the * same column the read path decorates as `_packageId`. The revert has to know * which of a package's rows are published as well as which are drafts, and a - * second spelling of the org half below is exactly the drift #3115 / #7705 / + * second spelling of the scope half below is exactly the drift #3115 / #7705 / * #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. + * * Deliberately NOT exported from the package entry: it is a shared predicate * between this module and `protocol.ts`, not a public read. */ export function packageScopedRowWhere( - organizationId: string | null, state: 'draft' | 'active', filter?: { type?: string; packageId?: string }, ): Record { - const where: Record = { state }; - // Surface BOTH org-scoped drafts and env-wide (`organization_id IS NULL`) - // drafts. Env-wide drafts are real pending changes — `getMetaItems`/preview - // overlays them and `publish-drafts` promotes them — but a strict - // `organization_id = this.organizationId` equality silently dropped them - // whenever the active org was non-null. AI-authored metadata is written - // env-wide, so its drafts vanished from the pending-changes list and the - // Publish CTA never appeared: the change showed in preview yet could not be - // published from the UI (the "orphaned draft" bug). - if (organizationId != null) { - where.$or = [ - { organization_id: organizationId }, - { organization_id: null }, - ]; - } else { - where.organization_id = null; - } + const where: Record = { state, organization_id: null }; if (filter?.type) where.type = filter.type; if (filter?.packageId) where.package_id = filter.packageId; return where; @@ -1373,28 +1359,20 @@ export class SysMetadataRepository implements MetadataRepository { * item this header is the only place a client can find a label at all. */ label: I18nLabel | null; - /** - * The scope the draft actually lives in — `null` for an env-wide draft, - * a string for a per-org overlay draft. The `$or` below surfaces BOTH to - * a non-null-org caller, so consumers that then act on a draft (promote / - * discard) MUST route the write to THIS scope, not the caller's active - * org, or they 404 on the env-wide row they can never match (#3115). - */ - organizationId: string | null; packageId: string | null; updatedAt: string | null; updatedBy: string | null; }> > { this.assertOpen(); - const where = packageScopedRowWhere(this.organizationId, 'draft', filter); + // [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 } }); return (rows as any[]).map((row) => ({ type: row.type, name: row.name, // [#22200] Off the row this read already holds — no second query. label: draftBodyLabel(row), - organizationId: row.organization_id ?? null, packageId: row.package_id ?? null, // [commit c383352cb] `updated_at` / `created_at` are the BUILTIN audit columns, // and on Postgres and MySQL they used to arrive here as a JS `Date`: 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 fb4a685694e..152cac16f49 100644 --- a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts +++ b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts @@ -293,7 +293,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 org-scoped and environment-scoped runtime containers are served', () => { +describe('#13407 environment-scoped runtime containers are served; [ADR-0131 D6] a legacy organization container only by the anonymous form doors\' legacy read', () => { 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,17 +312,30 @@ describe('#13407 org-scoped and environment-scoped runtime containers are served expect(switcherMatches(list.items, 'crm_lead').map((v: any) => v.name)).toEqual(['crm_lead.default']); }); - it('the cards own repro: a runtime container authored by a signed-in user with an ACTIVE ORG is now served', async () => { + 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 () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); - const list: any = await protocol.getMetaItems({ type: 'view', organizationId: 'org_acme' } as any); + const list: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_acme' }); 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); @@ -332,7 +345,7 @@ describe('#13407 org-scoped and environment-scoped runtime containers are served config: { type: 'grid', data: { provider: 'object', object: 'crm_lead' }, columns: [{ field: 'name' }] }, }); - const list: any = await protocol.getMetaItems({ type: 'view', organizationId: 'org_acme' } as any); + const list: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_acme' }); 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 @@ -345,10 +358,10 @@ describe('#13407 org-scoped and environment-scoped runtime containers are served const protocol = new ObjectStackProtocolImplementation(engine); await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); - const globex: any = await protocol.getMetaItems({ type: 'view', organizationId: 'org_globex' } as any); + 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', organizationId: 'org_acme' } as any); + const acme: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_acme' }); expect(switcherMatches(acme.items, 'crm_lead')).toHaveLength(2); }); @@ -356,7 +369,7 @@ describe('#13407 org-scoped and environment-scoped runtime containers are served const { engine, registered } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); - await protocol.getMetaItems({ type: 'view', organizationId: 'org_acme' } as any); + await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_acme' }); // The RESPONSE carries the expansion (re-confirmed above); the SHARED // registry — read by every org/environment this kernel serves — must @@ -632,8 +645,9 @@ 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 }, - { arm: 'package-less, organization-scoped', packageId: undefined, organizationId: ORG, ownPackage: undefined }, - ] as const; + // [ADR-0131 D6] No organization-scoped arm: a legacy organization + // container is served by no read (pinned in the ISOLATION case below). + ] 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({ @@ -881,19 +895,19 @@ describe('#21334 a container on another package\'s object never takes that packa }); } - it('ISOLATION — an organization-scoped container\'s names answer nothing by name for another organization', async () => { + it('[ADR-0131 D6] ISOLATION — a legacy organization-scoped container\'s names answer nothing, for any caller', async () => { const { protocol } = showcaseHarness(environmentId); - const org = CONTAINERS.find((c) => c.organizationId !== undefined)!; - await save(protocol, OWN, { name: OWN, object: TASK, ...MEMBER_CASES['listViews.*'].member }, org); + 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)).toBeTruthy(); + 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, ORG]) { + 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); const overlay = { @@ -1006,7 +1020,7 @@ describe('#21334 a container on another package\'s object never takes that packa for (const [kernel, environmentId] of KERNELS) { describe(`on ${kernel}`, () => { - for (const organizationId of [undefined, ORG]) { + for (const organizationId of [undefined] as Array) { const scope = organizationId ? 'organization-scoped' : 'environment-wide'; for (const order of ['the container first', 'the row first'] as const) { it(`${scope}, ${order}: the stored row answers its name on both doors; the row-less expanded name answers the expansion`, async () => { @@ -1043,19 +1057,16 @@ describe('#21334 a container on another package\'s object never takes that packa } } - it('the predicate is read over the caller\'s own rows: an organization\'s row wins for that organization only', async () => { + 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); - // The organization that holds the row: the row, on both doors. - await expectBothDoors(protocol, DEFAULT, ORG, expectTheRow); - // A caller for whom the name has no row of its own: the - // container's expansion, on both doors. 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); }); @@ -1937,7 +1948,7 @@ describe('#21334 a container on another package\'s object never takes that packa for (const [kernel, environmentId] of KERNELS) { describe(`on ${kernel}`, () => { - for (const organizationId of [undefined, ORG]) { + for (const organizationId of [undefined] as Array) { const scope = organizationId ? 'organization-scoped' : 'environment-wide'; for (const c of AT_REST) { it(`${scope}, ${c.member} stored at ${c.rowName}: ${c.spared} still answers the packaged view on both doors, and the container's view is served under its own name, with no package and no default`, async () => { diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 9b18363e90e..62d6aff0bfd 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -77,6 +77,17 @@ function hasLoadMetaFromDb(service: unknown): service is ProtocolWithDbRestore { ); } +/** + * [ADR-0131 D6] A `sys_metadata` row stored in a legacy organization's layer: + * no metadata read serves one, so the authored hook and action binders skip it + * too. Judged off the row in hand — `sys_metadata` is a platform table, never a + * federated object. + */ +function isLegacyOrganizationRow(row: { organization_id?: unknown } | null | undefined): boolean { + const organizationId = row?.organization_id; + return organizationId !== null && organizationId !== undefined && organizationId !== ''; +} + /** * Options for ObjectQLPlugin. * @@ -2379,11 +2390,14 @@ export class ObjectQLPlugin implements Plugin { * Read the ACTIVE runtime-authored hook rows from `sys_metadata`. * * Reads the table directly (like `protocol.getMetaItems` does) instead of - * going through the metadata service, because (a) env-scoped kernels have - * no DatabaseLoader so the service never surfaces these rows, and (b) rows - * published from a Studio session are org-scoped — engine hooks fire - * process-wide, so we take active rows across ALL organizations rather - * than one org's overlay view. + * 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. * * 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", @@ -2395,8 +2409,8 @@ export class ObjectQLPlugin implements Plugin { // No environment filter: per ADR-0005 (revised 2026-05) each // environment has its own physical DB, so this kernel's sys_metadata // only ever holds its own rows (saveMetaItem no longer stamps - // environment_id). Rows across ALL organizations are taken — engine - // hooks fire process-wide, matching flow-trigger semantics. + // environment_id). [ADR-0131 D6] The environment's rows only — see + // the TSDoc. // // [#21911, ADR-0096] The explicit system opt-in: a boot / resync read // of the platform store with no caller behind it, never a principal-less @@ -2414,6 +2428,8 @@ export class ObjectQLPlugin implements Plugin { } const hooks: any[] = []; for (const row of rows) { + // [ADR-0131 D6] The environment's rows only — see the TSDoc. + if (isLegacyOrganizationRow(row)) continue; try { const data = this.convertStoredRow( ctx, @@ -2564,9 +2580,9 @@ export class ObjectQLPlugin implements Plugin { * handler still needs registering. * * Same read discipline as {@link readAuthoredHookRows}: direct table read - * (env-scoped kernels surface authored rows nowhere else), all - * organizations (engine actions are process-wide), `null` on a failed - * read so callers never tear down live registrations on an error. + * (env-scoped kernels surface authored rows nowhere else), the + * environment's rows only (ADR-0131 D6), `null` on a failed read so callers + * never tear down live registrations on an error. */ private async readAuthoredActionRows(ctx: PluginContext): Promise { if (!this.ql) return null; @@ -2600,6 +2616,7 @@ export class ObjectQLPlugin implements Plugin { } const actions: any[] = []; for (const row of rows) { + if (isLegacyOrganizationRow(row)) continue; // [ADR-0131 D6] const data = parseRow(row, 'action'); if (data && typeof data.name === 'string') actions.push(data); } @@ -2612,6 +2629,7 @@ export class ObjectQLPlugin implements Plugin { context: { isSystem: true }, })) ?? []; for (const row of objectRows) { + if (isLegacyOrganizationRow(row)) continue; // [ADR-0131 D6] 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 b4ad600d7e5..bdf55384186 100644 --- a/packages/objectql/src/protocol-layered-get.test.ts +++ b/packages/objectql/src/protocol-layered-get.test.ts @@ -59,7 +59,7 @@ describe('ObjectStackProtocolImplementation - getMetaItemLayered', () => { expect(result.effective).toMatchObject({ label: 'Customised Label' }); }); - it('prefers org-scoped overlay over env-wide when both exist', async () => { + it('[ADR-0131 D6] reports the environment overlay even when a legacy organization row exists', async () => { const orgOverlay = { ...overlayBody, label: 'Org-Specific Label' }; mockEngine.findOne.mockImplementation((_table: string, opts: any) => { if (opts.where?.organization_id === 'org_alpha') { @@ -75,10 +75,10 @@ describe('ObjectStackProtocolImplementation - getMetaItemLayered', () => { type: 'view', name: 'my_view', organizationId: 'org_alpha', - }); - expect(result.overlayScope).toBe('org'); - expect(result.overlay).toMatchObject({ label: 'Org-Specific Label' }); - expect(result.effective).toMatchObject({ label: 'Org-Specific Label' }); + } as { type: string; name: string }); + expect(result.overlayScope).toBe('env'); + expect(result.overlay).toMatchObject({ label: overlayBody.label }); + expect(result.effective).toMatchObject({ label: overlayBody.label }); }); it('returns null code when artifact baseline missing but overlay exists', async () => { diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index c7f2007f93d..7d49c13cf87 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -61,128 +61,46 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { // ADR-0005 (revised 2026-05): per-organization overlay isolation // ═══════════════════════════════════════════════════════════════ - describe('per-organization overlay isolation', () => { - // [commit d5cbb44f3] Re-spelled from `app` to `view`, and its sibling below with - // it. The CLAIM is unchanged — an org row and an env-wide row of the - // same `(type, name)` both exist, and the org row is the one SERVED, - // whole, by precedence rather than a merge. It just has to be measured - // on a type that HAS an org partition to have precedence over. - // - // `getMetaItem` now resolves its own read scope through - // `organizationIdForMetaRead` — the read-side twin of the predicate - // `saveMetaItem` already gates on — so on `app`, which rolled back to - // `allowOrgOverride: false` in commit ee58392e1, `organizationId` is gated to - // `undefined` and the org partition is never queried. On `app` this - // case was pinning the phantom read commit d5cbb44f3 removed: a pre-#6190 - // org-scoped row served INSTEAD OF the live env-wide document. `view` - // is the whitelisted specimen — the same re-spelling #6190 made one - // case up and commit 96326040f made one case down. - it('getMetaItem returns org-specific overlay when both org and env-wide rows exist', async () => { - // findOverlay calls: first attempts org=org_alpha (returns row), - // env-wide fallback should be skipped. - mockEngine.findOne.mockImplementation((_table: string, opts: any) => { - if (opts?.where?.organization_id === 'org_alpha') { - return Promise.resolve({ - type: 'view', name: 'test_grid', state: 'active', - metadata: JSON.stringify({ ...sampleView, label: 'Org Alpha' }), - }); - } - if (opts?.where?.organization_id === null) { - return Promise.resolve({ - type: 'view', name: 'test_grid', state: 'active', - metadata: JSON.stringify({ ...sampleView, label: 'Env Default' }), - }); - } - return Promise.resolve(null); - }); - - const result = await protocol.getMetaItem({ - type: 'view', name: 'test_grid', organizationId: 'org_alpha', - }); - expect((result.item as any).label).toBe('Org Alpha'); - // ADR-0005 resolution order: overlay WINS, it does not merge. The - // env-wide partition is never reached once the org row answers — - // the assertion the prose above used to make in a comment only. - expect(mockEngine.findOne).not.toHaveBeenCalledWith('sys_metadata', { - where: { type: 'view', name: 'test_grid', state: 'active', organization_id: null }, - }); - }); + 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); + }; - it('getMetaItem falls through to env-wide overlay when no org-specific row exists', async () => { - mockEngine.findOne.mockImplementation((_table: string, opts: any) => { - if (opts?.where?.organization_id === null) { - return Promise.resolve({ - type: 'view', name: 'test_grid', state: 'active', - metadata: JSON.stringify({ ...sampleView, label: 'Env Default' }), - }); - } - return Promise.resolve(null); - }); + 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' }) }, + { type: 'view', name: 'test_grid', state: 'active', metadata: JSON.stringify({ ...sampleView, label: 'Env Default' }) }, + )); const result = await protocol.getMetaItem({ type: 'view', name: 'test_grid', organizationId: 'org_alpha', - }); + } as { type: string; name: string }); expect((result.item as any).label).toBe('Env Default'); - // ⚠️ NON-VACUITY, and the reason this case had to move too. A - // fall-through only states something if the org partition was - // actually read FIRST. Left on `app` this case kept passing after - // commit d5cbb44f3 while measuring nothing at all: the gate resolves `app` to - // `undefined`, so the only read ever issued was the env-wide one and - // the assertion could no longer fail. - expect(mockEngine.findOne).toHaveBeenCalledWith('sys_metadata', { - where: { type: 'view', name: 'test_grid', state: 'active', organization_id: 'org_alpha' }, - // [#21911] The platform store read carries the explicit system opt-in. - context: { isSystem: true }, - }); + for (const [table, query] of mockEngine.findOne.mock.calls) { + if (table === 'sys_metadata') expect((query as any)?.where?.organization_id ?? null).toBeNull(); + } }); - // [commit 96326040f] Re-spelled from `app` to `view`, the READ-side twin of the - // `[#6190]` re-spelling three cases up — same reason, one verb over. - // `getMetaItems` now resolves its own read scope through - // `organizationIdForMetaRead`, so an `organizationId` handed in for a - // type the registry declares NON-overridable is gated to `undefined` - // and the org partition is never queried. `app` rolled back to - // `allowOrgOverride: false` in commit ee58392e1, so on `app` this case was - // asserting a union the platform must NOT perform: the org rows it - // seeded are the pre-#6190 phantoms `loadMetaFromDb` walks past, and - // reading them back is the resurrection commit 96326040f closed. - // - // The CLAIM is unchanged and is what this case still pins — env-wide - // and org rows union, org winning on collision. It just has to be - // measured on a type that has an org partition to union. - // - // ⚠️ SUPERSEDED, 2026-09-03 (commit d5cbb44f3). This paragraph used to read "Its - // two `getMetaItem` (SINGULAR) siblings above keep `app` deliberately: - // that verb is untouched here." That was true when commit 96326040f landed and is - // the sentence commit d5cbb44f3 falsified: the singular verb now gates too, so - // both siblings moved to `view` in the same edit. The reasoning it gave - // — a singular caller CAN be right about its scope, and its REST door - // already gates — held for the DOOR and not for the VERB: commit d5cbb44f3 - // measured four runtime callers that reach the verb with a raw active - // organization, and on a `??` precedence read an ungated organization - // does not merely ADD a row, it SUBSTITUTES the served document. - it('getMetaItems unions env-wide and org-specific rows (org wins on collision)', async () => { - mockEngine.find.mockImplementation((_table: string, opts: any) => { - if (opts?.where?.organization_id === 'org_alpha') { - return Promise.resolve([ - { 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' }) }, - ]); - } - if (opts?.where?.organization_id === null) { - return Promise.resolve([ - { 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' }) }, - ]); - } - return Promise.resolve([]); - }); - const result = await protocol.getMetaItems({ - type: 'view', organizationId: 'org_alpha', - }); + 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' }) }, + ], + )); + 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(['alpha_only', 'env_only', 'shared']); - const shared = (result.items as any[]).find((i) => i.name === 'shared'); - expect(shared.label).toBe('Org Alpha'); + expect(names).toEqual(['env_only', 'shared']); + expect((result.items as any[]).find((i) => i.name === 'shared').label).toBe('Env Default'); }); }); @@ -1051,18 +969,20 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { }); it('keeps both colliding rows when EACH package has its own sys_metadata overlay (ADR-0048 #1828)', async () => { + // [ADR-0131 D6] `dashboard`, a regime-O type: an environment overlay of a + // packaged `page` is sealed content now, which no read serves. // The #1828 gap: two installed packages ship `page/home` AND each has // a package-scoped sys_metadata overlay. The DB-overlay merge deduped // by bare `name`, collapsing the two rows to one (last-write-wins). // Each overlay must merge onto its OWN package's entry → two rows. - registry.registerItem('page', { name: 'home', label: 'Acme (artifact)' }, 'name', 'com.acme.crm'); - registry.registerItem('page', { name: 'home', label: 'Globex (artifact)' }, 'name', 'com.globex.crm'); + registry.registerItem('dashboard', { name: 'home', label: 'Acme (artifact)' }, 'name', 'com.acme.crm'); + registry.registerItem('dashboard', { name: 'home', label: 'Globex (artifact)' }, 'name', 'com.globex.crm'); mockEngine.find.mockResolvedValue([ - { type: 'page', name: 'home', state: 'active', package_id: 'com.acme.crm', metadata: JSON.stringify({ name: 'home', label: 'Acme (overlay)' }) }, - { type: 'page', name: 'home', state: 'active', package_id: 'com.globex.crm', metadata: JSON.stringify({ name: 'home', label: 'Globex (overlay)' }) }, + { type: 'dashboard', name: 'home', state: 'active', package_id: 'com.acme.crm', metadata: JSON.stringify({ name: 'home', label: 'Acme (overlay)' }) }, + { type: 'dashboard', name: 'home', state: 'active', package_id: 'com.globex.crm', metadata: JSON.stringify({ name: 'home', label: 'Globex (overlay)' }) }, ]); - const result = await protocol.getMetaItems({ type: 'page' }); + const result = await protocol.getMetaItems({ type: 'dashboard' }); const homes = (result.items as any[]).filter((i) => i.name === 'home'); expect(homes).toHaveLength(2); @@ -1097,15 +1017,17 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { }); it('single-package env-wide (package-less) overlay still overlays the artifact (ADR-0005 unchanged, #1828)', async () => { + // [ADR-0131 D6] `dashboard`, a regime-O type: an environment overlay of a + // packaged `page` is sealed content now, which no read serves. // A package-less overlay (package_id IS NULL) must still WIN over the // one artifact it customizes and stay a single row — the package-aware // merge must not split a global overlay off from its lone artifact. - registry.registerItem('page', { name: 'home', label: 'Artifact' }, 'name', 'com.acme.crm'); + registry.registerItem('dashboard', { name: 'home', label: 'Artifact' }, 'name', 'com.acme.crm'); mockEngine.find.mockResolvedValue([ - { type: 'page', name: 'home', state: 'active', package_id: null, metadata: JSON.stringify({ name: 'home', label: 'Customized' }) }, + { type: 'dashboard', name: 'home', state: 'active', package_id: null, metadata: JSON.stringify({ name: 'home', label: 'Customized' }) }, ]); - const result = await protocol.getMetaItems({ type: 'page' }); + const result = await protocol.getMetaItems({ type: 'dashboard' }); const homes = (result.items as any[]).filter((i) => i.name === 'home'); expect(homes).toHaveLength(1); diff --git a/packages/objectql/src/protocol-object-overlay-layer.test.ts b/packages/objectql/src/protocol-object-overlay-layer.test.ts index f54c5a8f7ae..1ae32557060 100644 --- a/packages/objectql/src/protocol-object-overlay-layer.test.ts +++ b/packages/objectql/src/protocol-object-overlay-layer.test.ts @@ -58,6 +58,16 @@ import { assertEngineFindOnePredicate } from './engine-findone-predicate.js'; * cases below that drove the delete under the hatch are pinned REFUSED, the * layer and the data plane intact; the subtraction itself stays reachable where * the row leaves by another route (a replica converging on a removal). + * + * ## [ADR-0131 D6, triage ruling Q1 → C] …and the boot no longer LAYERS it + * + * An environment overlay of an object a managed package ships sits in no regime + * D6 recognises: the package definition wins. So boot hydration no longer + * registers the stored row as an `overlay` layer — the packaged definition is + * the object, the row stays at rest untouched, and the boot names it + * (`[metadata_sealed_overlay_unserved]`) with its remedies. The D9 layer + * mechanics (owner first, the row's package as provenance) are what the cases + * below still pin for the packaged owner; the layer itself is never added. */ const APP_PKG = 'app.myapp'; @@ -261,41 +271,51 @@ async function bootWithPackage(seed: any[], opts: { controlPlane?: boolean; inst const kinds = (registry: SchemaRegistry, name: string) => registry.getObjectContributors(name).map((c) => c.ownership); -describe('ADR-0029 D9 — the boot replay LAYERS the tenant row instead of destroying the package', () => { +describe('ADR-0029 D9 · ADR-0131 D6 — the boot replay neither destroys the package nor layers the sealed row over it', () => { /** * The direct inversion of this file's old premise test, which asserted * `getArtifactItem(...)` was `undefined` and `packaged_only` was gone. Both - * flip here, and that flip is the whole card. + * stay flipped; [ADR-0131 D6] and the tenant row is not layered either. */ - it('after hydration the packaged definition is still there, underneath', async () => { + it('after hydration the packaged definition is the object, and the sealed row is reported, not layered', async () => { const seed = await persistOverlayRow('myapp_invoice', APP_PKG); - const { registry, res } = await bootWithPackage(seed); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + let booted: Awaited>; + let warned: string[]; + try { + booted = await bootWithPackage(seed); + } finally { + warned = warn.mock.calls.map((c) => String(c[0])); + warn.mockRestore(); + } + const { registry, res, rows } = booted; - expect(res).toMatchObject({ loaded: 1, errors: 0, invalid: 0 }); + expect(res).toMatchObject({ loaded: 0, errors: 0, invalid: 0 }); - // Two layers, not one — and the owner is the package, not the tenant. - expect(kinds(registry, 'myapp_invoice')).toEqual(['own', 'overlay']); + // One layer: the package's own definition, and it is what resolves. + expect(kinds(registry, 'myapp_invoice')).toEqual(['own']); expect(ownerPackageId(registry, 'myapp_invoice')).toBe(APP_PKG); - const owner = registry.getObjectOwner('myapp_invoice')!; - expect(Object.keys((owner.definition as any).fields)).toContain('packaged_only'); - expect((owner.definition as any)._provenance).toBe('package'); - - // The RESOLVED object does not move: still the tenant's body, still - // stamped `org`. Bit-for-bit what the splice used to produce. - expect(fieldNames(registry, 'myapp_invoice')).toContain('overlay_only'); - expect(fieldNames(registry, 'myapp_invoice')).not.toContain('packaged_only'); - expect((registry.getObject('myapp_invoice') as any)._provenance).toBe('org'); - - // …and the predicate both gates read is honest again. + expect(fieldNames(registry, 'myapp_invoice')).toContain('packaged_only'); + expect(fieldNames(registry, 'myapp_invoice')).not.toContain('overlay_only'); expect(registry.getArtifactItem('object', 'myapp_invoice')).toBeDefined(); + + // The row is kept at rest, untouched, and named at boot. + expect(storedRows(rows, 'myapp_invoice')).toHaveLength(1); + const line = warned.find((m) => m.includes('[metadata_sealed_overlay_unserved]')); + expect(line).toContain('object×1 (myapp_invoice)'); }); - it('the hydration is idempotent across boots — one layer, not a stack', async () => { + it('the hydration is idempotent across boots — never a layer', async () => { const seed = await persistOverlayRow('myapp_invoice', APP_PKG); - const { protocol, registry } = await bootWithPackage(seed); - await protocol.loadMetaFromDb(); - await protocol.loadMetaFromDb(); - expect(kinds(registry, 'myapp_invoice')).toEqual(['own', 'overlay']); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const { protocol, registry } = await bootWithPackage(seed); + await protocol.loadMetaFromDb(); + await protocol.loadMetaFromDb(); + expect(kinds(registry, 'myapp_invoice')).toEqual(['own']); + } finally { + warn.mockRestore(); + } }); }); @@ -322,7 +342,7 @@ describe('ADR-0029 D9.6 — the declared contract, enforced consistently', () => // The refusal costs the packaged definition nothing. expect(registry.getArtifactItem('object', 'myapp_invoice')).toBeDefined(); - expect(kinds(registry, 'myapp_invoice')).toEqual(['own', 'overlay']); + expect(kinds(registry, 'myapp_invoice')).toEqual(['own']); }); /** @@ -390,7 +410,7 @@ describe('ADR-0029 D9.7 — the delete is a SUBTRACTION, and #7012\'s guard is r } expect(storedRows(rows, 'myapp_invoice')).toHaveLength(1); - expect(kinds(registry, 'myapp_invoice')).toEqual(['own', 'overlay']); + expect(kinds(registry, 'myapp_invoice')).toEqual(['own']); expect(ownerPackageId(registry, 'myapp_invoice')).toBe(APP_PKG); expect((await protocol.createData({ object: 'myapp_invoice', data: { name: 'INV-2' } })).id).toBeTruthy(); expect(dataRows).toHaveLength(2); @@ -422,7 +442,7 @@ describe('ADR-0029 D9.7 — the delete is a SUBTRACTION, and #7012\'s guard is r expect(refusedOpen).toBeInstanceOf(Error); expect(refusedOpen.code).toBe('NOT_OVERRIDABLE'); expect(refusedOpen.status).toBe(403); - expect(kinds(b.registry, 'myapp_invoice')).toEqual(['own', 'overlay']); + expect(kinds(b.registry, 'myapp_invoice')).toEqual(['own']); }); /** @@ -489,8 +509,11 @@ describe('ADR-0029 D9.7 — the delete is a SUBTRACTION, and #7012\'s guard is r }); describe('ADR-0029 D9.9 / #6995 — the row\'s package_id is provenance, never an ownership claim', () => { - /** `P == O`: the normal case. One overlay layer over the owner's object. */ - it('same package — a normal layer, no error, no warning', async () => { + /** + * `P == O`: the normal case. [ADR-0131 D6] No error and no hydration + * warning — the one line naming it is the sealed-overlay report. + */ + it('same package — no error, and only the sealed-overlay report names it', async () => { const seed = await persistOverlayRow('myapp_invoice', APP_PKG); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); let warned: string[] = []; @@ -501,8 +524,9 @@ describe('ADR-0029 D9.9 / #6995 — the row\'s package_id is provenance, never a warned = warn.mock.calls.map((c) => String(c[0])); warn.mockRestore(); } - expect(res).toMatchObject({ loaded: 1, errors: 0 }); - expect(warned.filter((m) => m.includes('myapp_invoice'))).toEqual([]); + expect(res).toMatchObject({ loaded: 0, errors: 0 }); + expect(warned.filter((m) => m.includes('myapp_invoice')).map((m) => m.slice(0, 50))) + .toEqual(['[Protocol] [metadata_sealed_overlay_unserved] 1 ac']); }); /** @@ -513,17 +537,22 @@ describe('ADR-0029 D9.9 / #6995 — the row\'s package_id is provenance, never a * ADR-0005's platform-global shape: the row addresses the object by name * and the registry knows who owns it. */ - it('package-LESS row over a packaged object — accepted as the layer, not refused', async () => { + it('package-LESS row over a packaged object — not refused at boot; [ADR-0131 D6] not layered either', async () => { const seed = await persistOverlayRow('myapp_invoice'); - const { registry, res } = await bootWithPackage(seed); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + let booted: Awaited>; + try { + booted = await bootWithPackage(seed); + } finally { + warn.mockRestore(); + } + const { registry, res } = booted; - expect(res).toMatchObject({ loaded: 1, errors: 0 }); - expect(kinds(registry, 'myapp_invoice')).toEqual(['own', 'overlay']); - // The layer carries the sentinel as its own provenance; ownership is - // untouched, and still the package's. - expect(registry.getObjectContributors('myapp_invoice')[1].packageId).toBe(SENTINEL); + expect(res).toMatchObject({ loaded: 0, errors: 0 }); + expect(kinds(registry, 'myapp_invoice')).toEqual(['own']); expect(ownerPackageId(registry, 'myapp_invoice')).toBe(APP_PKG); - expect(fieldNames(registry, 'myapp_invoice')).toContain('overlay_only'); + expect(fieldNames(registry, 'myapp_invoice')).not.toContain('overlay_only'); + expect(SENTINEL).toBe('sys_metadata'); }); /** @@ -554,8 +583,8 @@ describe('ADR-0029 D9.9 / #6995 — the row\'s package_id is provenance, never a // No success receipt, and no row for the mis-bound package. expect(storedRows(rows, 'myapp_invoice').filter((r) => r.package_id === OTHER_PKG)).toHaveLength(0); - // The registry is untouched: one owner, one layer, still the right ones. - expect(kinds(registry, 'myapp_invoice')).toEqual(['own', 'overlay']); + // The registry is untouched: the packaged owner alone (ADR-0131 D6). + expect(kinds(registry, 'myapp_invoice')).toEqual(['own']); expect(ownerPackageId(registry, 'myapp_invoice')).toBe(APP_PKG); }); @@ -564,7 +593,7 @@ describe('ADR-0029 D9.9 / #6995 — the row\'s package_id is provenance, never a * counted in `loadMetaFromDb`'s per-record `errors` with its reason, which * is why #6995 is a write-path divergence and not a boot-path one. */ - it('different package — the BOOT counts it in `errors` and leaves the package alone', async () => { + it('different package — [ADR-0131 D6] the BOOT reports it as a sealed overlay and leaves the package alone', async () => { const seed = await persistOverlayRow('myapp_invoice', OTHER_PKG); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); let warned: string[] = []; @@ -576,10 +605,12 @@ describe('ADR-0029 D9.9 / #6995 — the row\'s package_id is provenance, never a warn.mockRestore(); } - expect(booted.res).toMatchObject({ loaded: 0, errors: 1 }); - // A log line has no envelope, so the boot warning names the declared - // code itself rather than inheriting it from the refusal prose. - expect(warned.join('\n')).toContain('code=OBJECT_OVERLAY_PACKAGE_MISMATCH'); + // The row overlays an object a managed package ships, so it is a + // sealed overlay before it is a mis-bound one: never layered, so the + // D9.9 mismatch refusal is not reached, and the boot names it. + expect(booted.res).toMatchObject({ loaded: 0, errors: 0 }); + expect(warned.join('\n')).toContain('[metadata_sealed_overlay_unserved]'); + expect(warned.join('\n')).not.toContain('code=OBJECT_OVERLAY_PACKAGE_MISMATCH'); // The packaged definition is served, untouched — no half-applied layer. expect(kinds(booted.registry, 'myapp_invoice')).toEqual(['own']); 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 da624a0aeb3..505a9a77985 100644 --- a/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts +++ b/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts @@ -35,6 +35,11 @@ * * ── 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. + * * `hydrateOverlayIntoRegistry` is the ONE shared choke point all three * hydration callers already route through (#4521 made it so: boot, the * read-side hydration and the write-through). The row-scope verdict now @@ -285,7 +290,7 @@ describe('#6602 — READ seam: getMetaItems hydration refuses org-scoped rows', 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', organizationId: 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. @@ -307,7 +312,7 @@ describe('#6602 — READ seam: getMetaItems hydration refuses org-scoped rows', 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', organizationId: 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(); @@ -324,7 +329,7 @@ describe('#6602 — READ seam: getMetaItems hydration refuses org-scoped rows', 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', organizationId: ORG_A }); + 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 @@ -345,7 +350,7 @@ describe('#6602 — READ seam: getMetaItems hydration refuses org-scoped rows', 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', organizationId: ORG_A }); + await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); expect(registry.getItem('view', 'shared_grid')).toBeUndefined(); }); @@ -369,8 +374,8 @@ describe('#6602 — the disclosure shape, end to end', () => { // later reader started from it. engine.plant(metaRow('view', viewBody('org_a_only', 'Org A only'), ORG_A)); - await protocol.getMetaItems({ type: 'view', organizationId: ORG_A }); - const listedB = await protocol.getMetaItems({ type: 'view', organizationId: ORG_B }); + 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 @@ -383,14 +388,14 @@ describe('#6602 — the disclosure shape, end to end', () => { 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', organizationId: ORG_A }); - const listedB = await protocol.getMetaItems({ type: 'view', organizationId: ORG_B }); + 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('#6602 — the on-demand per-org read still serves org readers', () => { +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; @@ -402,26 +407,22 @@ describe('#6602 — the on-demand per-org read still serves org readers', () => protocol = new ObjectStackProtocolImplementation(engine); }); - it('getMetaItem serves the org body to its own org and never hydrates it', async () => { + 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 }); - expect(got.item.label).toBe('Org A grid'); - - // ADR-0005's "loaded on demand … to avoid cross-org leakage": serving - // it must not be the thing that grafts it. + 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 prefers the ORG body over the env-wide one for its own org', async () => { + 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)); - const forOrgA: any = await protocol.getMetaItem({ type: 'view', name: 'shared_grid', organizationId: ORG_A }); - expect(forOrgA.item.label).toBe('Org A grid'); - - const forOrgB: any = await protocol.getMetaItem({ type: 'view', name: 'shared_grid', organizationId: ORG_B }); - expect(forOrgB.item.label).toBe('Env grid'); + 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'); + } }); }); @@ -632,8 +633,10 @@ describe('#6780 — the registry heal is org-gated: an org DELETE never evicts t await protocol.deleteMetaItem({ type: 'view', name: 'shared_grid' }); expect(registry.getItem('view', 'shared_grid')).toBeUndefined(); - // Org A's own overlay row is untouched by an env-wide delete. - const forOrgA: any = await protocol.getMetaItem({ type: 'view', name: 'shared_grid', organizationId: ORG_A }); - expect(forOrgA.item.label).toBe('Org A grid'); + // 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-packaged-dashboard-base.test.ts b/packages/objectql/src/protocol-packaged-dashboard-base.test.ts index ea180b78e36..ac005743f26 100644 --- a/packages/objectql/src/protocol-packaged-dashboard-base.test.ts +++ b/packages/objectql/src/protocol-packaged-dashboard-base.test.ts @@ -40,7 +40,6 @@ import { assertEngineFindOnePredicate } from './engine-findone-predicate.js'; const PKG = 'com.objectstack.plugin-auth'; const DASH = 'system_overview'; -const ORG = 'org_acme'; const SHIPPED_TITLE = 'Total Users'; const EDITED_TITLE = 'Total Users (edited)'; @@ -138,7 +137,8 @@ function seedOrgOverlay(rows: Record[]) { type: 'dashboard', name: DASH, package_id: null, - organization_id: ORG, + // [ADR-0131 D6] An environment overlay: no read serves an organization's. + organization_id: null, state: 'active', metadata: JSON.stringify(overlayBody()), }); @@ -189,12 +189,12 @@ describe('#20680 getPackagedDashboardBase — the packaged declaration, never an }); }); -describe('#20680 the published org overlay, through the protocol read and the translation', () => { +describe('#20680 the published overlay, through the protocol read and the translation', () => { it('the item read serves the overlay — one identity for the write and the read', async () => { const s = makeSession(); registerPackaged(s.registry); seedOrgOverlay(s.rows); - const served = await s.protocol.getMetaItem({ type: 'dashboard', name: DASH, organizationId: ORG }); + const served = await s.protocol.getMetaItem({ type: 'dashboard', name: DASH }); expect(titleOf(served.item)).toBe(EDITED_TITLE); // The code artifact's envelope rides on the overlay body; it names the // registering package, not a second identity the overlay missed. @@ -205,7 +205,7 @@ describe('#20680 the published org overlay, through the protocol read and the tr const s = makeSession(); registerPackaged(s.registry); seedOrgOverlay(s.rows); - const served = (await s.protocol.getMetaItem({ type: 'dashboard', name: DASH, organizationId: ORG })).item; + const served = (await s.protocol.getMetaItem({ type: 'dashboard', name: DASH })).item; const packagedBase = s.protocol.getPackagedDashboardBase(DASH); for (const locale of ['en', 'zh-CN']) { @@ -221,7 +221,7 @@ describe('#20680 the published org overlay, through the protocol read and the tr const s = makeSession(); registerPackaged(s.registry); seedOrgOverlay(s.rows); - const served = (await s.protocol.getMetaItem({ type: 'dashboard', name: DASH, organizationId: ORG })).item; + const served = (await s.protocol.getMetaItem({ type: 'dashboard', name: DASH })).item; expect(titleOf(translateDashboard(served, BUNDLE, { locale: 'en' }))).toBe(SHIPPED_TITLE); expect(titleOf(translateDashboard(served, BUNDLE, { locale: 'zh-CN' }))).toBe('用户总数'); }); @@ -229,7 +229,7 @@ describe('#20680 the published org overlay, through the protocol read and the tr it('after reset (no overlay row) the shipped body is served and still translated', async () => { const s = makeSession(); registerPackaged(s.registry); - const served = (await s.protocol.getMetaItem({ type: 'dashboard', name: DASH, organizationId: ORG })).item; + const served = (await s.protocol.getMetaItem({ type: 'dashboard', name: DASH })).item; const packagedBase = s.protocol.getPackagedDashboardBase(DASH); expect(titleOf(served)).toBe(SHIPPED_TITLE); expect(titleOf(translateDashboard(served, BUNDLE, { locale: 'zh-CN', packagedBase }))).toBe('用户总数'); diff --git a/packages/objectql/src/protocol-packaged-view-base.test.ts b/packages/objectql/src/protocol-packaged-view-base.test.ts index 8b37b58bcd4..219220d4c9d 100644 --- a/packages/objectql/src/protocol-packaged-view-base.test.ts +++ b/packages/objectql/src/protocol-packaged-view-base.test.ts @@ -36,7 +36,6 @@ import { assertEngineFindOnePredicate } from './engine-findone-predicate.js'; const PKG = 'com.example.showcase'; const OBJ = 'showcase_task'; const VIEW = 'showcase_task.in_progress'; -const ORG = 'org_acme'; const SHIPPED_LABEL = 'In Progress'; const EDITED_LABEL = 'In Progress (edited)'; @@ -126,7 +125,8 @@ function seedOrgOverlay(rows: Record[]) { type: 'view', name: VIEW, package_id: null, - organization_id: ORG, + // [ADR-0131 D6] An environment overlay: no read serves an organization's. + organization_id: null, state: 'active', metadata: JSON.stringify(overlayBody()), }); @@ -190,18 +190,18 @@ describe('#20731 getPackagedViewBase — the packaged view, never an overlay', ( }); }); -describe('#20731 the published org overlay, through the protocol reads and the translation', () => { +describe('#20731 the published overlay, through the protocol reads and the translation', () => { it('the item and list reads serve the overlay — one identity for the write and both reads', async () => { const s = makeSession(); registerContainer(s.registry, OBJ, taskContainer(), PKG); seedOrgOverlay(s.rows); - const served = await s.protocol.getMetaItem({ type: 'view', name: VIEW, organizationId: ORG }); + const served = await s.protocol.getMetaItem({ type: 'view', name: VIEW }); expect(served.item.label).toBe(EDITED_LABEL); // The code artifact's envelope rides on the overlay body; it names the // registering package, not a second identity the overlay missed. expect(served.item._packageId).toBe(PKG); - const list = await s.protocol.getMetaItems({ type: 'view', organizationId: ORG }); + const list = await s.protocol.getMetaItems({ type: 'view' }); const listed = (list.items as any[]).find((v) => v?.name === VIEW); expect(listed?.label).toBe(EDITED_LABEL); }); @@ -210,8 +210,8 @@ describe('#20731 the published org overlay, through the protocol reads and the t const s = makeSession(); registerContainer(s.registry, OBJ, taskContainer(), PKG); seedOrgOverlay(s.rows); - const item = (await s.protocol.getMetaItem({ type: 'view', name: VIEW, organizationId: ORG })).item; - const listed = ((await s.protocol.getMetaItems({ type: 'view', organizationId: ORG })).items as any[]) + const item = (await s.protocol.getMetaItem({ type: 'view', name: VIEW })).item; + const listed = ((await s.protocol.getMetaItems({ type: 'view' })).items as any[]) .find((v) => v?.name === VIEW); for (const served of [item, listed]) { @@ -226,7 +226,7 @@ describe('#20731 the published org overlay, through the protocol reads and the t const s = makeSession(); registerContainer(s.registry, OBJ, taskContainer(), PKG); seedOrgOverlay(s.rows); - const urgent = (await s.protocol.getMetaItem({ type: 'view', name: 'showcase_task.urgent', organizationId: ORG })).item; + const urgent = (await s.protocol.getMetaItem({ type: 'view', name: 'showcase_task.urgent' })).item; const packagedBase = s.protocol.getPackagedViewBase('showcase_task.urgent'); expect(packagedBase?.label).toBe('Urgent'); expect(translateView(urgent, BUNDLE, { locale: 'zh-CN', packagedBase }).label).toBe('紧急'); @@ -236,7 +236,7 @@ describe('#20731 the published org overlay, through the protocol reads and the t const s = makeSession(); registerContainer(s.registry, OBJ, taskContainer(), PKG); seedOrgOverlay(s.rows); - const served = (await s.protocol.getMetaItem({ type: 'view', name: VIEW, organizationId: ORG })).item; + const served = (await s.protocol.getMetaItem({ type: 'view', name: VIEW })).item; expect(translateView(served, BUNDLE, { locale: 'zh-CN' }).label).toBe('进行中'); expect(translateView(served, BUNDLE, { locale: 'en' }).label).toBe(SHIPPED_LABEL); }); @@ -245,12 +245,12 @@ describe('#20731 the published org overlay, through the protocol reads and the t const s = makeSession(); registerContainer(s.registry, OBJ, taskContainer(), PKG); seedOrgOverlay(s.rows); - const edited = (await s.protocol.getMetaItem({ type: 'view', name: VIEW, organizationId: ORG })).item; + const edited = (await s.protocol.getMetaItem({ type: 'view', name: VIEW })).item; const packagedBase = s.protocol.getPackagedViewBase(VIEW); expect(translateView(edited, BUNDLE, { locale: 'zh-CN', packagedBase }).label).toBe(EDITED_LABEL); s.rows.length = 0; - const served = (await s.protocol.getMetaItem({ type: 'view', name: VIEW, organizationId: ORG })).item; + const served = (await s.protocol.getMetaItem({ type: 'view', name: VIEW })).item; expect(served.label).toBe(SHIPPED_LABEL); expect(translateView(served, BUNDLE, { locale: 'zh-CN', packagedBase }).label).toBe('进行中'); }); diff --git a/packages/objectql/src/protocol-publish-rollback.test.ts b/packages/objectql/src/protocol-publish-rollback.test.ts index 0a8eb9e4b68..f7e4cccd676 100644 --- a/packages/objectql/src/protocol-publish-rollback.test.ts +++ b/packages/objectql/src/protocol-publish-rollback.test.ts @@ -188,11 +188,11 @@ describe('publishMetaItem / rollbackMetaItem / diffMetaItem', () => { const states = Array.from(rows.values()).map((r) => r.state).sort(); expect(states).toEqual(['active', 'draft']); const active = await protocol.getMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', }); expect((active as any).item.label).toBe('Published'); const draft = await protocol.getMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', state: 'draft', + type: 'view', name: 'case_grid', state: 'draft', }); expect((draft as any).item.label).toBe('Pending'); }); @@ -249,7 +249,7 @@ describe('publishMetaItem / rollbackMetaItem / diffMetaItem', () => { }); await expect( protocol.getMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', state: 'draft', }), ).rejects.toMatchObject({ code: 'NO_DRAFT', status: 404 }); @@ -257,7 +257,7 @@ describe('publishMetaItem / rollbackMetaItem / diffMetaItem', () => { // Also covers the "never saved" path — no overlay row at all. await expect( protocol.getMetaItem({ - type: 'view', name: 'never_existed', organizationId: 'org_alpha', + type: 'view', name: 'never_existed', state: 'draft', }), ).rejects.toMatchObject({ code: 'NO_DRAFT', status: 404 }); @@ -277,7 +277,7 @@ describe('publishMetaItem / rollbackMetaItem / diffMetaItem', () => { mode: 'draft', } as any); const got = await protocol.getMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', state: 'draft', }); expect(got).toMatchObject({ @@ -305,7 +305,7 @@ describe('publishMetaItem / rollbackMetaItem / diffMetaItem', () => { expect((result as any).success).toBe(true); expect((result as any).restoredFromVersion).toBe(1); const live = await protocol.getMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', }); expect((live as any).item.label).toBe('v1'); // History records a revert. diff --git a/packages/objectql/src/protocol-registry-shadow.test.ts b/packages/objectql/src/protocol-registry-shadow.test.ts index f546dbc7fc0..b1b48c199e2 100644 --- a/packages/objectql/src/protocol-registry-shadow.test.ts +++ b/packages/objectql/src/protocol-registry-shadow.test.ts @@ -247,7 +247,7 @@ describe('registry shadow — control-plane PUT → GET → DELETE keeps the art engine.registry.registerItem('app', artifactApp(lock), 'name', PKG); } - it('GET list while the overlay row exists: overlay content wins, artifact envelope wins', async () => { + it('GET list while the overlay row exists: [ADR-0131 D6] the package content wins, and its envelope', async () => { await overlayRowThenLockedArtifact('full'); // A further PUT is refused. [ADR-0131 D6] By the package door now, ahead // of the item lock: the hatch opens no write onto an app a managed @@ -258,7 +258,10 @@ describe('registry shadow — control-plane PUT → GET → DELETE keeps the art const res = await protocol.getMetaItems({ type: 'app' }); const setup = findByName((res as any).items, 'setup'); expect(setup).toBeDefined(); - expect(setup.label).toBe('Setup HACKED'); // overlay content + // [ADR-0131 D6, triage ruling Q1 → C] An `app` a managed package ships + // is sealed: the stored row (written before the package arrived) is not + // served — the package's definition is. + expect(setup.label).toBe('Setup'); expect(setup._lock).toBe('full'); // artifact envelope (ADR-0010 §3.3) expect(setup._packageId).toBe(PKG); expect(setup._provenance).toBe('package'); diff --git a/packages/objectql/src/protocol-revert-org-scope.test.ts b/packages/objectql/src/protocol-revert-org-scope.test.ts index 1479593819e..a65d717da7c 100644 --- a/packages/objectql/src/protocol-revert-org-scope.test.ts +++ b/packages/objectql/src/protocol-revert-org-scope.test.ts @@ -190,7 +190,6 @@ function makeStubDriver() { } const PKG = 'app.revertscope'; -const ORG = 'org_x'; const viewBody = (label: string) => ({ name: 'cases', type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list' }); // [#7741] the inline arm requires the object binding pair describe('#7559 — the revert reads the history row under the key the writer stored it with', () => { @@ -277,7 +276,7 @@ describe('#7559 — the revert reads the history row under the key the writer st ]); // READER: the commit's revert plan asks for exactly that version. - const commits = await protocol.listCommits({ packageId: PKG, organizationId: ORG }); + const commits = await protocol.listCommits({ packageId: PKG }); const target = commits.find((c) => c.id === p2.commitId)!; expect(target.items).toEqual([ { type: 'view', name: 'cases', existedBefore: true, prevVersion: 2 }, 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 bfce4199fe6..febc9b4ff60 100644 --- a/packages/objectql/src/sys-metadata-repository-list-drafts.test.ts +++ b/packages/objectql/src/sys-metadata-repository-list-drafts.test.ts @@ -68,21 +68,17 @@ describe('SysMetadataRepository.listDrafts (ADR-0033)', () => { expect(names).not.toContain('live'); }); - it('returns both org-scoped and env-wide drafts for a non-null org, excluding other orgs', async () => { + 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' }, - { type: 'object', name: 'org_scoped', state: 'draft', package_id: 'p', organization_id: 'org_acme', updated_at: 't2' }, - { type: 'object', name: 'other_org', state: 'draft', package_id: 'p', organization_id: 'org_other', updated_at: 't3' }, + { 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).sort()).toEqual(['env_wide', 'org_scoped']); - // Each draft is projected with the scope it actually lives in, so the - // publish/discard paths can promote/delete it in THAT scope rather than the - // caller's active org — otherwise the env-wide row 404s as `no_draft` - // (#3115). This projection is the contract those callers depend on. - expect(out.find((d) => d.name === 'env_wide')?.organizationId).toBeNull(); - expect(out.find((d) => d.name === 'org_scoped')?.organizationId).toBe('org_acme'); + expect(out.map((d) => d.name)).toEqual(['env_wide']); + // The header carries no organization: every draft listed is the environment's. + expect(Object.keys(out[0]!)).not.toContain('organizationId'); }); it('filters by type', async () => { @@ -166,11 +162,10 @@ describe('SysMetadataRepository.listDrafts (ADR-0033)', () => { const { repo } = makeRepo(rows as any); const out = await repo.listDrafts({ type: 'object' }); - // Exactly the seven header keys — no `item`, no `body`, no `fields`. + // Exactly the six header keys — no `item`, no `body`, no `fields`. expect(Object.keys(out[0]).sort()).toEqual([ 'label', 'name', - 'organizationId', 'packageId', 'type', 'updatedAt', diff --git a/packages/plugins/plugin-security/src/permission-set-overlay-discard.ts b/packages/plugins/plugin-security/src/permission-set-overlay-discard.ts index 10b783f7358..5612a17eefa 100644 --- a/packages/plugins/plugin-security/src/permission-set-overlay-discard.ts +++ b/packages/plugins/plugin-security/src/permission-set-overlay-discard.ts @@ -278,7 +278,7 @@ export async function discardPermissionSetOverlay( let resyncRefused = false; if (protocol && typeof protocol.getMetaItemLayered === 'function') { await projectPermissionMutation(protocol, projectionDeps, { - type: 'permission', name: String(row.name), state: 'active', organizationId: organizationId ?? null, + type: 'permission', name: String(row.name), state: 'active', }); healedRow = (await tryFind(ql, 'sys_permission_set', { id }, 1, organizationId))[0] ?? row; } else { diff --git a/packages/plugins/plugin-security/src/permission-set-projection.test.ts b/packages/plugins/plugin-security/src/permission-set-projection.test.ts index 55ef1347ada..278322d28ea 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.test.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.test.ts @@ -611,7 +611,7 @@ describe('package-owned set customization lifecycle (ADR-0094 D5-R)', () => { ql.permRows.push({ id: 'ps_pkg', name: 'organization_admin', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]' }); seedLegacyOverlay(ql, 'organization_admin', envBody({ systemPermissions: ['legacy.overlay'] })); - await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'organization_admin', state: 'active', organizationId: null }); + await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'organization_admin', state: 'active' }); expect(JSON.parse(ql.permRows[0].system_permissions), 'the legacy overlay still wins at read time').toEqual(['legacy.overlay']); await protocol.deleteMetaItem({ type: 'permission', name: 'organization_admin' }); @@ -808,7 +808,7 @@ describe('createPermissionSetWriteThrough (data door → metadata store)', () => // name (ADR-0094 D5-R). The invariant under test is unchanged: the record // is projected from the overlay, and the data-door delete resets it. seedLegacyOverlay(ql, 'organization_admin', envBody({ systemPermissions: ['overlaid'] })); - await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'organization_admin', state: 'active', organizationId: null }); + await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'organization_admin', state: 'active' }); expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['overlaid']); const mw = makeMiddleware(ql, protocol); const nextCalled = await run(mw, { @@ -936,7 +936,7 @@ describe('createPermissionSetWriteThrough (data door → metadata store)', () => // The invariant under test is untouched: "delete" lifts the overlay and // the record resets to the shipped declaration. seedLegacyOverlay(ql, 'crm_rep', envBody({ name: 'crm_rep', systemPermissions: ['customized'] })); - await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'crm_rep', state: 'active', organizationId: null }); + await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'crm_rep', state: 'active' }); expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['customized']); // "delete" = reset const nextCalled = await run(mw, { object: 'sys_permission_set', operation: 'delete', options: { where: { id: 'ps_pkg' } }, context: userCtx }); @@ -990,7 +990,7 @@ describe('createPermissionSetWriteThrough (data door → metadata store)', () => package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]', }); seedLegacyOverlay(pkgQl, 'crm_rep', envBody({ name: 'crm_rep', systemPermissions: ['customized'] })); - await projectPermissionMutation(pkgProtocol, { ql: pkgQl }, { type: 'permission', name: 'crm_rep', state: 'active', organizationId: null }); + await projectPermissionMutation(pkgProtocol, { ql: pkgQl }, { type: 'permission', name: 'crm_rep', state: 'active' }); const pkgCtx: any = { object: 'sys_permission_set', operation: 'delete', diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts index 60d14e65e02..24fbadd7b5e 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -835,7 +835,7 @@ export interface ProjectionDeps { export async function projectPermissionMutation( protocol: any, deps: ProjectionDeps, - evt: { type?: string; name?: string; state?: string; organizationId?: string | null } | null | undefined, + evt: { type?: string; name?: string; state?: string } | null | undefined, /** * [#11097] Forwarded to {@link upsertEnvPermissionSet} so a boot pass over * many overlay names pays ONE existence read for the whole set. The live @@ -851,11 +851,9 @@ export async function projectPermissionMutation( // second source, exactly as the write doors hand it theirs. let layeredProbe: LayeredProbe | undefined; if (protocol && typeof protocol.getMetaItemLayered === 'function') { - const layered = await protocol.getMetaItemLayered({ - type: 'permission', - name: evt.name, - ...(evt.organizationId ? { organizationId: evt.organizationId } : {}), - }); + // [ADR-0131 D6] The environment → code read: no organization is part + // of a metadata read, so the projection reads what every reader reads. + const layered = await protocol.getMetaItemLayered({ type: 'permission', name: evt.name }); // `getMetaItemLayered` may return a layered envelope (`{ effective | code }`) // OR the effective body directly (top-level `name`) — accept both. The // envelope carries `name` too, so detect it by its layer keys: an envelope @@ -1136,7 +1134,7 @@ export function createPermissionSetWriteThrough( // The awaited projector inside saveMetaItem/deleteMetaItem normally did // this already — re-running is an idempotent upsert, and covers the // window where the projector isn't registered yet (pre-kernel:ready). - await projectPermissionMutation(protocol, deps, { type: 'permission', name, state: 'active', organizationId: null }); + await projectPermissionMutation(protocol, deps, { type: 'permission', name, state: 'active' }); return (await tryFind(ql, 'sys_permission_set', { name }, 1))[0] ?? null; }; @@ -1475,7 +1473,7 @@ export function createPermissionSetWriteThrough( for (const row of targets) { await protocol.deleteMetaItem({ type: 'permission', name: row.name, ...actorArg }); const res = await projectPermissionMutation(protocol, deps, { - type: 'permission', name: row.name, state: 'deleted', organizationId: null, + type: 'permission', name: row.name, state: 'deleted', }); if (res && (res.seeded + res.updated) > 0) { logger?.info?.('[security] permission set reset to its declared baseline (artifact-backed; ADR-0094)', { name: row.name }); @@ -1607,7 +1605,7 @@ export async function reconcilePermissionSetProjection( ); for (const name of overlayNames) { const res = await projectPermissionMutation(protocol, deps, { - type: 'permission', name, state: 'active', organizationId: null, + type: 'permission', name, state: 'active', }, { existing: existingByName }); // ⚠️ `unchanged` is deliberately NOT summed in. This counter reports records // this pass CREATED OR CHANGED, and now that an already-matching record is 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 f610f274cf6..d4c6de2b16e 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 @@ -300,19 +300,24 @@ describe('dogfood: the object-extension fold and the i18n catalog disagree on sc expect(await listedLabel()).toBe(EXTENSION_LABEL); }); - it('SHOULD: a tenant\'s own rename reaches the reads its forms derive from', async () => { + it('[triage ruling Q1 → C] a pre-seal rename of the PACKAGED object is kept at rest, not served: the package definition wins', async () => { // [ADR-0131 D6] The rename is the row a pre-seal Studio save stored — - // see `withPreSealRename` — read by a cold boot. + // see `withPreSealRename` — read by a cold boot. `object` is sealed + // against overlays of what a managed package ships, so that row is an + // overlay no regime recognises: boot names it + // (`[metadata_sealed_overlay_unserved]`) and does not load it, and the + // reads every writable form derives from serve the package's + // definition. Nothing is deleted. await withPreSealRename(); const layered: any = await (await stack.apiAs(token, 'GET', '/meta/object/showcase_account?layers=true')).json(); - // The row stored the rename — the customisation is real and readable… + // The row is still at rest — the diagnostic read shows it… expect(labelOf(layered?.overlay)).toBe(RENAMED_LABEL); - // …and neither read that a writable form derives from ever shows it. + // …and the two translated reads serve the package's (folded) label. const after: any = await (await stack.apiAs(token, 'GET', '/meta/object/showcase_account')).json(); - expect(labelOf(after?.item)).toBe(RENAMED_LABEL); - expect(await listedLabel()).toBe(RENAMED_LABEL); + expect(labelOf(after?.item)).toBe(EXTENSION_LABEL); + expect(await listedLabel()).toBe(EXTENSION_LABEL); }, 180_000); it('[#8284] after the rename the three reads still AGREE — on the extension, not the catalog', async () => { diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index fe5e633cb2c..4def040e9f4 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -63,9 +63,8 @@ import { AuthzStoreUnavailableError, AUTHZ_STORE_UNAVAILABLE_STATUS, } from '@objectstack/core'; // [#13538] §9 drives an ORG-OVERRIDABLE type on purpose, and asserts that -// choice against the registry rather than trusting a literal — the same -// predicate the read door itself consults. -import { declaresOrgOverride } from '@objectstack/metadata-core'; +// choice against the registry rather than trusting a literal. +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { RestServer } from './rest-server.js'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -1062,11 +1061,12 @@ const ORG_SCOPED_DOC = { name: 'ops', type: 'dashboard', widgets: [] }; describe('[#13538] §9 a PARTIAL outage is not served as an org-unscoped 200', () => { it('CONTROL: this section drives an ORG-OVERRIDABLE type; the type §8 drives is env-wide BY DESIGN', () => { - // The second half is why §8's green is not coverage of this card. On - // `object`, `organizationIdForMetaRead` returns `undefined` for an - // entitled caller too — so no fault model whatsoever could make §8 - // observe an org-scope difference on the type it sweeps. - expect({ driven: declaresOrgOverride(ORG_SCOPED_TYPE), section8: declaresOrgOverride(OBJECT_DOC.type) }) + // The second half is why §8's green is not coverage of this card: on + // `object` no organization was ever read, so no fault model whatsoever + // could make §8 observe an org-scope difference on the type it sweeps. + const declares = (type: string) => + DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === type)?.allowOrgOverride === true; + expect({ driven: declares(ORG_SCOPED_TYPE), section8: declares(OBJECT_DOC.type) }) .toEqual({ driven: true, section8: false }); }); diff --git a/packages/rest/src/meta-item-layered-route.test.ts b/packages/rest/src/meta-item-layered-route.test.ts index bc8b3fc3d8a..c95dbb1cfb2 100644 --- a/packages/rest/src/meta-item-layered-route.test.ts +++ b/packages/rest/src/meta-item-layered-route.test.ts @@ -45,7 +45,7 @@ const LAYERED = { name: 'customer', code: { name: 'customer', label: 'Customer' }, overlay: { label: 'Client' }, - overlayScope: 'org', + overlayScope: 'env', effective: { name: 'customer', label: 'Client' }, _diagnostics: { valid: true }, lock: 'none', @@ -159,7 +159,7 @@ describe('#5882 GET /meta/:type/:name/layers — the declared layered resource', expect(body.code).toEqual({ name: 'customer', label: 'Customer' }); expect(body.overlay).toEqual({ label: 'Client' }); expect(body.effective).toEqual({ name: 'customer', label: 'Client' }); - expect(body.overlayScope).toBe('org'); + expect(body.overlayScope).toBe('env'); expect(body.code).not.toEqual(body.effective); // And it is NOT the ordinary envelope. expect(body.item).toBeUndefined(); diff --git a/packages/rest/src/meta-object-extension-property-classes.test.ts b/packages/rest/src/meta-object-extension-property-classes.test.ts index c2422193f0c..4e1b68ff913 100644 --- a/packages/rest/src/meta-object-extension-property-classes.test.ts +++ b/packages/rest/src/meta-object-extension-property-classes.test.ts @@ -327,9 +327,10 @@ async function measure(opts: { layerCode: layeredBody?.code, layerOverlay: layeredBody?.overlay, layerEffective: layeredBody?.effective, - registryResolved: registry.foldObjectExtendersOnto( - objectName, clone(storedBody ?? declaration), - ), + // [ADR-0131 D6, triage ruling Q1 → C] The host object is one a package + // ships, so a stored row of it is an overlay of sealed managed content + // that no read serves: the base is the owner's declaration. + registryResolved: registry.foldObjectExtendersOnto(objectName, clone(declaration)), storedRow: storedBody, }; } diff --git a/packages/rest/src/meta-object-overlay-extension-fold.test.ts b/packages/rest/src/meta-object-overlay-extension-fold.test.ts index 84f12205c47..292f246d3a6 100644 --- a/packages/rest/src/meta-object-overlay-extension-fold.test.ts +++ b/packages/rest/src/meta-object-overlay-extension-fold.test.ts @@ -37,6 +37,14 @@ // `bridged` host below, live on `main`; and a stored row saved from a folded // read), so `foldObjectExtendersOnto` was made idempotent instead. The // `prefolded` and `bridged` hosts here are what hold that. +// +// [ADR-0131 D6, triage ruling Q1 → C] The host object here is one a package +// ships, so a stored row of it is an overlay of SEALED managed content: no +// read serves it any more — the package's definition, with its extenders +// folded on, is the object. The layered read still REPORTS the row as its +// `overlay` layer. What the cases below pin is therefore the fold on the +// package's definition whatever row is stored, and that the row is reported, +// not served. import { describe, it, expect, vi } from 'vitest'; import { SchemaRegistry, assertEngineFindOnePredicate } from '@objectstack/objectql'; @@ -306,12 +314,11 @@ async function measure(opts: { layerCode: layeredBody?.code, layerOverlay: layeredBody?.overlay, layerEffective: layeredBody?.effective, - // D9.2 applied to THIS host's base: the overlay row when one exists, - // else the owner's declaration. This is the definition the served body - // is checked against — never the other route. - registryResolved: registry.foldObjectExtendersOnto( - objectName, clone(storedBody ?? declaration), - ), + // D9.2 applied to THIS host's base: [ADR-0131 D6] the owner's + // declaration — a stored row of a packaged object is a sealed overlay + // no read serves. This is the definition the served body is checked + // against — never the other route. + registryResolved: registry.foldObjectExtendersOnto(objectName, clone(declaration)), storedRow: storedBody, }; } @@ -333,11 +340,10 @@ describe('[#8027] an object overlay row is a base layer, not the resolved schema expect(declaredFieldsOf(host.byName)).toEqual(declaredFieldsOf(host.registryResolved)); expect(declaredFieldsOf(host.listed)).toEqual(declaredFieldsOf(host.registryResolved)); - // The customisation itself still wins where it actually speaks. A fold - // that resolved the object by discarding the overlay would satisfy every - // field assertion above and be a worse bug than the one being fixed. - expect((host.byName as { label?: string }).label).toBe('Customer'); - expect((host.listed as { label?: string }).label).toBe('Customer'); + // [ADR-0131 D6] The sealed row's customisation is not served: the + // package's own label is. + expect((host.byName as { label?: string }).label).toBe('Account'); + expect((host.listed as { label?: string }).label).toBe('Account'); }); it('resolves `layers.effective`, and leaves `layers.overlay` the tenant\'s own row', async () => { @@ -442,8 +448,8 @@ describe('[#8027] an object overlay row is a base layer, not the resolved schema expect(entryNames(host.byName, 'validations')).toEqual(['task_rule']); expect(entryNames(host.byName, 'indexes')).toEqual(['task_idx']); } - // The row still customises what it customises. - expect((withRow.byName as { label?: string }).label).toBe('Customer'); + // [ADR-0131 D6] The sealed row is not served, row or no row. + expect((withRow.byName as { label?: string }).label).toBe('Task'); expect((withoutRow.byName as { label?: string }).label).toBe('Task'); }); @@ -487,9 +493,11 @@ describe('[#8027] an object overlay row is a base layer, not the resolved schema expect(fieldNamesOf(none.byName)).not.toEqual(fieldNamesOf(unextended.byName)); expect(fieldNamesOf(unextended.byName)).not.toContain('loyalty_tier'); - // 5. The customised row really does change something, so "the overlay - // still wins" is not vacuous either. - expect((artifact.byName as { label?: string }).label).toBe('Customer'); + // 5. The customised row really does change something — the layered + // read reports it — so "the package's label is served" is not + // vacuous either. + expect((artifact.layerOverlay as { label?: string }).label).toBe('Customer'); + expect((artifact.byName as { label?: string }).label).toBe('Account'); expect((none.byName as { label?: string }).label).toBe('Account'); }); }); diff --git a/packages/rest/src/public-form-withdrawal.test.ts b/packages/rest/src/public-form-withdrawal.test.ts index 30078c18000..34e4d9bcadb 100644 --- a/packages/rest/src/public-form-withdrawal.test.ts +++ b/packages/rest/src/public-form-withdrawal.test.ts @@ -104,11 +104,15 @@ interface Setup { function build(setup: Setup) { const createData = vi.fn().mockResolvedValue({ object: 'inquiry', id: 'rec_1', record: {} }); - const getMetaItems = vi.fn(async (req: { type: string; organizationId?: string }) => { + // [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 }) => { if (req.type === 'view') { - if (req.organizationId !== ORG && setup.envWideViews) return setup.envWideViews; - if (req.organizationId === ORG && setup.orgViews) return setup.orgViews; - const effective = req.organizationId === ORG && setup.inOrg !== undefined ? setup.inOrg : setup.envWide; + 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 (req.type === 'object') return [inquiryObject]; @@ -179,14 +183,16 @@ describe('[#21331] public form withdrawal reaches every intake door', () => { const post = await s.post(); expect(post.statusCode).toBe(201); expect(s.createData).toHaveBeenCalledTimes(1); - // Every read names the organization, except the env-wide view read the - // kill switch adds beneath the organization's view read. - const reads = s.getMetaItems.mock.calls.map(([r]) => [r.type, r.organizationId]); + // [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 [type, organizationId] of reads) { - if (type !== 'view') expect(organizationId).toBe(ORG); + 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 organization's view read, then the env-wide one. + // 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]); }); @@ -195,14 +201,14 @@ describe('[#21331] public form withdrawal reaches every intake door', () => { 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.organizationId).toBeUndefined(); + 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' }); expect((await s.get()).statusCode).toBe(200); expect((await s.post()).statusCode).toBe(201); - for (const [r] of s.getMetaItems.mock.calls) expect(r.organizationId).toBeUndefined(); + 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 () => { diff --git a/packages/rest/src/rest-server-audit-org-scope.test.ts b/packages/rest/src/rest-server-audit-org-scope.test.ts index bcb9cb9dbaa..42bf01c1030 100644 --- a/packages/rest/src/rest-server-audit-org-scope.test.ts +++ b/packages/rest/src/rest-server-audit-org-scope.test.ts @@ -103,28 +103,19 @@ const BUILDER = { systemPermissions: ['manage_metadata'] }; const requestFrom = (fn: any) => fn.mock.calls[0][0]; describe('#8747 GET /meta/:type/:name/audit scopes the read to the environment-wide rows', () => { - it('[ADR-0131 D6] reads the environment-wide rows (`organizationId: null`) even for a caller with an active organization', async () => { - // Every `/meta` write now audits environment-wide, so that is the - // partition this door reads — never every tenant's rows (an absent key). - const { auditMetaItem, drive } = boot({ ...BUILDER, userId: 'u1', tenantId: 'org_alpha' }); - await drive(); - - expect(auditMetaItem).toHaveBeenCalledTimes(1); - const request = requestFrom(auditMetaItem); - expect(request.organizationId).toBe(null); - expect(request).toHaveProperty('organizationId'); - }); - - it('is fail-closed when the caller resolves no organization', async () => { - // A principal with no active organization must read env-wide rows, not - // become a skeleton key. `null` is the env-wide read downstream; an - // ABSENT key would be the pre-fix unscoped call. - const { auditMetaItem, drive } = boot({ ...BUILDER, userId: 'u1' }); - await drive(); - - const request = requestFrom(auditMetaItem); - expect(request.organizationId).toBe(null); - expect(request).toHaveProperty('organizationId'); + it('[ADR-0131 D6] hands the read no organization, whatever the caller\'s: the protocol scopes it to the environment', async () => { + // The organization key retired from the read (the protocol builds the + // environment scope itself, unconditionally — #8747's fail-closed + // scoping now lives there, pinned by `protocol.audit-org-scope.test.ts`). + for (const ctx of [ + { ...BUILDER, userId: 'u1', tenantId: 'org_alpha' }, + { ...BUILDER, userId: 'u1' }, + ]) { + const { auditMetaItem, drive } = boot(ctx); + await drive(); + expect(auditMetaItem).toHaveBeenCalledTimes(1); + expect(requestFrom(auditMetaItem)).not.toHaveProperty('organizationId'); + } }); it('an unresolvable execution context never reaches the read — the anonymous floor refuses first', async () => { @@ -147,22 +138,6 @@ describe('#8747 GET /meta/:type/:name/audit scopes the read to the environment-w expect(auditMetaItem).not.toHaveBeenCalled(); }); - it('never omits the organization — the call shape that leaked is unreachable', async () => { - for (const ctx of [ - { ...BUILDER, userId: 'u1', tenantId: 'org_alpha' }, - { ...BUILDER, userId: 'u1', tenantId: undefined }, - { ...BUILDER, userId: 'u1' }, - ]) { - const { auditMetaItem, drive } = boot(ctx); - await drive(); - const request = requestFrom(auditMetaItem); - expect( - 'organizationId' in request, - `route omitted organizationId for ctx ${JSON.stringify(ctx)}`, - ).toBe(true); - } - }); - it('does not pass the dead `environmentId` the request type never declared', async () => { // Swept with the fix: `auditMetaItem` neither declares nor reads it. // Environment scoping is unaffected — it comes from WHICH protocol diff --git a/packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts b/packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts index 92c87912f77..c897b28f0a8 100644 --- a/packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts +++ b/packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts @@ -7,8 +7,8 @@ * and the representation it validates agree at the only door that reaches this * verb in production. The history below is how the door used to reduce the * organization by the registry read gate; §1 and §2 now pin that nothing - * reaches the read, and §3 that a supplied organization WOULD move the - * validator (so §1's equality is not vacuous). + * reaches the read, and §3 that since S5 the protocol itself folds no + * organization into the validator either (ADR-0131 D6). * * ── Why this file exists ────────────────────────────────────────────────── * @@ -329,25 +329,22 @@ describe('§2 [ADR-0131 D6] a legacy organization-scoped row is served to nobody // §3 — ⭐ the counterfactual: what the pre-gate is actually worth // ═══════════════════════════════════════════════════════════════════════════ -describe('§3 handing the protocol the RAW tenant issues a different validator', () => { - it(`${NON_OVERRIDABLE}: the raw-tenant validator differs from the one the door issued`, async () => { +describe('§3 [ADR-0131 D6] handing the protocol the RAW tenant issues the SAME validator', () => { + it(`${NON_OVERRIDABLE}: the protocol folds no organization into the validator, so the raw tenant changes nothing`, async () => { const rows = [row(NON_OVERRIDABLE)]; const b = boot(rows); b.as(ORG_A); const throughDoor = await b.get(NON_OVERRIDABLE); - // The same read the door performs, minus the door's `organizationIdFor - // MetaRead` reduction. The protocol re-folds for the BODY — the label - // below proves it — and does NOT re-fold for the validator. + // The same read the door performs, handed a retired organization key: + // no read takes one any more, so neither the body nor the validator + // moves. const raw = await b.protocol.getMetaItemCached({ type: NON_OVERRIDABLE, name: NAME, organizationId: ORG_A, - }); + } as { type: string; name: string }); - expect(raw.data?.label, 'the callee stopped re-folding for the body').toBe(`env ${NON_OVERRIDABLE}`); - expect( - throughDoor.etag, - 'the door\'s pre-gate no longer changes the validator — re-read #16525 §3', - ).not.toBe(`"${raw.etag.value}"`); + expect(raw.data?.label).toBe(`env ${NON_OVERRIDABLE}`); + expect(throughDoor.etag).toBe(`"${raw.etag.value}"`); }); }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index e1a572a1926..69fa203b47b 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -291,6 +291,18 @@ 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 }; + /** * [#15866] The DATA doors' sibling of {@link TransportScopedMetaRequest}, and * the reason it is a SECOND alias rather than a widening of the first: the data @@ -5949,7 +5961,7 @@ export class RestServer { // [commit 2a29caa53] Typed against the spec request shape plus the // transport-level `environmentId` — the `as any` this // literal used to carry is retired now that the spec - // declares `previewDrafts` (and `organizationId`, #9726). + // declares `previewDrafts`. const listRequest: TransportScopedMetaRequest = { type: req.params.type, packageId, @@ -6642,7 +6654,7 @@ export class RestServer { // [commit 2a29caa53] Typed against the spec request shape — // the `as any` this literal used to carry is // retired now that the spec declares `state` and - // `previewDrafts` (and `organizationId`, #9726). + // `previewDrafts`. // No transport envelope: this door does not thread // `environmentId` (the kernel was already resolved // above), so the plain declared shape suffices. @@ -7239,10 +7251,10 @@ export class RestServer { // [#13406 · ADR-0131 D6] The ENV partition, stated by // omission: `sys_metadata_history` is filtered by strict // equality on `organization_id`, and since the - // per-organization overlay axis retired every `/meta` write - // logs there (`organizationId ?? null` in - // `historyMetaItem`). A legacy organization-scoped log is - // not served; its promotion is ADR-0131 C7's. + // per-organization overlay axis retired every write logs + // there and `historyMetaItem` reads no other partition. A + // legacy organization-scoped log is not served; its + // promotion is ADR-0131 C7's. // // Typed through `TransportScopedMetaRequest` like the // reset door above, NOT as a plain `HistoryMetaItemRequest` @@ -7418,7 +7430,6 @@ export class RestServer { const auditRequest: AuditMetaItemRequest = { type: req.params.type, name: req.params.name, - organizationId: null, // Already finite or absent — the declared parse above // refuses anything else. ...(limit !== undefined ? { limit } : {}), @@ -7803,8 +7814,7 @@ export class RestServer { } // [#13406 · ADR-0131 D6] The ENV partition, stated by // omission, as on the history twin: `diffMetaItem` reads - // `sys_metadata_history` by strict equality on - // `organization_id` (`request.organizationId ?? null`), and + // the environment's `sys_metadata_history` rows, and // `version` is a per-(org, type, name) lineage counter, so // the environment lineage is the one every `/meta` write // now extends. @@ -10183,19 +10193,17 @@ export class RestServer { } /** - * The object schemas an anonymous form request reads, in the organization - * the form itself was resolved in (#21331). They carry the columns the - * registry injects, `organization_id` among them. + * The object schemas an anonymous form request reads (#21331): the + * environment's, as every object read is (ADR-0131 D6). They carry the + * columns the registry injects, `organization_id` among them. */ private async readFormObjectDefinitions( p: RestProtocol, environmentId: string | undefined, - organizationId: string | undefined, ): Promise { const objectsRequest: TransportScopedMetaRequest = { type: 'object', ...(environmentId ? { environmentId } : {}), - ...(organizationId ? { organizationId } : {}), }; const r: any = await p.getMetaItems(objectsRequest); return Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : []; @@ -10221,8 +10229,7 @@ export class RestServer { const tenancy = await this.resolveAnonymousFormTenancy(environmentId, req); const posture = anonymousFormIntakePosture(tenancy); let objects: Promise | undefined; - const readObjects = (): Promise => (objects ??= anonymousFormOrganization(tenancy) - .then((organizationId) => this.readFormObjectDefinitions(p, environmentId, organizationId))); + const readObjects = (): Promise => (objects ??= this.readFormObjectDefinitions(p, environmentId)); const warnings: Array<{ path: string; message: string }> = []; for (const candidate of candidates) { const object = anonymousFormObjectName(view, candidate.form); @@ -10310,13 +10317,14 @@ export class RestServer { }; // [#21331] WHICH organization's metadata an anonymous form request - // reads. A public-form request carries no session, so it carries no - // active organization, and `getMetaItems` without one merges only the - // env-wide overlays. An administrator's edit of a packaged form is - // saved as an overlay of THEIR organization, so that read missed every - // such edit, including the one that withdraws the form from anonymous - // intake. The editor showed the form closed while both doors kept - // serving and accepting it. + // 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. // // The answer is the tenancy service's `defaultOrgId()`: the // organization a single-posture deployment binds every principal to. @@ -10344,15 +10352,15 @@ export class RestServer { environmentId: string | undefined, req: any, slug: string, - ): Promise<{ view: any; form: any; object: string; organizationId: string | undefined } | null> => { + ): Promise<{ view: any; form: any; object: string } | null> => { 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: TransportScopedMetaRequest = { + const viewsRequest: LegacyFormViewsRequest = { type: 'view', ...(environmentId ? { environmentId } : {}), - ...(organizationId ? { organizationId } : {}), + ...(organizationId ? { legacyFormOrganizationId: organizationId } : {}), }; const listOf = (result: any): any[] => (Array.isArray(result?.items) ? result.items @@ -10383,10 +10391,10 @@ export class RestServer { const unavailable = await anonymousFormIntakeUnavailability( match.object, anonymousFormIntakePosture(tenancy), - async () => (await this.readFormObjectDefinitions(p, environmentId, organizationId)) + async () => (await this.readFormObjectDefinitions(p, environmentId)) .find((o: any) => o?.name === match.object), ); - return unavailable ? null : { ...match, organizationId }; + return unavailable ? null : match; }; // GET /forms/:slug — resolve and return the public form spec @@ -10441,13 +10449,11 @@ export class RestServer { try { const p = await this.resolveProtocol(environmentId, req); if (typeof (p as any).getMetaItems === 'function') { - // [#21331] The same organization the form itself - // was resolved in, so the published field schema - // matches the form the caller was served. + // [#21331] The environment's object schema, as + // every object read is (ADR-0131 D6). const objectsRequest: TransportScopedMetaRequest = { type: 'object', ...(environmentId ? { environmentId } : {}), - ...(match.organizationId ? { organizationId: match.organizationId } : {}), }; const r: any = await p.getMetaItems(objectsRequest); const items: any[] = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : []; 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 3735fbd8da4..7b654cb4c19 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 @@ -149,36 +149,20 @@ describe('#8747 auditMetaItem organization scope (real engine + real SqlDriver)' ]); }); - it('BOTH DIRECTIONS: an org-scoped read sees its own rows AND env-wide rows, and NOT a third org', async () => { + 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); - const result = await (protocol as any).auditMetaItem({ - type: 'view', name: NAME, organizationId: ORG_A, - }); - const actors = actorsOf(result); - - // (1) own-org rows visible — without this the filter is "hides everything". - expect(actors).toContain(ACTOR_A); - // (2) env-wide rows visible — THE discriminating control. Package-level - // and REST-authored writes are env-wide and must stay in the tab. - expect(actors).toContain(ACTOR_ENV); - // (3) the third org is gone — the disclosure this card exists to close. - expect(actors).not.toContain(ACTOR_B); - - expect(actors).toEqual([ACTOR_A, ACTOR_ENV].sort()); - }); - - it('is symmetric — org_beta sees its own rows plus env-wide, never org_alpha', async () => { - const { engine, protocol } = await boot(); - await seedThreeOrgs(protocol, engine); - - const actors = actorsOf(await (protocol as any).auditMetaItem({ - type: 'view', name: NAME, organizationId: ORG_B, - })); - - expect(actors).toEqual([ACTOR_B, ACTOR_ENV].sort()); - expect(actors).not.toContain(ACTOR_A); + for (const organizationId of [ORG_A, ORG_B]) { + const actors = actorsOf(await (protocol as any).auditMetaItem({ + type: 'view', name: NAME, 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]); + } }); it('an organization with no rows of its own still sees the env-wide rows, and only those', async () => { @@ -228,10 +212,10 @@ describe('#8747 auditMetaItem organization scope (real engine + real SqlDriver)' await plantLegacyOrgSave(engine, ORG_A, 'other_grid', 'carol@alpha.example'); const actors = actorsOf(await (protocol as any).auditMetaItem({ - type: 'view', name: NAME, organizationId: ORG_A, + type: 'view', name: NAME, })); expect(actors).not.toContain('carol@alpha.example'); - expect(actors).toEqual([ACTOR_A, ACTOR_ENV].sort()); + expect(actors).toEqual([ACTOR_ENV]); }); }); diff --git a/packages/runtime/src/domains/domain-protocol-handle-typing.test.ts b/packages/runtime/src/domains/domain-protocol-handle-typing.test.ts index 60aed30139d..b43d61c2a30 100644 --- a/packages/runtime/src/domains/domain-protocol-handle-typing.test.ts +++ b/packages/runtime/src/domains/domain-protocol-handle-typing.test.ts @@ -84,10 +84,10 @@ function declaredKeysCompile( ui.getUiView?.({ object: 'account', type: 'form' }), // `/meta` reads. meta.getMetaTypes?.({}), - meta.getMetaItems?.({ type: 'app', packageId: 'crm', organizationId: 'org_1', previewDrafts: true }), - meta.getMetaItem?.({ type: 'object', name: 'account', organizationId: 'org_1' }), - meta.getMetaItem?.({ type: 'app', name: 'crm', packageId: 'crm', organizationId: undefined, previewDrafts: false }), - meta.getMetaItemLayered?.({ type: 'view', name: 'account_list', organizationId: 'org_1' }), + meta.getMetaItems?.({ type: 'app', packageId: 'crm', previewDrafts: true }), + meta.getMetaItem?.({ type: 'object', name: 'account' }), + meta.getMetaItem?.({ type: 'app', name: 'crm', packageId: 'crm', previewDrafts: false }), + meta.getMetaItemLayered?.({ type: 'view', name: 'account_list' }), // `/meta` write — `writeFace` is a DECLARED closed set and // `'meta-dispatch'` is this door's member of it. meta.saveMetaItem?.({ @@ -99,7 +99,7 @@ function declaredKeysCompile( }), // The two undeclared-request verbs: the NAME is bought, the request // shape is honestly still `any` (nothing declares one). - meta.listDrafts?.({ packageId: 'crm', type: 'view', organizationId: 'org_1' }), + meta.listDrafts?.({ packageId: 'crm', type: 'view' }), meta.migrateStoredMetadata?.({ apply: false, types: ['view'], actor: 'u_1 (test)' }), meta.getProjectId?.(), // The `/mcp` merged skill read. diff --git a/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts b/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts index 34468d618b9..eb55bc153b0 100644 --- a/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts +++ b/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts @@ -36,17 +36,16 @@ * * ## The protocol double * - * It applies the read gate the real `metadata-protocol` applies to - * `getMetaItem`, `getMetaItems` and `getMetaItemLayered` - * (`organizationIdForMetaRead` over the folded type): an organization reaches a - * read only for an org-overridable type. So a transport that hands down a raw - * organization for `object` is answered what one that pre-gates it is — the - * `object` control below — and the difference measured is the organization - * VALUE, not where the gate runs. + * It models the read gate `metadata-protocol` applied before ADR-0131 D6 + * (an organization reached a read only for a type the registry declared + * `allowOrgOverride`), so a transport that still handed down an organization + * would be answered that organization's overlay — the double is the detector. + * The real protocol reads no organization at all since D6; what this file + * measures is that neither transport sends one. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { organizationIdForMetaRead } from '@objectstack/metadata-core'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { canonicalMetaUrlType } from '@objectstack/spec/shared'; import { RestServer } from '@objectstack/rest'; import { HttpDispatcher } from '../http-dispatcher.js'; @@ -76,9 +75,14 @@ const ENV_DRAFTS: any[] = [{ type: 'view', name: 'env_board', label: 'Env board const clone = (v: T): T => JSON.parse(JSON.stringify(v)); +/** The types the registry declares `allowOrgOverride` — the pre-D6 gate the double models. */ +const ORG_OVERRIDABLE = new Set(DEFAULT_METADATA_TYPE_REGISTRY.filter((e) => e.allowOrgOverride).map((e) => e.type)); + function protocolDouble() { - const gate = (type: string, organizationId: unknown) => - organizationIdForMetaRead(canonicalMetaUrlType(type), typeof organizationId === 'string' ? organizationId : undefined); + const gate = (type: string, organizationId: unknown) => { + const t = canonicalMetaUrlType(type); + return typeof organizationId === 'string' && ORG_OVERRIDABLE.has(t) ? organizationId : undefined; + }; const resolve = (type: string, name: string, organizationId: unknown) => { const t = canonicalMetaUrlType(type); const org = gate(type, organizationId); diff --git a/packages/runtime/src/domains/packages-protocol-handle-typing.test.ts b/packages/runtime/src/domains/packages-protocol-handle-typing.test.ts index 8fb0bca9a14..5234e7951c5 100644 --- a/packages/runtime/src/domains/packages-protocol-handle-typing.test.ts +++ b/packages/runtime/src/domains/packages-protocol-handle-typing.test.ts @@ -73,7 +73,7 @@ import type { PackagesDomainProtocol } from './packages.js'; function declaredKeysCompile(protocol: PackagesDomainProtocol) { return [ // ADR-0045 visibility flip — `GET` half. - protocol.getMetaItems?.({ type: 'app', packageId: 'crm', organizationId: 'org_1' }), + protocol.getMetaItems?.({ type: 'app', packageId: 'crm' }), // ADR-0045 visibility flip — `SAVE` half. `packageId` is declared // `nullable().optional()`, `actor` optional; both are load-bearing here. protocol.saveMetaItem?.({ @@ -83,11 +83,10 @@ function declaredKeysCompile(protocol: PackagesDomainProtocol) { packageId: 'crm', actor: 'u_publisher', }), - // `applyPublishedSeeds`' seed body read-back, both attempts. - protocol.getMetaItem?.({ type: 'seed', name: 'crm_seed', organizationId: 'org_1' }), + // `applyPublishedSeeds`' seed body read-back. protocol.getMetaItem?.({ type: 'seed', name: 'crm_seed' }), // The manifest-export read. - protocol.getMetaItems?.({ type: 'view', packageId: 'crm', organizationId: undefined }), + protocol.getMetaItems?.({ type: 'view', packageId: 'crm' }), ]; } diff --git a/packages/runtime/src/domains/packages-seed-apply-org-scope.test.ts b/packages/runtime/src/domains/packages-seed-apply-org-scope.test.ts index f68e616d48d..139bc7827b4 100644 --- a/packages/runtime/src/domains/packages-seed-apply-org-scope.test.ts +++ b/packages/runtime/src/domains/packages-seed-apply-org-scope.test.ts @@ -23,13 +23,11 @@ * * ## The mechanism, in one line * - * `getMetaItem` opens with `organizationIdForMetaRead(request.type, - * request.organizationId)` (commit d5cbb44f3, the singular twin of commit 96326040f's plural - * gate) and spends that binding — never `request.organizationId` — on every - * read below it. `seed` declares `allowOrgOverride: false` - * (`metadata-plugin.zod.ts`), so the predicate answers `undefined` whatever - * organization arrives. ⇒ `{ type:'seed', name, organizationId }` and - * `{ type:'seed', name }` differ in a field the callee provably drops. + * `getMetaItem` reads no organization at all (ADR-0131 D6: every metadata + * read is environment → code; before it, commit d5cbb44f3's gate already + * dropped an organization for `seed`, which declares `allowOrgOverride: + * false`). ⇒ `{ type:'seed', name, organizationId }` and `{ type:'seed', name }` + * differ in a field the callee provably drops. * * ⛔ The repair is NOT to restore org-awareness to this read. An org-scoped * `seed` row is the unhydratable phantom `reportUnhydratableOrgScopedRows` @@ -80,7 +78,6 @@ import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch, - organizationIdForMetaRead, } from '@objectstack/metadata-core'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; @@ -403,15 +400,11 @@ describe('#15068 · 0 · the publish-then-read path really runs', () => { // ═══════════════════════════════════════════════════════════════════════════ describe('#15068 · 1 · the two rungs resolve to one read', () => { - it('`seed` is non-overridable, so the read gate drops the organization', () => { + it('`seed` is non-overridable — and since ADR-0131 D6 no read takes an organization', () => { // The registry fact the card rests on, read from the registry rather // than restated. expect(DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'seed')?.allowOrgOverride) .toBe(false); - expect(organizationIdForMetaRead('seed', ORG)).toBeUndefined(); - // ⇒ and the control that makes that reading mean something: the same - // predicate DOES carry an organization for an overridable type. - expect(organizationIdForMetaRead('view', ORG)).toBe(ORG); }); it('the two rungs issue the same predicates and serve the same answer', async () => { @@ -434,10 +427,11 @@ describe('#15068 · 1 · the two rungs resolve to one read', () => { } }); - it('[CONTROL] the same comparison DOES separate the two rungs for an overridable type', async () => { - // Anti-vacuity, and the reason the assertion above is a reading rather - // than a tautology: on `view` — `allowOrgOverride: true` — the org-first - // rung reads a partition the env-wide rung never touches. + it('[ADR-0131 D6] no type separates the two rungs any more — the formerly overridable `view` included', async () => { + // Before S5 this was the anti-vacuity control: on `view` the org-first + // rung read a partition the env-wide rung never touched. Every read is + // environment → code now, so the comparison is equal for every type — + // and the reads it compares were really issued (non-empty). const engine = seededStore(); const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; @@ -446,8 +440,9 @@ describe('#15068 · 1 · the two rungs resolve to one read', () => { await protocol.getMetaItem({ type: 'view', name: 'anything' }); const envWideReads = engine.metaReads.splice(0); - expect(JSON.stringify(orgFirstReads)).not.toBe(JSON.stringify(envWideReads)); - expect(orgFirstReads.map((w: any) => w.organization_id)).toContain(ORG); + expect(orgFirstReads.length).toBeGreaterThan(0); + expect(JSON.stringify(orgFirstReads)).toBe(JSON.stringify(envWideReads)); + expect(orgFirstReads.map((w: any) => w.organization_id ?? null)).not.toContain(ORG); }); it('every partition the publish path touches for a seed is env-wide', async () => { diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 7706d7a055c..c854d1ee036 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -1547,13 +1547,9 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // true` on the next restart, because the env-wide row it left // untouched is the only one cold boot loads. The // `getMetaItems` read below is env-wide for the same - // reason, and since commit 96326040f it is so by construction: that - // method applies `organizationIdForMetaRead` to - // `request.type` itself, and the predicate answers - // `undefined` for every type the registry declares - // non-overridable — `app` among them, rolled back to - // `allowOrgOverride: false` in commit ee58392e1. [ADR-0131 D6] - // This route hands it no organization at all any more. + // reason, and by construction: [ADR-0131 D6] every + // metadata read is environment → code, and this route + // hands it no organization at all. // // ⛔ Dropping it is the REPAIR, not an oversight to undo. // An org-scoped `app` row is an unhydratable phantom — @@ -2362,15 +2358,11 @@ _context: HttpProtocolContext, const datasets: any[] = []; const readErrors: string[] = []; for (const name of names) { - // Read the just-published seed body. THE REGISTRY DECIDES THE SCOPE, - // not this call site: `seed` declares `allowOrgOverride: false`, and - // since commit d5cbb44f3 `getMetaItem` opens by resolving - // `organizationIdForMetaRead(request.type, request.organizationId)` - // and spends THAT binding — never the raw argument — on every read - // beneath it. The predicate answers `undefined` for every type the - // registry declares non-overridable, so this read is env-wide by - // construction: `organization_id IS NULL`, the partition a workspace - // seed is stored in and the only one cold boot hydrates. + // Read the just-published seed body. The protocol decides the scope, + // not this call site: [ADR-0131 D6] every metadata read is + // environment → code, so this read is env-wide by construction: + // `organization_id IS NULL`, the partition a workspace seed is stored + // in and the only one cold boot hydrates. // // [commit 8744de9e9] This used to be a two-attempt org-then-env ladder, written // when resolving the wrong scope here is what silently produced "0 diff --git a/packages/runtime/src/meta-write-org-scope.test.ts b/packages/runtime/src/meta-write-org-scope.test.ts index d4a6832ec95..1b52976651f 100644 --- a/packages/runtime/src/meta-write-org-scope.test.ts +++ b/packages/runtime/src/meta-write-org-scope.test.ts @@ -72,15 +72,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, - // [#8805] The predicate moved here from `../meta-write-org-scope.js` so the - // REST `/meta` write doors share it. This suite still drives the DISPATCHER - // through the real stack — that is why it stays in this package. - declaresOrgOverride, assertEngineFindOnePredicate, } from '@objectstack/metadata-core'; import * as metadataCore from '@objectstack/metadata-core'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; -import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; // [commit 67ceb9aef] The URL spelling contract itself — the map storage folds through, // and the fold the transport was missing. Imported so the sweep below is // quantified over the CONTRACT rather than over a hand-copied specimen list @@ -359,23 +354,12 @@ describe('#7018 — the registry decides whether a metadata write carries the se // ── the predicate itself ────────────────────────────────────────────── - it('is derived from the registry, not a parallel allowlist (PD #8)', () => { - // Deliberately recomputed from `DEFAULT_METADATA_TYPE_REGISTRY` rather - // than spelled out: a hand-written list here would agree with a - // hand-written list there and pin nothing. Flipping any registry entry - // moves both sides of this assertion together. - for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { - expect(declaresOrgOverride(entry.type)).toBe(entry.allowOrgOverride); + it('[ADR-0131 D6] the organization-scope predicates are gone: no door and no read asks them', () => { + // The write-side predicate retired with the doors' organization (S3); + // the read-side pair retired with the protocol's read narrowing (S5). + for (const retired of ['organizationIdForMetaWrite', 'organizationIdForMetaRead', 'declaresOrgOverride']) { + expect(retired in metadataCore, retired).toBe(false); } - // Plural URL spellings are judged identically (`/meta/views/...`). - expect(declaresOrgOverride('views')).toBe(declaresOrgOverride('view')); - expect(declaresOrgOverride('flows')).toBe(declaresOrgOverride('flow')); - // A runtime-registered type with no registry entry has no per-org read - // channel either, so it is env-wide too. (`webhook` took this slot - // from `theme` at commit 35ad101bc — the retired kind left the contract.) - expect(declaresOrgOverride('webhook')).toBe(false); - // [ADR-0131 D6] The write-side predicate is gone: no door asks it. - expect('organizationIdForMetaWrite' in metadataCore).toBe(false); }); // ── PUT /meta/:type/:name — the dispatcher's metadata write ─────────── 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 af0b1abafd4..12923e586d0 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 @@ -266,7 +266,7 @@ describe('#7779 — org-scoped listCommits must not hide env-wide commit rows', ]); }); - it('returns the env-wide commits to an org-scoped caller (was: returned none of them)', async () => { + it('[ADR-0131 D6] returns the env-wide commits to a caller naming an organization — and not its legacy organization commit', async () => { const { protocol } = await boot(); const { envWide, own } = await seed(protocol as any); @@ -275,15 +275,12 @@ describe('#7779 — org-scoped listCommits must not hide env-wide commit rows', organizationId: ACTIVE_ORG, }); - // The CONSEQUENCE: the caller's own commit AND the env-wide one. Before - // the fix this was `[own]` alone — `envWide` was missing outright. - expect(idsOf(commits)).toEqual([envWide, own].sort()); - - // Newest-first is the contract the timeline UI and - // `rollbackToPackageCommit` both rely on, and widening the predicate must - // not disturb it: `own` was published after `envWide`. - expect(commits[0].id).toBe(own); - expect(commits.map((c: any) => c.message)).toEqual(['own-org publish', 'env-wide publish']); + // 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 () => { @@ -313,28 +310,32 @@ describe('#7779 — org-scoped listCommits must not hide env-wide commit rows', expect(idsOf(commits)).not.toContain(otherPkg); }); - it('a caller with NO org still sees the package’s whole timeline (the other door)', async () => { + it('[ADR-0131 D6] a caller with NO org sees the environment\'s timeline — no organization\'s legacy commits', async () => { const { protocol } = await boot(); const { envWide, own, foreign } = await seed(protocol as any); - // The no-org branch is deliberately left package-wide. Narrowing it to - // `organization_id IS NULL` — mirroring only half the #3115 shape — would - // hide every org-scoped commit from this door instead, re-creating the - // bug pointed the other way. #7705 left its own no-org branch alone for - // the same reason and this pins the equivalent here. + // 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, own, foreign].sort()); + expect(idsOf(commits)).toEqual([envWide]); + expect(idsOf(commits)).not.toContain(own); + expect(idsOf(commits)).not.toContain(foreign); }); it('the timeline the rollback planner reads now contains the env-wide commit', async () => { const { protocol } = await boot(); const p = protocol as any; - // C1 under an active org, then C2 env-wide and strictly newer — the shape - // a session hits when its org resolution lapses between two publishes. - const c1 = await publishOne(p, { - view: 'roll_a', packageId: PKG, organizationId: ACTIVE_ORG, message: 'c1', + // [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. + 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' }); @@ -344,8 +345,9 @@ describe('#7779 — org-scoped listCommits must not hide env-wide commit rows', // list was `[c1]` and an org-scoped rollback to C1 answered // `{success: true, revertedCommits: []}` — reporting a rollback it had not // performed, with C2's changes still live. - const commits = await p.listCommits({ packageId: PKG, organizationId: ACTIVE_ORG }); + 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 90ef048dead..4fa6cd71adf 100644 --- a/packages/runtime/src/package-revert-stored-members.integration.test.ts +++ b/packages/runtime/src/package-revert-stored-members.integration.test.ts @@ -183,8 +183,8 @@ async function publish(protocol: any, packageId: string): Promise { expect(res.success).toBe(true); } -async function draftNames(protocol: any, packageId: string, organizationId?: string): Promise { - const { drafts } = await protocol.listDrafts({ packageId, ...(organizationId ? { organizationId } : {}) }); +async function draftNames(protocol: any, packageId: string): Promise { + const { drafts } = await protocol.listDrafts({ packageId }); return drafts.map((d: any) => `${d.type}/${d.name}`).sort(); } @@ -273,7 +273,14 @@ describe('#22090 POST /packages/:id/revert on a Studio-authored package', () => const answer = await revert(STUDIO_PKG, ACTIVE_ORG); expect(answer.status).toBe(200); - expect(await draftNames(protocol, STUDIO_PKG, OTHER_ORG)).toEqual(['view/repairs_board']); + // [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([]); }); }); diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 93ac9d2a8b0..31e609358d2 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -206,7 +206,6 @@ "api/AssembledInstalledPackage:upgradeHistory", "api/AuditMetaItemRequest:limit", "api/AuditMetaItemRequest:name", - "api/AuditMetaItemRequest:organizationId", "api/AuditMetaItemRequest:type", "api/AuditMetaItemResponse:events", "api/AuthEndpoint:forgetPassword", @@ -748,7 +747,6 @@ "api/GetMetaItemCachedRequest:cacheRequest", "api/GetMetaItemCachedRequest:locale", "api/GetMetaItemCachedRequest:name", - "api/GetMetaItemCachedRequest:organizationId", "api/GetMetaItemCachedRequest:type", "api/GetMetaItemCachedResponse:cacheControl", "api/GetMetaItemCachedResponse:data", @@ -757,7 +755,6 @@ "api/GetMetaItemCachedResponse:notModified", "api/GetMetaItemCachedResponse:version", "api/GetMetaItemLayeredRequest:name", - "api/GetMetaItemLayeredRequest:organizationId", "api/GetMetaItemLayeredRequest:packageId", "api/GetMetaItemLayeredRequest:type", "api/GetMetaItemLayeredResponse:_diagnostics", @@ -778,7 +775,6 @@ "api/GetMetaItemLayeredResponse:resettable", "api/GetMetaItemLayeredResponse:type", "api/GetMetaItemRequest:name", - "api/GetMetaItemRequest:organizationId", "api/GetMetaItemRequest:packageId", "api/GetMetaItemRequest:previewDrafts", "api/GetMetaItemRequest:state", @@ -798,7 +794,6 @@ "api/GetMetaItemResponse:sortability", "api/GetMetaItemResponse:type", "api/GetMetaItemResponse:version", - "api/GetMetaItemsRequest:organizationId", "api/GetMetaItemsRequest:packageId", "api/GetMetaItemsRequest:previewDrafts", "api/GetMetaItemsRequest:type", @@ -849,7 +844,6 @@ "api/GetUiViewResponse:protection", "api/HistoryMetaItemRequest:limit", "api/HistoryMetaItemRequest:name", - "api/HistoryMetaItemRequest:organizationId", "api/HistoryMetaItemRequest:sinceSeq", "api/HistoryMetaItemRequest:type", "api/HistoryMetaItemResponse:events", diff --git a/packages/spec/src/api/meta-item-response-shapes.test.ts b/packages/spec/src/api/meta-item-response-shapes.test.ts index 683fc6f3775..3280179b1af 100644 --- a/packages/spec/src/api/meta-item-response-shapes.test.ts +++ b/packages/spec/src/api/meta-item-response-shapes.test.ts @@ -149,7 +149,7 @@ describe('GetMetaItemLayeredResponseSchema — the three-layer projection', () = name: 'customer', code: { name: 'customer', label: 'Customer' }, overlay: { label: 'Client' }, - overlayScope: 'org', + overlayScope: 'env', effective: { name: 'customer', label: 'Client' }, _diagnostics: { valid: true }, lock: 'none', @@ -166,7 +166,7 @@ describe('GetMetaItemLayeredResponseSchema — the three-layer projection', () = expect(parsed.code).toEqual({ name: 'customer', label: 'Customer' }); expect(parsed.overlay).toEqual({ label: 'Client' }); expect(parsed.effective).toEqual({ name: 'customer', label: 'Client' }); - expect(parsed.overlayScope).toBe('org'); + expect(parsed.overlayScope).toBe('env'); expect(parsed._diagnostics).toEqual({ valid: true }); }); @@ -196,10 +196,10 @@ describe('GetMetaItemLayeredResponseSchema — the three-layer projection', () = } }); - it('rejects an overlayScope outside `org` | `env`', () => { - expect(() => - GetMetaItemLayeredResponseSchema.parse({ ...LAYERED_BODY, overlayScope: 'package' }), - ).toThrow(); + it('[ADR-0131 D6] rejects an overlayScope other than `env` — `org` included: no read serves an organization\'s row', () => { + for (const overlayScope of ['package', 'org']) { + expect(() => GetMetaItemLayeredResponseSchema.parse({ ...LAYERED_BODY, overlayScope })).toThrow(); + } }); it('is a DIFFERENT shape from the ordinary read — which is why it got its own path', () => { diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index 443ad278942..350d0b7aef3 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -1597,16 +1597,12 @@ import { GetMetaItemLayeredRequestSchema, } from './protocol.zod'; -describe('meta-read request schemas declare organizationId (declared = enforced)', () => { - // The implementation (`@objectstack/metadata-protocol`) accepts and HONOURS - // `organizationId` on all four read verbs: it selects the org partition in - // the ADR-0005 overlay read order, i.e. it decides which tenant's row is - // served. These pins hold the DECLARED surface to that enforced shape. - // - // Why the accept-pin asserts the parsed VALUE, not just `success`: these are - // non-strict objects, so before #9726 a request carrying `organizationId` - // still parsed green — the member was silently STRIPPED. Presence in the - // parse OUTPUT is the observable that actually changed. +describe('[ADR-0131 D6] meta-read request schemas declare no organizationId (declared = enforced)', () => { + // Every metadata read is environment → code: the implementation + // (`@objectstack/metadata-protocol`) reads no organization on any read + // verb, so the declared shape carries none. These are non-strict objects, + // so a request still carrying the retired key parses green with the member + // STRIPPED — absence from the parse OUTPUT is the observable. const cases = [ ['GetMetaItemsRequestSchema', GetMetaItemsRequestSchema, { type: 'object' }], ['GetMetaItemRequestSchema', GetMetaItemRequestSchema, { type: 'view', name: 'account_list' }], @@ -1614,37 +1610,27 @@ describe('meta-read request schemas declare organizationId (declared = enforced) ['GetMetaItemLayeredRequestSchema', GetMetaItemLayeredRequestSchema, { type: 'view', name: 'account_list' }], ] as const; - it.each(cases)('%s accepts organizationId and PRESERVES it through parse', (_n, schema, base) => { + it.each(cases)('%s strips a retired organizationId at parse', (_n, schema, base) => { const result = schema.safeParse({ ...base, organizationId: 'org_alpha' }); expect(result.success).toBe(true); - if (result.success) { - expect((result.data as { organizationId?: string }).organizationId).toBe('org_alpha'); - } - }); - - it.each(cases)('%s keeps organizationId OPTIONAL — an env-wide (org-less) read stays valid', (_n, schema, base) => { - const result = schema.safeParse(base); - expect(result.success).toBe(true); if (result.success) { expect('organizationId' in (result.data as object)).toBe(false); } }); - it.each(cases)('%s rejects a non-string organizationId — the scope key is a value, not a bag', (_n, schema, base) => { - expect(schema.safeParse({ ...base, organizationId: 42 }).success).toBe(false); - expect(schema.safeParse({ ...base, organizationId: { id: 'org_alpha' } }).success).toBe(false); + it.each(cases)('%s declares no organizationId member', (_n, schema) => { + expect('organizationId' in (schema as unknown as { shape: object }).shape).toBe(false); }); it('GetMetaItemLayeredRequestSchema mirrors the implementation inline type member for member', () => { - // `{ type, name, packageId?, organizationId? }` — the exact parameter type - // of `getMetaItemLayered` in metadata-protocol. packageId scopes the code + // `{ type, name, packageId? }` — the exact parameter type of + // `getMetaItemLayered` in metadata-protocol. packageId scopes the code // layer (ADR-0048); nothing else is declared because nothing else is // enforced. const full = GetMetaItemLayeredRequestSchema.safeParse({ type: 'view', name: 'account_list', packageId: 'pkg_crm', - organizationId: 'org_alpha', }); expect(full.success).toBe(true); expect(GetMetaItemLayeredRequestSchema.safeParse({ type: 'view' }).success).toBe(false); @@ -1944,8 +1930,8 @@ describe('AuditMetaItemRequestSchema mirrors the implementation parameter type', // commit cccbe51bf): NEITHER side was declared, and the REST call site reached the // verb through `(p as any)` twice (guard + call). The measure is the // implementation's parameter type in `@objectstack/metadata-protocol` — - // `{ type, name, organizationId?: string | null, limit?: number }` — and the - // REST door's actual sends; nothing else is declared because nothing else is + // `{ type, name, limit?: number }` (ADR-0131 D6 retired `organizationId`) — + // and the REST door's actual sends; nothing else is declared because nothing else is // enforced. As in the #9726 / commit 2a29caa53 / commit cccbe51bf blocks above, // accept-pins assert the parsed VALUE: this is a non-strict object, so // `success` alone is exactly the silent-strip state this family of cards closes. @@ -1953,7 +1939,7 @@ describe('AuditMetaItemRequestSchema mirrors the implementation parameter type', const base = { type: 'view', name: 'account_list' } as const; it('accepts the full request and PRESERVES every member through parse', () => { - const full = { ...base, organizationId: 'org_alpha', limit: 50 }; + const full = { ...base, limit: 50 }; const result = AuditMetaItemRequestSchema.safeParse(full); expect(result.success).toBe(true); if (result.success) { @@ -1967,18 +1953,12 @@ describe('AuditMetaItemRequestSchema mirrors the implementation parameter type', expect(AuditMetaItemRequestSchema.safeParse({ name: 'account_list' }).success).toBe(false); }); - it('organizationId accepts null AND preserves it — the REST door always sends it, possibly null', () => { - // The door sends `ctx?.tenantId ?? null` unconditionally (#8747's - // fail-closed scoping), so `null` must be a declared spelling for the - // literal to compile at all. Semantically `null` and absent are the same - // env-wide read; the pin is that the parse neither refuses nor strips it. - const withNull = AuditMetaItemRequestSchema.safeParse({ ...base, organizationId: null }); - expect(withNull.success).toBe(true); - if (withNull.success) { - expect('organizationId' in (withNull.data as object)).toBe(true); - expect((withNull.data as { organizationId?: string | null }).organizationId).toBeNull(); + it('[ADR-0131 D6] a retired organizationId is STRIPPED at parse — the trail is the environment\'s', () => { + for (const organizationId of ['org_alpha', null]) { + const result = AuditMetaItemRequestSchema.safeParse({ ...base, organizationId }); + expect(result.success).toBe(true); + if (result.success) expect('organizationId' in (result.data as object)).toBe(false); } - expect(AuditMetaItemRequestSchema.safeParse({ ...base, organizationId: 42 }).success).toBe(false); }); it('limit is an optional number — values, not bags', () => { @@ -2076,8 +2056,11 @@ describe('MetadataProtocol declares auditMetaItem', () => { }); it('refuses an undeclared key at the member call shape', () => { - const good: AuditMetaItemRequest = { type: 'view', name: 'account_list', organizationId: null }; + const good: AuditMetaItemRequest = { type: 'view', name: 'account_list', limit: 5 }; expect(good.type).toBe('view'); + // @ts-expect-error `organizationId` is retired (ADR-0131 D6): no metadata read is organization-scoped. + const withOrg: AuditMetaItemRequest = { type: 'view', name: 'account_list', organizationId: null }; + expect(withOrg.name).toBe('account_list'); // @ts-expect-error `environmentId` is transport-level (commit 2a29caa53) — not a declared request member (and #8747 removed it from this door's wire payload entirely). const withEnv: AuditMetaItemRequest = { type: 'view', name: 'account_list', environmentId: 'env_a' }; expect(withEnv.name).toBe('account_list'); @@ -2097,8 +2080,8 @@ describe('HistoryMetaItemRequestSchema mirrors the implementation parameter type // declared, the REST call site reaching the verb through `(p as any)` twice // (guard + call). The measure is the implementation's parameter type in // `@objectstack/metadata-protocol` — - // `{ type, name, organizationId?: string, sinceSeq?: number, limit?: number }` - // — and the REST door's actual sends; nothing else is declared because + // `{ type, name, sinceSeq?: number, limit?: number }` (ADR-0131 D6 retired + // `organizationId`) — and the REST door's actual sends; nothing else is declared because // nothing else is enforced. As in the #11678 block above, accept-pins // assert the parsed VALUE: this is a non-strict object, so `success` alone // is exactly the silent-strip state this family of cards closes. @@ -2106,7 +2089,7 @@ describe('HistoryMetaItemRequestSchema mirrors the implementation parameter type const base = { type: 'view', name: 'account_list' } as const; it('accepts the full request and PRESERVES every member through parse', () => { - const full = { ...base, organizationId: 'org_alpha', sinceSeq: 3, limit: 50 }; + const full = { ...base, sinceSeq: 3, limit: 50 }; const result = HistoryMetaItemRequestSchema.safeParse(full); expect(result.success).toBe(true); if (result.success) { @@ -2120,21 +2103,10 @@ describe('HistoryMetaItemRequestSchema mirrors the implementation parameter type expect(HistoryMetaItemRequestSchema.safeParse({ name: 'account_list' }).success).toBe(false); }); - it('organizationId is an optional string — the implementation\'s declared type, not the audit twin\'s nullable', () => { - // The audit twin declares `string | null` because ITS implementation - // does and its door always sends `ctx?.tenantId ?? null`. This - // implementation declares plain `organizationId?: string`, and this door - // sends no organization at all — so the mirror is `.optional()` without - // `.nullable()`. (Whether the door SHOULD send one is the #8747-family - // measurement the card fences to a future issue, deliberately not - // answered by this declaration.) + it('[ADR-0131 D6] a retired organizationId is STRIPPED at parse — the change log is the environment\'s', () => { const withOrg = HistoryMetaItemRequestSchema.safeParse({ ...base, organizationId: 'org_alpha' }); expect(withOrg.success).toBe(true); - if (withOrg.success) { - expect((withOrg.data as { organizationId?: string }).organizationId).toBe('org_alpha'); - } - expect(HistoryMetaItemRequestSchema.safeParse(base).success).toBe(true); - expect(HistoryMetaItemRequestSchema.safeParse({ ...base, organizationId: 42 }).success).toBe(false); + if (withOrg.success) expect('organizationId' in (withOrg.data as object)).toBe(false); }); it('sinceSeq and limit are optional numbers — values, not bags', () => { @@ -2620,7 +2592,6 @@ describe('ListDraftsResponseSchema declares the pending-drafts body', () => { type: 'view', name: 'account_pipeline', label: 'Account Pipeline', - organizationId: 'org_01', packageId: 'com.example.crm', updatedAt: '2026-08-27T09:12:44.000Z', updatedBy: 'admin@objectos.ai', @@ -2629,7 +2600,6 @@ describe('ListDraftsResponseSchema declares the pending-drafts body', () => { type: 'app', name: 'field_service', label: { en: 'Field Service', 'zh-CN': '现场服务' }, - organizationId: null, packageId: 'com.example.fsm', updatedAt: '2026-08-27T09:13:02.000Z', updatedBy: 'admin@objectos.ai', @@ -2638,7 +2608,6 @@ describe('ListDraftsResponseSchema declares the pending-drafts body', () => { type: 'object', name: 'lead_source', label: null, - organizationId: null, packageId: null, updatedAt: null, updatedBy: null, diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index ff35c73e3e5..37d5e921ccb 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -237,15 +237,6 @@ export const GetMetaTypesResponseSchema = lazySchema(() => z.object({ export const GetMetaItemsRequestSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name (e.g., "object", "plugin")'), packageId: z.string().optional().describe('Optional package ID to filter items by'), - organizationId: z.string().optional().describe( - 'Organization (tenant) scope for the read. When an org partition applies, ' - + 'this selects it in the ADR-0005 overlay read order — org overlay wins ' - + 'over env-wide overlay wins over packaged artifact — so it decides which ' - + 'tenant\'s customization rows are merged into the list. Supplying a value ' - + 'does not by itself guarantee an org partition is consulted; where none ' - + 'applies, and whenever it is absent, the read is environment-wide and ' - + 'only env-level overlays apply.', - ), previewDrafts: z.boolean().optional().describe( 'Draft-visibility switch (ADR-0033 draft-overlay preview): when true, ' + 'pending `state=\'draft\'` rows are overlaid on the active list — draft ' @@ -274,15 +265,6 @@ export const GetMetaItemRequestSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name'), name: z.string().describe('Item name (snake_case identifier)'), packageId: z.string().optional().describe('Optional package ID to filter items by'), - organizationId: z.string().optional().describe( - 'Organization (tenant) scope for the read. When an org partition applies, ' - + 'this selects it in the ADR-0005 overlay read order — org overlay wins ' - + 'over env-wide overlay wins over packaged artifact — so it decides which ' - + 'tenant\'s customization row is served as the item. Supplying a value ' - + 'does not by itself guarantee an org partition is consulted; where none ' - + 'applies, and whenever it is absent, the read is environment-wide and ' - + 'only env-level overlays apply.', - ), state: z.enum(['active', 'draft']).optional().describe( 'Draft-visibility switch — which lifecycle row to read (strict mode): ' + '`\'draft\'` opens the pending draft buffer (Studio\'s editor read) and ' @@ -333,7 +315,7 @@ const MetadataProtectionEnvelopeFields = { 'Resolved lock verdict for this item (ADR-0010 §3.3). `none` means unlocked; ' + '`no-overlay` / `no-delete` / `full` mean the write doors refuse the ' + 'corresponding write with 403. Joins two refusals: the document\'s own ' - + '`_lock` (`ITEM_LOCKED`; the packaged artifact wins over any org overlay), ' + + '`_lock` (`ITEM_LOCKED`; the packaged artifact wins over any overlay), ' + 'and the locked packaged base — an item a code package ships, on a type with ' + 'no per-org overlay channel (`NOT_OVERRIDABLE`, or `ITEM_LOCKED` when the ' + 'write names the read-only package).', @@ -447,16 +429,16 @@ export const GetMetaItemResponseSchema = lazySchema(() => z.object({ ...MetadataProtectionEnvelopeFields, version: z.string().nullable().optional().describe( 'ADR-0008 version token of the STORED row a save to this item compares ' - + 'against, at this read\'s scope (the caller\'s organization partition and ' + + 'against, at this read\'s scope (the environment\'s partition and ' + '`?package=`) and lifecycle (`?state=draft` reads the draft row, the plain ' + 'read the active row). The same keyed token a save receipt\'s `version` ' + 'serves for that row: send it back as the `If-Match` request header on ' + '`PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a ' + 'concurrent edit is refused 409 `METADATA_CONFLICT` instead of ' + 'overwritten. `null` when no stored row is there — an item served from ' - + 'code or a package artifact, or from a row in another scope (the ' - + 'environment-wide row an organization falls back to, a package-less row a ' - + 'package read falls back to) — so the next save creates one: pin it with ' + + 'code or a package artifact, or from a row in another scope (a ' + + 'package-less row a package read falls back to) — so the next save ' + + 'creates one: pin it with ' + '`If-None-Match: *`. ABSENT on the cached published-value branch and on a ' + '`?preview=draft` read, which publish no version: absence means "not ' + 'published here", never "no row". Opaque — echo it verbatim, never parse ' @@ -480,15 +462,6 @@ export const GetMetaItemLayeredRequestSchema = lazySchema(() => z.object({ 'Optional package ID — scopes the `code` layer so a same-name collision ' + 'resolves to the requested package\'s artifact (ADR-0048).', ), - organizationId: z.string().optional().describe( - 'Organization (tenant) scope for the read. When an org partition applies, ' - + 'this selects it in the ADR-0005 overlay read order, so it decides which ' - + 'tenant\'s customization row is reported as the `overlay` layer (and ' - + 'merged into `effective`). Supplying a value does not by itself guarantee ' - + 'an org partition is consulted; where none applies, and whenever it is ' - + 'absent, the read is environment-wide: `overlay` reports the env-level ' - + 'row only.', - ), })); /** @@ -533,11 +506,11 @@ export const GetMetaItemLayeredResponseSchema = lazySchema(() => z.object({ ), overlay: z.unknown().describe( 'LAYER 2 — the stored customization row ALONE, not merged with `code`. ' - + '`null` when this tenant has not customized the item.', + + '`null` when the environment has not customized the item.', ), - overlayScope: z.enum(['org', 'env']).nullable().describe( - 'Which scope the `overlay` row was read from — `org` for a tenant overlay, ' - + '`env` for an environment-level one. `null` exactly when `overlay` is null.', + overlayScope: z.enum(['env']).nullable().describe( + 'Which scope the `overlay` row was read from: `env`, the environment layer — ' + + 'the only layer a read serves (ADR-0131 D6). `null` exactly when `overlay` is null.', ), effective: z.unknown().describe( 'LAYER 3 — the merged result, i.e. the value an ordinary ' @@ -1490,15 +1463,6 @@ export const DeleteMetaItemResponseSchema = lazySchema(() => z.object({ export const AuditMetaItemRequestSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name'), name: z.string().describe('Item name'), - organizationId: z.string().nullable().optional().describe( - 'Organization (tenant) scope for the read. With an organization, ' - + 'the trail includes that org\'s rows AND the env-wide ' - + '(`organization_id IS NULL`) rows — the env-wide limb is load-bearing, ' - + 'because env-level writes are stamped org-less. `null` and absent are ' - + 'equivalent and both mean the env-wide rows only — the fail-closed ' - + 'direction: an unresolved organization reads env-wide rows, never every ' - + 'tenant\'s.', - ), limit: z.number().optional().describe( 'Maximum events to return, newest first. The implementation clamps to ' + '[1, 500] and defaults to 100 — out-of-range values are clamped, never ' @@ -1593,19 +1557,6 @@ export const AuditMetaItemResponseSchema = lazySchema(() => z.object({ export const HistoryMetaItemRequestSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name'), name: z.string().describe('Item name'), - organizationId: z.string().optional().describe( - 'Organization (tenant) partition the change log is read from. Absent ' - + 'means the env-wide partition (the implementation resolves the overlay ' - + 'repository as `organizationId ?? null`, keyed `org: \'env\'`). ' - + 'Declared because the implementation declares and reads it; plain ' - + '`string`, not nullable, because that is the implementation\'s ' - + 'parameter type — unlike the audit twin, whose door always sends ' - + '`ctx?.tenantId ?? null` and whose implementation declares ' - + '`string | null`. The REST history door currently sends no ' - + 'organization at all (whether it should is a tenant-scoping question ' - + 'measured separately for that door — declaring the member records ' - + 'the implementation contract, it does not answer that question).', - ), sinceSeq: z.number().optional().describe( 'Exclusive lower bound on `seq` for pagination: only events with ' + '`seq > sinceSeq` are returned. Absent means "from the beginning".', @@ -1780,9 +1731,6 @@ export const ListDraftsResponseSchema = lazySchema(() => z.object({ + 'locale map (`I18nLabel`), resolved by the reader. `null` when the body declares ' + 'none, or none this shape admits — never the item name standing in for it.', ), - organizationId: z.string().nullable().describe( - 'Owning organization of the draft row, `null` for an environment-wide draft.', - ), packageId: z.string().nullable().describe( 'Package the draft is bound to, `null` for a package-less draft.', ), @@ -1916,16 +1864,6 @@ export const GetMetaItemCachedRequestSchema = lazySchema(() => z.object({ + 'never returns a stale-locale 304 — metadata is translated *after* the ' + 'cache validator check (issue).', ), - organizationId: z.string().optional().describe( - 'Organization (tenant) scope for the read. When an org partition applies, ' - + 'this selects it in the ADR-0005 overlay read order — org overlay wins ' - + 'over env-wide overlay wins over packaged artifact — exactly as on the ' - + 'uncached read. Also ' - + 'folded into the ETag, so a scope switch never returns a stale 304 from ' - + 'another scope\'s cached representation. Supplying a value does not by ' - + 'itself guarantee an org partition is consulted; where none applies, and ' - + 'whenever it is absent, the read is environment-wide.', - ), })); /** diff --git a/packages/spec/src/migrations/entries/semantic/18.metadata-read-organization-scope-retired.ts b/packages/spec/src/migrations/entries/semantic/18.metadata-read-organization-scope-retired.ts new file mode 100644 index 00000000000..c01e0115a8e --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.metadata-read-organization-scope-retired.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0131 D6 (C5, stage S5) — the read half of the retirement whose write half +// `metadata-write-organization-scope-refused` records: every metadata read is +// environment → code. Registered because a read that named an organization +// served that organization's rows and now serves the environment's, because +// the `organizationId` key leaves six declared request shapes and the drafts +// listing, and because `overlayScope` loses its `org` value. +export const entry: SemanticMigration = { + id: 'metadata-read-organization-scope-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the organizationId member of the GetMetaItems, GetMetaItem, GetMetaItemLayered, AuditMetaItem, ' + + 'HistoryMetaItem and GetMetaItemCached request schemas and of each ListDraftsResponse draft of ' + + '@objectstack/spec, and the org value of GetMetaItemLayeredResponse overlayScope; and every ' + + 'metadata read of @objectstack/metadata-protocol that served a row stored organization-scoped: ' + + 'the item, list, layered, cached, history, diff, audit, drafts, commit timeline, lock, search ' + + 'and references reads, and the boot hydration — plus an environment overlay row of an item a ' + + 'managed package ships on a type sealed against overlays (any type whose registry entry admits ' + + 'no overlay: a managed flow, action, hook, object or datasource, for example), which the reads ' + + 'served over the package definition', + replacement: + 'drop `organizationId` from the read request: every read serves the environment row, else the ' + + 'code definition, to every caller, and `overlayScope` is `env` or null. A legacy row stored ' + + 'organization-scoped is served by no read; boot names each one, per type, and the v18 migration ' + + 'ceremony (ADR-0131 D10) carries it — nothing is deleted or rewritten before it. An environment ' + + 'overlay of sealed managed content is not served either, and boot names it: keep the change by ' + + 're-expressing it as a new item under a new name (a linkage-free clone), or delete the stored ' + + 'row. The anonymous public-form doors alone keep reading the Default Organization\'s legacy ' + + 'view rows, fail-closed, until the ceremony carries them', + reason: + 'ADR-0131 D6 retires the per-organization overlay axis: environment metadata belongs to the ' + + 'whole deployment. The doors stopped carrying an organization ' + + '(meta-doors-organization-scope-retired) and the protocol refuses every organization-scoped ' + + 'write (metadata-write-organization-scope-refused); a read that still served a legacy ' + + 'organization row would keep a retired layer live, one caller at a time. D6 also seals managed ' + + 'content: an overlay of a managed item of a sealed type sits in no regime D6 recognises, ' + + 'so the package definition wins at read as the write doors already make it win at write. ' + + 'Permission sets keep their own ruling on stored forks.', + acceptanceCriteria: + 'With a legacy organization-scoped view row and an environment row of the same name stored, ' + + 'getMetaItem, getMetaItems, getMetaItemLayered and getMetaItemCached serve the environment row ' + + 'whether or not the request names the organization, and the organization-only name answers ' + + 'nothing; historyMetaItem, auditMetaItem, listDrafts and listCommits show no legacy ' + + 'organization row; the ETag is one validator for every caller; overlayScope is env or null. A ' + + 'stored environment row of an action a managed package ships is not served — the package ' + + 'definition is. Boot logs metadata_org_scoped_unserved and metadata_sealed_overlay_unserved, ' + + 'per type. Remove organizationId from any typed read request literal: the key no longer ' + + 'type-checks.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 0a7af06e22a..0fb702a0497 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -16556,6 +16556,55 @@ const step18: MigrationStep = { + 'set stays exactly `DEFAULT_METADATA_TYPE_REGISTRY` plus item-population growth, ' + 'before and after.', }, + // ADR-0131 D6 (C5, stage S5) — the read half of the retirement whose write half + // `metadata-write-organization-scope-refused` records: every metadata read is + // environment → code. Registered because a read that named an organization + // served that organization's rows and now serves the environment's, because + // the `organizationId` key leaves six declared request shapes and the drafts + // listing, and because `overlayScope` loses its `org` value. + { + id: 'metadata-read-organization-scope-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the organizationId member of the GetMetaItems, GetMetaItem, GetMetaItemLayered, AuditMetaItem, ' + + 'HistoryMetaItem and GetMetaItemCached request schemas and of each ListDraftsResponse draft of ' + + '@objectstack/spec, and the org value of GetMetaItemLayeredResponse overlayScope; and every ' + + 'metadata read of @objectstack/metadata-protocol that served a row stored organization-scoped: ' + + 'the item, list, layered, cached, history, diff, audit, drafts, commit timeline, lock, search ' + + 'and references reads, and the boot hydration — plus an environment overlay row of an item a ' + + 'managed package ships on a type sealed against overlays (any type whose registry entry admits ' + + 'no overlay: a managed flow, action, hook, object or datasource, for example), which the reads ' + + 'served over the package definition', + replacement: + 'drop `organizationId` from the read request: every read serves the environment row, else the ' + + 'code definition, to every caller, and `overlayScope` is `env` or null. A legacy row stored ' + + 'organization-scoped is served by no read; boot names each one, per type, and the v18 migration ' + + 'ceremony (ADR-0131 D10) carries it — nothing is deleted or rewritten before it. An environment ' + + 'overlay of sealed managed content is not served either, and boot names it: keep the change by ' + + 're-expressing it as a new item under a new name (a linkage-free clone), or delete the stored ' + + 'row. The anonymous public-form doors alone keep reading the Default Organization\'s legacy ' + + 'view rows, fail-closed, until the ceremony carries them', + reason: + 'ADR-0131 D6 retires the per-organization overlay axis: environment metadata belongs to the ' + + 'whole deployment. The doors stopped carrying an organization ' + + '(meta-doors-organization-scope-retired) and the protocol refuses every organization-scoped ' + + 'write (metadata-write-organization-scope-refused); a read that still served a legacy ' + + 'organization row would keep a retired layer live, one caller at a time. D6 also seals managed ' + + 'content: an overlay of a managed item of a sealed type sits in no regime D6 recognises, ' + + 'so the package definition wins at read as the write doors already make it win at write. ' + + 'Permission sets keep their own ruling on stored forks.', + acceptanceCriteria: + 'With a legacy organization-scoped view row and an environment row of the same name stored, ' + + 'getMetaItem, getMetaItems, getMetaItemLayered and getMetaItemCached serve the environment row ' + + 'whether or not the request names the organization, and the organization-only name answers ' + + 'nothing; historyMetaItem, auditMetaItem, listDrafts and listCommits show no legacy ' + + 'organization row; the ETag is one validator for every caller; overlayScope is env or null. A ' + + 'stored environment row of an action a managed package ships is not served — the package ' + + 'definition is. Boot logs metadata_org_scoped_unserved and metadata_sealed_overlay_unserved, ' + + 'per type. Remove organizationId from any typed read request literal: the key no longer ' + + 'type-checks.', + }, // ADR-0131 D6 (C5, stage S4) — the protocol half of the door narrowing // `meta-doors-organization-scope-retired` records: the metadata protocol itself // refuses every organization-scoped write, and the per-organization write path diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 55e84d66a21..9164e60220e 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -111,11 +111,6 @@ "verb": "update", "pinned": 1 }, - { - "file": "packages/metadata-protocol/src/get-meta-item-cached-etag-scope.test.ts", - "verb": "findOne", - "pinned": 1 - }, { "file": "packages/metadata-protocol/src/migrations/recorded-by-sentinel.test.ts", "verb": "delete", @@ -1036,21 +1031,6 @@ "verb": "update", "pinned": 1 }, - { - "file": "packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit-live-registry.test.ts", - "verb": "delete", - "pinned": 1 - }, - { - "file": "packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit-live-registry.test.ts", - "verb": "findOne", - "pinned": 1 - }, - { - "file": "packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit-live-registry.test.ts", - "verb": "update", - "pinned": 1 - }, { "file": "packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts", "verb": "delete", @@ -1256,6 +1236,21 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.reads-environment-only.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.reads-environment-only.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.reads-environment-only.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.record-not-found.test.ts", "verb": "delete", diff --git a/scripts/objectql-double-limit.baseline.json b/scripts/objectql-double-limit.baseline.json index 58a61e505bb..4349aff2929 100644 --- a/scripts/objectql-double-limit.baseline.json +++ b/scripts/objectql-double-limit.baseline.json @@ -156,12 +156,6 @@ "packages/metadata-protocol/src/protocol.object-registry-write-through-spelling.test.ts": { "blind": 1 }, - "packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit-live-registry.test.ts": { - "blind": 1 - }, - "packages/metadata-protocol/src/protocol.org-scoped-cold-boot-audit.test.ts": { - "unjudged": 1 - }, "packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts": { "blind": 1 },