diff --git a/.changeset/15206-protocol-environment-only.md b/.changeset/15206-protocol-environment-only.md new file mode 100644 index 00000000000..34de9073ea6 --- /dev/null +++ b/.changeset/15206-protocol-environment-only.md @@ -0,0 +1,44 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/runtime': minor +'@objectstack/service-automation': minor +'@objectstack/spec': minor +--- + +feat(metadata-protocol,runtime,service-automation,spec)!: the metadata protocol refuses every organization-scoped write, and an uninstall is environment-wide (ADR-0131 D6/D12) + +Clause-②: yes (narrowing) + + + +**BREAKING, in both directions.** It narrows: an organization-scoped metadata write is refused, and request keys retire. It widens one refusal: a package uninstall naming neither `organizationId` nor `allTenants`, refused before with `400 TENANT_SCOPE_REQUIRED`, is now accepted and runs environment-wide. 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 `/meta` doors already carry no organization; now the metadata protocol itself refuses an organization-scoped write from every door, and the per-organization write path behind it is deleted. + +**What changes.** + +- Every protocol write that names an organization is refused with `403 NOT_OVERRIDABLE`, before anything is read or written, for every metadata type and every tenancy posture. This covers `saveMetaItem` (draft and publish), `publishMetaItem`, `deleteMetaItem`, `rollbackMetaItem`, `revertCommit`, `rollbackToPackageCommit`, `publishPackageDrafts`, `discardPackageDrafts`, `revertStoredPackage`, `duplicatePackage` and `reassignOrphanedMetadata`. The message's first sentence names the tenancy posture in force. The five types that declared `allowOrgOverride` (`view`, `dashboard`, `report`, `translation`, `email_template`) and the `OS_METADATA_WRITABLE` hatch no longer open an organization scope. +- The audit ledger (`sys_metadata_audit`) and the commit ledger (`sys_metadata_commit`) record `organization_id` NULL. +- The `/packages` doors of the runtime dispatcher thread no organization into any verb: publish-drafts, discard-drafts, the commit list, commit revert, rollback, revert, adopt-orphans, duplicate, delete, and the package manifest read. +- `deletePackage` retires its `organizationId` and `allTenants` request keys and both `400 TENANT_SCOPE_REQUIRED` refusals. The dispatcher's `DELETE /api/v1/packages/:id` no longer refuses an operator with no active organization. An uninstall removes every row bound to the package in this environment. Who may uninstall is the door's operator gate, as before. +- A seed published with a package no longer takes the publisher's active organization: a seed dataset names the organization it populates (ADR-0131 D9). + +**What moves for consumers.** + +| From | To | +|:--|:--| +| `organizationId` on a `SaveMetaItem` / `PublishMetaItem` / `DeleteMetaItem` request (`@objectstack/spec`) | drop it: the write lands environment-wide. The key is stripped at a spec parse (the schemas are not strict) and refused at the protocol: a request still naming one answers `403 NOT_OVERRIDABLE` | +| `organizationId` on any other protocol write verb's request | drop it, same refusal | +| `deletePackage({ packageId, organizationId })` or `deletePackage({ packageId, allTenants: true })` | `deletePackage({ packageId })`; either retired key answers `400 INVALID_REQUEST` and removes nothing | +| `DeletePackageRequest.organizationId` / `.allTenants` (`@objectstack/metadata-protocol`) | gone from the type | +| `UninstallCleanup`'s `organizationId` argument | gone; a cleanup receives `{ packageId, actor? }` | +| `TENANT_SCOPE_REQUIRED` in the error-code ledger | retired; no producer emits it | +| `findPlatformScheduleOrgGaps`' `organizationId` input | gone: every write is platform-level | +| a `seed` draft whose records carry no `organization_id`, relying on the publisher's active organization under `group` | set `organization_id` on each record (ADR-0131 D12, item 12); under `group` a seed record that names no organization is refused at load, and under `single` the loader still derives the Default Organization | + +**What a deployment observes.** + +- Rows stored organization-scoped before this release are not touched by any write. `POST /meta/_migrate-stored` reports each one as `skipped` and names the promotion ceremony (ADR-0131 C7); the stored-flow credential move reports such a flow as not moved (`NOT_OVERRIDABLE`) and logs that its credential is still in cleartext in that row — rotate it. A legacy organization-scoped draft is no longer promoted, discarded or reverted by a package verb, a commit recorded in a legacy organization layer is refused by `revertCommit`, and `duplicatePackage` and adopt-orphans copy or adopt the environment's rows only. +- An uninstall removes the package's legacy organization-scoped rows with it, as the declared cross-tenant uninstall did. + +**What does not change.** The protocol's reads still accept an organization and still serve legacy organization rows; that narrowing is a later stage. The anonymous form doors' read of the Default Organization's layer is unchanged. diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index 8c68fc8f0b0..6f4c2102a6a 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, and an organization-scoped write of a type with no per-organization channel. 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. 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). | --- diff --git a/content/docs/kernel/contracts/metadata-service.mdx b/content/docs/kernel/contracts/metadata-service.mdx index 9505a0325b0..200875c5892 100644 --- a/content/docs/kernel/contracts/metadata-service.mdx +++ b/content/docs/kernel/contracts/metadata-service.mdx @@ -497,6 +497,6 @@ pending draft is done via the `/meta/:type/:name/publish` route. | `POST` | `/api/v1/packages/publish` | Publish a package to the marketplace registry (body: `{ manifest, metadata }`) — the REST registrar's one route | | `GET` | `/api/v1/packages` | List the installed packages (the in-memory registry; published-but-not-installed artifacts are not listed) | | `GET` | `/api/v1/packages/:id` | Get an installed package — the bare row under `data`; a missing id answers `404 RESOURCE_NOT_FOUND`, message `Package 'ID' not found` | -| `DELETE` | `/api/v1/packages/:id` | Uninstall a package for the caller's organization (`?keepData=true` keeps the object tables) | +| `DELETE` | `/api/v1/packages/:id` | Uninstall a package environment-wide: every row bound to it in this environment (`?keepData=true` keeps the object tables) | | `POST` | `/api/v1/meta/:type/:name/publish` | Promote a metadata item's pending draft to live | | `POST` | `/api/v1/meta/:type/:name/rollback` | Restore a historical version as the live overlay | diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 0fe81e25063..66bcac5cb1d 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +322 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +321 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -347,7 +347,6 @@ const result = ApiErrorSchema.parse(data); * `SUGGESTION_NOT_FOUND` * `SUGGESTION_STATE` * `SUMMARY_RECOMPUTE_FAILED` -* `TENANT_SCOPE_REQUIRED` * `THROTTLED` * `UNAUTHORIZED` * `UNIQUE_SCOPE_CONFIRMATION_REQUIRED` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 4be28519b78..b0c50e4fea2 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -514,7 +514,6 @@ const result = ErrorCode.parse(data); * `SUGGESTION_NOT_FOUND` * `SUGGESTION_STATE` * `SUMMARY_RECOMPUTE_FAILED` -* `TENANT_SCOPE_REQUIRED` * `THROTTLED` * `UNAUTHORIZED` * `UNIQUE_SCOPE_CONFIRMATION_REQUIRED` diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index f05b0e279e8..247fb2bac5e 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -703,7 +703,6 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name | -| **organizationId** | `string` | optional | Organization (tenant) scope for the reset. Load-bearing, not advisory: it selects the ADR-0005 overlay partition, so it decides WHICH row the reset destroys — an org-scoped delete removes that tenant's own overlay, while an org-less delete reaches the environment-wide row and would blank the item for every tenant. Absent = environment-wide. | | **parentVersion** | `string` | optional | ADR-0008 optimistic-concurrency pin: the version token the caller believes is current (on the REST door, the `If-Match` request header). Present, a concurrent edit is reported as a 409 conflict instead of silently reset; absent = last-write-wins against the current row (Studio's "Reset" button is unpinned). | | **actor** | `string` | optional | Identity recorded on the delete's history tombstone row. On the REST door this is the request's authenticated identity (one producer) — never a caller-supplied header. Absent, the event is recorded actor-less (null), deliberately not attributed to "system". | | **state** | `Enum<'active' \| 'draft'>` | optional | Which lifecycle row to discard: `draft` discards the pending draft overlay only (the still-active overlay, if any, keeps serving); `active` or absent resets the live row. Absent defaults to `active`. | @@ -2299,7 +2298,6 @@ Installed package with runtime lifecycle state | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name — lowercase snake_case segments, optionally dot-qualified (`crm_lead`, `crm_lead.pipeline`). The promotion door enforces the same grammar as `saveMetaItem`. | -| **organizationId** | `string` | optional | Organization (tenant) scope for the promotion. The implementation resolves the draft through the org partition (ADR-0005), so a draft authored org-scoped must be published under the same scope or the lookup answers 404 `NO_DRAFT`. Absent = environment-wide. | | **actor** | `string` | optional | Identity recorded on the `op='publish'` history event. On the REST door this is the request's authenticated identity (one producer) — never a caller-supplied header. | | **message** | `string` | optional | Optional human-readable note recorded with the publish history event. | | **packageId** | `string \| null` | optional | ADR-0048 — the software package the draft being promoted was listed under, when the caller has one to state (`?package=` on the REST door). ⚠️ `null` is NOT the same as absent, and the difference is load-bearing: the implementation branches on the KEY BEING PRESENT, so an ABSENT key keeps the historical "match any package" resolution while `null` pins the lookup to the package-UNBOUND row. Spread it in conditionally; a present-and-`undefined` key coerces to `null` downstream and makes a package-bound draft unfindable — a silent `no_draft` on the untouched path. | @@ -2597,7 +2595,6 @@ Installed package with runtime lifecycle state | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name — lowercase snake_case segments, optionally dot-qualified (`crm_lead`, `crm_lead.pipeline`). Slash-compound names are refused at the publish door. | | **item** | `any` | ✅ | Metadata item definition | -| **organizationId** | `string` | optional | Organization (tenant) scope for the write. Load-bearing, not advisory: it selects the overlay partition (ADR-0005) the row lands in — an org-scoped save writes that tenant's own overlay, while an org-less save writes the environment-wide row every tenant reads — and it is the scope stamped on the write's audit row. An org-scoped write of a type whose registry entry declares `allowOrgOverride: false` is refused (403). Absent = environment-wide. | | **parentVersion** | `string \| null` | optional | ADR-0008 optimistic-concurrency pin: the version token the caller believes is current (on the REST door, the `If-Match` request header; the item read's `version` and a save receipt's `version` serve it). Present as a string, a concurrent edit is reported as a 409 conflict instead of silently overwritten. ⚠️ `null` is NOT the same as absent: a present `null` asserts "no current row of this lifecycle" — the first-write pin, refused 409 when a row already exists (on the REST door, `If-None-Match: *`; that header takes `*` alone and never beside `If-Match`, and any other spelling is refused 400) — while an ABSENT key is unpinned: the implementation adopts the current row's hash as the parent (last-write-wins). Nullable because that is the implementation's parameter type, and unlike the reset twin (which folds a present `null` back to the current hash) this verb passes `null` through to the repository's conflict check unchanged. | | **actor** | `string` | optional | Identity recorded on the write's history event (`recorded_by`, a lookup into `sys_user`) and audit row. On the REST door this is the request's authenticated identity (one producer) — never a caller-supplied header. Absent, the event is recorded actor-less (null), deliberately not attributed to "system". | | **force** | `boolean` | optional | Destructive-change acknowledgement (`?force=true` on the REST door): skips the safety diff that refuses an `object` save whose body drops fields or narrows types the stored item still carries (409 with the findings otherwise). Only `object` saves reach that diff, so the flag is inert for every other type. Absent = the guard runs. | diff --git a/content/docs/ui/public-data-collection.mdx b/content/docs/ui/public-data-collection.mdx index 2e9c776e914..f992e62b066 100644 --- a/content/docs/ui/public-data-collection.mdx +++ b/content/docs/ui/public-data-collection.mdx @@ -56,21 +56,17 @@ Set `sharingModel: 'private'` on the object so submissions are staff-scoped afte To stop taking submissions, keep the form's `publicLink` and set a switch to `false`: `enabled: false` or `allowAnonymous: false`. Both anonymous endpoints then answer `404 FORM_NOT_FOUND`, and nothing is created. -A withdrawal is a kill switch across metadata layers. If the environment-wide definition withdraws the form, an organization's copy of the same view cannot open it again: the endpoints keep answering not found, and an organization-scoped save or publish that would leave the form open is refused with `403 NOT_OVERRIDABLE`. To publish the form again, save it environment-wide with both switches on. An organization's copy can always withdraw the form for itself. +A withdrawal is a kill switch across metadata layers. The anonymous endpoints read the form view layered: the Default Organization's legacy copy of the view, if one exists, is preferred for the form's body, while a withdrawal in either layer closes the form, fail-closed. An organization copy is a legacy row stored before [ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6. No write can edit it now: every organization-scoped save or publish is refused with `403 NOT_OVERRIDABLE`. Its withdrawal still closes the form. The environment-wide definition is the one switch an author edits. An environment-wide withdrawal closes the form even beneath an open legacy copy. To publish the form again, save it environment-wide with both switches on; a form a legacy copy withdraws stays closed. The promotion ceremony (ADR-0131 C7) carries the legacy layer to the environment layer. -**What counts as a withdrawal.** Only an explicit `false` withdraws, on a sharing that keeps its `publicLink`. A switch that is simply absent is not a withdrawal. Removing the `sharing` block, clearing the `publicLink`, or deleting the view at one layer does not withdraw the form at the other layers. A form that only an organization publishes stays open there. +**What counts as a withdrawal.** Only an explicit `false` withdraws, on a sharing that keeps its `publicLink`. A switch that is simply absent is not a withdrawal. Removing the `sharing` block, clearing the `publicLink`, or deleting the view at one layer does not withdraw the form at the other layers. -**Which form a withdrawal closes.** Two checks apply the rule, and they match forms differently: +**Which form a withdrawal closes.** The anonymous endpoints judge each form by the name of the view item they serve. Beneath the Default Organization's legacy copy they read the environment-wide view list, and a form is closed when the environment-wide item of the same name explicitly withdraws a form in the same place (`form`, the same `formViews` entry, or the view's own `config`) or under the same public link. A different view is a different form: a different view that uses the same public link (for example, another app's "contact us" form) neither closes this one nor is closed by it. -- **Saving and publishing in an organization** judges the organization's copy against the stored environment-wide definition it overrides (the row its copy is keyed by, in each package that ships it). Inside that definition, a withdrawn form is the same form as the organization's when they share a place (`form`, the same `formViews` entry, or the view's own `config`) or a public link. A match on either is enough, so a renamed `formViews` key, a `form.name`, a move to another place, a renamed expanded item, and a new or re-cased slug all still count as the same form. A form that differs from every withdrawn form in both place and link is a different form, such as a sibling in the same view. -- **The anonymous endpoints** judge each form by the name of the view item they serve. Beneath the organization's read they read the environment-wide view list, and a form is closed when the environment-wide item of the same name explicitly withdraws a form in the same place or under the same link. -- **A different view is a different form.** A different view that uses the same public link (for example, another app's "contact us" form) neither closes this one nor is closed by it. +**Forms a package ships.** A package's form is part of the environment-wide definition, not a separate layer beneath it. A definition parsed by the stack schema (strict `defineStack`, the default) gets the schema's default `enabled: false`, so a shipped form that keeps its link without setting `enabled: true` counts as withdrawn. A definition loaded without that parse (`defineStack(..., { strict: false })` or a hand-built manifest) is judged as written: a switch it leaves out is absent, which is not a withdrawal, so set `enabled: false` explicitly to ship a form closed. The environment-wide definition is the administrator's switch: an environment-wide save may open a form that the package ships closed. -**Forms a package ships.** A package's form is part of the environment-wide definition, not a separate layer beneath it. A definition parsed by the stack schema (strict `defineStack`, the default) gets the schema's default `enabled: false`, so a shipped form that keeps its link without setting `enabled: true` counts as withdrawn and an organization's copy cannot open it. A definition loaded without that parse (`defineStack(..., { strict: false })` or a hand-built manifest) is judged as written: a switch it leaves out is absent, which is not a withdrawal, so set `enabled: false` explicitly to ship a form closed. The environment-wide definition is the administrator's switch: an environment-wide save may open a form that the package ships closed. +**Known limit: packages and names.** A withdrawal of a view name closes that name in every package. When two packages each ship a view of the same name, one package's withdrawal also closes the other package's form of that name, so this may close more than was meant. -**Known limit: packages and names.** A withdrawal of a view name closes that name in every package. When two packages each ship a view of the same name, one package's withdrawal also closes the other package's form of that name, so this may close more than was meant. The organization-scoped save check judges every package's environment-wide definition of the name. The endpoints do too. - -**Known limit.** The save check runs only when an organization's copy is saved or published. A copy that was already stored before the environment-wide withdrawal, or that a rollback or revert restores, is judged only by the endpoints, which match by served item name. If that copy keeps the form open under a different key or place than the environment-wide definition, the endpoints can still serve it. To close it, withdraw the form in that organization's copy too; the next organization-scoped save of a copy that keeps it open is refused. +**Known limit: legacy copies.** The endpoints match a legacy copy's form against the environment-wide definition by place and link. If a legacy copy keeps its form open under a different place and link than the environment-wide definition, an environment-wide withdrawal does not close it, and no write can edit the legacy copy, until the promotion ceremony carries it to the environment layer. ## Why diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index f72946075bb..0b7fd29022a 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -1119,6 +1119,9 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - **`metadata-plugin-additional-types-retired`** — `metadata plugin `config.additionalTypes` (on `MetadataPluginConfig`)` → nothing to re-declare — delete the key. There is no declared-kind channel: a kind enters the live metadata-type set as a side effect of registering an ITEM of that kind (`SchemaRegistry.registerItem` during app/manifest registration, or `MetadataManager.register` at runtime). Bind the kind's schema with `registerMetadataTypeSchema(type, schema)` from the plugin's `init(ctx)` so `GET /api/v1/meta` serves a real JSON Schema for it - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling of 2026-08-14: remove the key, jointly with refusing unknown types at the `/meta` boundary by the static registry. The key was declared, authorable, on the published authorable surface, and documented on four docs pages as THE way a plugin registers a custom metadata type — and read by NOTHING. The only production writer of the manager's type registry is `setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY)` (`packages/metadata/src/plugin.ts`), called exactly once outside tests, and it REPLACES the array outright; nothing ever merged `additionalTypes` into it. Measured against the real `MetadataManager`: declared count == live count (27 == 27), `getRegisteredTypes()` sorted equals the built-in registry sorted. So an author who followed the published instructions wrote the key, got no error, and nothing happened — the same silence trap as the plugin lifecycle's `onInstall` (a documented hook with no invocation site), one level down, in exactly the AI-authoring path (ADR-0033). The joint consequence: with this plugin-declared channel removed, the static registry is the total universe of legal metadata kinds, which makes refuse-by-static-registry at the /meta boundary safe by construction. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections. A metadata-plugin config is neither — `PLURAL_TO_SINGULAR` has no `plugins` entry, so it is not a stack collection member and a conversion would be a transform with no seam that ever runs (the `kernel/Manifest:loading` precedent). - Done when: No `MetadataPluginConfig` — inline in TypeScript or embedded at the manifest's `config` key — carries `additionalTypes`. TypeScript authors get the refusal at compile time (`additionalTypes` is typed `never`); a value reaching the parse is refused with the prescription (`invalid_type` at path `additionalTypes`). ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read the key, so removing it removes no behaviour — the live type set stays exactly `DEFAULT_METADATA_TYPE_REGISTRY` plus item-population growth, before and after. +- **`metadata-write-organization-scope-refused`** — `the organizationId member of the SaveMetaItem, PublishMetaItem and DeleteMetaItem request schemas of @objectstack/spec; and every organization-scoped write the metadata protocol of @objectstack/metadata-protocol accepted: saveMetaItem (draft and publish), publishMetaItem, deleteMetaItem, rollbackMetaItem, revertCommit, rollbackToPackageCommit, publishPackageDrafts, discardPackageDrafts, revertStoredPackage, duplicatePackage and reassignOrphanedMetadata — including the five types that declared allowOrgOverride (view, dashboard, report, translation, email_template) and the OS_METADATA_WRITABLE hatch; and the active organization of the caller publishing a package, which a published seed draft whose records named no organization was loaded into under the group posture` → drop `organizationId` from the request: every metadata write lands environment-wide (`organization_id` NULL), where every organization reads it. The key is stripped at a spec parse and refused at the protocol: a request that still names an organization is refused with 403 `NOT_OVERRIDABLE`, before anything is read or written, and the message names the tenancy posture in force. A seed draft sets `organization_id` on each record (ADR-0131 D12, item 12); under group a seed record that names none is refused at load + - Why not automatic: ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by Studio, by the cloud build agent or by an install belongs to the whole deployment. The /meta doors already carry no organization (meta-doors-organization-scope-retired); this is the protocol refusing the same write from every other door — the /packages verbs, the stored-row migrations, a plugin — so no path is left that stamps an organization on a metadata row. The audit and commit ledgers are environment-level too (ADR-0131 D7) and record no organization. Legacy organization-scoped rows are not touched: the stored-metadata migration reports them as skipped, the flow credential move reports them as not moved, and the promotion ceremony (ADR-0131 C7) carries them to the environment layer. + - Done when: A protocol write naming an organization — a saveMetaItem of a view with organizationId set, a publishPackageDrafts or a revertCommit with one — answers 403 NOT_OVERRIDABLE and writes nothing, for every metadata type and every tenancy posture; the same call without the key succeeds and stores organization_id NULL. POST /meta/_migrate-stored reports each organization-scoped row as skipped, naming the promotion ceremony, and re-saves none. A commit recorded in a legacy organization layer is refused by revertCommit with the same code, and duplicatePackage and reassignOrphanedMetadata copy or adopt the environment rows only. Remove organizationId from any typed SaveMetaItem / PublishMetaItem / DeleteMetaItem request literal: the key no longer type-checks. - **`migrations-entry-split`** — `The ADR-0087 migration chain and change manifest, imported from the package root @objectstack/spec: MIGRATIONS_BY_MAJOR, MIGRATION_MAJORS, MIGRATION_SUPPORT_FLOOR, RETIRED_KEYS_BY_MAJOR, RETIRED_DEFS_BY_MAJOR, applyMetaMigrations, composeMigrationChain, MigrationFloorError, composeSpecChanges, composeReleaseChanges, the seven change-manifest schemas (SpecChangesSchema, SpecConvertedSchema, SpecMigratedSchema, SpecSurfaceAddSchema, SpecSurfaceRemoveSchema, SpecReleaseChangesSchema, SpecReleaseSurfaceSchema), and the types MigrationStep, MigrationApplication, MigrationChainResult, MigrationHopResult, MigrationTodo, SemanticMigration, SpecChanges, SpecConverted, SpecMigrated, SpecSurfaceAdd, SpecSurfaceRemove, SpecReleaseChanges, SpecReleaseSurface, SurfaceDiff, ReleaseSurfaceDiff and PreviousReleaseRegistries` → the same names, unchanged, imported from `@objectstack/spec/migrations` — change the import path and nothing else. The chain, its steps and semantic entries, the retired-key and retired-def tables and the change-manifest schemas are the same objects, and `objectstack migrate meta` replays the same chain. The ADR-0087 conversion layer stays on the package root: `ALL_CONVERSIONS`, `CONVERSIONS_BY_MAJOR`, `applyConversions`, `applyConversionsToFlow`, `applyConversionsToStoredItem`, `collectConversionNotices`, the three `CONVERSION_*_CODE` constants and their types still import from `@objectstack/spec`. - Why not automatic: The maintainer ruled that the console first-screen size ceiling is raised now and paid back at the source; this split is that payback. The migration registry is mostly the guidance text `objectstack migrate meta` prints, and the package root re-exported it. The registry does work when its module loads (the list of majors and each step's rationale are computed then), so no bundler could prove it unused, and all of that text rode in every bundle of the root, whatever the consumer imported: 1,761,987 of the root ESM bundle's 3,766,221 bytes. With the chain on its own subpath the CommonJS root is 2,009,810 bytes instead of 3,780,033, and a browser bundle of the ten names the Studio console imports from the root drops from 700,884 to 301,287 bytes gzipped. The conversion layer does not move: `defineStack` and `normalizeStackInput` read it at run time, so moving its names would narrow the root and shrink it by under two kilobytes. The split moves an import path, which is TypeScript source rather than metadata — nothing authors, stores or parses it — so there is no source a D2 conversion could rewrite, and the move is recorded here. - Done when: No code imports any of these names from the package root `@objectstack/spec` — each such import is a TS2305 "has no exported member" error after upgrade, and at run time the binding is undefined. The same names import cleanly from `@objectstack/spec/migrations`. No metadata document, stored row or JSON Schema reference needs editing: the chain, its tables and the schemas did not change, and `objectstack migrate meta` rewrites the same documents it did before. @@ -1164,6 +1167,9 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - **`package-rollback-response-retired`** — `api.packageRollbackResponse (`PackageRollbackResponseSchema` in api/package-api.zod.ts — 1 def, 3 exported names: `PackageRollbackResponseSchema`, `PackageRollbackResponse`, `PackageRollbackResponseParsed` — plus the `PackageApiContracts.rollbackPackage` contract-map entry that bound it to `POST /api/v1/packages/:packageId/rollback`)` → `RollbackToPackageCommitResponseSchema` (api/package-lifecycle.zod.ts) — the transcription of what the live route actually answers: the dispatcher routes `POST /packages/:id/rollback` (body `{ commitId }`) to `rollbackToPackageCommit`, the ADR-0067 COMMIT rollback, whose declared return is `{ success, revertedCommits: string[], failed: Array<{ commitId, error }> }`. Consumers of the retired type were reading a VERSION-rollback shape (`restoredVersion`) the route has never answered; read `revertedCommits`/`failed` instead. `PackageRollbackRequestSchema` stays published (ruled out of the retirement), bound to no route. - Why not automatic: Maintainer ruling of 2026-08-27 on the client SDK's unbound response contracts, sub-question 3A: retire this false declaration first, then author the true one. The schema declared a version rollback — `{ success, restoredVersion?, message? }`, matching its file header "Rollback a package" — while the live path it was contract-bound to serves the ADR-0067 commit rollback: a different operation with a different result. Binding it in the SDK would compile and be false (the change that typed the SDK's un-annotated return values left a compile-time guard against exactly that substitution). Zero consumers measured across objectstack, objectui and cloud (the ruling's own survey, re-verified at the retiring PR's base): only its own unit test and that negative guard. A published declaration that outran the implementation is the hazard of response bodies never checked against the schemas that declare them, realised in the opposite direction — not "no declaration" but a WRONG one — and it is retired BEFORE the true schema is authored so no window exists in which both claims are published. - Done when: No code imports `PackageRollbackResponseSchema`, `PackageRollbackResponse` or `PackageRollbackResponseParsed` from `@objectstack/spec` or `@objectstack/spec/api` — every one is TS2305 after upgrade (pinned by runtime namespace probes in api/package-api.test.ts). `PackageApiContracts` carries no entry whose path is `/api/v1/packages/:packageId/rollback` (same pin). No metadata document needs editing: the schema was reachable from no metadata-type binding, stack collection or /meta door. ⚠️ Runtime behaviour is deliberately UNCHANGED: nothing ever registered routes or generated SDKs from the contract entry, and the route's handler emits the same bytes before and after — the retirement removes a false claim, not behaviour. +- **`package-uninstall-environment-wide`** — `the organizationId and allTenants members of the deletePackage request of @objectstack/metadata-protocol (DeletePackageRequest), the two TENANT_SCOPE_REQUIRED refusals of deletePackage, and the organization-scope refusal of DELETE /api/v1/packages/:id on the runtime dispatcher` → call `deletePackage({ packageId })` with neither key: the uninstall removes every row bound to the package in this environment. A request still carrying `organizationId` or `allTenants` is refused with 400 `INVALID_REQUEST` and removes nothing; drop the key and retry. Who may uninstall is the package door's operator gate + - Why not automatic: The guard existed because an uninstall naming no organization once matched every organization's rows, so a cross-tenant uninstall had to be declared (allTenants: true) and a scoped one named (organizationId). ADR-0131 D6 removes that premise: no metadata write lands organization-scoped any more, so a package's rows belong to the environment and an organization names nothing. The HTTP door never sent allTenants, so an operator with no active organization could not uninstall over HTTP at all. The keys are refused rather than ignored, because a caller still sending one believes it scopes the uninstall. Legacy organization-scoped rows bound to the package are removed with it, as the declared cross-tenant uninstall removed them, rather than stranded for the promotion ceremony. + - Done when: DELETE /api/v1/packages/:id by a manage_metadata caller with no active organization succeeds and removes every sys_metadata row bound to the package, environment-wide and legacy organization-scoped alike; the same call with an active organization behaves identically. A deletePackage request carrying organizationId or allTenants (true or false) answers 400 INVALID_REQUEST and changes nothing. No response carries TENANT_SCOPE_REQUIRED. Remove both keys from every deletePackage caller, and any deploy script that passed allTenants: true. - **`package-version-row-semver-2-0-0`** — `PackageVersionSchema.version (`marketplace/package-version.zod.ts`) — the `version` column of a `sys_package_version` row, and through `CreatePackageVersionRequestSchema.version`, which references it, the version a draft release is created with` → a SemVer 2.0.0 string matching `SEMVER_2_0_0_VERSION_PATTERN` (`kernel/version-grammar.ts`). Two changes, opposite in direction. ⭐ WIDER: suffix identifiers may now carry either ASCII case, because SemVer 2.0.0 is case-preserving — `1.0.0-Beta.1` and `1.0.0+Build.5` are accepted where this key used to demand lowercase, and the plugin boot path has always accepted them. ⛔ NARROWER: the forms the standard forbids are refused — `01.1.1` (§2), `1.0.0-0123` and `1.0.0-alpha..1` (§9), `1.0.0+.` (§10). - Why not automatic: This key's own docstring advertised `2.0.0-beta.1` as an example of itself while a sibling carrier of the same concept refused that exact string — the contradiction the canon card was filed over. The lowercase restriction was the narrowest published accept set of the four and had no standard behind it: it made a release row refuse a version the runtime that loads the release accepts, so a publisher could be turned away for a capitalisation the loader would never have noticed. Why the narrowing is a D3 semantic TODO rather than a mechanical rewrite: a published version row is immutable by contract — `manifestJson` and `checksum` freeze on transition to `published` — so a stored degenerate version is not edited in place at all. It is republished under a version that sorts, and whether the old row should be deprecated or left standing is a release decision the chain cannot make. - Done when: Publishing and installing every release you have works unchanged. The widening needs no action and can be confirmed cheaply: a mixed-case prerelease that used to be refused at publish now creates a draft. For the narrowing, list your `sys_package_version` rows and check each `version` against the grammar — a leading zero in a numeric segment, or a doubled or trailing dot in a suffix, are the only shapes affected. Any row that fails stays readable and installable; what it can no longer do is receive a NEW draft at that spelling, so cut the next release at a version that sorts. diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 7f0b160b747..d8106e63843 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -153,7 +153,7 @@ const ROUTE_BASE = '/api/v1/marketplace/install-local'; */ type UninstallCleanupRunner = { runUninstallCleanups?( - request: Pick, + request: Pick, ): Promise; }; diff --git a/packages/metadata-protocol/src/durable-package.test.ts b/packages/metadata-protocol/src/durable-package.test.ts index 3b8e5a29728..af28eb91926 100644 --- a/packages/metadata-protocol/src/durable-package.test.ts +++ b/packages/metadata-protocol/src/durable-package.test.ts @@ -124,7 +124,7 @@ describe('installPackage — durable persistence (#2532)', () => { describe('deletePackage — durable un-registration (#2532 counterpart)', () => { it('drops the sys_packages record so the package cannot resurrect at boot', async () => { const { impl, del } = makeImpl(); - await (impl as any).deletePackage({ packageId: 'com.example.orders', allTenants: true }); + await (impl as any).deletePackage({ packageId: 'com.example.orders' }); expect(del).toHaveBeenCalledWith('com.example.orders'); }); }); @@ -135,7 +135,7 @@ describe('deletePackage — uninstall cleanups (#2747)', () => { const cleanup = vi.fn(async () => ({ success: true, removed: 3 })); (impl as any).registerUninstallCleanup('security.package-permissions', cleanup); - const res: any = await (impl as any).deletePackage({ packageId: 'com.example.orders', allTenants: true, actor: 'usr_1' }); + const res: any = await (impl as any).deletePackage({ packageId: 'com.example.orders', actor: 'usr_1' }); expect(cleanup).toHaveBeenCalledWith(expect.objectContaining({ packageId: 'com.example.orders', actor: 'usr_1' })); expect(res.cleanups).toEqual([ @@ -160,7 +160,7 @@ describe('deletePackage — uninstall cleanups (#2747)', () => { // `protocol.driver-text-disclosure.test.ts`, so "reported as failed" and // "reported in the driver's words" cannot collapse into one another. (impl as any).registerUninstallCleanup('boom', async () => { throw new Error('db down'); }); - const res: any = await (impl as any).deletePackage({ packageId: 'com.example.orders', allTenants: true }); + const res: any = await (impl as any).deletePackage({ packageId: 'com.example.orders' }); expect(res.cleanups).toEqual([ { name: 'boom', success: false, removed: 0, error: 'cleanup failed' }, ]); @@ -176,7 +176,7 @@ describe('deletePackage — uninstall cleanups (#2747)', () => { const second = vi.fn(async () => ({ success: true, removed: 2 })); (impl as any).registerUninstallCleanup('x', first); (impl as any).registerUninstallCleanup('x', second); - const res: any = await (impl as any).deletePackage({ packageId: 'p', allTenants: true }); + const res: any = await (impl as any).deletePackage({ packageId: 'p' }); expect(first).not.toHaveBeenCalled(); expect(res.cleanups[0].removed).toBe(2); }); diff --git a/packages/metadata-protocol/src/protocol-publish-drafts-endpoint-gate.test.ts b/packages/metadata-protocol/src/protocol-publish-drafts-endpoint-gate.test.ts index 1450cb457c0..de4dc032600 100644 --- a/packages/metadata-protocol/src/protocol-publish-drafts-endpoint-gate.test.ts +++ b/packages/metadata-protocol/src/protocol-publish-drafts-endpoint-gate.test.ts @@ -307,10 +307,7 @@ describe('`api` runtime writes are refused at the inlet — the retired gate’s const { engine, rows } = makeStubEngine('showcase'); const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test'); - const err = await attemptApiWrite(protocol, 'list_things', validEndpoint(), { - mode: 'draft', - organizationId: 'org_1', - }); + const err = await attemptApiWrite(protocol, 'list_things', validEndpoint(), { mode: 'draft' }); expectCodeOnlyRefusal(err, 'NOT_CREATABLE'); expect(rows.size).toBe(0); diff --git a/packages/metadata-protocol/src/protocol-publish-drafts-org-scope.test.ts b/packages/metadata-protocol/src/protocol-publish-drafts-org-scope.test.ts index 67d22a65834..4f14f4a8908 100644 --- a/packages/metadata-protocol/src/protocol-publish-drafts-org-scope.test.ts +++ b/packages/metadata-protocol/src/protocol-publish-drafts-org-scope.test.ts @@ -30,9 +30,11 @@ import { ObjectStackProtocolImplementation } from './protocol.js'; * `organization_id = ` equality and 404'd (`no_draft`) on the * env-wide row it could never match. * - * The fix promotes each listed draft in the org scope it actually lives in - * (the scope `listDrafts` surfaced it from), so the pending-changes list and - * the publish path agree. + * The fix promoted each listed draft in the org scope it actually lived in. + * [ADR-0131 D6] Superseded: an organization-scoped publish request is now + * refused outright (403 NOT_OVERRIDABLE), and the publish lists and promotes + * ENVIRONMENT-WIDE drafts only — a legacy org-scoped draft is never promoted + * (it waits for the promotion ceremony, ADR-0131 C7). * * These tests exercise the REAL `listDrafts` + `promoteDraft` interaction * against a faithful multi-table stub engine (honours `$or` and @@ -237,13 +239,19 @@ describe('publishPackageDrafts — env-wide draft under a non-null active org (# mode: 'draft', }); - // 2. Studio "Publish" — the dispatcher resolved a non-null active org. - const res = await protocol.publishPackageDrafts({ + // 2. [ADR-0131 D6] A publish request carrying an organization is + // refused before anything is read — the draft stays a draft. + const refused: any = await protocol.publishPackageDrafts({ packageId: 'app.projects', organizationId: 'org_alpha', - }); + } as any).then(() => null, (e: unknown) => e); + expect({ code: refused?.code, status: refused?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(Array.from(rows.values()).filter((r) => r.state === 'draft')).toHaveLength(1); + + // 3. The environment-wide publish promotes the env-wide draft. + const res = await protocol.publishPackageDrafts({ packageId: 'app.projects' }); - // Before the fix this returned { success:false, failedCount:1, + // Before #3115's fix this returned { success:false, failedCount:1, // failed:[{ code:'NO_DRAFT' }] }. expect(res.failed).toEqual([]); expect(res).toMatchObject({ success: true, publishedCount: 1, failedCount: 0 }); @@ -258,39 +266,27 @@ describe('publishPackageDrafts — env-wide draft under a non-null active org (# expect(active[0].organization_id).toBeNull(); }); - it('still publishes an org-scoped draft under that same org (no regression)', async () => { + it('never promotes a legacy org-scoped draft — the publish lists env-wide drafts only (ADR-0131 D6)', async () => { const { engine, rows } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); - // A per-org overlay draft (organization_id = org_alpha). - // - // [#6190, 2026-08-09] Re-spelled from `object` to `view`. The org-scope - // resolution this case guards (#3115 — promote the draft in the scope - // `listDrafts` surfaced it from) is unchanged and is what is measured - // here; what changed is which TYPES may carry an org-scoped row at all. - // `object` is `allowOrgOverride: false`, so since the #6190 ruling its - // org-scoped draft cannot be written in the first place — a fixture - // that kept spelling it would have been pinning a write the platform - // refuses, i.e. nothing. `view` is `allowOrgOverride: true`: it HAS a - // per-org channel, its org rows ARE read back, and it therefore - // exercises the #3115 seam exactly as `object` used to. - await protocol.saveMetaItem({ + // A legacy per-org draft (organization_id = org_alpha), planted + // directly: no protocol write can create one any more. + await engine.insert('sys_metadata', { type: 'view', name: 'proj_task_grid', - item: viewBody('proj_task_grid'), - organizationId: 'org_alpha', - packageId: 'app.projects', - mode: 'draft', + organization_id: 'org_alpha', + package_id: 'app.projects', + state: 'draft', + metadata: JSON.stringify(viewBody('proj_task_grid')), }); - const res = await protocol.publishPackageDrafts({ - packageId: 'app.projects', - organizationId: 'org_alpha', - }); + const res = await protocol.publishPackageDrafts({ packageId: 'app.projects' }); - expect(res).toMatchObject({ success: true, publishedCount: 1, failedCount: 0 }); - const active = Array.from(rows.values()).filter((r) => r.state === 'active'); - expect(active).toHaveLength(1); - expect(active[0].organization_id).toBe('org_alpha'); + expect(res).toMatchObject({ publishedCount: 0, failedCount: 0 }); + expect(Array.from(rows.values()).filter((r) => r.state === 'active')).toHaveLength(0); + const draft = Array.from(rows.values()).filter((r) => r.state === 'draft'); + expect(draft).toHaveLength(1); + expect(draft[0].organization_id).toBe('org_alpha'); }); }); diff --git a/packages/metadata-protocol/src/protocol-publish-drafts-package-scope.test.ts b/packages/metadata-protocol/src/protocol-publish-drafts-package-scope.test.ts index 8ae5cd629f8..c4a98fb04c8 100644 --- a/packages/metadata-protocol/src/protocol-publish-drafts-package-scope.test.ts +++ b/packages/metadata-protocol/src/protocol-publish-drafts-package-scope.test.ts @@ -1,12 +1,12 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; // [#5619] The producer's OWN write-verb dispatch decisions (#4550 delete / // #5480 update), so the fake engine below cannot accept a call ObjectQL // refuses. Imported from `@objectstack/metadata-core` and not from // `@objectstack/objectql`: objectql DEPENDS ON this package, so that import // would close a dependency cycle turbo rejects outright. -import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, hashSpec, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; import { ObjectStackProtocolImplementation } from './protocol.js'; /** @@ -394,282 +394,3 @@ describe('publishMetaItem — the per-item door names a package too (#10350)', ( expect(labelOf(active[0])).toBe('FROM_OTHER'); }); }); - -/** - * [commit c74aefe63] The ORG-SCOPE probes' half of the same ADR-0048 key — maintainer - * ruling 2026-08-22, option A (recorded in that commit's message): the scope probes ask - * the promote's question, i.e. `resolveDraftOrgScopeForPublish` threads the - * stated `packageId` into BOTH of its `sys_metadata` probes. - * - * ## The defect these cases reproduce - * - * With two packages holding drafts for ONE `(type, name)` in DIFFERENT org - * scopes, a package-stating publish resolved the wrong scope: probe 1 was - * package-agnostic, matched the OTHER package's row in the caller's org, - * named that org as the scope — and the promote (whose `whereFor` IS - * package-exact since #8907/commit 490879ad0) then found nothing there and answered - * `404 [no_draft]` over a draft sitting env-wide, publishable, and named by - * the caller. - * - * Unlike the #8907 cases above, NO insertion-order rigging is needed for the - * wrong row to win: the two drafts live in different org partitions, so probe - * 1's `organization_id` filter alone selects the foreign package's row — the - * pre-fix failure is deterministic, not a driver-order coin toss. - * - * ## Why type `object` and the `OS_METADATA_WRITABLE` hatch - * - * The card's scenario is `(object, shared_ticket)`. `object` is - * `allowOrgOverride: false` in the static registry, so an org-scoped object - * draft exists only where the operator hatch (`OS_METADATA_WRITABLE=object` - * — the Studio-side editing escape, #6190 R7) is open; with the hatch closed - * the promote's own #6190 gate would answer `403 [not_overridable]` before - * the probes' answer mattered, and the card's measured `404 [no_draft]` - * could not be reproduced as filed. The hatch is scoped to this describe - * (`beforeAll`/`afterAll` + cache reset), the same pattern - * `protocol.org-scoped-write-refused.test.ts` R7 uses. - * - * ## Accepted cost, pinned on purpose - * - * The ruling's own words: a caller stating a package no longer discovers a - * no-package draft of the same `(type, name)` — it 404s and the caller - * retries without `?package=`; that narrowing is the ruling, not a side - * effect. The last case pins BOTH halves of that sentence. The package-less - * draft row is seeded by DIRECT `engine.insert`, not through - * `saveMetaItem(mode:'draft')` — PR #11139 is changing how a package-less - * draft save resolves its binding (inheriting the overlaid active row's - * `package_id`), so a fixture seeded through that save path would stop - * meaning "a package-less draft exists" the day it lands. - */ -describe('publishMetaItem — the scope probes ask the promote\'s question (#11003)', () => { - beforeAll(() => { - process.env.OS_METADATA_WRITABLE = 'object'; - ObjectStackProtocolImplementation.resetEnvWritableCache(); - }); - afterAll(() => { - delete process.env.OS_METADATA_WRITABLE; - ObjectStackProtocolImplementation.resetEnvWritableCache(); - }); - - /** - * The card's coexistence arrangement: `app.other` holds the caller's-org - * (`org1`) draft, `app.demo` holds the env-wide one. Distinct ADR-0048 - * rows — different `(org, package)` pairings, one `(type, name)`. - */ - async function seedCrossScopeDrafts(protocol: ObjectStackProtocolImplementation) { - await protocol.saveMetaItem({ - type: 'object', - name: 'shared_ticket', - item: objectBody('shared_ticket', 'FROM_OTHER_ORG1'), - packageId: 'app.other', - organizationId: 'org1', - mode: 'draft', - }); - await protocol.saveMetaItem({ - type: 'object', - name: 'shared_ticket', - item: objectBody('shared_ticket', 'FROM_DEMO_ENV'), - packageId: 'app.demo', - mode: 'draft', - }); - } - - it('finds the draft the caller NAMED: publishing app.demo succeeds over app.other\'s same-org row', async () => { - const { engine, rows } = makeStubEngine(); - const protocol = new ObjectStackProtocolImplementation(engine); - - await seedCrossScopeDrafts(protocol); - - // The ADR-0048 coexistence precondition, asserted so a future change - // to the seeding cannot silently drain these cases' discriminating - // power: two draft rows, the foreign package's in the CALLER'S org, - // the named package's env-wide. - const drafts = draftRowsOf(rows); - expect(drafts.map((r) => [r.package_id, r.organization_id])).toEqual([ - ['app.other', 'org1'], - ['app.demo', null], - ]); - - // Pre-fix this REJECTED with `404 [no_draft]`: probe 1, package- - // agnostic, matched app.other's org1 row and answered `org1`; the - // package-exact promote then looked in org1 WITH - // `package_id = 'app.demo'` and found nothing — while app.demo's - // draft sat env-wide, publishable, and was the row the caller named. - const res = await protocol.publishMetaItem({ - type: 'object', - name: 'shared_ticket', - packageId: 'app.demo', - organizationId: 'org1', - }); - - expect(res).toMatchObject({ success: true }); - const active = activeRowsOf(rows); - expect(active).toHaveLength(1); - expect(labelOf(active[0])).toBe('FROM_DEMO_ENV'); - }); - - it('lands env-wide: the caller\'s org row belongs to another package, and the promotion never touches that partition', async () => { - const { engine, rows } = makeStubEngine(); - const protocol = new ObjectStackProtocolImplementation(engine); - - await seedCrossScopeDrafts(protocol); - await protocol.publishMetaItem({ - type: 'object', - name: 'shared_ticket', - packageId: 'app.demo', - organizationId: 'org1', - }); - - // The resolution's landing, pinned row-by-row: the probe fell through - // to env-wide BECAUSE the caller's own org row belongs to another - // package, so the active row is ENV-WIDE under the named package — - // not an org1 row minted from a partition holding nothing of - // app.demo's. - const active = activeRowsOf(rows); - expect(active).toHaveLength(1); - expect(active[0].organization_id).toBeNull(); - expect(active[0].package_id).toBe('app.demo'); - // …and app.other's org1 draft is untouched — pending, undrained, in - // its own partition. Pre-fix there was nothing to assert here: the - // door had already refused. - const drafts = draftRowsOf(rows); - expect(drafts).toHaveLength(1); - expect(drafts[0].package_id).toBe('app.other'); - expect(drafts[0].organization_id).toBe('org1'); - expect(labelOf(drafts[0])).toBe('FROM_OTHER_ORG1'); - }); - - it('still resolves the caller\'s own org when THAT is where the named package\'s draft lives (no overshoot)', async () => { - const { engine, rows } = makeStubEngine(); - const protocol = new ObjectStackProtocolImplementation(engine); - - // Mirrored arrangement: app.other env-wide (seeded FIRST, so any - // regression back toward package-agnostic env probing has a wrong row - // to find), app.demo in the caller's org. - await protocol.saveMetaItem({ - type: 'object', - name: 'shared_ticket', - item: objectBody('shared_ticket', 'FROM_OTHER_ENV'), - packageId: 'app.other', - mode: 'draft', - }); - await protocol.saveMetaItem({ - type: 'object', - name: 'shared_ticket', - item: objectBody('shared_ticket', 'FROM_DEMO_ORG1'), - packageId: 'app.demo', - organizationId: 'org1', - mode: 'draft', - }); - - const res = await protocol.publishMetaItem({ - type: 'object', - name: 'shared_ticket', - packageId: 'app.demo', - organizationId: 'org1', - }); - - // GREEN BEFORE THE FIX TOO, and stated so nobody reads a repro into - // it: pre-fix probe 1 happened to answer `org1` because the only org1 - // row WAS app.demo's. What this case bounds is the fix itself — the - // ADR-0005 precedence (own org shadows env-wide) must survive the - // package narrowing, so a "package-exact means env-first/env-only" - // mis-fix fails here loudly. - expect(res).toMatchObject({ success: true }); - const active = activeRowsOf(rows); - expect(active).toHaveLength(1); - expect(active[0].organization_id).toBe('org1'); - expect(active[0].package_id).toBe('app.demo'); - expect(labelOf(active[0])).toBe('FROM_DEMO_ORG1'); - // app.other's env-wide draft: pending, undrained. - const drafts = draftRowsOf(rows); - expect(drafts).toHaveLength(1); - expect(drafts[0].package_id).toBe('app.other'); - expect(drafts[0].organization_id).toBeNull(); - }); - - it('keeps the historical match-any probes when the caller states NO package (cross-scope fixture)', async () => { - const { engine, rows } = makeStubEngine(); - const protocol = new ObjectStackProtocolImplementation(engine); - - await seedCrossScopeDrafts(protocol); - - // No `packageId` key at all: the probes stay package-agnostic, the - // promote matches any package, and the ADR-0005 precedence picks the - // caller's own org row — app.other's, whatever package it belongs to. - // This is the same absent-key contract the case above (commit 490879ad0) pins - // env-wide, exercised HERE because these probes only run for an - // org-scoped caller (`requestOrgId === null` returns early). - const res = await protocol.publishMetaItem({ - type: 'object', - name: 'shared_ticket', - organizationId: 'org1', - }); - - expect(res).toMatchObject({ success: true }); - const active = activeRowsOf(rows); - expect(active).toHaveLength(1); - expect(active[0].organization_id).toBe('org1'); - expect(active[0].package_id).toBe('app.other'); - expect(labelOf(active[0])).toBe('FROM_OTHER_ORG1'); - }); - - it('accepted cost (the ruling, not a side effect): a package-stating caller 404s over a package-less draft, and retrying without ?package= publishes it', async () => { - const { engine, rows } = makeStubEngine(); - const protocol = new ObjectStackProtocolImplementation(engine); - - // Seeded by DIRECT insert — see the describe header for why this row - // must not come from `saveMetaItem(mode:'draft')` while PR #11139 is - // changing that path's binding resolution. The shape mirrors what the - // repository's `put` writes for a package-less org draft — `checksum` - // included. (On this fixture's first run a checksum-less row made the - // post-promotion drain read as the benign "newer draft saved" race and - // survive; since #21978 the drain's lock accepts such a row's served - // version, and the stamp stays only so the row is the one `put` writes.) - const noPackageBody = objectBody('shared_ticket', 'NO_PACKAGE'); - await engine.insert('sys_metadata', { - type: 'object', - name: 'shared_ticket', - organization_id: 'org1', - package_id: null, - state: 'draft', - metadata: JSON.stringify(noPackageBody), - checksum: hashSpec(noPackageBody), - version: 1, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }); - - // Half 1 — the narrowing: the caller stated `app.demo`, so neither - // probe nor promote may discover the unbound row. ADR-0112 envelope, - // not a bare `toThrow`. (No pre-fix red here, stated plainly: the - // package-exact PROMOTE already answered `no_draft` for this - // arrangement; what this pins is that the ruling's cost sentence - // holds end-to-end and stays held.) - await expect( - protocol.publishMetaItem({ - type: 'object', - name: 'shared_ticket', - packageId: 'app.demo', - organizationId: 'org1', - }), - ).rejects.toMatchObject({ code: 'NO_DRAFT', status: 404 }); - // …and the refusal touched nothing: the package-less draft is intact. - expect(draftRowsOf(rows)).toHaveLength(1); - - // Half 2 — the documented remedy: retry WITHOUT `?package=`. The - // absent key restores the match-any resolution and the unbound draft - // publishes. - const res = await protocol.publishMetaItem({ - type: 'object', - name: 'shared_ticket', - organizationId: 'org1', - }); - expect(res).toMatchObject({ success: true }); - const active = activeRowsOf(rows); - expect(active).toHaveLength(1); - expect(active[0].package_id).toBeNull(); - expect(active[0].organization_id).toBe('org1'); - expect(labelOf(active[0])).toBe('NO_PACKAGE'); - expect(draftRowsOf(rows)).toHaveLength(0); - }); -}); diff --git a/packages/metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts b/packages/metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts index db9d0cdefce..c9f3c61b2cf 100644 --- a/packages/metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts +++ b/packages/metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts @@ -344,7 +344,7 @@ describe('#6483 — the nine ADR-0005 divergences: allowOrgOverride rolled back // ── the control that makes the red half mean something ──────────────── - it('view — still allowOrgOverride:true — is still accepted over a packaged artifact', async () => { + it('view is still accepted over a packaged artifact — env-wide (ADR-0131 D6 retired the org axis)', async () => { // Without this, the refusals above would also pass on a harness that // could not save ANYTHING over an artifact. const { protocol, rows } = makeProtocol([{ type: 'view', name: 'probe_view' }], 'env_prod'); @@ -353,7 +353,6 @@ describe('#6483 — the nine ADR-0005 divergences: allowOrgOverride rolled back type: 'view', name: 'probe_view', item: VIEW, - organizationId: 'org_alpha', }); expect(result.success).toBe(true); diff --git a/packages/metadata-protocol/src/protocol.batch-verb-driver-code.test.ts b/packages/metadata-protocol/src/protocol.batch-verb-driver-code.test.ts index 5020cbefdf4..41ec980db99 100644 --- a/packages/metadata-protocol/src/protocol.batch-verb-driver-code.test.ts +++ b/packages/metadata-protocol/src/protocol.batch-verb-driver-code.test.ts @@ -496,7 +496,7 @@ describe('[#8441] the delete-backed `failed[]` collectors are clean at their pro seed: [row({ type: 'view', name: 'acct_view' })], }); - const r = await protocol.deletePackage({ packageId: PKG, allTenants: true }); + const r = await protocol.deletePackage({ packageId: PKG }); expect(r.failed).toHaveLength(1); expect(r.failed[0].code).toBeUndefined(); diff --git a/packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts b/packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts index fc4a3d904d0..e6d23feb270 100644 --- a/packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts +++ b/packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts @@ -218,7 +218,6 @@ describe('the refusal a caller actually receives', () => { type: 'flow', name: 'nightly_sweep', state: 'active', - organizationId: null, orgWallEnforced: true, body: { name: 'nightly_sweep', diff --git a/packages/metadata-protocol/src/protocol.capability-write-door.test.ts b/packages/metadata-protocol/src/protocol.capability-write-door.test.ts index c540b6af36e..2a77d6796b3 100644 --- a/packages/metadata-protocol/src/protocol.capability-write-door.test.ts +++ b/packages/metadata-protocol/src/protocol.capability-write-door.test.ts @@ -203,7 +203,6 @@ describe('#5961 — capability: the runtime write door is closed, and validated type: 'capability', name: 'billing.refund', item: NOT_A_CAPABILITY, - organizationId: 'org_alpha', }), ).rejects.toMatchObject({ code: 'NOT_CREATABLE', status: 403 }); @@ -224,7 +223,6 @@ describe('#5961 — capability: the runtime write door is closed, and validated type: 'capability', name: 'billing.refund', item: CAPABILITY, - organizationId: 'org_alpha', }), ).rejects.toMatchObject({ code: 'NOT_CREATABLE', status: 403 }); expect(rows.size).toBe(0); diff --git a/packages/metadata-protocol/src/protocol.code-only-types.test.ts b/packages/metadata-protocol/src/protocol.code-only-types.test.ts index cbbbc013bf8..d17b86cfb53 100644 --- a/packages/metadata-protocol/src/protocol.code-only-types.test.ts +++ b/packages/metadata-protocol/src/protocol.code-only-types.test.ts @@ -319,6 +319,8 @@ describe('code-only metadata types are refused on every kernel (#5086)', () => { }); it(`refuses an org-scoped ${type} create too`, async () => { + // [ADR-0131 D6] Every organization-scoped write is refused + // first, before the code-only gate is consulted. const { protocol, rows } = makeProtocol(environmentId); const err = await protocol .saveMetaItem({ @@ -326,10 +328,11 @@ describe('code-only metadata types are refused on every kernel (#5086)', () => { name: probe.name, item: probe.item, organizationId: 'org_alpha', - }) + } as any) .then(() => null, (e: any) => e); - expect(err?.code).toBe('NOT_CREATABLE'); + expect(err?.code).toBe('NOT_OVERRIDABLE'); + expect(err?.status).toBe(403); expect(metaRows(rows)).toEqual([]); }); diff --git a/packages/metadata-protocol/src/protocol.driver-text-disclosure.test.ts b/packages/metadata-protocol/src/protocol.driver-text-disclosure.test.ts index 31f0a6e2943..36a535054f4 100644 --- a/packages/metadata-protocol/src/protocol.driver-text-disclosure.test.ts +++ b/packages/metadata-protocol/src/protocol.driver-text-disclosure.test.ts @@ -313,7 +313,7 @@ describe('[#8136] a driver failure on the uninstall overlay read is declared, no const { protocol } = makeKernel({ dbError: text, failOn: ['find'] }); const err = await captureThrow(() => - protocol.deletePackage({ packageId: 'com.acme.crm', allTenants: true })); + protocol.deletePackage({ packageId: 'com.acme.crm' })); // The POSITIVE shape. This is the file's existing contract for "a // `sys_metadata` read failed" (`metadataStoreUnavailableError`), @@ -329,7 +329,7 @@ describe('[#8136] a driver failure on the uninstall overlay read is declared, no const { protocol } = makeKernel({ dbError: text, failOn: ['find'] }); const err = await captureThrow(() => - protocol.deletePackage({ packageId: 'com.acme.crm', allTenants: true })); + protocol.deletePackage({ packageId: 'com.acme.crm' })); // The operator half of the contract: withheld from the caller, intact // for `handleRouteError` / `logWithheldServerFault`. Without this the @@ -345,7 +345,7 @@ describe('[#8136] a driver failure on the uninstall overlay read is declared, no // facts — on a destructive verb. const { protocol } = makeKernel({ dbError: DIALECTS[1]!.text, failOn: ['find'] }); const err = await captureThrow(() => - protocol.deletePackage({ packageId: 'com.acme.crm', allTenants: true })); + protocol.deletePackage({ packageId: 'com.acme.crm' })); expect(err).toBeInstanceOf(Error); expect((err as any).status).toBeGreaterThanOrEqual(500); }); @@ -487,7 +487,7 @@ describe('[#8136] the uninstall response body carries no driver text either', () seed: [seedRow(REPO_PATH_TYPE, 'acct_overlay', 'com.acme.crm')], }); - const result = await protocol.deletePackage({ packageId: 'com.acme.crm', allTenants: true }); + const result = await protocol.deletePackage({ packageId: 'com.acme.crm' }); expect(result.failedCount).toBe(1); expect(result.failed[0]?.name).toBe('acct_overlay'); @@ -505,7 +505,7 @@ describe('[#8136] the uninstall response body carries no driver text either', () throw new Error(text); }); - const result = await protocol.deletePackage({ packageId: 'com.acme.crm', allTenants: true }); + const result = await protocol.deletePackage({ packageId: 'com.acme.crm' }); const cleanup = result.cleanups.find((c: any) => c.name === 'security-grants'); expect(cleanup?.success).toBe(false); @@ -523,7 +523,7 @@ describe('[#8136] the uninstall response body carries no driver text either', () const { protocol } = makeKernel({ dbError: 'unused', failOn: [] }); protocol.registerUninstallCleanup('security-grants', async () => { throw refusal; }); - const result = await protocol.deletePackage({ packageId: 'com.acme.crm', allTenants: true }); + const result = await protocol.deletePackage({ packageId: 'com.acme.crm' }); const cleanup = result.cleanups.find((c: any) => c.name === 'security-grants'); expect(cleanup?.error).toContain('[grant_revocation_blocked]'); diff --git a/packages/metadata-protocol/src/protocol.duplicate-package-bundle-key.test.ts b/packages/metadata-protocol/src/protocol.duplicate-package-bundle-key.test.ts index 35ae2e3e1f9..3e707485a50 100644 --- a/packages/metadata-protocol/src/protocol.duplicate-package-bundle-key.test.ts +++ b/packages/metadata-protocol/src/protocol.duplicate-package-bundle-key.test.ts @@ -57,6 +57,16 @@ * goes red under the revert would mean this change altered a key it promised * not to. The precedence cases must also stay GREEN: they assert org-over-env * for the SAME member, which both keys agree on. Measured per arm in the PR. + * + * --------------------------------------------------------------------------- + * [ADR-0131 D6] What remains + * --------------------------------------------------------------------------- + * An organization-scoped duplicate is now refused (403 NOT_OVERRIDABLE), and + * the scan reads ENVIRONMENT rows only (`organization_id IS NULL`), so the two + * tiers no longer meet here and the cross-tier dedup above is gone with them. + * The cases that drove it are deleted; what this file still pins is that every + * environment bundle member is copied, a legacy organization-scoped row is + * not, and another package's row is never scanned. */ import { describe, expect, it, vi } from 'vitest'; // [#7774] The identity table's home is `@objectstack/metadata-core`, not the @@ -162,12 +172,13 @@ describe('[#7932] duplicatePackage keeps every i18n bundle member', () => { expect(Object.keys(ITEM_KEY_DISCRIMINATORS)).toEqual(['email_template']); }); - it('the dedup block does not run at all on the no-org door', async () => { - // Without `organizationId` there is no `$or`, no dedup, and every - // scanned row is copied verbatim. That door is deliberately left - // byte-identical by this change, exactly as #7819 tier 2 left it. + it('copies every environment bundle member, and never a legacy organization-scoped row', async () => { + // [ADR-0131 D6] The scan is `organization_id IS NULL`: both env-wide + // members are copied verbatim (no dedup collapses them), and the + // legacy org row waits for the promotion ceremony (ADR-0131 C7). const { protocol, saveMetaItem } = makeProtocol([ { type: 'email_template', name: 'auth.welcome', metadata: tpl('auth.welcome', 'en-US') }, + { type: 'email_template', name: 'auth.welcome', metadata: tpl('auth.welcome', 'ja-JP') }, { type: 'email_template', name: 'auth.welcome', organization_id: ORG, metadata: tpl('auth.welcome', 'zh-CN'), @@ -177,195 +188,26 @@ describe('[#7932] duplicatePackage keeps every i18n bundle member', () => { const res = await duplicate(protocol); expect(res).toMatchObject({ success: true, copiedCount: 2, failedCount: 0 }); - expect(written(saveMetaItem)).toEqual([ - 'email_template/auth.welcome@en-US→env', - 'email_template/auth.welcome@zh-CN→env', - ]); - }); - }); - - describe('the defect — two tiers customizing DIFFERENT members of one bundle', () => { - it('copies BOTH locales, each into the scope of the row it came from', async () => { - // ⭐ The case the card measured. Before the fix this answered - // `copiedCount: 1` — the org `zh-CN` row displaced the env-wide - // `en-US` one, and the duplicate shipped one locale of a two-locale - // customization while reporting success. - const { protocol, saveMetaItem } = makeProtocol([ - { type: 'email_template', name: 'auth.welcome', metadata: tpl('auth.welcome', 'en-US') }, - { - type: 'email_template', name: 'auth.welcome', organization_id: ORG, - metadata: tpl('auth.welcome', 'zh-CN'), - }, - ]); - - const res = await duplicate(protocol, ORG); - - expect(res).toMatchObject({ success: true, copiedCount: 2, failedCount: 0 }); - expect(written(saveMetaItem)).toEqual([ - 'email_template/auth.welcome@en-US→env', - `email_template/auth.welcome@zh-CN→${ORG}`, - ]); - }); - - it('keeps a three-member bundle split across the two tiers', async () => { - const { protocol, saveMetaItem } = makeProtocol([ - { type: 'email_template', name: 'auth.welcome', metadata: tpl('auth.welcome', 'en-US') }, - { type: 'email_template', name: 'auth.welcome', metadata: tpl('auth.welcome', 'ja-JP') }, - { - type: 'email_template', name: 'auth.welcome', organization_id: ORG, - metadata: tpl('auth.welcome', 'zh-CN'), - }, - ]); - - const res = await duplicate(protocol, ORG); - - expect(res).toMatchObject({ success: true, copiedCount: 3, failedCount: 0 }); expect(written(saveMetaItem)).toEqual([ 'email_template/auth.welcome@en-US→env', 'email_template/auth.welcome@ja-JP→env', - `email_template/auth.welcome@zh-CN→${ORG}`, ]); }); - it('a member with NO locale is the canonical member, not a fourth slot', async () => { - // `itemDiscriminator` keys a declared-nothing member as `canonical` - // (`en-US`), so the bundle-blind and bundle-aware answers agree for - // a single-member "bundle" — an env-wide row declaring no locale and - // an org row declaring `en-US` are the SAME member, and collapse. - const { protocol, saveMetaItem } = makeProtocol([ - { type: 'email_template', name: 'auth.welcome', metadata: tpl('auth.welcome', undefined) }, - { - type: 'email_template', name: 'auth.welcome', organization_id: ORG, - metadata: tpl('auth.welcome', 'en-US'), - }, - ]); - - const res = await duplicate(protocol, ORG); - - expect(res).toMatchObject({ success: true, copiedCount: 1, failedCount: 0 }); - expect(written(saveMetaItem)).toEqual([ - `email_template/auth.welcome@en-US→${ORG}`, - ]); - }); - }); - - describe('precedence — unchanged everywhere it was ever meaningful', () => { - it('the org row still overrides the env-wide row of the SAME member', async () => { - // ⭐ The guard that catches an over-split. A key change is trivially - // satisfiable by splitting keys that should merge; this is the case - // that refuses that shortcut. - const { protocol, saveMetaItem } = makeProtocol([ - { - type: 'email_template', name: 'auth.welcome', - metadata: tpl('auth.welcome', 'en-US', { label: 'ENV-WIDE' }), - }, - { - type: 'email_template', name: 'auth.welcome', organization_id: ORG, - metadata: tpl('auth.welcome', 'en-US', { label: 'ORG-OVERRIDE' }), - }, - ]); - - const res = await duplicate(protocol, ORG); - - expect(res).toMatchObject({ success: true, copiedCount: 1, failedCount: 0 }); - expect(labels(saveMetaItem)).toEqual(['ORG-OVERRIDE']); - }); - - it('overrides the matching member and leaves the others alone', async () => { - // Both halves in one fixture: `en-US` exists at both tiers and the - // org body wins it; `ja-JP` exists only env-wide and survives - // untouched; `zh-CN` exists only org-side and is carried over. + it('an organization-scoped duplicate is refused 403 NOT_OVERRIDABLE and copies nothing', async () => { const { protocol, saveMetaItem } = makeProtocol([ - { - type: 'email_template', name: 'auth.welcome', - metadata: tpl('auth.welcome', 'en-US', { label: 'ENV-WIDE en' }), - }, - { - type: 'email_template', name: 'auth.welcome', - metadata: tpl('auth.welcome', 'ja-JP', { label: 'ENV-WIDE ja' }), - }, - { - type: 'email_template', name: 'auth.welcome', organization_id: ORG, - metadata: tpl('auth.welcome', 'en-US', { label: 'ORG en' }), - }, - { - type: 'email_template', name: 'auth.welcome', organization_id: ORG, - metadata: tpl('auth.welcome', 'zh-CN', { label: 'ORG zh' }), - }, + { type: 'email_template', name: 'auth.welcome', metadata: tpl('auth.welcome', 'en-US') }, ]); - const res = await duplicate(protocol, ORG); + const err = await duplicate(protocol, ORG).then(() => null, (e: any) => e); - expect(res).toMatchObject({ success: true, copiedCount: 3, failedCount: 0 }); - expect(labels(saveMetaItem)).toEqual(['ENV-WIDE ja', 'ORG en', 'ORG zh']); - expect(written(saveMetaItem)).toEqual([ - `email_template/auth.welcome@en-US→${ORG}`, - 'email_template/auth.welcome@ja-JP→env', - `email_template/auth.welcome@zh-CN→${ORG}`, - ]); + expect(err?.code).toBe('NOT_OVERRIDABLE'); + expect(err?.status).toBe(403); + expect(written(saveMetaItem)).toEqual([]); }); }); - describe('byte-identical keys for every undiscriminated type', () => { - it('a same-name `page` at both tiers still dedups to ONE row, org winning', async () => { - // ⭐ `page` is absent from `ITEM_KEY_DISCRIMINATORS`, so - // `storedRowDiscriminator` returns `undefined` before any JSON work - // and the key is the exact two-component string it has always been. - // This case is what proves nothing was changed that was promised - // unchanged — it must stay GREEN under the reverse-verification. - const { protocol, saveMetaItem } = makeProtocol([ - { type: 'page', name: 'home', metadata: { name: 'home', label: 'ENV-WIDE' } }, - { - type: 'page', name: 'home', organization_id: ORG, - metadata: { name: 'home', label: 'ORG-OVERRIDE' }, - }, - ]); - - const res = await duplicate(protocol, ORG); - - expect(res).toMatchObject({ success: true, copiedCount: 1, failedCount: 0 }); - expect(labels(saveMetaItem)).toEqual(['ORG-OVERRIDE']); - }); - - it('a `page` carrying a locale-ish body key is STILL undiscriminated', async () => { - // The table is declared per type rather than duck-typed off a - // `locale` property precisely so that another type growing such a - // field is not silently re-keyed. Two `page/home` rows collapse to - // one even though their bodies disagree on `locale`. - const { protocol, saveMetaItem } = makeProtocol([ - { type: 'page', name: 'home', metadata: { name: 'home', locale: 'en-US', label: 'ENV-WIDE' } }, - { - type: 'page', name: 'home', organization_id: ORG, - metadata: { name: 'home', locale: 'zh-CN', label: 'ORG-OVERRIDE' }, - }, - ]); - - const res = await duplicate(protocol, ORG); - - expect(res).toMatchObject({ success: true, copiedCount: 1, failedCount: 0 }); - expect(labels(saveMetaItem)).toEqual(['ORG-OVERRIDE']); - }); - - it('two DIFFERENT types sharing one name never collapse', async () => { - // The key's FIRST component. ADR-0048's package dimension is carried - // at this site by the scan filter rather than by the key (every - // scanned row shares `package_id = sourcePackageId`), so `type` is - // the separating component the dedup itself owns. - const { protocol, saveMetaItem } = makeProtocol([ - { type: 'page', name: 'home', metadata: { name: 'home', label: 'PAGE env' } }, - { - type: 'page', name: 'home', organization_id: ORG, - metadata: { name: 'home', label: 'PAGE org' }, - }, - { type: 'dashboard', name: 'home', metadata: { name: 'home', label: 'DASHBOARD env' } }, - ]); - - const res = await duplicate(protocol, ORG); - - expect(res).toMatchObject({ success: true, copiedCount: 2, failedCount: 0 }); - expect(labels(saveMetaItem)).toEqual(['DASHBOARD env', 'PAGE org']); - }); - + describe('the package dimension', () => { it("ADR-0048 — another package's same-name row is never scanned", async () => { // The package dimension at this site: the scan is keyed on // `package_id = sourcePackageId`, so a second package shipping @@ -379,36 +221,10 @@ describe('[#7932] duplicatePackage keeps every i18n bundle member', () => { }, ]); - const res = await duplicate(protocol, ORG); + const res = await duplicate(protocol); expect(res).toMatchObject({ success: true, copiedCount: 1, failedCount: 0 }); expect(labels(saveMetaItem)).toEqual(['SOURCE PKG']); }); - - it('an email_template bundle and an undiscriminated type in ONE copy', async () => { - // The two behaviours composing in a single duplication: the bundle - // splits into its members, the `page` still collapses to one. - const { protocol, saveMetaItem } = makeProtocol([ - { type: 'email_template', name: 'auth.welcome', metadata: tpl('auth.welcome', 'en-US') }, - { - type: 'email_template', name: 'auth.welcome', organization_id: ORG, - metadata: tpl('auth.welcome', 'zh-CN'), - }, - { type: 'page', name: 'home', metadata: { name: 'home', label: 'ENV-WIDE' } }, - { - type: 'page', name: 'home', organization_id: ORG, - metadata: { name: 'home', label: 'ORG-OVERRIDE' }, - }, - ]); - - const res = await duplicate(protocol, ORG); - - expect(res).toMatchObject({ success: true, copiedCount: 3, failedCount: 0 }); - expect(written(saveMetaItem)).toEqual([ - 'email_template/auth.welcome@en-US→env', - `email_template/auth.welcome@zh-CN→${ORG}`, - `page/home@(none)→${ORG}`, - ]); - }); }); }); diff --git a/packages/metadata-protocol/src/protocol.flow-org-override-closed.test.ts b/packages/metadata-protocol/src/protocol.flow-org-override-closed.test.ts index c499d35dadb..aa82fc9cc3a 100644 --- a/packages/metadata-protocol/src/protocol.flow-org-override-closed.test.ts +++ b/packages/metadata-protocol/src/protocol.flow-org-override-closed.test.ts @@ -288,7 +288,7 @@ describe('#6283 — flow: allowOrgOverride rolled back to false', () => { // ── the control that makes the red half mean something ──────────────── - it('view — still allowOrgOverride:true — is still accepted over a packaged artifact', async () => { + it('view is still accepted over a packaged artifact — env-wide (ADR-0131 D6 retired the org axis)', async () => { // Without this, the two refusals above would also pass on a harness // that could not save ANYTHING. `view` is the type ADR-0005 whitelists // and #6283 does not touch. @@ -298,7 +298,6 @@ describe('#6283 — flow: allowOrgOverride rolled back to false', () => { type: 'view', name: 'overdue_grid', item: VIEW, - organizationId: 'org_alpha', }); expect(result.success).toBe(true); diff --git a/packages/metadata-protocol/src/protocol.lifecycle-audit-rows.test.ts b/packages/metadata-protocol/src/protocol.lifecycle-audit-rows.test.ts index 760ac65ae93..79f3f2a9fd7 100644 --- a/packages/metadata-protocol/src/protocol.lifecycle-audit-rows.test.ts +++ b/packages/metadata-protocol/src/protocol.lifecycle-audit-rows.test.ts @@ -272,8 +272,6 @@ const viewBody = (label: string) => ({ viewKind: 'list', }); -const ORG = 'org_alpha'; - /** Audit rows for one operation, in write order. */ const opRows = (h: Harness, operation: string) => h.auditRows.filter((a) => a.operation === operation); @@ -309,7 +307,7 @@ describe('[#7748] the audit trail records the whole lifecycle, not only `save`', expect(h.auditRows).toHaveLength(0); await protocol.saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: ORG, + type: 'view', name: 'case_grid', item: viewBody('v1'), actor: 'admin', } as any); @@ -317,7 +315,7 @@ describe('[#7748] the audit trail records the whole lifecycle, not only `save`', expect(opRows(h, 'save')[0]).toMatchObject({ type: 'view', name: 'case_grid', - organization_id: ORG, + organization_id: null, operation: 'save', outcome: 'allowed', code: 'ok', @@ -331,7 +329,7 @@ describe('[#7748] the audit trail records the whole lifecycle, not only `save`', const protocol = new ObjectStackProtocolImplementation(h.engine); await protocol.saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: ORG, + type: 'view', name: 'case_grid', item: viewBody('staged'), mode: 'draft', actor: 'admin', } as any); @@ -339,7 +337,7 @@ describe('[#7748] the audit trail records the whole lifecycle, not only `save`', expect(opRows(h, 'publish')).toHaveLength(0); const res = await protocol.publishMetaItem({ - type: 'view', name: 'case_grid', organizationId: ORG, actor: 'admin', + type: 'view', name: 'case_grid', actor: 'admin', } as any); expect((res as any).success).toBe(true); @@ -348,7 +346,7 @@ describe('[#7748] the audit trail records the whole lifecycle, not only `save`', expect(opRows(h, 'publish')[0]).toMatchObject({ type: 'view', name: 'case_grid', - organization_id: ORG, + organization_id: null, operation: 'publish', outcome: 'allowed', code: 'ok', @@ -363,15 +361,15 @@ describe('[#7748] the audit trail records the whole lifecycle, not only `save`', for (const label of ['v1', 'v2']) { await protocol.saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: ORG, + type: 'view', name: 'case_grid', item: viewBody(label), mode: 'draft', actor: 'admin', } as any); await protocol.publishMetaItem({ - type: 'view', name: 'case_grid', organizationId: ORG, actor: 'admin', + type: 'view', name: 'case_grid', actor: 'admin', } as any); } await protocol.rollbackMetaItem({ - type: 'view', name: 'case_grid', organizationId: ORG, + type: 'view', name: 'case_grid', toVersion: 1, actor: 'admin', } as any); @@ -390,18 +388,18 @@ describe('[#7748] the audit trail records the whole lifecycle, not only `save`', const protocol = new ObjectStackProtocolImplementation(h.engine); await protocol.saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: ORG, + type: 'view', name: 'case_grid', item: viewBody('v1'), actor: 'admin', } as any); await protocol.saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: ORG, + type: 'view', name: 'case_grid', item: viewBody('v2'), actor: 'admin', } as any); expect(opRows(h, 'rollback')).toHaveLength(0); const res = await protocol.rollbackMetaItem({ - type: 'view', name: 'case_grid', organizationId: ORG, + type: 'view', name: 'case_grid', toVersion: 1, actor: 'admin', } as any); expect((res as any).success).toBe(true); @@ -429,13 +427,13 @@ describe('[#7748] the audit trail records the whole lifecycle, not only `save`', const protocol = new ObjectStackProtocolImplementation(h.engine); await protocol.saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: ORG, + type: 'view', name: 'case_grid', item: viewBody('head'), actor: 'admin', } as any); const auditedBefore = h.auditRows.length; const caught = await rejection(() => protocol.saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: ORG, + type: 'view', name: 'case_grid', item: viewBody('should not land'), parentVersion: 'sha256:stale', actor: 'admin', } as any)); @@ -471,11 +469,11 @@ describe('[#7748] the audit trail records the whole lifecycle, not only `save`', const protocol = new ObjectStackProtocolImplementation(h.engine); await protocol.saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: ORG, + type: 'view', name: 'case_grid', item: viewBody('staged'), mode: 'draft', actor: 'admin', } as any); await protocol.publishMetaItem({ - type: 'view', name: 'case_grid', organizationId: ORG, actor: 'admin', + type: 'view', name: 'case_grid', actor: 'admin', } as any); const { events } = await protocol.auditMetaItem({ type: 'view', name: 'case_grid' }); @@ -497,11 +495,11 @@ describe('[#7748] the audit trail records the whole lifecycle, not only `save`', const protocol = new ObjectStackProtocolImplementation(h.engine); await protocol.saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: ORG, + type: 'view', name: 'case_grid', item: viewBody('staged'), mode: 'draft', actor: 'admin', } as any); const res = await protocol.publishMetaItem({ - type: 'view', name: 'case_grid', organizationId: ORG, actor: 'admin', + type: 'view', name: 'case_grid', actor: 'admin', } as any); // The publish still succeeds — best-effort, by contract. 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 4bb054c1623..d8edadf3546 100644 --- a/packages/metadata-protocol/src/protocol.lock-one-resolution.test.ts +++ b/packages/metadata-protocol/src/protocol.lock-one-resolution.test.ts @@ -590,10 +590,12 @@ const settle = (run: Promise) => run.then(() => null, (e: unknown) => e type Verdict = { refused: { code: unknown; status: unknown } } | 'admitted'; const ITEM_LOCKED: Verdict = { refused: { code: 'ITEM_LOCKED', status: 403 } }; +/** [ADR-0131 D6] Every organization-scoped write is refused first, before the `_lock` gate is reached. */ +const NOT_OVERRIDABLE: Verdict = { refused: { code: 'NOT_OVERRIDABLE', status: 403 } }; /** * The door, end to end. Refused ⇔ the ADR-0112 `ITEM_LOCKED` / 403 envelope - * came back. Admitted ⇔ the ADR-0010 `_lock` gate was reached and answered no + * came back (or, for an organization-scoped request, `NOT_OVERRIDABLE` / 403). Admitted ⇔ the ADR-0010 `_lock` gate was reached and answered no * refusal; whatever the write does after that is not a lock verdict. */ async function door( @@ -610,7 +612,7 @@ async function door( type, name, item: { name, label: name, object: 'account' }, ...scope, ...(packageId ? { packageId } : {}), }) : protocol.deleteMetaItem({ type, name, ...scope })); - if (outcome instanceof Error && (outcome as any).code === 'ITEM_LOCKED') { + if (outcome instanceof Error && ['ITEM_LOCKED', 'NOT_OVERRIDABLE'].includes((outcome as any).code)) { return { refused: { code: (outcome as any).code, status: (outcome as any).status } }; } expect(gate, `${type}/${name} ${operation}: not refused, yet the _lock gate was never reached`).toHaveBeenCalledTimes(1); @@ -770,7 +772,10 @@ describe('[#21738, #21803] pin 1 — the family\'s enumeration, generated from t const doorAllows = row.operation === 'save' ? evaluateLockForWrite(expectedLock(row, 'door')) === null : evaluateLockForDelete(expectedLock(row, 'door')) === null; - expect(verdict, `${at}: the door`).toEqual(doorAllows ? 'admitted' : ITEM_LOCKED); + // [ADR-0131 D6] An organization-scoped request never reaches + // the `_lock` gate: every org-scoped write is refused first. + expect(verdict, `${at}: the door`) + .toEqual(row.requestScope ? NOT_OVERRIDABLE : doorAllows ? 'admitted' : ITEM_LOCKED); // …and the two agree: the door admits exactly when the envelope // says it may. Except on the one declared difference // (`overlayLockLayerAt`'s `otherSpelling`): a row stored under @@ -779,7 +784,7 @@ describe('[#21738, #21803] pin 1 — the family\'s enumeration, generated from t // row — so those rows flip, by name, the day the reads' // fallback retires. const residue = expectedLock(row, 'reads') !== expectedLock(row, 'door'); - if (!residue) { + if (!residue && !row.requestScope) { const readAllows = row.operation === 'save' ? read.editable : read.deletable; expect(verdict, `${at}: the door and the read envelope (lock ${read.lock}) disagree`) .toEqual(readAllows ? 'admitted' : ITEM_LOCKED); @@ -1076,7 +1081,7 @@ 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 scope, for the reads and the door alike', async () => { + it('an organization holding only another package\'s row: its rows are the reads\' 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), @@ -1087,8 +1092,9 @@ describe('[#21761] pin 7 — a third package\'s row is in scope: no write the ga // 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'); - expect(await door(protocol, 'view', 'save', ORG, 'v_org', PACKAGE_ID)).toEqual(ITEM_LOCKED); - expect(await door(protocol, 'view', 'delete', ORG, 'v_org')).toEqual(ITEM_LOCKED); + // [ADR-0131 D6] The organization's writes are refused before the gate. + expect(await door(protocol, 'view', 'save', ORG, 'v_org', PACKAGE_ID)).toEqual(NOT_OVERRIDABLE); + expect(await door(protocol, 'view', 'delete', ORG, 'v_org')).toEqual(NOT_OVERRIDABLE); }); it('the list item and the directory tile report the same lock as the item\'s envelope', async () => { @@ -1226,9 +1232,9 @@ describe('[#21803] pin 11 — the folded position: a body served from outside th expect(Object.keys(read.layered.effective ?? {}).filter((k) => k.startsWith('_lock'))).toEqual([]); // The layered read still reports the stored layer as stored. expect(read.layered.overlay?._lock).toBe('full'); - // The door agrees with the envelope. - expect(await door(protocol, 'view', 'save', ORG, 'v_scope', PACKAGE_ID)).toBe('admitted'); - expect(await door(protocol, 'view', 'delete', ORG, 'v_scope')).toBe('admitted'); + // [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 b640b9c1a0f..9787fbc9c44 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 @@ -32,6 +32,10 @@ * * It sits above PR #21693's and PR #21715's per-case pins and replaces neither. * + * [ADR-0131 D6] Every organization-scoped write is now refused first + * (`NOT_OVERRIDABLE` / 403), before the `_lock` gate: the door's half of the + * agreement is pinned on requests naming no organization. + * * Every row here is stored under the canonical type spelling — every row a * live write can mint. The reads' at-rest tolerance for the other spelling is * the one declared difference from the gate (`findServedOverlayRow`'s @@ -131,10 +135,12 @@ const settle = (run: Promise) => run.then(() => null, (e: unknown) => e type Verdict = { refused: { code: unknown; status: unknown } } | 'admitted'; const ITEM_LOCKED: Verdict = { refused: { code: 'ITEM_LOCKED', status: 403 } }; +/** [ADR-0131 D6] Every organization-scoped write is refused first, before the `_lock` gate is reached. */ +const NOT_OVERRIDABLE: Verdict = { refused: { code: 'NOT_OVERRIDABLE', status: 403 } }; /** * The door, end to end. Refused ⇔ the ADR-0112 `ITEM_LOCKED` / 403 envelope - * came back. Admitted ⇔ the ADR-0010 `_lock` gate was reached and answered no + * came back (or, for an organization-scoped request, `NOT_OVERRIDABLE` / 403). Admitted ⇔ the ADR-0010 `_lock` gate was reached and answered no * refusal; whatever the write does after that (validation, a double that * persists nothing) is not a lock verdict and is not read as one. */ @@ -148,7 +154,7 @@ async function door( const outcome: any = await settle(operation === 'save' ? protocol.saveMetaItem({ type, name, item: { name, label: name, object: 'account' }, ...scope }) : protocol.deleteMetaItem({ type, name, ...scope })); - if (outcome instanceof Error && (outcome as any).code === 'ITEM_LOCKED') { + if (outcome instanceof Error && ['ITEM_LOCKED', 'NOT_OVERRIDABLE'].includes((outcome as any).code)) { return { refused: { code: (outcome as any).code, status: (outcome as any).status } }; } expect(gate, `${type}/${name} ${operation}: not refused, yet the _lock gate was never reached`).toHaveBeenCalledTimes(1); @@ -183,35 +189,29 @@ const OPERATIONS = ['save', 'delete'] as const; describe('[#21716] pin 2 — an env-wide _lock: full row binds an organization with no row of its own', () => { for (const { kernel, environmentId } of TOPOLOGIES) { - it(`${kernel} kernel: the org-scoped read says locked, and save / delete are refused ITEM_LOCKED (403)`, async () => { + it(`${kernel} kernel: the org-scoped read says locked, and save / delete are refused NOT_OVERRIDABLE (403) before any read`, async () => { const { protocol, inserted } = harness(environmentId, [viewRow('v_env_full', null, 'full')]); expect(await envelope(protocol, 'v_env_full', ORG)).toMatchObject({ lock: 'full', editable: false, deletable: false, served: 'env-wide row', }); for (const operation of OPERATIONS) { const err: any = await settle(operation === 'save' - ? protocol.saveMetaItem({ type: 'view', name: 'v_env_full', item: { name: 'v_env_full', label: 'x', object: 'account' }, organizationId: ORG }) - : protocol.deleteMetaItem({ type: 'view', name: 'v_env_full', organizationId: ORG })); + ? protocol.saveMetaItem({ type: 'view', name: 'v_env_full', item: { name: 'v_env_full', label: 'x', object: 'account' }, organizationId: ORG } as any) + : protocol.deleteMetaItem({ type: 'view', name: 'v_env_full', organizationId: ORG } as any)); expect(err, operation).toBeInstanceOf(Error); - expect({ code: err.code, status: err.status, lock: err.lock }, operation) - .toEqual({ code: 'ITEM_LOCKED', status: 403, lock: 'full' }); + expect({ code: err.code, status: err.status }, operation) + .toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); } - // The denial is recorded against the organization that asked, as the - // ADR-0010 §3.6 trail records every refused write. - const denials = inserted.filter((r) => r.table === 'sys_metadata_audit').map((r) => r.values); - expect(denials.map((d) => ({ operation: d.operation, outcome: d.outcome, organization_id: d.organization_id, lock_state: d.lock_state }))) - .toEqual([ - { operation: 'save', outcome: 'denied', organization_id: ORG, lock_state: 'full' }, - { operation: 'delete', outcome: 'denied', organization_id: ORG, lock_state: 'full' }, - ]); + // [ADR-0131 D6] Refused before the `_lock` gate: nothing is written. + expect(inserted).toEqual([]); }); it(`${kernel} kernel: an org-scoped publish and rollback are refused the same way (the shared write gate)`, async () => { const { protocol } = harness(environmentId, [viewRow('v_env_full', null, 'full')]); - const published: any = await settle(protocol.publishMetaItem({ type: 'view', name: 'v_env_full', organizationId: ORG })); - expect({ code: published?.code, status: published?.status }).toEqual({ code: 'ITEM_LOCKED', status: 403 }); - const rolledBack: any = await settle(protocol.rollbackMetaItem({ type: 'view', name: 'v_env_full', toVersion: 1, organizationId: ORG })); - expect({ code: rolledBack?.code, status: rolledBack?.status }).toEqual({ code: 'ITEM_LOCKED', status: 403 }); + const published: any = await settle(protocol.publishMetaItem({ type: 'view', name: 'v_env_full', organizationId: ORG } as any)); + expect({ code: published?.code, status: published?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + const rolledBack: any = await settle(protocol.rollbackMetaItem({ type: 'view', name: 'v_env_full', toVersion: 1, organizationId: ORG } as any)); + expect({ code: rolledBack?.code, status: rolledBack?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); }); } }); @@ -237,11 +237,12 @@ describe('[#21716] pin 3 — both rows present: the door binds the lock of the r served: q.organizationId ? 'org row' : 'env-wide row', overlayScope: q.organizationId ? 'org' : 'env', }); - // …and that row's lock is the one the doors enforce. + // …and that row's lock is the one the doors enforce. [ADR-0131 D6] + // An org-scoped write is refused before the `_lock` gate. expect(await door(protocol, 'v_both', 'save', q.organizationId)) - .toEqual(read.editable ? 'admitted' : ITEM_LOCKED); + .toEqual(q.organizationId ? NOT_OVERRIDABLE : read.editable ? 'admitted' : ITEM_LOCKED); expect(await door(protocol, 'v_both', 'delete', q.organizationId)) - .toEqual(read.deletable ? 'admitted' : ITEM_LOCKED); + .toEqual(q.organizationId ? NOT_OVERRIDABLE : read.deletable ? 'admitted' : ITEM_LOCKED); }); } } @@ -252,9 +253,8 @@ describe('[#21716] pin 4 — a type with no per-org channel: neither the read no // `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 - // row; the door asks the same gate, so it binds that row's `_lock` too. A - // save is refused earlier, by the org-scope door (`NOT_OVERRIDABLE`), so - // the removal is the verb that reaches the `_lock` gate with an organization. + // row. [ADR-0131 D6] Every org-scoped write — the removal included — is + // refused by the org-scope door (`NOT_OVERRIDABLE`) before the `_lock` gate. const cases: Array<{ env: Lock; org: Lock }> = [ { env: 'full', org: 'none' }, { env: 'none', org: 'full' }, @@ -266,8 +266,7 @@ describe('[#21716] pin 4 — a type with no per-org channel: neither the read no const { protocol } = harness(environmentId, rows); const read = await envelope(protocol, 'p_both', ORG, 'page'); expect(read).toMatchObject({ lock: c.env, served: 'env-wide row', overlayScope: 'env' }); - expect(await door(protocol, 'p_both', 'delete', ORG, 'page')) - .toEqual(read.deletable ? 'admitted' : ITEM_LOCKED); + expect(await door(protocol, 'p_both', 'delete', ORG, 'page')).toEqual(NOT_OVERRIDABLE); }); } } diff --git a/packages/metadata-protocol/src/protocol.marked-refusal-classification.test.ts b/packages/metadata-protocol/src/protocol.marked-refusal-classification.test.ts index 062fa7ef03c..5f5b1d7ba2a 100644 --- a/packages/metadata-protocol/src/protocol.marked-refusal-classification.test.ts +++ b/packages/metadata-protocol/src/protocol.marked-refusal-classification.test.ts @@ -422,7 +422,7 @@ describe('[#12536 §3] `deletePackage` reports a marked refusal as a refusal on it('carries the mark onto `failed[]`, where no HTTP boundary could put it', async () => { const p = new ObjectStackProtocolImplementation(uninstallEngine(() => markedRefusal())) as any; - const res = await p.deletePackage({ packageId: 'com.acme.crm', allTenants: true }); + const res = await p.deletePackage({ packageId: 'com.acme.crm' }); expect(res.failedCount).toBe(1); expect(declaredUserMessage(res.failed[0])).toBe(AUTHOR_TEXT); @@ -434,7 +434,7 @@ describe('[#12536 §3] `deletePackage` reports a marked refusal as a refusal on it('leaves the row unmarked — and unleaked — for a genuine driver fault', async () => { const p = new ObjectStackProtocolImplementation(uninstallEngine(driverFault)) as any; - const res = await p.deletePackage({ packageId: 'com.acme.crm', allTenants: true }); + const res = await p.deletePackage({ packageId: 'com.acme.crm' }); expect(res.failedCount).toBe(1); expect(res.failed[0].userMessage).toBeUndefined(); @@ -447,13 +447,13 @@ describe('[#12536 §3] `deletePackage` reports a marked refusal as a refusal on it('classifies the uninstall\'s own overlay READ the same way', async () => { const marked = new ObjectStackProtocolImplementation(unreadableEngine(() => markedRefusal())) as any; const caughtMarked = await captureThrow( - () => marked.deletePackage({ packageId: 'com.acme.crm', allTenants: true })); + () => marked.deletePackage({ packageId: 'com.acme.crm' })); expect(declaredUserMessage(caughtMarked)).toBe(AUTHOR_TEXT); expect(caughtMarked.status).not.toBe(503); const fault = new ObjectStackProtocolImplementation(unreadableEngine(driverFault)) as any; const caughtFault = await captureThrow( - () => fault.deletePackage({ packageId: 'com.acme.crm', allTenants: true })); + () => fault.deletePackage({ packageId: 'com.acme.crm' })); expect(caughtFault.status).toBe(503); expect(caughtFault.message).toBe(STORE_UNAVAILABLE_MESSAGE); expect(outsideTheDoor(caughtFault)).not.toContain(DRIVER_SENTINEL); @@ -464,7 +464,7 @@ describe('[#12536 §3] `deletePackage` reports a marked refusal as a refusal on p.registerUninstallCleanup('marked-cleanup', async () => { throw markedRefusal({ status: 403 }); }); p.registerUninstallCleanup('faulting-cleanup', async () => { throw driverFault(); }); - const res = await p.deletePackage({ packageId: 'com.acme.crm', allTenants: true }); + const res = await p.deletePackage({ packageId: 'com.acme.crm' }); const marked = res.cleanups.find((c: any) => c.name === 'marked-cleanup'); const faulting = res.cleanups.find((c: any) => c.name === 'faulting-cleanup'); 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 index 1ea4a008836..c74d7996d47 100644 --- 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 @@ -20,13 +20,10 @@ * …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 is - * untouched here and stays static-registry-keyed on purpose — see - * `protocol.org-scoped-write-refused.test.ts` and the divergence note in - * `reportUnhydratableOrgScopedRows`' TSDoc. The asymmetry is the file's own - * stated posture: a warning is free and should be maximal, a refusal removes a - * capability. The last case in this file PINS the divergence, so a future - * reader who "harmonises" the two sets gets a red test and the reason. + * 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 @@ -336,21 +333,19 @@ describe('#6992 — the cold-boot audit scans the live registry, not just the de // ── the divergence from the refusal is deliberate, and pinned ───────── - it('does NOT extend the write refusal to the family it now reports', async () => { - // #6992's scope is the diagnostic half ONLY. `orgScopedWriteRefusal` - // keeps its "statically-declared types only" predicate: a warning is - // free and should be maximal, a refusal removes a capability measured - // over a different set. This case is the guard against a future reader - // "harmonising" the two — if the refusal is ever widened, that is a new - // ruling and this case is where it gets recorded, not deleted. - const refuse = (ObjectStackProtocolImplementation as any).orgScopedWriteRefusal.bind( - ObjectStackProtocolImplementation, + 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', ); - // Declared, not org-overridable → refused (#6190's landing, untouched). - expect(refuse('flow', 'x', 'org_a')).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - // Plugin-registered → still accepted, even though the audit now reports it. + expect(refuse('flow')).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); for (const type of ['webhook', 'theme', 'sharing_rule', 'connector']) { - expect(refuse(type, 'x', 'org_a'), `${type} write refusal changed`).toBeNull(); + expect(refuse(type), `${type} write refusal changed`).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); } }); }); 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 6ac5a2dc1b0..a670d7a317f 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 @@ -1,72 +1,41 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * #6190 — an org-scoped write of a type the registry declares NOT per-org - * overridable is REFUSED at write time, on both minting paths. + * [ADR-0131 D6] The identity pin of the protocol's organization-scope refusal: + * NO organization-scoped metadata write is accepted, for any type. * - * ## The defect this closes + * ## What it pins * - * `allowOrgOverride` and `allowRuntimeCreate` are orthogonal tiers. #6283 / - * commit 474f131cf closed the OVERLAY tier for `flow`; the runtime-create tier stayed - * open by design and never consulted the ORG dimension at all — - * `SysMetadataRepository.put` stamps `organization_id: this.organizationId` - * whatever the type is. So a Studio-authored item of an - * `allowOrgOverride: false` type persisted a per-org row that the platform can - * never read back: `loadMetaFromDb` filters `organization_id: null`, and the - * env-wide consumers never ask for the org partition. The write path was - * strictly more permissive than the read path — ADR-0049's false-compliance - * shape, and the reason #6190 was filed. + * #6190 refused an org-scoped write of every type the registry declared + * `allowOrgOverride: false`, and accepted exactly five — `view`, `dashboard`, + * `report`, `translation`, `email_template` — plus whatever the + * `OS_METADATA_WRITABLE` hatch opened. ADR-0131 D6 retires the + * per-organization overlay axis, so that exemption is gone: the protocol now + * refuses every organization-scoped write, `403 NOT_OVERRIDABLE`, on the save + * door (draft and publish), the per-item promotion and the package publish, + * and its first sentence names the tenancy posture in force. The set below is + * ENUMERATED from `DEFAULT_METADATA_TYPE_REGISTRY` plus one plugin-registered + * type, never hand-written, so a type the registry gains is pinned the day it + * lands. * - * Two measured specimens, in ascending severity: - * - * • `flow` — binds its triggers for the life of the process that wrote it, - * then silently stops firing after the next restart. - * • `object` — fails CLOSED. The row is absent from the registry after boot - * while its physical table still holds the data, so `assertObjectRegistered` - * answers 404 `OBJECT_NOT_FOUND` for every record in it. That gate's own - * TSDoc justified failing closed with "`object` is `allowOrgOverride: false` - * … so no per-org overlay can legitimately exist outside the process-wide - * registry" — true of the overlay tier, false of the runtime-create tier - * until this refusal landed. `object` is kept as a named specimen below so - * that premise cannot silently go stale again. - * - * Maintainer ruling 2026-08-08 (option A of three): reject the write. Option B - * — silently coercing the row to env-wide — was rejected because it rewrites - * the tenancy statement the author made; option D — the cold-boot log alone, - * shipped in PR #6600 — leaves declared ≠ enforced. Ruling 2 = A: rows written - * BEFORE this gate are residue handled non-destructively (audible via - * `reportUnhydratableOrgScopedRows`, disposed of operationally); this PR ships - * NO data migration, which is why the promotion half below matters — residue - * must not be promotable into a fresh phantom. + * Rows stored organization-scoped BEFORE this refusal are residue the + * promotion ceremony (ADR-0131 C7) carries; they must not be promotable from + * here, and they are not deleted here either. * * ## Reverse verification, direction predicted BEFORE running * - * Ordinary red with a deliberately green half. Predicted: removing the two - * `orgScopedWriteRefusal` call sites turns every enforcement case in this file - * red and leaves the 4 controls + the declaration pin green. - * - * Measured: **9 red / 5 green** here (and 21 red across the package, the other - * 12 being the fixtures elsewhere that had pinned the reversed behaviour). The - * enforcement cases failed in the shape that names the bug — - * `AssertionError: promise resolved "{ success: true, …(4) }" instead of - * rejecting` — i.e. the accepted-then-unreadable write, reproduced on demand. + * Ordinary red with a deliberately green half: re-inserting the five-type + * exemption (`if (isOverlayAllowed(type)) return null;` in the refusal) turns + * the five tier-A cases of each enumerated door red and leaves every other + * type's case and the environment-wide controls green. * - * One prediction missed, recorded rather than tidied away: the written - * prediction said "10 enforcement cases", counting R1 and R2 as two. They are - * one `it()` — the envelope and the "nothing persisted" assertion belong to a - * single case, because "refused AFTER writing" would satisfy either one alone - * and neither is the claim on its own. So the predicted count was 10 and the - * real one is 9; the direction and the membership were right, the arithmetic - * was not. - * - * The green half is not slack: a "fix" that closed this by making the whole - * type unwritable would pass the red half and fail G2/G3, and a harness that - * could not save anything would pass the red half for the wrong reason — which - * is what G1/G2 exclude. + * The green half is not slack: a "fix" that closed this by making the types + * unwritable would pass the red half and fail the controls, and a harness that + * could not save anything would pass the red half for the wrong reason. * * Harness: the real write path over a stub engine — the gate runs inside - * `saveMetaItem` / `promoteDraftForPublish`, so a harness that mocks either - * cannot see it. + * `saveMetaItem` / `publishMetaItem` / `publishPackageDrafts`, so a harness + * that mocks them cannot see it. */ import { afterEach, describe, expect, it } from 'vitest'; // [#5619] The producer's OWN write-verb dispatch decisions (#4550 delete / @@ -78,7 +47,7 @@ import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFin // `@objectstack/rest`), applied here to the protocol's real list reads. import { anonymousFormIntakeCandidates, anonymousFormIntakeWithdrawnIn } from '@objectstack/metadata-core'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; -import { expandViewContainer, SharingConfigSchema } from '@objectstack/spec/ui'; +import { expandViewContainer } from '@objectstack/spec/ui'; import { ObjectStackProtocolImplementation } from './protocol.js'; interface Row { @@ -285,733 +254,161 @@ const VIEW = { const orgRows = (rows: Map) => Array.from(rows.values()).map((r) => ({ type: r.type, name: r.name, org: r.organization_id, state: r.state })); -describe('#6190 — org-scoped writes of non-org-overridable types are refused', () => { - afterEach(() => { - delete process.env.OS_METADATA_WRITABLE; - ObjectStackProtocolImplementation.resetEnvWritableCache(); - }); +/** One plugin-registered type: no `DEFAULT_METADATA_TYPE_REGISTRY` entry. */ +const PLUGIN_TYPE = 'acme_widget'; - // ── path 1: saveMetaItem ────────────────────────────────────────────── +/** Every registry type plus the plugin-registered one — the refused set, derived. */ +const ALL_TYPES = [...DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type), PLUGIN_TYPE]; - it('R1/R2 — object: the org-scoped save is refused with the envelope, and NOTHING is persisted', async () => { - // The specimen whose post-restart consequence fails CLOSED: the row's - // organization_id makes cold boot skip it, the object is absent from - // the registry, and every record in its still-populated table 404s. - const { protocol, rows } = makeProtocol([], 'env_prod'); +/** The five types the retired exemption accepted (`allowOrgOverride: true`). */ +const TIER_A = ['view', 'dashboard', 'report', 'translation', 'email_template']; - await expect( - protocol.saveMetaItem({ type: 'object', name: 'org_widget', item: OBJECT, organizationId: 'org_a' }), - ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); +const REFUSED = { code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' }; - // "Refused", not "refused after writing" — the phantom row IS the - // defect, so its absence is part of the claim. - expect(orgRows(rows)).toEqual([]); +describe('[ADR-0131 D6] no organization-scoped metadata write is accepted', () => { + afterEach(() => { + delete process.env.OS_METADATA_WRITABLE; + delete process.env.OS_TENANCY_POSTURE; + ObjectStackProtocolImplementation.resetEnvWritableCache(); }); - it('R3 — the refusal does not depend on deployment topology (no environmentId either)', async () => { - // ADR-0005's "single kernels keep their existing behaviour" carve-out is - // keyed on `environmentId`. A refusal that only bit in one topology - // would leave the flagship showcase — a host config boots with NO - // environmentId (#5086) — still writing phantoms. - const { protocol, rows } = makeProtocol(); - - await expect( - protocol.saveMetaItem({ type: 'object', name: 'org_widget', item: OBJECT, organizationId: 'org_a' }), - ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(orgRows(rows)).toEqual([]); + it('the enumeration covers the registry, the five formerly exempt types and a plugin-registered one', () => { + expect(DEFAULT_METADATA_TYPE_REGISTRY.filter((e) => e.allowOrgOverride).map((e) => e.type)).toEqual(TIER_A); + for (const t of TIER_A) expect(ALL_TYPES).toContain(t); + expect(DEFAULT_METADATA_TYPE_REGISTRY.some((e) => (e.type as string) === PLUGIN_TYPE)).toBe(false); }); - it('R4 — flow: the original #6190 specimen, brand-new and org-scoped, is refused', async () => { - // No artifact is shadowed here, so this is the `allowRuntimeCreate` - // tier — the tier commit 474f131cf deliberately left open and the tier the - // tenant scenario in the issue actually uses (authoring a NEW flow in - // Studio, not overlaying a packaged one). - const { protocol, rows } = makeProtocol([], 'env_prod'); + // ── the save door, publish and draft ────────────────────────────────── - await expect( - protocol.saveMetaItem({ type: 'flow', name: 'org_sweep', item: FLOW, organizationId: 'org_a' }), - ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(orgRows(rows)).toEqual([]); - }); + for (const type of ALL_TYPES) { + for (const mode of ['publish', 'draft'] as const) { + it(`saveMetaItem (${mode}) of '${type}' org-scoped → 403 NOT_OVERRIDABLE, nothing persisted`, async () => { + const { protocol, rows } = makeProtocol([], 'env_prod'); + await expect(protocol.saveMetaItem({ + type, name: 'org_item', item: { name: 'org_item', label: 'Org item' }, + organizationId: 'org_a', mode, + })).rejects.toMatchObject(REFUSED); + expect(orgRows(rows)).toEqual([]); + }); + } + } - it('R5 — the draft door is gated identically (#4463 D1)', async () => { - // Gating the direct-active save and letting drafts through would make - // the refusal bypassable by anyone who saves `?mode=draft` and then - // POSTs `/publish` — which is exactly what Studio's designer does on - // every edit. - const { protocol, rows } = makeProtocol([], 'env_prod'); + // ── the per-item promotion ──────────────────────────────────────────── - await expect( - protocol.saveMetaItem({ - type: 'object', name: 'org_widget', item: OBJECT, organizationId: 'org_a', mode: 'draft', - }), - ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(orgRows(rows)).toEqual([]); - }); + // The refusal precedes every read, so no draft needs to exist for it. + for (const type of ALL_TYPES) { + it(`publishMetaItem of '${type}' org-scoped → 403 NOT_OVERRIDABLE, nothing persisted`, async () => { + const { protocol, rows } = makeProtocol([], 'env_prod'); + await expect( + protocol.publishMetaItem({ type, name: 'org_item', organizationId: 'org_a' }), + ).rejects.toMatchObject(REFUSED); + expect(orgRows(rows)).toEqual([]); + }); + } - it('R6 — the plural REST spelling is refused too', async () => { - // `PUT /api/v1/meta/objects/:name` reaches the same gate; a refusal - // keyed on one spelling is a refusal with a documented bypass. - const { protocol, rows } = makeProtocol([], 'env_prod'); + for (const type of TIER_A) { + it(`publishMetaItem of a LEGACY organization-scoped '${type}' draft → refused; the draft stays, no active row is minted`, async () => { + const { protocol, rows } = makeProtocol([], 'env_prod'); + await seedLegacyOrgDraft(protocol, { + type, name: 'org_item', body: { name: 'org_item', label: 'Org item' }, organizationId: 'org_a', + }); + await expect( + protocol.publishMetaItem({ type, name: 'org_item', organizationId: 'org_a' }), + ).rejects.toMatchObject(REFUSED); + expect(orgRows(rows)).toEqual([{ type, name: 'org_item', org: 'org_a', state: 'draft' }]); + }); + } - await expect( - protocol.saveMetaItem({ type: 'objects', name: 'org_widget', item: OBJECT, organizationId: 'org_a' }), - ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(orgRows(rows)).toEqual([]); - }); + // ── the package publish ─────────────────────────────────────────────── - it('R7 — OS_METADATA_WRITABLE unlocks org scoping too: the operator hatch stays ONE door', async () => { - // Not a leak, and the case is here because the alternative was - // seriously considered and rejected. The predicate is - // `isOverlayAllowed`, the SAME one the sibling `NOT_OVERRIDABLE` - // refusal uses — the ruling named this refusal that sibling, and this - // file already promises "unlocking a type there unlocks it here too". - // A second, differently-keyed notion of "overridable" inside one method - // is the drift, not the safety. - // - // What keeps that honest is that the DIAGNOSTIC is deliberately wider - // than the refusal: `reportUnhydratableOrgScopedRows` ignores the hatch - // (PR #6600) and reports the row at every boot, because no hatch can - // teach `loadMetaFromDb` to read it back. So an operator who opens the - // door still gets told what it cost them. Warning is free and should be - // maximal; refusing removes a capability, and the declaration — with - // its documented override — is what decides that. - process.env.OS_METADATA_WRITABLE = 'object'; - ObjectStackProtocolImplementation.resetEnvWritableCache(); + it('publishPackageDrafts naming an organization → 403 NOT_OVERRIDABLE, nothing promoted', async () => { const { protocol, rows } = makeProtocol([], 'env_prod'); - - const result = await protocol.saveMetaItem({ - type: 'object', name: 'org_widget', item: OBJECT, organizationId: 'org_a', - }); - - expect(result.success).toBe(true); - expect(orgRows(rows)).toEqual([ - { type: 'object', name: 'org_widget', org: 'org_a', state: 'active' }, - ]); + await protocol.saveMetaItem({ type: 'view', name: 'org_grid', item: VIEW, packageId: 'app.demo', mode: 'draft' }); + for (const type of TIER_A) { + await seedLegacyOrgDraft(protocol, { + type, name: 'org_item', body: { name: 'org_item' }, organizationId: 'org_a', packageId: 'app.demo', + }); + } + await expect( + protocol.publishPackageDrafts({ packageId: 'app.demo', organizationId: 'org_a' }), + ).rejects.toMatchObject(REFUSED); + expect(orgRows(rows).filter((r) => r.state === 'active')).toEqual([]); }); - it('R7b — …and with the hatch CLOSED the same write is refused', async () => { - // The pair that makes R7 evidence rather than a hole: the hatch is what - // opens it, and nothing else does. Without this, R7 would be - // indistinguishable from a gate that never fired for `object` at all. - delete process.env.OS_METADATA_WRITABLE; + it('the operator hatch does not open organization scope: OS_METADATA_WRITABLE=view,object still refuses', async () => { + process.env.OS_METADATA_WRITABLE = 'view,object'; ObjectStackProtocolImplementation.resetEnvWritableCache(); const { protocol, rows } = makeProtocol([], 'env_prod'); - - await expect( - protocol.saveMetaItem({ type: 'object', name: 'org_widget', item: OBJECT, organizationId: 'org_a' }), - ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + await expect(protocol.saveMetaItem({ type: 'object', name: 'org_widget', item: OBJECT, organizationId: 'org_a' })) + .rejects.toMatchObject(REFUSED); + await expect(protocol.saveMetaItem({ type: 'view', name: 'org_grid', item: VIEW, organizationId: 'org_a' })) + .rejects.toMatchObject(REFUSED); expect(orgRows(rows)).toEqual([]); }); - it('R8 — the refusal names the scope, the flag and the remedy (#5240: one condition, one wording)', async () => { - // An AI author cannot self-correct from a vague 403. The wording is - // contract here: it must name the ORG (so the author knows which - // dimension was refused, not just "forbidden"), the flag that produced - // the verdict, and the two legitimate alternatives. - const { protocol } = makeProtocol([], 'env_prod'); - - const err = await protocol - .saveMetaItem({ type: 'object', name: 'org_widget', item: OBJECT, organizationId: 'org_a' }) - .catch((e: any) => e); - - expect(err.message).toContain( - "Metadata item 'object/org_widget' cannot be written org-scoped (organization 'org_a').", - ); - expect(err.message).toContain('allowOrgOverride=false'); - expect(err.message).toContain('Save it env-wide instead'); - expect(err.organizationId).toBe('org_a'); - }); - - // ── path 2: draft → active promotion ────────────────────────────────── - - it('R9 — a LEGACY org-scoped draft cannot be promoted into a fresh active phantom', async () => { - // This PR ships no data migration (ruling 2 = A), so residue exists by - // design. It must not be promotable: promoting it would mint a NEW - // active org-scoped row — the very thing path 1 now refuses. - const { protocol, rows } = makeProtocol([], 'env_prod'); - await seedLegacyOrgDraft(protocol, { - type: 'object', name: 'org_widget', body: OBJECT, organizationId: 'org_a', - }); - expect(orgRows(rows)).toEqual([ - { type: 'object', name: 'org_widget', org: 'org_a', state: 'draft' }, - ]); - - await expect( - protocol.publishMetaItem({ type: 'object', name: 'org_widget', organizationId: 'org_a' }), - ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - - // The draft is still there (refusing is not disposal — ruling 2 = A), - // and no ACTIVE row was minted. - expect(orgRows(rows).filter((r) => r.state === 'active')).toEqual([]); + it('the refusal does not depend on deployment topology (no environmentId either)', async () => { + const { protocol, rows } = makeProtocol(); + await expect(protocol.saveMetaItem({ type: 'view', name: 'org_grid', item: VIEW, organizationId: 'org_a' })) + .rejects.toMatchObject(REFUSED); + expect(orgRows(rows)).toEqual([]); }); - it('R10 — publishPackageDrafts refuses the batch rather than promoting the residue', async () => { - // Studio's "publish whole app". The batch is atomic by ADR-0067 D2, so - // one refused item fails the whole publish loudly instead of half-landing. - const { protocol, rows } = makeProtocol([], 'env_prod'); - await seedLegacyOrgDraft(protocol, { - type: 'object', name: 'org_widget', body: OBJECT, organizationId: 'org_a', packageId: 'app.demo', + for (const posture of ['single', 'group', 'isolated'] as const) { + it(`the first sentence names the posture in force: '${posture}'`, async () => { + process.env.OS_TENANCY_POSTURE = posture; + const { protocol } = makeProtocol([], 'env_prod'); + const err = await protocol + .saveMetaItem({ type: 'view', name: 'org_grid', item: VIEW, organizationId: 'org_a' }) + .catch((e: any) => e); + expect(err).toMatchObject(REFUSED); + expect(err.message.split(' (ADR-0131 D6). ')[0]).toBe( + "Metadata item 'view/org_grid' cannot be written organization-scoped (organization 'org_a'): " + + `under the '${posture}' tenancy posture, as under every posture, environment metadata belongs ` + + 'to the whole deployment and no organization holds its own copy', + ); }); + } - const res = await protocol.publishPackageDrafts({ packageId: 'app.demo', organizationId: 'org_a' }); - - expect(res.success).toBe(false); - expect(res.publishedCount).toBe(0); - expect(res.failed).toHaveLength(1); - expect(res.failed[0]).toMatchObject({ type: 'object', name: 'org_widget', code: 'NOT_OVERRIDABLE' }); - expect(orgRows(rows).filter((r) => r.state === 'active')).toEqual([]); - }); - - // ── the controls: what must NOT change ──────────────────────────────── + // ── the controls: environment-wide writes are accepted ───────────────── - it('G1 — view IS allowOrgOverride:true, so its org-scoped write still succeeds', async () => { - // Without this control the refusals above would also pass on a harness - // that could not save anything at all. `view` is the type ADR-0005 - // whitelists, and its per-org rows ARE read back (on demand, by - // `getMetaItem`/`getMetaItems`) — which is the whole distinction. + it('control — an environment-wide view save succeeds and stores organization_id NULL', async () => { const { protocol, rows } = makeProtocol([], 'env_prod'); - - const result = await protocol.saveMetaItem({ - type: 'view', name: 'org_grid', item: VIEW, organizationId: 'org_a', - }); - - expect(result.success).toBe(true); - expect(orgRows(rows)).toEqual([ - { type: 'view', name: 'org_grid', org: 'org_a', state: 'active' }, - ]); + expect((await protocol.saveMetaItem({ type: 'view', name: 'org_grid', item: VIEW })).success).toBe(true); + expect(orgRows(rows)).toEqual([{ type: 'view', name: 'org_grid', org: null, state: 'active' }]); }); - it('G2 — an ENV-WIDE write of the same object still succeeds', async () => { - // The refusal is about the org dimension only. A tenant-authored object - // remains authorable; it just lands where boot can read it back. + it('control — an environment-wide object save succeeds', async () => { const { protocol, rows } = makeProtocol([], 'env_prod'); - - const result = await protocol.saveMetaItem({ type: 'object', name: 'org_widget', item: OBJECT }); - - expect(result.success).toBe(true); - expect(orgRows(rows)).toEqual([ - { type: 'object', name: 'org_widget', org: null, state: 'active' }, - ]); + expect((await protocol.saveMetaItem({ type: 'object', name: 'org_widget', item: OBJECT })).success).toBe(true); + expect(orgRows(rows)).toEqual([{ type: 'object', name: 'org_widget', org: null, state: 'active' }]); }); - it('G3 — an ENV-WIDE brand-new flow still saves (the allowRuntimeCreate tier is intact)', async () => { - // #6283 left this tier open and this change does not close it. What - // changed is the SCOPE such a write may claim, not whether tenants may - // author automations. + it('control — an environment-wide brand-new flow saves (the runtime-create tier is intact)', async () => { const { protocol, rows } = makeProtocol([], 'env_prod'); - - const result = await protocol.saveMetaItem({ type: 'flow', name: 'org_sweep', item: FLOW }); - - expect(result.success).toBe(true); - expect(orgRows(rows)).toEqual([ - { type: 'flow', name: 'org_sweep', org: null, state: 'active' }, - ]); + expect((await protocol.saveMetaItem({ type: 'flow', name: 'org_sweep', item: FLOW })).success).toBe(true); + expect(orgRows(rows)).toEqual([{ type: 'flow', name: 'org_sweep', org: null, state: 'active' }]); }); - it('G4 — an env-wide draft still publishes under a session carrying an active org (#3115)', async () => { - // The highest-traffic path in Studio, and the one most at risk from a - // gate keyed on the wrong org: `publishPackageDrafts` promotes each - // draft in the draft's OWN scope, so a session's active org must not - // make an env-wide draft look org-scoped. + it('control — an environment-wide draft publishes through the package publish, and a legacy organization draft is left as stored', async () => { const { protocol, rows } = makeProtocol([], 'env_prod'); await protocol.saveMetaItem({ - type: 'object', name: 'org_widget', item: OBJECT, packageId: 'app.demo', mode: 'draft', - }); - - const res = await protocol.publishPackageDrafts({ packageId: 'app.demo', organizationId: 'org_a' }); - - expect(res.failed).toEqual([]); - expect(res).toMatchObject({ success: true, publishedCount: 1, failedCount: 0 }); - expect(orgRows(rows).filter((r) => r.state === 'active')).toEqual([ - { type: 'object', name: 'org_widget', org: null, state: 'active' }, - ]); - }); - - // ── the declaration behind the enforcement ──────────────────────────── - - it('G5 — the refused set is DERIVED from the registry, not a parallel list', async () => { - // Prime Directive #8. If anyone re-adds a type to a hand-written list - // instead, the enforcement cases above go red rather than this one — - // which is why they, not this, are the acceptance criterion. Recorded - // as a measurement so the blast radius of the ruling is auditable: - // 17 of 27 registry entries change behaviour here. (It was 19 when the - // ruling was made; #5488 has since withdrawn `api`'s runtime-create - // door entirely and #7893 withdrew `field`'s, so both now sit in the - // CODE-ONLY tier — refused env-wide and org-scoped alike, before this - // gate is consulted.) - const affected = DEFAULT_METADATA_TYPE_REGISTRY - .filter((e) => !e.allowOrgOverride && e.allowRuntimeCreate) - .map((e) => e.type); - const orgOverridable = DEFAULT_METADATA_TYPE_REGISTRY - .filter((e) => e.allowOrgOverride) - .map((e) => e.type); - - expect(orgOverridable).toEqual(['view', 'dashboard', 'report', 'translation', 'email_template']); - // The types the maintainer ruling names explicitly, all present. - for (const t of ['object', 'hook', 'seed', 'mapping', 'flow']) { - expect(affected, `${t} must be refused org-scoped`).toContain(t); - } - // `api` and `field` were also named by the ruling; each left this set - // for the STRONGER tier, not for a per-org channel — pin the direction, - // because "no longer in the org-scoped refusal set" reads identically - // to "now permitted org-scoped" unless the destination is asserted. - // (`field`: #7893, maintainer-ruled 2026-08-12 — the standalone create - // door minted a row no read path composed into its parent object.) - for (const t of ['api', 'field']) { - expect(affected, `${t} must have left this set for the code-only tier`).not.toContain(t); - expect( - DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === t), - ).toMatchObject({ allowOrgOverride: false, allowRuntimeCreate: false }); - } - expect(affected).toHaveLength(17); - }); -}); - -/** - * An org-scoped change to which public forms accept anonymous intake is - * refused when the anonymous form doors would never read that organization: - * they resolve the form in `tenancy.defaultOrgId()`'s organization, which a - * walled posture (degraded or not) answers `null`. Same stub engine as above; - * the `tenancy` service is the only addition. - */ -describe('org-scoped anonymous form intake changes the anonymous doors cannot see', () => { - const sharing = (allowAnonymous: boolean) => ({ enabled: true, allowAnonymous, publicLink: '/forms/walled-intake' }); - const FORM_VIEW = (allowAnonymous: boolean, label = 'Intake') => ({ - name: 'task.intake_form', - label, - object: 'task', - viewKind: 'form', - config: { sharing: sharing(allowAnonymous) }, - }); - - /** `defaultOrgId` answers what the anonymous doors resolve. */ - function makeTenancyProtocol(defaultOrgId: string | null) { - const { engine, rows } = makeStubEngine(); - const services = new Map([['tenancy', { defaultOrgId: async () => defaultOrgId }]]); - const protocol = new ObjectStackProtocolImplementation(engine, () => services, 'env_prod') as any; - return { protocol, rows }; - } - - async function publishEnvWide(protocol: any) { - const res = await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: FORM_VIEW(true) }); - expect(res.success).toBe(true); - } - - it('walled (no organization for an anonymous request): the org-scoped withdrawal is refused and nothing is saved', async () => { - const { protocol, rows } = makeTenancyProtocol(null); - await publishEnvWide(protocol); - - const refusal = protocol.saveMetaItem({ - type: 'view', name: 'task.intake_form', item: FORM_VIEW(false), organizationId: 'org_a', + type: 'view', name: 'org_grid', item: VIEW, packageId: 'app.demo', mode: 'draft', }); - await expect(refusal).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' }); - await expect(refusal).rejects.toThrow(/Save it env-wide instead/); - expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]); - }); - - it('walled: an org-scoped withdrawal through `sharing.enabled` alone is refused the same way', async () => { - const { protocol, rows } = makeTenancyProtocol(null); - await publishEnvWide(protocol); - const body = FORM_VIEW(true); - body.config.sharing.enabled = false; - - await expect(protocol.saveMetaItem({ - type: 'view', name: 'task.intake_form', item: body, organizationId: 'org_a', - })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' }); - expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]); - }); - - it('walled: an org-scoped draft of the withdrawal is refused too', async () => { - const { protocol, rows } = makeTenancyProtocol(null); - await publishEnvWide(protocol); - - await expect(protocol.saveMetaItem({ - type: 'view', name: 'task.intake_form', item: FORM_VIEW(false), organizationId: 'org_a', mode: 'draft', - })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]); - }); - - it('walled: an org-scoped publish of a form the env-wide definition keeps private is refused', async () => { - const { protocol } = makeTenancyProtocol(null); - const res = await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: FORM_VIEW(false) }); - expect(res.success).toBe(true); - - await expect(protocol.saveMetaItem({ - type: 'view', name: 'task.intake_form', item: FORM_VIEW(true), organizationId: 'org_a', - })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - }); - - it('walled: a legacy org-scoped draft of the withdrawal cannot be promoted', async () => { - const { protocol, rows } = makeTenancyProtocol(null); - await publishEnvWide(protocol); await seedLegacyOrgDraft(protocol, { - type: 'view', name: 'task.intake_form', body: FORM_VIEW(false), organizationId: 'org_a', + type: 'view', name: 'org_legacy', body: { ...VIEW, name: 'org_legacy' }, organizationId: 'org_a', packageId: 'app.demo', }); - await expect( - protocol.publishMetaItem({ type: 'view', name: 'task.intake_form', organizationId: 'org_a' }), - ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(orgRows(rows).filter((r) => r.org === 'org_a' && r.state === 'active')).toEqual([]); - }); + const res = await protocol.publishPackageDrafts({ packageId: 'app.demo' }); - // ADR-0048 keys a draft by its package too: two packages can each hold a - // draft of the same view in one organization. The promotion judges the - // draft it promotes, under the same key, never the other package's. - describe('walled: two packages hold a draft of the same view in one organization', () => { - async function seedTwoPackageDrafts() { - const { protocol, rows } = makeTenancyProtocol(null); - await publishEnvWide(protocol); - // Package A's draft leaves the anonymous intake alone; package B's - // withdraws it, which an organization the doors never read refuses. - await seedLegacyOrgDraft(protocol, { - type: 'view', name: 'task.intake_form', body: FORM_VIEW(true, 'Intake (A)'), - organizationId: 'org_a', packageId: 'pkg_a', - }); - await seedLegacyOrgDraft(protocol, { - type: 'view', name: 'task.intake_form', body: FORM_VIEW(false), - organizationId: 'org_a', packageId: 'pkg_b', - }); - const draftsOf = () => Array.from(rows.values()) - .filter((r) => r.organization_id === 'org_a' && r.state === 'draft') - .map((r) => r.package_id) - .sort(); - const activeOf = () => Array.from(rows.values()) - .filter((r) => r.organization_id === 'org_a' && r.state === 'active') - .map((r) => r.package_id); - expect(draftsOf()).toEqual(['pkg_a', 'pkg_b']); - return { protocol, draftsOf, activeOf }; - } - - it('promoting package B judges B\'s draft: refused, and nothing becomes active', async () => { - const { protocol, draftsOf, activeOf } = await seedTwoPackageDrafts(); - await expect(protocol.publishMetaItem({ - type: 'view', name: 'task.intake_form', organizationId: 'org_a', packageId: 'pkg_b', - })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' }); - expect(activeOf()).toEqual([]); - expect(draftsOf()).toEqual(['pkg_a', 'pkg_b']); - }); - - it('control: promoting package A judges A\'s draft and promotes it, leaving B\'s draft pending', async () => { - const { protocol, draftsOf, activeOf } = await seedTwoPackageDrafts(); - const res = await protocol.publishMetaItem({ - type: 'view', name: 'task.intake_form', organizationId: 'org_a', packageId: 'pkg_a', - }); - expect(res.success).toBe(true); - expect(activeOf()).toEqual(['pkg_a']); - expect(draftsOf()).toEqual(['pkg_b']); - }); - }); - - it('control (walled): an org-scoped edit that leaves the anonymous intake alone still saves', async () => { - const { protocol, rows } = makeTenancyProtocol(null); - await publishEnvWide(protocol); - - const res = await protocol.saveMetaItem({ - type: 'view', name: 'task.intake_form', item: FORM_VIEW(true, 'Intake (tenant)'), organizationId: 'org_a', - }); - expect(res.success).toBe(true); - expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([ - { type: 'view', name: 'task.intake_form', org: 'org_a', state: 'active' }, - ]); - }); - - it('control (walled): the env-wide withdrawal is accepted', async () => { - const { protocol } = makeTenancyProtocol(null); - await publishEnvWide(protocol); - - const res = await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: FORM_VIEW(false) }); - expect(res.success).toBe(true); - expect(res.message).toContain('env-wide'); - }); - - it('control (single): the doors resolve this organization, so the org-scoped withdrawal is accepted', async () => { - const { protocol, rows } = makeTenancyProtocol('org_a'); - await publishEnvWide(protocol); - - const res = await protocol.saveMetaItem({ - type: 'view', name: 'task.intake_form', item: FORM_VIEW(false), organizationId: 'org_a', - }); - expect(res.success).toBe(true); - expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([ - { type: 'view', name: 'task.intake_form', org: 'org_a', state: 'active' }, - ]); - }); - - // A withdrawal is a kill switch: in the organization the doors DO read, an - // org-scoped write may narrow intake but never re-open a form the env-wide - // definition withdrew (the doors would keep answering it as not found). - it('single: an org-scoped re-open of a form the env-wide definition withdrew is refused and nothing is saved', async () => { - const { protocol, rows } = makeTenancyProtocol('org_a'); - const res = await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: FORM_VIEW(false) }); - expect(res.success).toBe(true); - - const refusal = protocol.saveMetaItem({ - type: 'view', name: 'task.intake_form', item: FORM_VIEW(true), organizationId: 'org_a', - }); - await expect(refusal).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' }); - await expect(refusal).rejects.toThrow(/cannot keep public form '\/forms\/walled-intake' open/); - expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]); - }); - - it('single: the re-open through `sharing.enabled` is refused when the env-wide definition switched it off', async () => { - const { protocol, rows } = makeTenancyProtocol('org_a'); - const off = FORM_VIEW(true); - off.config.sharing.enabled = false; - expect((await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: off })).success).toBe(true); - - await expect(protocol.saveMetaItem({ - type: 'view', name: 'task.intake_form', item: FORM_VIEW(true), organizationId: 'org_a', mode: 'draft', - })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]); - }); - - it('control (single): an org-scoped edit that keeps the env-wide withdrawal still saves', async () => { - const { protocol, rows } = makeTenancyProtocol('org_a'); - await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: FORM_VIEW(false) }); - - const res = await protocol.saveMetaItem({ - type: 'view', name: 'task.intake_form', item: FORM_VIEW(false, 'Intake (tenant)'), organizationId: 'org_a', - }); - expect(res.success).toBe(true); - expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([ - { type: 'view', name: 'task.intake_form', org: 'org_a', state: 'active' }, + expect(res.failed).toEqual([]); + expect(res).toMatchObject({ success: true, publishedCount: 1, failedCount: 0 }); + expect(orgRows(rows).sort((a, b) => a.name.localeCompare(b.name))).toEqual([ + { type: 'view', name: 'org_grid', org: null, state: 'active' }, + { type: 'view', name: 'org_legacy', org: 'org_a', state: 'draft' }, ]); }); - - it('single: re-saving an org overlay that was open before the env-wide withdrawal is refused', async () => { - const { protocol, rows } = makeTenancyProtocol('org_a'); - await publishEnvWide(protocol); - // Open in the organization while open env-wide: accepted. - expect((await protocol.saveMetaItem({ - type: 'view', name: 'task.intake_form', item: FORM_VIEW(true), organizationId: 'org_a', - })).success).toBe(true); - // Then withdrawn env-wide (the link kept, anonymous access cleared). - expect((await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: FORM_VIEW(false) })).success) - .toBe(true); - const before = orgRows(rows).filter((r) => r.org === 'org_a'); - // A re-save of the still-open overlay (only its label changes) would - // leave open a form the env-wide layer withdrew: refused. - await expect(protocol.saveMetaItem({ - type: 'view', name: 'task.intake_form', item: FORM_VIEW(true, 'Intake (renamed)'), organizationId: 'org_a', - })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' }); - expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual(before); - }); - - it('single: a container-shaped org save is judged as the list read expands it', async () => { - const { protocol, rows } = makeTenancyProtocol('org_a'); - const container = (allowAnonymous: boolean) => ({ - name: 'task', object: 'task', formViews: { intake_form: { sharing: sharing(allowAnonymous) } }, - }); - expect((await protocol.saveMetaItem({ type: 'view', name: 'task', item: container(false) })).success).toBe(true); - const refusal = protocol.saveMetaItem({ type: 'view', name: 'task', item: container(true), organizationId: 'org_a' }); - await expect(refusal).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - await expect(refusal).rejects.toThrow(/cannot keep public form '\/forms\/walled-intake' open/); - expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]); - // Control: the same container kept withdrawn in the organization saves. - expect((await protocol.saveMetaItem({ - type: 'view', name: 'task', item: container(false), organizationId: 'org_a', - })).success).toBe(true); - }); - - describe('single: identity is the stored row, so moving the form inside its row does not escape', () => { - const LINK = '/forms/walled-intake'; - const open = { enabled: true, allowAnonymous: true, publicLink: LINK }; - const withdrawnRow = { - name: 'task', object: 'task', - formViews: { intake_form: { sharing: { ...open, allowAnonymous: false } } }, - }; - const overlays: Array<[string, Record]> = [ - ['a formViews key rename', { name: 'task', object: 'task', formViews: { intake_v2: { sharing: open } } }], - ['a move to the nested form with a form.name rename', - { name: 'task', object: 'task', form: { name: 'renamed_intake', sharing: open } }], - ['a listViews collision that makes the expansion rename it', - { name: 'task', object: 'task', listViews: { intake_form: { type: 'grid' } }, formViews: { intake_form: { sharing: open } } }], - ['the same key re-pointed at a new slug', - { name: 'task', object: 'task', formViews: { intake_form: { sharing: { ...open, publicLink: '/forms/walled-intake-2' } } } }], - ['the same key re-pointed at a case-only variant', - { name: 'task', object: 'task', formViews: { intake_form: { sharing: { ...open, publicLink: '/forms/Walled-Intake' } } } }], - ]; - for (const [label, overlay] of overlays) { - it(`${label}: refused and nothing is saved`, async () => { - const { protocol, rows } = makeTenancyProtocol('org_a'); - expect((await protocol.saveMetaItem({ type: 'view', name: 'task', item: withdrawnRow })).success).toBe(true); - await expect(protocol.saveMetaItem({ type: 'view', name: 'task', item: overlay, organizationId: 'org_a' })) - .rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' }); - expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]); - }); - } - - it('control: a sibling form in another slot with another slug still saves', async () => { - const { protocol } = makeTenancyProtocol('org_a'); - expect((await protocol.saveMetaItem({ type: 'view', name: 'task', item: withdrawnRow })).success).toBe(true); - const sibling = { - name: 'task', object: 'task', - formViews: { - intake_form: { sharing: { ...open, allowAnonymous: false } }, - feedback: { sharing: { ...open, publicLink: '/forms/feedback' } }, - }, - }; - expect((await protocol.saveMetaItem({ type: 'view', name: 'task', item: sibling, organizationId: 'org_a' })).success) - .toBe(true); - }); - }); - - it('single: the same view item re-pointed at a new slug is refused', async () => { - const { protocol } = makeTenancyProtocol('org_a'); - expect((await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: FORM_VIEW(false) })).success).toBe(true); - const moved = FORM_VIEW(true); - moved.config.sharing.publicLink = '/forms/walled-intake-2'; - await expect(protocol.saveMetaItem({ - type: 'view', name: 'task.intake_form', item: moved, organizationId: 'org_a', - })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - }); - - it('control (single): only an explicit false withdraws — allowAnonymous absent env-wide is not a withdrawal', async () => { - const { protocol } = makeTenancyProtocol('org_a'); - const absent = FORM_VIEW(true); - delete (absent.config.sharing as any).allowAnonymous; - expect((await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: absent })).success).toBe(true); - expect((await protocol.saveMetaItem({ - type: 'view', name: 'task.intake_form', item: FORM_VIEW(true), organizationId: 'org_a', - })).success).toBe(true); - }); - - it('control (single): a sharing with no public link env-wide is not a withdrawal', async () => { - const { protocol } = makeTenancyProtocol('org_a'); - const linkless = FORM_VIEW(true); - linkless.config.sharing = { enabled: false, allowAnonymous: false } as any; - expect((await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: linkless })).success).toBe(true); - expect((await protocol.saveMetaItem({ - type: 'view', name: 'task.intake_form', item: FORM_VIEW(true), organizationId: 'org_a', - })).success).toBe(true); - }); - - it('control (single): an org-scoped republish over an env-wide published form still saves', async () => { - const { protocol } = makeTenancyProtocol('org_a'); - await publishEnvWide(protocol); - const off = await protocol.saveMetaItem({ - type: 'view', name: 'task.intake_form', item: FORM_VIEW(false), organizationId: 'org_a', - }); - expect(off.success).toBe(true); - const on = await protocol.saveMetaItem({ - type: 'view', name: 'task.intake_form', item: FORM_VIEW(true), organizationId: 'org_a', - }); - expect(on.success).toBe(true); - }); - - // Known limit (fails closed): a withdrawal of a view name closes that name - // in every package, so the row anchor judges an overlay against the - // env-wide row of its name whichever package that row came from. Two - // packages ship the container `task`; the env-wide row read for the name - // is package B's, which withdraws the form. - describe('single: two packages ship the same view name', () => { - const LINK = '/forms/walled-intake'; - const open = { enabled: true, allowAnonymous: true, publicLink: LINK }; - const shippedA = { - name: 'task', object: 'task', formViews: { intake_form: { sharing: open } }, _packageId: 'pkg_a', - }; - const shippedB = { - name: 'task', object: 'task', - formViews: { intake_form: { sharing: { ...open, allowAnonymous: false } } }, _packageId: 'pkg_b', - }; - - function makeTwoPackageProtocol() { - const { engine, rows } = makeStubEngine(); - engine.registry.listItems = (type: string) => (type === 'view' ? [shippedA, shippedB] : []); - engine.registry.getArtifactItem = (type: string, name: string, pkg?: string) => { - if (type !== 'view' || name !== 'task') return undefined; - if (pkg === 'pkg_a') return shippedA; - return shippedB; - }; - const services = new Map([['tenancy', { defaultOrgId: async () => 'org_a' }]]); - const protocol = new ObjectStackProtocolImplementation(engine, () => services, 'env_prod') as any; - return { protocol, rows }; - } - - it('a row-anchored rename by a package-bound org save is refused, and nothing is saved', async () => { - const { protocol, rows } = makeTwoPackageProtocol(); - const renamed = { name: 'task', object: 'task', formViews: { intake_v2: { sharing: open } } }; - await expect(protocol.saveMetaItem({ - type: 'view', name: 'task', item: renamed, organizationId: 'org_a', packageId: 'pkg_a', - })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' }); - expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]); - }); - - it('control: the same package-bound org save that keeps the form withdrawn saves', async () => { - const { protocol } = makeTwoPackageProtocol(); - const kept = { - name: 'task', object: 'task', - formViews: { intake_v2: { sharing: { ...open, allowAnonymous: false } } }, - }; - expect((await protocol.saveMetaItem({ - type: 'view', name: 'task', item: kept, organizationId: 'org_a', packageId: 'pkg_a', - })).success).toBe(true); - }); - }); - - // A package's shipped form is part of the env-wide definition, not a layer - // of its own beneath it. A schema-parsed `false` on the artifact (the schema - // defaults `enabled` to false) is an explicit withdrawal, so it fails closed; - // and the env-wide definition is the administrator's switch, so an env-wide - // save may open a form the package ships closed. - describe('single: a package-shipped form', () => { - const LINK = '/forms/walled-intake'; - // As the loader serves it: parsed, `enabled` never switched on. - const shipped = { - name: 'task.intake_form', label: 'Intake', object: 'task', viewKind: 'form', - config: { sharing: SharingConfigSchema.parse({ allowAnonymous: true, publicLink: LINK }) }, - _packageId: 'showcase', - }; - - function makePackageProtocol() { - const { engine, rows } = makeStubEngine(); - engine.registry.listItems = (type: string) => (type === 'view' ? [shipped] : []); - engine.registry.getArtifactItem = (type: string, name: string) => - (type === 'view' && name === shipped.name ? shipped : undefined); - const services = new Map([['tenancy', { defaultOrgId: async () => 'org_a' }]]); - const protocol = new ObjectStackProtocolImplementation(engine, () => services, 'env_prod') as any; - return { protocol, rows }; - } - - it('the parsed artifact carries an explicit `false` that keeps the link', () => { - expect(shipped.config.sharing).toMatchObject({ enabled: false, allowAnonymous: true, publicLink: LINK }); - }); - - it('a schema-parsed `false` is a withdrawal: an org-scoped save that opens it is refused', async () => { - const { protocol, rows } = makePackageProtocol(); - await expect(protocol.saveMetaItem({ - type: 'view', name: 'task.intake_form', item: FORM_VIEW(true), organizationId: 'org_a', - })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' }); - expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]); - }); - - it('the env-wide definition is the switch: an env-wide save opens it, and the env-wide list serves that body', async () => { - const { protocol } = makePackageProtocol(); - expect((await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: FORM_VIEW(true) })).success) - .toBe(true); - // The env-wide layer the anonymous doors read beneath an organization. - const envWide: any = await protocol.getMetaItems({ type: 'view' }); - const named = (envWide.items as any[]).filter((v) => v?.name === 'task.intake_form'); - expect(named).toHaveLength(1); - expect(named[0].config.sharing).toMatchObject({ enabled: true, allowAnonymous: true, publicLink: LINK }); - // So an organization overlay that keeps it open is no longer refused. - expect((await protocol.saveMetaItem({ - type: 'view', name: 'task.intake_form', item: FORM_VIEW(true), organizationId: 'org_a', - })).success).toBe(true); - }); - }); }); -// The package identity of a served organization overlay. A package-less -// organization overlay is served once per package that ships its name, and -// the list merge stamps each copy with that package. The anonymous doors and -// the organization-scoped save compare no package: a withdrawal of a view name -// closes that name in every package. So an overlay stored before one -// package's env-wide withdrawal stays closed whichever package's copy is -// served, and whichever package withdraws, first or second in registry order. -// The env-wide list the doors judge against holds every package's body of the -// name, the withdrawing package's included. describe('a package-less organization overlay, two packages shipping its view name', () => { const NAME = 'task.intake_form'; const LINK = '/forms/shared-intake'; @@ -1055,11 +452,18 @@ describe('a package-less organization overlay, two packages shipping its view na .some((c) => c.slug === 'shared-intake' && !anonymousFormIntakeWithdrawnIn(layer, view, c))); } - /** The organization overlay, stored package-less while the form is open everywhere. */ + /** + * The organization overlay, stored package-less while the form is open + * everywhere — a LEGACY row (no write lands organization-scoped since + * ADR-0131 D6), planted through the repository the way it was written. + */ async function storeOpenOverlay(protocol: any, rows: Map) { - expect((await protocol.saveMetaItem({ - type: 'view', name: NAME, item: formView(true, 'Intake (org)'), organizationId: 'org_a', - })).success).toBe(true); + await protocol.ensureOverlayIndex(); + await protocol.getOverlayRepo('org_a').put( + { type: 'view', name: NAME, org: 'org_a' }, + formView(true, 'Intake (org)'), + { parentVersion: null, actor: null, source: 'test.legacy-residue', intent: 'runtime-only', state: 'active', packageId: null }, + ); expect(Array.from(rows.values()).filter((r) => r.organization_id === 'org_a').map((r) => r.package_id)) .toEqual([null]); } @@ -1101,7 +505,7 @@ describe('a package-less organization overlay, two packages shipping its view na expect(await doorsServe(protocol)).toEqual([]); }); - it('a re-save of the overlay is refused, and nothing changes', async () => { + it('a re-save of the overlay is refused (no write is organization-scoped), and nothing changes', async () => { const { protocol, rows } = await withdrawAfterOverlay(); const before = Array.from(rows.values()).filter((r) => r.organization_id === 'org_a'); await expect(protocol.saveMetaItem({ @@ -1206,75 +610,6 @@ describe('a publish consults the lock of the package key it resolved', () => { }); }); -// The organization-scoped save check anchors the overlay on the env-wide -// definition of its row, one per package that holds the name: each package's -// own env-wide row, else the package-less env-wide row (which stands in for -// every package), else that package's artifact. So a package that withdraws -// the form is judged whatever its place in registry order, and another -// package's stored row anchors that package only. -describe('the save check anchors each package\'s row on that package\'s env-wide definition', () => { - const LINK = '/forms/walled-intake'; - const open = { enabled: true, allowAnonymous: true, publicLink: LINK }; - const container = (sharing: Record, key = 'intake_form') => ({ - name: 'task', object: 'task', formViews: { [key]: { sharing } }, - }); - // Registry order: package A (open) first, so a lookup that names no - // package answers A's artifact. Package B, which withdraws, is second. - const shippedA = { ...container(open), _packageId: 'pkg_a' }; - const shippedB = { ...container({ ...open, allowAnonymous: false }), _packageId: 'pkg_b' }; - // The overlay moves the form to another key, so only the row anchor matches it. - const renamedOpen = container(open, 'intake_v2'); - - function makeTwoPackageProtocol() { - const { engine, rows } = makeStubEngine(); - engine.registry.listItems = (type: string) => (type === 'view' ? [shippedA, shippedB] : []); - engine.registry.getArtifactItem = (type: string, name: string, pkg?: string) => { - if (type !== 'view' || name !== 'task') return undefined; - return pkg === 'pkg_b' ? shippedB : shippedA; - }; - const services = new Map([['tenancy', { defaultOrgId: async () => 'org_a' }]]); - const protocol = new ObjectStackProtocolImplementation(engine, () => services, 'env_prod') as any; - return { protocol, rows }; - } - - for (const [label, packageId] of [['a package-less', undefined], ['a package A-bound', 'pkg_a']] as const) { - it(`the withdrawing package is not first in registry order: ${label} org save that renames the form is refused`, async () => { - const { protocol, rows } = makeTwoPackageProtocol(); - await expect(protocol.saveMetaItem({ - type: 'view', name: 'task', item: renamedOpen, organizationId: 'org_a', - ...(packageId ? { packageId } : {}), - })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' }); - expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]); - }); - } - - it('another package\'s env-wide row anchors that package only: the org save is still judged against the withdrawing package', async () => { - const { protocol, rows } = makeTwoPackageProtocol(); - expect((await protocol.saveMetaItem({ - type: 'view', name: 'task', item: { ...container(open), label: 'Task (A, env-wide)' }, packageId: 'pkg_a', - })).success).toBe(true); - await expect(protocol.saveMetaItem({ - type: 'view', name: 'task', item: renamedOpen, organizationId: 'org_a', - })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' }); - expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]); - }); - - it('control: the same org save that keeps the form withdrawn saves', async () => { - const { protocol } = makeTwoPackageProtocol(); - expect((await protocol.saveMetaItem({ - type: 'view', name: 'task', item: container({ ...open, allowAnonymous: false }, 'intake_v2'), organizationId: 'org_a', - })).success).toBe(true); - }); - - it('control: a package-less env-wide row stands in for every package, so the org save it leaves open saves', async () => { - const { protocol } = makeTwoPackageProtocol(); - expect((await protocol.saveMetaItem({ type: 'view', name: 'task', item: container(open) })).success).toBe(true); - expect((await protocol.saveMetaItem({ - type: 'view', name: 'task', item: renamedOpen, organizationId: 'org_a', - })).success).toBe(true); - }); -}); - // The draft-key read runs ahead of the lock check, so an unreadable store is // answered there as the lock read answers it: a 503, never a driver error. describe('a publish that states no package, over a store that cannot be read', () => { diff --git a/packages/metadata-protocol/src/protocol.package-delete-refusal.test.ts b/packages/metadata-protocol/src/protocol.package-delete-refusal.test.ts index 7ecfc4633c7..a7a677ac296 100644 --- a/packages/metadata-protocol/src/protocol.package-delete-refusal.test.ts +++ b/packages/metadata-protocol/src/protocol.package-delete-refusal.test.ts @@ -134,9 +134,9 @@ function boot(world: World, storeDelete: StoreDelete = landed, opts: { uninstall const engine = { registry, // Scalar equality on every `where` key, and the caller's `limit` by - // presence after the filter. A combinator (`$or`, the org-scoped - // uninstall's) is not implemented here, so it is refused, never answered - // as "no rows" — these pins uninstall with `allTenants: true`. + // presence after the filter. A combinator (`$or`) is not implemented + // here, so it is refused, never answered as "no rows" — the uninstall is + // package-wide (ADR-0131 D6), which states no combinator. find: async (object: string, query?: { where?: Record; limit?: number }) => { if (object !== 'sys_metadata') return []; const where = query?.where ?? {}; @@ -212,7 +212,7 @@ describe('#21276 deletePackage — a refused sys_packages delete fails the unins const before = snapshot(world); const { impl, registry } = boot(world, refusal); - const err = await rejectionOf(impl.deletePackage({ packageId: PKG, allTenants: true })); + const err = await rejectionOf(impl.deletePackage({ packageId: PKG })); expect(door(err)).toMatchObject({ status: 500, code }); // The driver's words stay on `cause` for the operator, never in the caller's sentence. @@ -232,7 +232,7 @@ describe('#21276 deletePackage — a refused sys_packages delete fails the unins throw refusal; }); - const err = await rejectionOf(impl.deletePackage({ packageId: PKG, allTenants: true })); + const err = await rejectionOf(impl.deletePackage({ packageId: PKG })); expect(err).toBe(refusal); expect(door(err)).toMatchObject({ status: 409, code: 'DESTRUCTIVE_CHANGE' }); @@ -242,6 +242,26 @@ describe('#21276 deletePackage — a refused sys_packages delete fails the unins }); }); +describe('[ADR-0131 D6] deletePackage — the retired organization request keys', () => { + it.each([ + ['organizationId', { organizationId: 'org_a' }], + ['allTenants: true', { allTenants: true }], + ['allTenants: false', { allTenants: false }], + ['allTenants: undefined', { allTenants: undefined }], + ] as const)('a request carrying %s is refused 400 INVALID_REQUEST, and nothing is removed', async (_label, keys) => { + const world = makeWorld(); + const before = snapshot(world); + const { impl, registry } = boot(world); + + const err = await rejectionOf(impl.deletePackage({ packageId: PKG, ...keys } as any)); + + expect(door(err)).toMatchObject({ status: 400, code: 'INVALID_REQUEST' }); + expect(world.steps).toEqual([]); + expect(snapshot(world)).toEqual(before); + expect(registry.getPackage(PKG)).toBeDefined(); + }); +}); + describe('#21276 deletePackage — the registry\'s uninstall refusal is asked BEFORE the store delete', () => { it('another package extends an object this one owns: the refusal is thrown as is, and the sys_packages row survives', async () => { const extender = new Error( @@ -251,7 +271,7 @@ describe('#21276 deletePackage — the registry\'s uninstall refusal is asked BE const before = snapshot(world); const { impl, registry } = boot(world, landed, { uninstallRefusal: extender }); - const err = await rejectionOf(impl.deletePackage({ packageId: PKG, allTenants: true })); + const err = await rejectionOf(impl.deletePackage({ packageId: PKG })); // The registry's own error, unwrapped: the door answers it as it always did. expect(err).toBe(extender); @@ -267,7 +287,7 @@ describe('#21276 deletePackage — the registry\'s uninstall refusal is asked BE describe('#21276 the restart — a fresh process over the same store holds the package as the uninstall reported it', () => { it.each(REFUSALS)('after a %s refusal, the package comes back WITH its metadata and grants', async (_label, _code, refusal) => { const world = makeWorld(); - await rejectionOf(boot(world, refusal).impl.deletePackage({ packageId: PKG, allTenants: true })); + await rejectionOf(boot(world, refusal).impl.deletePackage({ packageId: PKG })); const restarted = boot(world); @@ -284,7 +304,7 @@ describe('#21276 the restart — a fresh process over the same store holds the p const world = makeWorld(); const first = boot(world); - const res = await first.impl.deletePackage({ packageId: PKG, allTenants: true }); + const res = await first.impl.deletePackage({ packageId: PKG }); expect(res).toMatchObject({ success: true, deletedCount: 2, failedCount: 0 }); expect(res.cleanups).toEqual([{ name: 'security.package-permissions', success: true, removed: 1 }]); diff --git a/packages/metadata-protocol/src/protocol.package-publish-audit-rows.test.ts b/packages/metadata-protocol/src/protocol.package-publish-audit-rows.test.ts index 29ccb75e6ac..2bb47078a19 100644 --- a/packages/metadata-protocol/src/protocol.package-publish-audit-rows.test.ts +++ b/packages/metadata-protocol/src/protocol.package-publish-audit-rows.test.ts @@ -368,7 +368,6 @@ const viewBody = (name: string, label: string, extra: Record = ...extra, }); -const ORG = 'org_alpha'; const PKG = 'com.example.helpdesk'; /** Audit rows for one operation, in write order. */ @@ -379,21 +378,17 @@ const publishRows = (h: Harness, outcome: 'allowed' | 'denied') => opRows(h, 'publish').filter((a) => a.outcome === outcome); /** - * Stage one package-bound draft through the ordinary save path. - * - * `org: null` stages it ENV-WIDE (`organization_id IS NULL`), which is how - * Studio and AI authoring actually write — see the scope cases below. + * Stage one package-bound draft through the ordinary save path — ENV-WIDE + * (`organization_id IS NULL`), the only scope a write lands in (ADR-0131 D6). */ async function stageDraft( protocol: ObjectStackProtocolImplementation, name: string, extra: Record = {}, - org: string | null = ORG, ) { await protocol.saveMetaItem({ type: 'view', name, - ...(org ? { organizationId: org } : {}), item: viewBody(name, `${name} staged`, extra), mode: 'draft', packageId: PKG, @@ -414,7 +409,7 @@ describe('[#8400] publishPackageDrafts audits the batch it publishes', () => { expect(h.auditRows).toHaveLength(0); await protocol.saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: ORG, + type: 'view', name: 'case_grid', item: viewBody('case_grid', 'v1'), actor: 'admin', } as any); @@ -425,7 +420,7 @@ describe('[#8400] publishPackageDrafts audits the batch it publishes', () => { expect(opRows(h, 'save')[0]).toMatchObject({ type: 'view', name: 'case_grid', - organization_id: ORG, + organization_id: null, operation: 'save', outcome: 'allowed', code: 'ok', @@ -465,7 +460,7 @@ describe('[#8400] publishPackageDrafts audits the batch it publishes', () => { expect(publishRows(h, 'allowed')).toHaveLength(0); const res = await protocol.publishPackageDrafts({ - packageId: PKG, organizationId: ORG, actor: 'admin', + packageId: PKG, actor: 'admin', } as any); expect(res.publishedCount).toBe(2); expect(res.success).toBe(true); @@ -478,7 +473,7 @@ describe('[#8400] publishPackageDrafts audits the batch it publishes', () => { for (const row of allowed) { expect(row).toMatchObject({ type: 'view', - organization_id: ORG, + organization_id: null, operation: 'publish', outcome: 'allowed', code: 'ok', @@ -496,7 +491,7 @@ describe('[#8400] publishPackageDrafts audits the batch it publishes', () => { await stageDraft(protocol, 'case_grid'); await stageDraft(protocol, 'ticket_grid'); await protocol.publishPackageDrafts({ - packageId: PKG, organizationId: ORG, actor: 'admin', + packageId: PKG, actor: 'admin', } as any); // The counter shape the #7748 QA run took — which, on the batch route, @@ -515,7 +510,7 @@ describe('[#8400] publishPackageDrafts audits the batch it publishes', () => { await stageDraft(protocol, 'case_grid'); await protocol.publishPackageDrafts({ - packageId: PKG, organizationId: ORG, actor: 'admin', + packageId: PKG, actor: 'admin', } as any); const { events } = await protocol.auditMetaItem({ type: 'view', name: 'case_grid' }); @@ -527,75 +522,6 @@ describe('[#8400] publishPackageDrafts audits the batch it publishes', () => { }); }); - // ── scope: the row is keyed on the DRAFT's org, not the caller's ──────── - // Both fixtures above use `ORG` for the draft AND the publishing session, - // so they cannot tell the two apart — an audit row keyed on either would - // pass. These two pin it, and they are the only cases in this file where - // the two values differ. - // - // Studio and AI authoring write drafts ENV-WIDE (`organization_id IS NULL`) - // while the publishing session may carry a non-null active org. - // `listDrafts` surfaces those env-wide rows to such a caller via its `$or`, - // and `promoteDraftForPublish` is called with the DRAFT's scope (#3115), so - // the active row lands env-wide. An audit row keyed on the caller's active - // org would therefore record the publish against a partition the active row - // never entered. - it('scope: an env-wide draft published by an org-scoped caller audits ENV-WIDE, not to the caller org', async () => { - const h = makeStubEngine(); - const protocol = new ObjectStackProtocolImplementation(h.engine); - - // Draft is env-wide… - await stageDraft(protocol, 'envwide_grid', {}, null); - // …but the publishing session carries a non-null active org. - const res = await protocol.publishPackageDrafts({ - packageId: PKG, organizationId: ORG, actor: 'admin', - } as any); - expect(res.publishedCount).toBe(1); - - const allowed = publishRows(h, 'allowed'); - expect(allowed).toHaveLength(1); - expect(allowed[0].name).toBe('envwide_grid'); - expect(allowed[0].organization_id).toBeNull(); - expect(allowed[0].organization_id).not.toBe(ORG); - }); - - it('scope: an env-wide draft REFUSED under an org-scoped caller audits ENV-WIDE too', async () => { - const h = makeStubEngine(); - const protocol = new ObjectStackProtocolImplementation(h.engine, undefined, 'env_test'); - - await stageDraft(protocol, 'envwide_locked', {}, null); - // Lock the ENV-WIDE active row — the scope `promoteDraftForPublish` - // reads the lock from for an env-wide draft. - await protocol.saveMetaItem({ - type: 'view', name: 'envwide_locked', - item: viewBody('envwide_locked', 'protected', { _lock: 'no-overlay' }), - packageId: PKG, actor: 'admin', - } as any); - - const res = await protocol.publishPackageDrafts({ - packageId: PKG, organizationId: ORG, actor: 'admin', - } as any); - expect(res.publishedCount).toBe(0); - - // Membership, not length: [#8594] a lock refusal now leaves BOTH the - // batch row and the inner verdict's own row, and the scope claim under - // test here is about each row's `organization_id`, not about how many - // rows one refusal produces. - const denied = publishRows(h, 'denied'); - expect(denied.map((a) => a.code).sort()).toEqual(['batch_aborted', 'item_locked']); - for (const row of denied) { - expect(row.name).toBe('envwide_locked'); - // The denied rows read their scope from the draft's OWN scope — the - // `listDrafts` row (`__batchItem` for the batch row, the promote - // request for the inner one), the same source the allowed row's - // `draftOrgId` comes from. Keyed on the caller's active org these - // would be `ORG` and the two outcomes would disagree about where the - // publish was refused. - expect(row.organization_id).toBeNull(); - expect(row.organization_id).not.toBe(ORG); - } - }); - // ── the denied outcome, and the placement that makes it durable ────────── // The sharpest case in this file. A locked item refuses promotion from // INSIDE the batch transaction, and `assertLockAllowsWrite` writes its @@ -613,14 +539,14 @@ describe('[#8400] publishPackageDrafts audits the batch it publishes', () => { // Lock the ACTIVE row AFTER both drafts are staged — locking first // would refuse the draft save itself. await protocol.saveMetaItem({ - type: 'view', name: 'locked_grid', organizationId: ORG, + type: 'view', name: 'locked_grid', item: viewBody('locked_grid', 'protected', { _lock: 'no-overlay' }), packageId: PKG, actor: 'admin', } as any); const auditedBefore = h.auditRows.length; const res = await protocol.publishPackageDrafts({ - packageId: PKG, organizationId: ORG, actor: 'admin', + packageId: PKG, actor: 'admin', } as any); // The batch is all-or-nothing (ADR-0067 D2). @@ -649,7 +575,7 @@ describe('[#8400] publishPackageDrafts audits the batch it publishes', () => { expect(batchAborted[0]).toMatchObject({ type: 'view', name: 'locked_grid', - organization_id: ORG, + organization_id: null, operation: 'publish', outcome: 'denied', // The persisted audit column's own vocabulary, lower-case like @@ -694,7 +620,7 @@ describe('[#8400] publishPackageDrafts audits the batch it publishes', () => { // `sys_metadata` write and must succeed. h.faults.failMetadataWriteFor = 'broken_grid'; const res = await protocol.publishPackageDrafts({ - packageId: PKG, organizationId: ORG, actor: 'admin', + packageId: PKG, actor: 'admin', } as any); expect(res.publishedCount).toBe(0); @@ -733,7 +659,7 @@ describe('[#8400] publishPackageDrafts audits the batch it publishes', () => { await stageDraft(protocol, 'case_grid'); await stageDraft(protocol, 'ticket_grid'); const res = await protocol.publishPackageDrafts({ - packageId: PKG, organizationId: ORG, actor: 'admin', + packageId: PKG, actor: 'admin', } as any); // The publish still succeeds — best-effort, by contract. @@ -809,7 +735,7 @@ describe('[#8594] a refused publish leaves the INNER verdict, in its own vocabul await stageDraft(protocol, 'solo_locked'); await protocol.saveMetaItem({ - type: 'view', name: 'solo_locked', organizationId: ORG, + type: 'view', name: 'solo_locked', item: viewBody('solo_locked', 'protected', { _lock: 'no-overlay' }), packageId: PKG, actor: 'admin', } as any); @@ -817,7 +743,7 @@ describe('[#8594] a refused publish leaves the INNER verdict, in its own vocabul let caught: any; try { await protocol.publishMetaItem({ - type: 'view', name: 'solo_locked', organizationId: ORG, actor: 'admin', + type: 'view', name: 'solo_locked', actor: 'admin', } as any); } catch (e) { caught = e; } @@ -830,7 +756,7 @@ describe('[#8594] a refused publish leaves the INNER verdict, in its own vocabul expect(locked[0]).toMatchObject({ type: 'view', name: 'solo_locked', - organization_id: ORG, + organization_id: null, operation: 'publish', outcome: 'denied', // adr0112-ok: D6b — persisted audit column, its own vocabulary @@ -850,13 +776,13 @@ describe('[#8594] a refused publish leaves the INNER verdict, in its own vocabul await stageDraft(protocol, 'case_grid'); await stageDraft(protocol, 'locked_grid'); await protocol.saveMetaItem({ - type: 'view', name: 'locked_grid', organizationId: ORG, + type: 'view', name: 'locked_grid', item: viewBody('locked_grid', 'protected', { _lock: 'no-overlay' }), packageId: PKG, actor: 'admin', } as any); const res = await protocol.publishPackageDrafts({ - packageId: PKG, organizationId: ORG, actor: 'admin', + packageId: PKG, actor: 'admin', } as any); // ── THE DELIVERABLE ────────────────────────────────────────────────── @@ -867,7 +793,7 @@ describe('[#8594] a refused publish leaves the INNER verdict, in its own vocabul expect(locked[0]).toMatchObject({ type: 'view', name: 'locked_grid', - organization_id: ORG, + organization_id: null, operation: 'publish', outcome: 'denied', // adr0112-ok: D6b — persisted audit column, its own vocabulary @@ -916,12 +842,12 @@ describe('[#8594] a refused publish leaves the INNER verdict, in its own vocabul await stageDraft(protocol, 'locked_grid'); await protocol.saveMetaItem({ - type: 'view', name: 'locked_grid', organizationId: ORG, + type: 'view', name: 'locked_grid', item: viewBody('locked_grid', 'protected', { _lock: 'no-overlay' }), packageId: PKG, actor: 'admin', } as any); await protocol.publishPackageDrafts({ - packageId: PKG, organizationId: ORG, actor: 'admin', + packageId: PKG, actor: 'admin', } as any); const { events } = await protocol.auditMetaItem({ type: 'view', name: 'locked_grid' }); @@ -954,7 +880,7 @@ describe('[#8594] a refused publish leaves the INNER verdict, in its own vocabul // An active row for the draft to advance PAST — with no active row // the parent version is null on both reads and there is no race. await protocol.saveMetaItem({ - type: 'view', name: 'raced_grid', organizationId: ORG, + type: 'view', name: 'raced_grid', item: viewBody('raced_grid', 'head'), packageId: PKG, actor: 'admin', } as any); @@ -962,7 +888,7 @@ describe('[#8594] a refused publish leaves the INNER verdict, in its own vocabul h.faults.advanceActiveOnRead = 'raced_grid'; const res = await protocol.publishPackageDrafts({ - packageId: PKG, organizationId: ORG, actor: 'admin', + packageId: PKG, actor: 'admin', } as any); // ── THE DELIVERABLE ────────────────────────────────────────────── @@ -971,7 +897,7 @@ describe('[#8594] a refused publish leaves the INNER verdict, in its own vocabul expect(conflict[0]).toMatchObject({ type: 'view', name: 'raced_grid', - organization_id: ORG, + organization_id: null, operation: 'publish', outcome: 'denied', // adr0112-ok: D6b — persisted audit column, its own vocabulary @@ -1014,13 +940,13 @@ describe('[#8594] a refused publish leaves the INNER verdict, in its own vocabul await stageDraft(protocol, 'locked_grid'); await protocol.saveMetaItem({ - type: 'view', name: 'locked_grid', organizationId: ORG, + type: 'view', name: 'locked_grid', item: viewBody('locked_grid', 'protected', { _lock: 'no-overlay' }), packageId: PKG, actor: 'admin', } as any); const res = await protocol.publishPackageDrafts({ - packageId: PKG, organizationId: ORG, actor: 'admin', + packageId: PKG, actor: 'admin', } as any); expect(res.publishedCount).toBe(0); @@ -1135,7 +1061,7 @@ describe('[#8595] a PRE-FLIGHT refused publish leaves a row per violation', () = expect(publishRows(h, 'denied')).toHaveLength(0); const res = await protocol.publishPackageDrafts({ - packageId: PKG, organizationId: ORG, actor: 'admin', + packageId: PKG, actor: 'admin', } as any); // The whole batch is refused pre-flight — nothing promoted. @@ -1167,13 +1093,10 @@ describe('[#8595] a PRE-FLIGHT refused publish leaves a row per violation', () = expect(String(row.note)).toContain(`${NS}_`); } - // Scope: the DRAFT's own partition (env-wide), not the caller's active - // org — the same rule the promoted rows follow. Keyed on the caller's - // org these would be `ORG` and the trail would record the refusal - // against a partition the item never lived in. + // Scope: the DRAFT's own partition (env-wide) — the same rule the + // promoted rows follow. for (const row of denied) { expect(row.organization_id).toBeNull(); - expect(row.organization_id).not.toBe(ORG); } // The COMPLIANT sibling gets no row of either kind: the batch is @@ -1225,7 +1148,7 @@ describe('[#8595] a PRE-FLIGHT refused publish leaves a row per violation', () = await stageObjectDraft(protocol, 'ticket'); declareNamespace(h); await protocol.publishPackageDrafts({ - packageId: PKG, organizationId: ORG, actor: 'admin', + packageId: PKG, actor: 'admin', } as any); // adr0112-ok: D6b — persisted audit column, its own vocabulary @@ -1254,7 +1177,7 @@ describe('[#8595] a PRE-FLIGHT refused publish leaves a row per violation', () = await stageObjectDraft(protocol, 'escalation'); declareNamespace(h); await protocol.publishPackageDrafts({ - packageId: PKG, organizationId: ORG, actor: 'admin', + packageId: PKG, actor: 'admin', } as any); const { events } = await protocol.auditMetaItem({ type: 'object', name: 'ticket' }); @@ -1287,7 +1210,7 @@ describe('[#8595] a PRE-FLIGHT refused publish leaves a row per violation', () = declareNamespace(h); const res = await protocol.publishPackageDrafts({ - packageId: PKG, organizationId: ORG, actor: 'admin', + packageId: PKG, actor: 'admin', } as any); expect(res.success).toBe(true); expect(res.publishedCount).toBe(1); diff --git a/packages/metadata-protocol/src/protocol.platform-schedule-org-gate.test.ts b/packages/metadata-protocol/src/protocol.platform-schedule-org-gate.test.ts index 3a3dbe82d6c..0bece4965f9 100644 --- a/packages/metadata-protocol/src/protocol.platform-schedule-org-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.platform-schedule-org-gate.test.ts @@ -9,7 +9,8 @@ * The refusal combination, all five limbs: * * multi-organization posture - * && platform-level flow (the write carries no organization) + * && platform-level flow (every write is, since ADR-0131 D6 — an org-scoped + * write is refused 403 before this rule runs; pinned below) * && schedule trigger * && contains a `create_record` * && that node declares no `fields.organization_id` @@ -176,10 +177,6 @@ describe('findPlatformScheduleOrgGaps — the refusal combination (#6285)', () = expect(judge({ orgWallEnforced: false })).toEqual([]); }); - it('passes when the flow is written INTO an organization', () => { - expect(judge({ organizationId: 'org_a' })).toEqual([]); - }); - it('passes for a non-schedule trigger — a record-change flow resolves an org', () => { expect(judge({ body: recordChangeCreator() })).toEqual([]); }); diff --git a/packages/metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts b/packages/metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts index eca38ece3f8..cb4a1493807 100644 --- a/packages/metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts +++ b/packages/metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts @@ -292,7 +292,7 @@ describe('platform-store calls carry the explicit system opt-in (#21911)', () => } as any).catch(() => undefined); }); await expectSystemOptIn(calls, 'deletePackage', async () => { - await protocol.deletePackage({ packageId: 'com.example.pincopy', allTenants: true } as any).catch(() => undefined); + await protocol.deletePackage({ packageId: 'com.example.pincopy' } as any).catch(() => undefined); }); }); }); @@ -338,7 +338,7 @@ describe('[#21908] the deny round — the platform-store calls still reaching th // (publish, history, audit, diff, commits, migration, the legacy delete), // so each carries the explicit opt-in now, like the calls above. - it('the publish path: the promotion draft read and the org-scoped publish probes', async () => { + it('the publish path: the promotion draft read', async () => { const { engine, calls } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine) as any; await protocol.saveMetaItem({ type: 'view', name: 'proj_task_grid', item: viewBody('proj_task_grid'), mode: 'draft' }); @@ -349,8 +349,6 @@ describe('[#21908] the deny round — the platform-store calls still reaching th }); await expectSystemOptIn(calls, 'promoteDraftForPublish', () => protocol.promoteDraftForPublish({ type: 'view', name: 'proj_task_grid' }).catch(() => undefined)); - await expectSystemOptIn(calls, 'resolveDraftOrgScopeForPublish', () => - protocol.resolveDraftOrgScopeForPublish('view', 'proj_task_grid', 'org_a')); }); it('the readers behind the history, audit, diff and commit doors', async () => { @@ -358,8 +356,6 @@ describe('[#21908] the deny round — the platform-store calls still reaching th const protocol = new ObjectStackProtocolImplementation(engine) as any; await protocol.saveMetaItem({ type: 'view', name: 'proj_task_grid', item: viewBody('proj_task_grid'), mode: 'publish' }); - await expectSystemOptIn(calls, 'resolveMetaItemOrgScope', () => - protocol.resolveMetaItemOrgScope('view', 'proj_task_grid', 'org_a')); await expectSystemOptIn(calls, 'auditMetaItem', () => protocol.auditMetaItem({ type: 'view', name: 'proj_task_grid' })); const diffCalls = await expectSystemOptIn(calls, 'diffMetaItem', () => diff --git a/packages/metadata-protocol/src/protocol.publish-item-draft-org-scope.test.ts b/packages/metadata-protocol/src/protocol.publish-item-draft-org-scope.test.ts index 53633b5341d..9dea1efebb5 100644 --- a/packages/metadata-protocol/src/protocol.publish-item-draft-org-scope.test.ts +++ b/packages/metadata-protocol/src/protocol.publish-item-draft-org-scope.test.ts @@ -17,8 +17,13 @@ * per-item publish worked for them and failed for views. * * That is the single-item twin of #3115, which the batch door fixed by promoting - * each draft in the scope `listDrafts` surfaced it FROM. The per-item door now - * DISCOVERS the draft's scope the same way, with the ADR-0005 precedence. + * each draft in the scope `listDrafts` surfaced it FROM. + * + * [ADR-0131 D6] Every write is now environment-wide: an organization-scoped + * publish request is refused (403 NOT_OVERRIDABLE) before any read, and the + * per-item door no longer discovers an org scope at all. What remains pinned + * here is the outcome #10219 B asked for — an env-wide draft publishes, and is + * reported, env-wide — plus that refusal. * * Harness: the faithful multi-table stub engine used by * `protocol-publish-drafts-org-scope.test.ts` / `-advisories.test.ts` (kept @@ -178,23 +183,21 @@ const viewBody = (name: string) => ({ columns: [{ field: 'name', label: 'Name' }], }); -describe('publishMetaItem resolves the draft\'s OWN org scope (#10219 B, the single-item #3115)', () => { - it('publishes an env-wide `view` draft although the session carries an active org', async () => { +describe('publishMetaItem publishes env-wide drafts (#10219 B; ADR-0131 D6)', () => { + it('publishes an env-wide `view` draft', async () => { const { engine, rows } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); // Authored env-wide — what package/AI authoring writes, and what - // `PUT ?mode=draft` writes with no active org threaded. + // `PUT ?mode=draft` writes. await protocol.saveMetaItem({ type: 'view', name: 'customer_list', item: viewBody('customer_list'), packageId: PKG, mode: 'draft', }); - // `POST /meta/view/customer_list/publish` from a session with an active - // org: `view` is org-overridable, so the REST seam threads it. Before the - // fix this answered 404 `[no_draft]`. + // Before #10219 B this answered 404 `[no_draft]` once an org was threaded. const res = await protocol.publishMetaItem({ - type: 'view', name: 'customer_list', organizationId: 'org_alpha', actor: 'admin', + type: 'view', name: 'customer_list', actor: 'admin', }); expect(res.success).toBe(true); @@ -202,7 +205,7 @@ describe('publishMetaItem resolves the draft\'s OWN org scope (#10219 B, the sin expect(remaining.filter((r) => r.state === 'draft')).toHaveLength(0); const active = remaining.filter((r) => r.state === 'active'); expect(active).toHaveLength(1); - // Promoted in the scope it was authored in — NOT copied into the org. + // Promoted in the scope it was authored in. expect(active[0]!.organization_id).toBeNull(); }); @@ -217,7 +220,7 @@ describe('publishMetaItem resolves the draft\'s OWN org scope (#10219 B, the sin packageId: PKG, mode: 'draft', }); await protocol.publishMetaItem({ - type: 'view', name: 'customer_list', organizationId: 'org_alpha', actor: 'admin', + type: 'view', name: 'customer_list', actor: 'admin', }); expect(seen).toEqual([ @@ -225,30 +228,24 @@ describe('publishMetaItem resolves the draft\'s OWN org scope (#10219 B, the sin ]); }); - it('PRECEDENCE — an org that has its own draft publishes THAT one, not the env-wide row', async () => { + it('an organization-scoped publish request is refused 403 NOT_OVERRIDABLE, and the draft stays pending', async () => { const { engine, rows } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); await protocol.saveMetaItem({ - type: 'view', name: 'customer_list', item: { ...viewBody('customer_list'), label: 'Env wide' }, + type: 'view', name: 'customer_list', item: viewBody('customer_list'), packageId: PKG, mode: 'draft', }); - await protocol.saveMetaItem({ - type: 'view', name: 'customer_list', item: { ...viewBody('customer_list'), label: 'Org alpha' }, - organizationId: 'org_alpha', packageId: PKG, mode: 'draft', - }); - await protocol.publishMetaItem({ - type: 'view', name: 'customer_list', organizationId: 'org_alpha', actor: 'admin', - }); + await expect( + protocol.publishMetaItem({ + type: 'view', name: 'customer_list', organizationId: 'org_alpha', actor: 'admin', + } as any), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - // The ADR-0005 overlay order: the caller's own org shadows env-wide. - const active = Array.from(rows.values()).filter((r) => r.state === 'active'); - expect(active).toHaveLength(1); - expect(active[0]!.organization_id).toBe('org_alpha'); - expect(JSON.parse(active[0]!.metadata).label).toBe('Org alpha'); - // The env-wide draft is untouched — it was never this publish's subject. - const drafts = Array.from(rows.values()).filter((r) => r.state === 'draft'); + const remaining = Array.from(rows.values()); + expect(remaining.filter((r) => r.state === 'active')).toHaveLength(0); + const drafts = remaining.filter((r) => r.state === 'draft'); expect(drafts).toHaveLength(1); expect(drafts[0]!.organization_id).toBeNull(); }); @@ -264,7 +261,7 @@ describe('publishMetaItem resolves the draft\'s OWN org scope (#10219 B, the sin await expect( protocol.publishMetaItem({ - type: 'view', name: 'customer_list', organizationId: 'org_alpha', actor: 'admin', + type: 'view', name: 'customer_list', actor: 'admin', }), ).rejects.toMatchObject({ code: 'NO_DRAFT', status: 404 }); }); diff --git a/packages/metadata-protocol/src/protocol.reassign-orphans-durability.test.ts b/packages/metadata-protocol/src/protocol.reassign-orphans-durability.test.ts index 6e415e6b3c2..8a71b9f282a 100644 --- a/packages/metadata-protocol/src/protocol.reassign-orphans-durability.test.ts +++ b/packages/metadata-protocol/src/protocol.reassign-orphans-durability.test.ts @@ -84,22 +84,24 @@ function makeEngine(rows: MetaRow[]) { const engine = { async find(table: string, opts?: { where?: Record; limit?: number }) { if (table !== 'sys_metadata') return []; - // This double implements NEITHER a `where` combinator NOR a bound, - // and REFUSES both rather than answering them silently. Every case - // in this file adopts env-wide orphans, so the producer passes - // `{ where: {} }` and no `limit`; the org-scoped `$or` branch and - // paging belong to tests that do not exist yet. A double looser - // than the engine it stands in for converts a green suite into no - // suite at all (#4434) — and the reason to refuse rather than - // approximate is that the approximation is invisible on the day the - // producer starts using the shape. - const where = opts?.where ?? {}; - if (Object.keys(where).length > 0) { - throw new Error(`fake engine: unsupported where ${JSON.stringify(where)}`); - } + // This double implements ONE `where` shape — the env-wide filter + // `{ organization_id: null }` the producer passes (ADR-0131 D6: + // only env-wide orphans are adopted) — and NO bound, and REFUSES + // anything else rather than answering it silently. Paging belongs + // to tests that do not exist yet. A double looser than the engine + // it stands in for converts a green suite into no suite at all + // (#4434) — and the reason to refuse rather than approximate is + // that the approximation is invisible on the day the producer + // starts using the shape. if (opts?.limit !== undefined) { throw new Error('fake engine: unsupported `limit` — this double holds no bound'); } + const where = opts?.where ?? {}; + const keys = Object.keys(where); + if (keys.length !== 1 || keys[0] !== 'organization_id' || where.organization_id !== null) { + throw new Error(`fake engine: unsupported where ${JSON.stringify(where)}`); + } + // Every fixture row is env-wide, so the store IS the env-wide answer. return [...store.values()]; }, async update(_table: string, data: Record, opts: { where: Record }) { diff --git a/packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts b/packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts index 61bfccaafec..dbc356020be 100644 --- a/packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts +++ b/packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts @@ -498,25 +498,6 @@ describe('[#14179] door 3 — deleteMetaItem’s legacy raw-engine exit', () => ]); }); - it('carries the org scope the delete predicate used', async () => { - const { protocol, seen, engine } = makeProtocol('bootstrap'); - await engine.insert('sys_metadata', { - type: 'api', name: 'legacy_endpoint', - organization_id: 'org_acme', - package_id: null, - state: 'active', - metadata: JSON.stringify({ name: 'legacy_endpoint' }), - }); - - const res = await protocol.deleteMetaItem({ - type: 'api', name: 'legacy_endpoint', organizationId: 'org_acme', - }); - - expect(res.success).toBe(true); - expect(seen).toEqual([ - { type: 'api', name: 'legacy_endpoint', state: 'deleted', organizationId: 'org_acme' }, - ]); - }); }); // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts b/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts index 1195c6d48a2..11218d8556a 100644 --- a/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts +++ b/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts @@ -462,7 +462,6 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => { await protocol.saveMetaItem({ type: 'view', name: 'case_grid', - organizationId: 'org_alpha', // [#7741] carries the object binding the inline arm now requires. item: { name: 'case_grid', type: 'grid', label: 'Cases', columns: ['id', 'title'], object: 'case', viewKind: 'list' }, }); diff --git a/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts b/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts index efa3324f94f..da814e96d73 100644 --- a/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts +++ b/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts @@ -207,29 +207,6 @@ describe('#5265 — a save receipt names what was actually written', () => { }); } - it('an org-scoped runtime-only save names the org, not an overlay', async () => { - // [#6190, 2026-08-09] Re-spelled from `hook` to `view`. The claim is - // about the RECEIPT — "(org=…)" rather than the overlay phrasing — and - // the receipt does not vary by type. What changed is which types can - // reach this receipt at all: since the #6190 ruling an org-scoped write - // requires a type that declares `allowOrgOverride`, and the - // overlay-less-yet-overridable population is empty by ruling (see the - // population pin above). `view` is runtime-only here for the reason the - // case below states — no artifact was shipped at this name — so this - // still measures a RUNTIME-ONLY org-scoped save, not an overlay. - const { protocol } = makeProtocol(); - - const result = await protocol.saveMetaItem({ - type: 'view', name: 'rc5_probe_view', item: VIEW, - organizationId: 'org_alpha', - }); - - expect(result.message).not.toContain('customization overlay'); - expect(result.message).toBe( - `Saved view 'rc5_probe_view' (org=org_alpha, state=active) [seq=${result.seq}]`, - ); - }); - it('a runtime-only draft still reports its lifecycle state', async () => { const { protocol } = makeProtocol(); @@ -277,18 +254,6 @@ describe('#5265 — a save receipt names what was actually written', () => { ); }); - it('an org-scoped overlay OF a packaged artifact keeps the original sentence', async () => { - const { protocol } = makeProtocol([{ type: 'view', name: 'rc5_probe_view' }]); - - const result = await protocol.saveMetaItem({ - type: 'view', name: 'rc5_probe_view', item: VIEW, organizationId: 'org_alpha', - }); - - expect(result.message).toBe( - `Saved customization overlay (org=org_alpha, state=active) — type=view, name=rc5_probe_view [seq=${result.seq}]`, - ); - }); - it('an overlay draft keeps the original sentence too', async () => { const { protocol } = makeProtocol([{ type: 'view', name: 'rc5_probe_view' }]); @@ -344,9 +309,7 @@ describe('#5265 — a save receipt names what was actually written', () => { const { protocol: overlaid } = makeProtocol([{ type: 'view', name: 'rc5_probe_view' }]); const runtimeOnly = await plain.saveMetaItem({ type: 'view', name: 'rc5_probe_view', item: VIEW }); - const override = await overlaid.saveMetaItem({ - type: 'view', name: 'rc5_probe_view', item: VIEW, organizationId: 'org_alpha', - }); + const override = await overlaid.saveMetaItem({ type: 'view', name: 'rc5_probe_view', item: VIEW }); expect(runtimeOnly.message.length).toBeLessThan(200); expect(override.message.length).toBeLessThan(200); diff --git a/packages/metadata-protocol/src/protocol.served-content-hash.test.ts b/packages/metadata-protocol/src/protocol.served-content-hash.test.ts index 009dc8bdd52..6b2bb685e21 100644 --- a/packages/metadata-protocol/src/protocol.served-content-hash.test.ts +++ b/packages/metadata-protocol/src/protocol.served-content-hash.test.ts @@ -168,8 +168,7 @@ const viewBody = (label: string) => ({ object: 'case', viewKind: 'list', }); -const ORG = 'org_alpha'; -const ref = { type: 'view', name: 'case_grid', organizationId: ORG, actor: 'admin' } as const; +const ref = { type: 'view', name: 'case_grid', actor: 'admin' } as const; /** Every stored content hash the double holds, active rows and history alike. */ function storedHashes(h: ReturnType): Set { @@ -257,8 +256,8 @@ describe('[#21207] the history read serves keyed hashes per event', () => { const v1: any = await p.saveMetaItem({ ...ref, item: viewBody('v1') } as any); await p.saveMetaItem({ ...ref, item: viewBody('v2'), parentVersion: v1.version } as any); - const read1 = await p.historyMetaItem({ type: 'view', name: 'case_grid', organizationId: ORG }); - const read2 = await p.historyMetaItem({ type: 'view', name: 'case_grid', organizationId: ORG }); + const read1 = await p.historyMetaItem({ type: 'view', name: 'case_grid' }); + const read2 = await p.historyMetaItem({ type: 'view', name: 'case_grid' }); expect(read1.events.length).toBeGreaterThanOrEqual(2); for (const ev of read1.events) { expect(ev.hash).toMatch(KEYED); @@ -354,8 +353,8 @@ describe('[#21207] no crypto provider: tokens keyed under a process-scoped ephem expect(v2.version).toMatch(KEYED); expect(v2.version).not.toBe(v1.version); - const read1 = await p.historyMetaItem({ type: 'view', name: 'case_grid', organizationId: ORG }); - const read2 = await p.historyMetaItem({ type: 'view', name: 'case_grid', organizationId: ORG }); + const read1 = await p.historyMetaItem({ type: 'view', name: 'case_grid' }); + const read2 = await p.historyMetaItem({ type: 'view', name: 'case_grid' }); expect(read1.events.length).toBeGreaterThanOrEqual(2); for (const ev of read1.events) expect(ev.hash).toMatch(KEYED); expect(read1.events.map((e) => e.hash)).toContain(v2.version); @@ -408,7 +407,7 @@ describe('[#21207] no crypto provider: tokens keyed under a process-scoped ephem expect(refused.code).toBe('METADATA_CONFLICT'); expect(refused.status).toBe(409); - const { events } = await p.historyMetaItem({ type: 'view', name: 'case_grid', organizationId: ORG }); + const { events } = await p.historyMetaItem({ type: 'view', name: 'case_grid' }); const current = events.find((e) => e.hash === refused.actualHead); expect(refused.actualHead).toBe(await keyedDigest(activeHash(h))); expect(current).toBeDefined(); @@ -458,7 +457,7 @@ describe('[#21207] a change note that quotes a stored hash', () => { // A row written before the publish door stated its own message. for (const row of h.historyRows) row.change_note = `publish draft (hash ${stored})`; - const { events } = await p.historyMetaItem({ type: 'view', name: 'case_grid', organizationId: ORG }); + const { events } = await p.historyMetaItem({ type: 'view', name: 'case_grid' }); expect(events.length).toBeGreaterThan(0); for (const ev of events) { // The quote is served as the very token the receipt served. @@ -483,14 +482,14 @@ describe('[#21207] a change note that quotes a stored hash', () => { // from the same read. The lock is type-agnostic, so this file's `view` row // stands in for the datasource one. -const VIEW_REF = { type: 'view', name: 'case_grid', org: ORG } as const; +const VIEW_REF = { type: 'view', name: 'case_grid' } as const; /** Store the active row the way a writer that stamps no `checksum` did. */ async function seedUnstamped(h: ReturnType, label = 'legacy', state = 'active'): Promise { await h.engine.insert('sys_metadata', { type: 'view', name: 'case_grid', - organization_id: ORG, + organization_id: null, package_id: null, state, metadata: JSON.stringify(viewBody(label)), @@ -503,7 +502,7 @@ function caseGridRow(h: ReturnType, state = 'active'): Row | } function repoFor(h: ReturnType): SysMetadataRepository { - return new SysMetadataRepository({ engine: h.engine, organizationId: ORG, orgLabel: ORG }); + return new SysMetadataRepository({ engine: h.engine }); } /** The version the repository's own read serves for the row, keyed as a door hands it out. */ @@ -643,7 +642,7 @@ describe('[#21978] the repository lock: a row with a checksum is judged exactly await h.engine.insert('sys_metadata', { type: 'view', name: 'case_grid', - organization_id: ORG, + organization_id: null, package_id: null, state: 'active', metadata: JSON.stringify(viewBody('stamped')), @@ -711,7 +710,7 @@ function withReadRegistry(h: ReturnType): ReturnType { it('active and draft: the read\'s `version` is the receipt\'s token, keyed, never the stored hash — and it pins the next save', async () => { @@ -733,25 +732,6 @@ describe('[#22114] the item read serves the version token the save door accepts' expect(((await p.getMetaItem({ ...READ })) as any).version).toBe(next.version); }); - it('`null` where the save\'s address holds no row — an organization falling back to the env-wide row', async () => { - const h = withReadRegistry(makeEngine()); - const p = new ObjectStackProtocolImplementation(h.engine); - // Env-wide row only; the org-scoped read serves it (ADR-0005 fallback), - // while an org-scoped save writes the org partition, where nothing is. - await p.saveMetaItem({ type: 'view', name: 'case_grid', item: viewBody('env-wide'), actor: 'admin' } as any); - - const read: any = await p.getMetaItem({ ...READ }); - expect(read.item?.label).toBe('env-wide'); - expect(read.version).toBeNull(); - // `null` is the create pin: honoured, and then refused once the row exists. - const created: any = await p.saveMetaItem({ ...ref, item: viewBody('org'), parentVersion: null } as any); - const refused = await rejection(() => - p.saveMetaItem({ ...ref, item: viewBody('org again'), parentVersion: null } as any)); - expect(refused.code).toBe('METADATA_CONFLICT'); - expect(refused.status).toBe(409); - expect(refused.currentVersion).toBe(created.version); - }); - it('a `previewDrafts` read publishes no `version` — it serves two lifecycles', async () => { const h = withReadRegistry(makeEngine()); const p = new ObjectStackProtocolImplementation(h.engine); diff --git a/packages/metadata-protocol/src/protocol.stored-migration.test.ts b/packages/metadata-protocol/src/protocol.stored-migration.test.ts index c1f0aed627d..e9c1a3f4f4f 100644 --- a/packages/metadata-protocol/src/protocol.stored-migration.test.ts +++ b/packages/metadata-protocol/src/protocol.stored-migration.test.ts @@ -281,23 +281,12 @@ describe('migrateStoredMetadata — apply (#4327)', () => { // The walk is what this case is named for, and the walk is unchanged: // both buckets are scanned. What changed is the org row's OUTCOME. // - // [#6190, 2026-08-09] `action` is `allowOrgOverride: false`, so since - // that ruling an org-scoped row of it cannot be written — and this pass - // rewrites through `saveMetaItem`, so it is refused like any other - // write. That is the correct outcome, not a gap to route around: - // - // • Ruling 2 = A made existing org-scoped rows of such types - // NON-DESTRUCTIVE residue — audible, disposed of operationally, - // never rewritten by a migration. A canonicalization pass that - // quietly rewrote them would be doing exactly the migration the - // ruling declined to authorise, one row at a time. - // • And the refusal is not silent: the row surfaces in the report - // with the reason, which makes this pass a SECOND residue detector - // alongside the cold-boot warn (PR #6600). - // - // Deliberately NOT re-spelled to an org-overridable type: that would - // have kept the assertion green while deleting the only coverage of - // what the pass does with residue. + // [ADR-0131 D6] Environment metadata has no per-organization layer, so + // the pass never re-saves an org-scoped row (that would move it — the + // promotion ceremony's job, ADR-0131 C7). The row is NON-DESTRUCTIVE + // residue: reported `skipped` with the reason, its bytes untouched — + // which keeps this pass a SECOND residue detector alongside the + // cold-boot warn (PR #6600). const { engine, tables } = makeStubEngine([ legacyObjectRow, { ...legacyActionRow, organization_id: 'org_a' }, @@ -308,11 +297,12 @@ describe('migrateStoredMetadata — apply (#4327)', () => { expect(report.scanned).toBe(2); expect(report.rewritten).toBe(1); - expect(report.failed).toBe(1); + expect(report.skipped).toBe(1); + expect(report.failed).toBe(0); const orgReport = report.rows.find((r: any) => r.type === 'action')!; - expect(orgReport.outcome).toBe('failed'); - expect(orgReport.reason).toContain('cannot be written org-scoped'); + expect(orgReport.outcome).toBe('skipped'); + expect(orgReport.reason).toContain('promotion ceremony (ADR-0131 C7)'); // Non-destructive: the stored bytes are exactly as they were. const orgRow = metaRows(tables).find((r) => r.organization_id === 'org_a')!; @@ -909,7 +899,7 @@ describe('migrateStoredMetadata — the decision review list: stored rows take f }; it('a stored two-branch decision with no `mode` is LISTED — row, flow, node, label and path — and the row is canonical', async () => { - const { engine, tables } = makeStubEngine([flowRow('lead_verdict', gatewayBody('lead_verdict'), { organization_id: 'org_1' })]); + const { engine, tables } = makeStubEngine([flowRow('lead_verdict', gatewayBody('lead_verdict'))]); const before = JSON.stringify(metaRows(tables)); const protocol = new ObjectStackProtocolImplementation(engine); @@ -918,7 +908,7 @@ describe('migrateStoredMetadata — the decision review list: stored rows take f expect(report.decisionModeReview).toEqual([{ id: metaRows(tables)[0]!.id, name: 'lead_verdict', - organizationId: 'org_1', + organizationId: null, packageId: null, state: 'active', nodeId: 'check', diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index ddbbc5a74a0..ac55aabbb8d 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -107,13 +107,6 @@ import { // {@link ObjectStackProtocolImplementation.getMetaItemLayered}'s code-layer // fallback so a hydrated row is never answered as the code layer. isTenantAuthored, - // The one rule for which forms a `view` body opens to anonymous intake — - // the same rule the anonymous form doors in `@objectstack/rest` serve by. - anonymousFormIntakeSlugs, - // A withdrawal is a kill switch: the doors' layer predicate, which the - // org-scoped write door asks before accepting a re-opening write. - anonymousFormIntakeCandidates, - anonymousFormIntakeWithdrawnIn, // [#21476] The posture IN FORCE, read off the `tenancy` service the one way // the anonymous form doors read it — the runtime authoring gate's input for // its public-form intake advisory (see `tenancyPostureInForce()`). @@ -5080,7 +5073,6 @@ export type PublishMaterializer = (args: { */ export type UninstallCleanup = (args: { packageId: string; - organizationId?: string; actor?: string; }) => Promise<{ success: boolean; removed: number; error?: string }>; @@ -5123,21 +5115,13 @@ export interface UninstallCleanupOutcome { * not asked for. Should an external consumer ever appear, the spec declaration * is its own card for the spec seat, not a rider on a typing convergence. * - * `organizationId` is the load-bearing member. The tenant-scope gate in - * `deletePackage` refuses a call naming neither it nor `allTenants` - * (`TENANT_SCOPE_REQUIRED`, 400 — #7780), so it is precisely the key whose - * presence decides an uninstall's blast radius, and it was the key the REST - * seam's own type had no word for. + * [ADR-0131 D6/D12] No organization member: an uninstall is environment-wide + * by construction. The `organizationId` and `allTenants` keys that once chose + * its blast radius (#7780) are retired, and `deletePackage` refuses a request + * still carrying either (`INVALID_REQUEST`, 400). */ export interface DeletePackageRequest { packageId: string; - /** - * Scope the uninstall to ONE organization's rows (#7705). Omitted together - * with `allTenants` ⇒ refused, never inferred as "every tenant" (#7780). - */ - organizationId?: string; - /** DECLARE a cross-tenant uninstall. Never deduced from an absent org (#7780). */ - allTenants?: boolean; actor?: string; /** Remove the metadata but PRESERVE each object's physical table. */ keepData?: boolean; @@ -5408,7 +5392,12 @@ export type MetadataAuthoringChannel = 'environment' | 'package-author'; interface MetadataAuditEntry { type: string; name: string; - organizationId?: string | null; + /** + * [ADR-0131 D6/D7] Always environment-wide: the audit ledger is + * deployment-level, and no metadata write is organization-scoped any more. + * Typed `null` so an organization cannot be recorded by accident. + */ + organizationId?: null; operation: 'save' | 'publish' | 'rollback' | 'delete' | 'reset'; outcome: 'allowed' | 'denied' | 'forced'; code: string; @@ -5876,7 +5865,7 @@ export class ObjectStackProtocolImplementation implements * that door before this existed: the package's object answered 404 after a * restart while its `managed_by: package` `sys_permission_set` row and its * user grant survived. `deletePackage` itself does not fit that door: it - * refuses without a tenant scope, answers `success: false` when it deletes + * answers `success: false` when it deletes * no `sys_metadata` row, and withdraws the package from the running * registry, which that door never did. * @@ -5887,14 +5876,13 @@ export class ObjectStackProtocolImplementation implements * does — and never swallows it. */ async runUninstallCleanups( - request: Pick, + request: Pick, ): Promise { const cleanups: UninstallCleanupOutcome[] = []; for (const [name, cleanup] of this.uninstallCleanups) { try { const r = await cleanup({ packageId: request.packageId, - ...(request.organizationId ? { organizationId: request.organizationId } : {}), ...(request.actor ? { actor: request.actor } : {}), }); cleanups.push({ @@ -6935,12 +6923,17 @@ export class ObjectStackProtocolImplementation implements */ private async applyRemoteMetadataMutation(evt: MetadataMutationEvent): Promise { const type = canonicalMetaType(evt.type); - const orgId = evt.organizationId ?? null; + // [ADR-0131 D6] The registry reflects the ENVIRONMENT row, whatever + // organization the event names: no write lands organization-scoped + // any more, and an organization-scoped row never entered the shared + // 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 ref = { type, name: evt.name, - org: orgId ?? 'env', + org: 'env', } as Parameters[0]; const current = await repo.get(ref, { state: 'active' }); if (current) { @@ -7076,171 +7069,6 @@ export class ObjectStackProtocolImplementation implements return repo; } - /** - * [#7559] ADR-0005 / #3115 — resolve the org scope an item's lineage - * ACTUALLY lives in, for a caller whose active org may not be that scope. - * - * This is the read-side half of the rule {@link SysMetadataRepository.listDrafts} - * states on the write side: a non-null-org caller sees BOTH its own overlay - * rows and the env-wide (`organization_id IS NULL`) ones, "so consumers that - * then act on a draft 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". - * {@link publishPackageDrafts} learned it — it promotes each draft through - * `getOverlayRepo(d.organizationId)` and captures `prevVersion` from the - * row in the draft's OWN scope. The two revert callers did not, and read - * back under `getOverlayRepo(request.organizationId)` instead. - * - * Measured on `origin/main` (#7559): an env-wide `view` published twice from - * a console request carrying an active org lands its `sys_metadata` and - * `sys_metadata_history` rows at `organization_id = NULL` while the commit - * records `prevVersion: 2`; `revertCommit` with that same active org then - * asks `sys_metadata_history` for `(organization_id='org_x', version=2)`, - * matches nothing, and answers `VERSION_NOT_FOUND: No history row at - * version 2` — over a row `GET …/history` lists. Same input with no active - * org succeeds, and an org-scoped item reverted by its own org succeeds: - * the disagreement is `organization_id` alone. - * - * NOT the `package_id` scoping #6215 fixed — that one is a step later, in - * {@link SysMetadataRepository.restoreVersion}'s `put()` parent lookup, and - * is intact and uninvolved here (the history table carries no `package_id` - * column at all). - * - * Precedence is the ADR-0005 overlay order — the caller's own org shadows - * env-wide — so an org that has its own overlay row reverts THAT row, and - * only an org with no overlay of its own falls through to the env-wide - * lineage it was already publishing into. When neither scope has a lineage - * the caller's own scope is returned unchanged, so a genuinely absent item - * still fails in the scope the caller asked about. - * - * Deliberately NO `catch`: a driver failure here must fail the revert, not - * resolve to a scope nobody verified (AGENTS.md read-seam invention rule). - */ - private async resolveMetaItemOrgScope( - singularType: string, - name: string, - requestOrgId: string | null, - ): Promise { - if (requestOrgId === null) return null; - // [#21908, ADR-0096 D5] The explicit system opt-in on both probes: a - // platform store read the door that asked already authorized, scoped by - // the protocol itself (`organization_id` in each `where`). Principal- - // less, the engine refuses it. - const inOrg = await this.engine.findOne('sys_metadata_history', { - where: { organization_id: requestOrgId, type: singularType, name }, - context: { isSystem: true }, - }); - if (inOrg) return requestOrgId; - const inEnv = await this.engine.findOne('sys_metadata_history', { - where: { organization_id: null, type: singularType, name }, - context: { isSystem: true }, - }); - return inEnv ? null : requestOrgId; - } - - /** - * [#10219] ADR-0005 / #3115 — resolve the org scope the PENDING DRAFT of an - * item actually lives in, for a per-item publish whose caller may not be in - * that scope. - * - * This is the single-item twin of the rule `publishPackageDrafts` already - * follows. The batch door DISCOVERS each draft's scope (`listDrafts` - * surfaces a non-null-org caller's own rows AND the env-wide ones via its - * `$or`, and the promote targets `d.organizationId`); the per-item door - * DEDUCED one instead, from `organizationIdForMetaWrite(type, activeOrg)` at - * the REST seam. The two answers differ for exactly the types the registry - * declares `allowOrgOverride: true` (`view`, `dashboard`, `report`, - * `translation`, `email_template`): a draft authored env-wide — which is - * what package/AI authoring writes, and what `PUT ?mode=draft` writes when no - * active org is threaded — is looked up under `organization_id = `, - * matches nothing, and answers `404 NO_DRAFT` — `… nothing to publish` — over a - * row the console's own pending-changes list is showing. Measured on a cloud - * rig: four AI-authored `view` drafts, visible in `sys_metadata` at - * `state='draft'`, all four refused by the per-item door while the batch - * "publish 4 changes" button promoted them. - * - * Non-org-overridable types (`object`, `flow`, …) never reached this at all: - * `organizationIdForMetaWrite` already answers `undefined` for them, which is - * why per-item publish worked for objects and flows and failed for views. - * - * Precedence is the ADR-0005 overlay order — the caller's own org shadows - * env-wide — so an org holding its own draft publishes THAT draft, and only - * an org with no draft of its own falls through to the env-wide row it was - * already authoring into. When NEITHER scope holds a draft the caller's own - * scope is returned unchanged, so a genuinely absent draft still raises the - * same `NO_DRAFT` refusal, from the scope the caller asked about. - * - * [commit c74aefe63] `packageId` — the ADR-0048 package dimension, threaded into BOTH - * probes exactly as {@link promoteDraftForPublish} threads it into - * `repo.promoteDraft`: stated (string, or `null` pinning the unbound row), - * each probe adds `package_id` to its `where`, so the scope probes ask the - * promote's question and the ADR-0005 precedence above is applied WITHIN - * the stated package's rows. `undefined` (caller stated no package) keeps - * the historical package-agnostic probes — the promote is then - * package-agnostic too, so the two questions still agree. - * - * Maintainer ruling 2026-08-22 (option A — recorded in commit c74aefe63): - * a package-stating publish resolves the scope of the draft it NAMED. - * Without the dimension, probe 1 could match ANOTHER package's row in the - * caller's org, name a scope the package-exact promote then finds empty, - * and answer `404 NO_DRAFT` over a publishable draft sitting env-wide. - * Accepted cost, on the record: a caller stating a package no longer - * discovers a no-package draft of the same `(type, name)` — it 404s and the - * caller retries without `?package=`; that narrowing is the ruling, not a - * side effect. A package-first probe FALLING BACK to package-agnostic was - * rejected by name (it reintroduces the two-question resolution #8907 - * removed, and a mistyped package would silently publish another package's - * draft instead of failing loudly). - * - * ⛔ This is discovery, not a tolerant fallback: it names the one row the - * promote will then address, and it reads DRAFT rows in `sys_metadata` (the - * thing being promoted) rather than the history lineage - * {@link resolveMetaItemOrgScope} reads — a first-ever draft of a - * never-published item has no lineage to resolve. - * - * Deliberately NO `catch`, for the same reason as its read-side sibling: a - * driver failure must fail the publish, not resolve to a scope nobody - * verified. - */ - private async resolveDraftOrgScopeForPublish( - singularType: string, - name: string, - requestOrgId: string | null, - packageId?: string | null, - ): Promise { - if (requestOrgId === null) return null; - // These reads must ask the same question `promoteDraft` will (see - // `SysMetadataRepository.whereFor`), because their whole job is to name - // the scope the promote then addresses. A probe NARROWER than the - // promote hides a draft the promote can see; a probe WIDER names a - // scope it cannot. - // - // [commit c74aefe63] That rule is what threads the package dimension in: since - // commit 9e04c3e35 the per-item door names a package whenever its HTTP caller - // does (`?package=PKG_ID`), and the promote's `whereFor` then - // constrains `package_id` — so a package-agnostic probe here was the - // WIDER shape, naming a scope off another package's row (ADR-0048 keys - // overlay rows by `(org, type, name, package_id)`, so two packages' - // same-name drafts coexist in different scopes). `undefined` spreads - // NOTHING — the caller stated no package, the promote matches any - // package, and these probes keep asking that same question. See the - // docblock above for the ruling commit c74aefe63 records and its accepted narrowing. - const packageDim = packageId !== undefined ? { package_id: packageId } : {}; - // [#21908, ADR-0096 D5] The explicit system opt-in on both probes: a - // platform store read the door that asked already authorized, scoped by - // the protocol itself (`organization_id` in each `where`). Principal- - // less, the engine refuses it. - const inOrg = await this.engine.findOne('sys_metadata', { - where: { organization_id: requestOrgId, type: singularType, name, state: 'draft', ...packageDim }, - context: { isSystem: true }, - }); - if (inOrg) return requestOrgId; - const inEnv = await this.engine.findOne('sys_metadata', { - where: { organization_id: null, type: singularType, name, state: 'draft', ...packageDim }, - context: { isSystem: true }, - }); - return inEnv ? null : requestOrgId; - } - /** * One-time guard for ensuring the overlay-uniqueness UNIQUE INDEXes exist * on `sys_metadata`. ADR-0005 (revised 2026-05) + ADR-0048: per-env DBs @@ -8914,8 +8742,8 @@ export class ObjectStackProtocolImplementation implements // ── [commit 96326040f] The registry read gate, resolved ONCE, HERE ────────────────── // // {@link organizationIdForMetaRead} — the predicate the REST `/meta` - // read doors have applied since #9454, twin of the write side's - // `organizationIdForMetaWrite` (#6190 / #7018). Until this line + // 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 @@ -10056,9 +9884,8 @@ export class ObjectStackProtocolImplementation implements let item: unknown; // ── [commit d5cbb44f3] The registry read gate, resolved ONCE, HERE ──────────────────── // - // {@link organizationIdForMetaRead} — the read-side twin of - // `organizationIdForMetaWrite` (#6190 / #7018), which the REST `/meta` - // read doors have applied since #9454 and which commit 96326040f moved INSIDE the + // {@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. @@ -10128,19 +9955,12 @@ export class ObjectStackProtocolImplementation implements // • 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. - // • `organizationIdForMetaWrite` has the identical body, so the - // write-side pre-reads (`saveMetaItem`'s destructive-change probe, - // `publishMetaItem`'s seed-loader adapter, `publishPackageDrafts`' - // build probes) now read the partition their write LANDS in. Read - // scope and write scope cannot disagree — the property #9454 chose - // this predicate for. ⚠️ True BY DEFAULT, and deliberately not under - // the operator hatch: {@link orgScopedWriteRefusal} returns early - // when `isOverlayAllowed` is satisfied via `OS_METADATA_WRITABLE`, - // so a non-overridable type CAN still land an org-scoped write while - // this read resolves env-wide. That divergence is the hatch's own - // stated contract — its refusal message says it "unlocks the write, - // not the read" — and the row it admits is exactly the kind boot - // hydration walks past. + // • [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 @@ -10700,8 +10520,7 @@ export class ObjectStackProtocolImplementation implements // ── [commit e1d4f9e3f] The registry read gate, resolved AFTER the fold ───────── // // {@link organizationIdForMetaRead} — the predicate the REST `/meta` - // read doors have applied since #9454, twin of the write side's - // `organizationIdForMetaWrite` (#6190 / #7018) and the same gate + // 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 @@ -11556,9 +11375,9 @@ export class ObjectStackProtocolImplementation implements * Studio-authored `object` COULD legitimately exist as a per-org row, * invisible to boot hydration, and this gate's fail-closed answer meant * 404 for every record in a table that still held the data. The premise - * is now true by enforcement: {@link orgScopedWriteRefusal} refuses an - * org-scoped write of any type the registry declares non-org-overridable, - * on both minting paths, so the only org-scoped `object` rows that can + * is now true by enforcement: {@link organizationScopedWriteRefusal} + * refuses an org-scoped write of every type (ADR-0131 D6), on every + * minting path, so the only org-scoped `object` rows that can * exist are residue written before that gate (#6190's ruling 2 = A: * handled non-destructively — made audible by * {@link reportUnhydratableOrgScopedRows} and disposed of operationally, @@ -16157,294 +15976,75 @@ export class ObjectStackProtocolImplementation implements } /** - * [#6190] The org-scope half of the same family: a write that would stamp - * `sys_metadata.organization_id` on a type the registry declares has NO - * per-org channel. Returns the refusal, or `null` when the write is fine. - * - * ## Why a write-time refusal and not a read-time repair - * - * `allowOrgOverride` and `allowRuntimeCreate` are orthogonal tiers (see - * {@link isRuntimeCreateAllowed}), and the runtime-create tier never - * consulted the ORG dimension: `SysMetadataRepository.put` stamps - * `organization_id: this.organizationId` whatever the type is, so a - * Studio-authored item of an `allowOrgOverride: false` type persisted a - * per-org row that the platform can never read back. Cold boot - * (`loadMetaFromDb`, `organization_id: null`) walks past it and the - * env-wide consumers never ask for it — the write path was strictly more - * permissive than the read path, which is the false-compliance shape - * ADR-0049 forbids. Measured consequences, both silent before this gate: - * - * - `flow` — the row binds its triggers for the life of the process that - * wrote it and stops firing after the next restart, with no log line - * (#6190's original report; the cold-boot warn that made the residue - * audible shipped separately, see - * {@link reportUnhydratableOrgScopedRows}). - * - `object` — worse, and fails CLOSED: the object is absent from the - * registry after boot while its physical table still holds the data, so - * {@link assertObjectRegistered} answers 404 `OBJECT_NOT_FOUND` for - * every record in it. - * - * Maintainer ruling 2026-08-08 on #6190 (option A of three): refuse the - * write. Option B — silently coercing the row to env-wide — was rejected - * because it rewrites the tenancy statement the author made; option D — - * the log alone — leaves declared ≠ enforced. - * - * ## Shape decisions - * - * - **Registry-derived, never a hand-written type list** (Prime Directive - * #8): the predicate is {@link isOverlayAllowed} — the same one the - * sibling refusal below it uses, over the same derived - * {@link OVERLAY_ALLOWED_TYPES} set. A type that gains - * `allowOrgOverride: true` tomorrow is admitted here the same day, with - * nothing to keep in sync. - * - **The operator hatch stays ONE door.** Because the predicate is - * `isOverlayAllowed`, `OS_METADATA_WRITABLE` unlocks org scoping exactly - * as it unlocks the overlay — which is what this file already promises a - * few lines down ("unlocking a type there unlocks it here too") and what - * the ruling asked for by naming this the *sibling* of the - * `NOT_OVERRIDABLE` refusal. Two differently-keyed notions of - * "overridable" inside one method would be the drift, not the safety. - * - * The DIAGNOSTIC is deliberately wider than the refusal: - * {@link reportUnhydratableOrgScopedRows} ignores the hatch and reports - * an org-scoped row of any non-org-overridable type, because the hatch - * unlocks the write and cannot teach `loadMetaFromDb` to read the row - * back. So an operator who deliberately opens the door still gets told, - * at every boot, that what they wrote did not survive it. Warning is - * free and should be maximal; refusing removes a capability, and the - * declaration — including its documented override — decides that. - * - **Statically-declared types only.** A type with no entry in - * `DEFAULT_METADATA_TYPE_REGISTRY` is plugin-registered at runtime, and - * both existing gates ({@link isRuntimeCreateAllowed} here, - * `assertAllowed` in the repository) treat that family as permissive by - * construction — `getMetaTypes()` synthesises `allowRuntimeCreate: true` - * for it. Refusing those here would extend a ruling measured over the - * registry to a surface nobody measured, so they keep today's behaviour. - * Their org rows are skipped by cold boot too; that gap is stated in the - * PR rather than silently widened here. - * - **`NOT_OVERRIDABLE`, not a new code.** The condition IS "this type has - * no per-org override channel", the sentence `NOT_OVERRIDABLE` already - * carries, and the code vocabulary is a closed set owned by - * `packages/spec`'s ledger (ADR-0112 D3) — a cross-package edit this - * card is not authorised to make. The message carries the distinction. - * - * Pinned by `protocol.org-scoped-write-refused.test.ts`. + * [ADR-0131 D6/D7] The protocol refuses EVERY organization-scoped + * metadata write. Returns the refusal, or `null` when the request names no + * organization. + * + * Environment metadata — whatever Studio, the cloud build agent or an + * install writes — belongs to the whole deployment, so `sys_metadata` has + * no per-organization partition left to write into: the ADR-0005 + * per-organization overlay axis is retired (ADR-0131 D6), and with it the + * five-type exemption this gate used to grant (`allowOrgOverride: true` — + * `view`, `dashboard`, `report`, `translation`, `email_template` — and the + * `OS_METADATA_WRITABLE` hatch, which unlocked org scoping exactly as it + * unlocked the overlay). The `/meta` doors already carry no organization + * into a write; this is the protocol's own refusal, so every other door + * (the `/packages` verbs, the stored-row migrations, a plugin) answers + * the same. + * + * One gate for every write verb — `saveMetaItem` (draft and publish), + * `publishMetaItem`, `deleteMetaItem`, `rollbackMetaItem`, the commit + * reverts and every package verb — asked FIRST, before any read, so + * "refused, not refused after writing" holds on each. A legacy + * organization-scoped row is not rewritten here or anywhere else in this + * file: the promotion ceremony (ADR-0131 C7) carries those rows to the + * environment layer. + * + * `NOT_OVERRIDABLE` / 403, the code this refusal has always carried: the + * condition is still "this item has no per-organization channel", now for + * every type. The first sentence names the tenancy posture in force, + * because the posture is what a reader suspects first — and none of the + * three (`single`, `group`, `isolated`) opens the channel. */ - private static orgScopedWriteRefusal( - type: string, - name: string, - organizationId: string | null | undefined, + private static organizationScopedWriteRefusal( + subject: string, + organizationId: unknown, + remedy = 'Retry with no organization: the write then lands environment-wide, where every organization reads it.', ): Error | null { - if (!organizationId) return null; - const singular = PLURAL_TO_SINGULAR[type] ?? type; - if (this.isOverlayAllowed(type)) return null; - if (!this.STATIC_REGISTRY_TYPES.has(singular) && !this.STATIC_REGISTRY_TYPES.has(type)) return null; + if (organizationId === undefined || organizationId === null || organizationId === '') return null; + let posture: string; + try { + posture = `the '${resolveTenancyPosture()}' tenancy posture`; + } catch { + posture = 'an unrecognized tenancy posture'; + } const err: any = new Error( - `Metadata item '${type}/${name}' cannot be written org-scoped ` - + `(organization '${organizationId}'). ` - + `The metadata-type registry declares allowOrgOverride=false for '${singular}', so the platform has ` - + `no per-org channel for it: boot hydration loads env-wide rows only, so this row would be absent ` - + `from the registry after the next restart — a '${singular}' that answered today would stop ` - + `(an 'object' answers 404 OBJECT_NOT_FOUND for every record in its still-populated table, a 'flow' ` - + `silently stops firing). Save it env-wide instead (retry with no active organization), or ship the ` - + `per-org variant as its own deployment (ADR-0005: "Per-org variants are a deployment, not an ` - + `overlay"). An operator may set OS_METADATA_WRITABLE=${singular} to grant a runtime escape hatch, ` - + `but note the row still will not survive a restart — the hatch unlocks the write, not the read, ` - + `and boot logs every such row it walks past. ` - + `See docs/adr/0005-metadata-customization-overlay.md.` + `${subject} cannot be written organization-scoped (organization '${String(organizationId)}'): ` + + `under ${posture}, as under every posture, environment metadata belongs to the whole deployment ` + + `and no organization holds its own copy (ADR-0131 D6). ` + + `${remedy} ` + + `A row stored organization-scoped before this release stays where it is until the promotion ceremony ` + + `(ADR-0131 C7) carries it to the environment layer. ` + + `See docs/adr/0131-total-organization-ownership-no-null-organization-id.md.` ); err.code = 'NOT_OVERRIDABLE'; err.status = 403; err.organizationId = organizationId; - err.docs = 'docs/adr/0005-metadata-customization-overlay.md'; - return err; - } - - /** - * An organization-scoped `view` write that changes which public forms - * accept anonymous intake, on a deployment whose anonymous form doors do - * not read that organization. Returns the refusal, or `null` when the - * write is fine. - * - * An anonymous form request carries no session and so no organization. - * The doors resolve the form in `tenancy.defaultOrgId()`'s organization - * (`registerFormEndpoints` in `@objectstack/rest`). Where that is not the - * write's organization (every walled posture, degraded or not, answers - * `null`), the doors read the env-wide definition, so the write is refused - * and the author is pointed at the env-wide save, which every door - * honours. A composition with no tenancy service has no posture to judge - * (and no session to carry an organization over HTTP), so it is left as is. - * - * Judged on the anonymous slug set alone ({@link anonymousFormIntakeSlugs}): - * an organization-scoped edit that leaves it as the env-wide definition has - * it is unaffected. Same code and status as {@link orgScopedWriteRefusal}: - * this item's anonymous intake has no per-org channel on this deployment. - */ - private async anonymousFormIntakeOrgScopeRefusal(args: { - type: string; - name: string; - organizationId: string | null | undefined; - body: unknown; - /** The package binding the row is saved under (a container's expansion is placed by it). */ - packageId?: string | null; - }): Promise { - if (!args.organizationId) return null; - const singular = PLURAL_TO_SINGULAR[args.type] ?? args.type; - if (singular !== 'view') return null; - const tenancy = this.getServicesRegistry?.().get('tenancy') as - | { defaultOrgId?: () => Promise } - | undefined; - if (typeof tenancy?.defaultOrgId !== 'function') return null; - const doorOrganization = await tenancy.defaultOrgId(); - if (doorOrganization === args.organizationId) { - return this.anonymousFormIntakeReopenRefusal({ ...args, type: singular, organizationId: args.organizationId }); - } - const proposed = anonymousFormIntakeSlugs(args.body); - const served = anonymousFormIntakeSlugs( - ((await this.getMetaItem({ type: singular, name: args.name })) as any)?.item, - ); - if (proposed.length === served.length && proposed.every((s, i) => s === served[i])) return null; - const list = (slugs: string[]) => (slugs.length ? slugs.map((s) => `'${s}'`).join(', ') : 'none'); - const err: any = new Error( - `Metadata item 'view/${args.name}' cannot change which public forms accept anonymous intake ` - + `in organization '${args.organizationId}' (env-wide: ${list(served)}; this write: ${list(proposed)}). ` - + `An anonymous form request carries no organization, and this deployment resolves ` - + (doorOrganization - ? `it in organization '${doorOrganization}'` - : `none for it (a walled tenancy posture never guesses one)`) - + `, so the anonymous form doors serve the env-wide definition and would never see this change. ` - + `Save it env-wide instead (retry with no active organization): that withdraws or publishes the form ` - + `on every anonymous door. An organization-scoped edit that leaves the form's sharing as the env-wide ` - + `definition has it is still accepted. See docs/adr/0005-metadata-customization-overlay.md.` - ); - err.code = 'NOT_OVERRIDABLE'; - err.status = 403; - err.organizationId = args.organizationId; - err.docs = 'docs/adr/0005-metadata-customization-overlay.md'; + err.docs = 'docs/adr/0131-total-organization-ownership-no-null-organization-id.md'; return err; } - /** - * An organization-scoped `view` write, in the organization the anonymous - * form doors read, that would leave open a public form the env-wide layer - * withdrew. Returns the refusal, or `null` when the write is fine. - * - * A withdrawal is a kill switch: an explicit withdrawal of a public form - * (the same view, the same slot, the link kept with `enabled` or - * `allowAnonymous` cleared) at any layer closes it, and layering may only - * narrow intake, never re-open it. The doors enforce that at read time - * (`registerFormEndpoints` in `@objectstack/rest` reads the env-wide layer - * beneath the organization's and lets its withdrawal close the form), so - * such a write would be accepted and then never honoured. It is refused - * instead, and the author is pointed at the env-wide definition, which is - * the switch. - * - * Judged by the doors' own verdict ({@link anonymousFormIntakeWithdrawnIn}) - * over the env-wide `view` list, for every form this write would leave - * open — whether or not the organization's current definition has it open - * already, so re-saving an overlay that was open before the env-wide - * withdrawal is refused too. The body is judged as the list read serves - * it: a container-shaped body (`formViews`, `form`, …) is expanded into - * the view items the doors read ({@link expandRuntimeViewContainer}). An - * organization-scoped save that keeps the form withdrawn, or that opens - * nothing the env-wide layer withdrew, is never refused here. - */ - private async anonymousFormIntakeReopenRefusal(args: { - type: string; - name: string; - organizationId: string; - body: unknown; - packageId?: string | null; - }): Promise { - if (!args.body || typeof args.body !== 'object' || Array.isArray(args.body)) return null; - const raw = args.body as Record; - // The name stamp the container-collision judge applies (a body with no - // `name` is a container under the save name); a view item is the save - // name's own row. - const stamped = raw.name ? raw : { ...raw, name: args.name }; - const served: unknown[] = isAggregatedViewContainer(stamped) - ? this.expandRuntimeViewContainer(args.type, stamped, { packageId: args.packageId ?? undefined }) - : [{ ...raw, name: args.name }]; - const open = served.flatMap((view) => anonymousFormIntakeCandidates(view).map((c) => ({ view, c }))); - if (open.length === 0) return null; - const envWide: any = await this.getMetaItems({ type: args.type }); - const layer: unknown[] = Array.isArray(envWide?.items) ? envWide.items : []; - const closed = new Set( - open.filter(({ view, c }) => anonymousFormIntakeWithdrawnIn(layer, view, c)).map(({ c }) => c.slug), - ); - // The same judgement anchored on the stored ROW this overlay is keyed - // by: the env-wide body of row `name`, as stored (a container is not - // expanded, so a form moved to another key or slot, renamed through - // `form.name`, or renamed by an expansion collision is still matched - // against the form it was, by slot or by slug). [#21934] One body per - // package that holds the name ({@link envWideRawViewRows}), so every - // package's withdrawal of it is judged, whatever the registry order. - const envRows = (await this.envWideRawViewRows(args.type, args.name)).map((r) => ({ ...r, name: args.name })); - if (envRows.length > 0) { - const own = { ...raw, name: args.name }; - for (const c of anonymousFormIntakeCandidates(own)) { - if (anonymousFormIntakeWithdrawnIn(envRows, own, c)) closed.add(c.slug); - } - } - const reopened = [...closed].sort(); - if (reopened.length === 0) return null; - const list = reopened.map((s) => `'/forms/${s}'`).join(', '); - const err: any = new Error( - `Metadata item 'view/${args.name}' cannot keep public form ${list} open for anonymous intake ` - + `in organization '${args.organizationId}': the env-wide definition withdraws it. A withdrawal is ` - + `a kill switch, so an organization overlay may narrow a public form's intake but never re-open it, ` - + `and the anonymous form doors keep answering it as not found. Save this overlay with the form's ` - + `sharing withdrawn (enabled or allowAnonymous false), or, to publish the form again, save it ` - + `env-wide (retry with no active organization) with sharing enabled and anonymous access allowed. ` - + `See docs/adr/0005-metadata-customization-overlay.md.` - ); - err.code = 'NOT_OVERRIDABLE'; - err.status = 403; - // The sentence an end user is shown (the producer-declared channel). - err.userMessage = `This public form was withdrawn for the whole environment, so it cannot be open ` - + `for one organization. Publish it again from the environment-wide form definition.`; - err.organizationId = args.organizationId; - err.docs = 'docs/adr/0005-metadata-customization-overlay.md'; - return err; + /** The request's `organizationId`, read off whatever the caller sent — the key is retired from every write verb's declared request, so a caller still sending it is untyped by construction. */ + private static requestedOrganization(request: unknown): unknown { + return request && typeof request === 'object' + ? (request as { organizationId?: unknown }).organizationId + : undefined; } - /** - * The env-wide bodies of the `view` row `name`, as stored, for every - * package that holds the name. [#21934] Resolved per package, the way the - * list read resolves each package's item (ADR-0048): the package's own - * active env-wide `sys_metadata` row (the env-wide overlay is keyed by its - * own name, ADR-0005), else the package-less env-wide row, which stands in - * for every package's row of the name, else that package's artifact of - * the name. So a stored row of one package anchors that package only, and - * every package that ships the name is judged on its own definition, - * whatever the registry order. Empty when no package holds the name. - * - * Read raw, never through the list read: that serves a container only as - * its expansion, whose item names and slots the overlay author chooses, - * and the kill switch anchors identity on the row instead. - */ - private async envWideRawViewRows(type: string, name: string): Promise[]> { - let records: any[] = []; - try { - records = await this.readActiveOverlayRows({ type }, undefined); - } catch (error) { - // [#5532] Only an unprovisioned store means "no rows". - this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); - } - const stored = this.storedOverlayEntries({ type }, records) - .filter((e) => e.name === name && e.organizationId === null) - .filter((e) => !!e.data && typeof e.data === 'object' && !Array.isArray(e.data)); - const bodies = stored.map((e) => e.data as Record); - const withOwnRow = new Set(stored.map((e) => e.packageId)); - // The package-less row stands in for every package without a row of its own. - if (withOwnRow.has(undefined)) return bodies; - for (const artifact of this.shippedArtifactsOf(type, name)) { - if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) continue; - const pkg = (artifact as { _packageId?: unknown })._packageId; - if (typeof pkg === 'string' && withOwnRow.has(pkg)) continue; - bodies.push(artifact as Record); - } - return bodies; + /** Throw {@link organizationScopedWriteRefusal} for an organization-scoped write request. */ + private static refuseOrganizationScopedWrite(subject: string, request: unknown): void { + const refusal = this.organizationScopedWriteRefusal(subject, this.requestedOrganization(request)); + if (refusal) throw refusal; } /** @@ -17881,7 +17481,7 @@ export class ObjectStackProtocolImplementation implements // canonical, and a fold here would make the assert unfalsifiable. type: entry.type, name: entry.name, - organization_id: entry.organizationId ?? null, + organization_id: null, operation: entry.operation, outcome: entry.outcome, code: entry.code, @@ -17940,7 +17540,6 @@ export class ObjectStackProtocolImplementation implements private async lockWriteRefusal(args: { type: string; name: string; - organizationId?: string; /** * [#21761] The package the write names (ADR-0048 `?package=`), part of * the item's address the gate selects the rows in scope from. It never @@ -17953,7 +17552,7 @@ export class ObjectStackProtocolImplementation implements source?: string; requestId?: string; }): Promise<{ err: Error; audit: MetadataAuditEntry } | null> { - const state = await this.getEffectiveLock(args.type, args.name, args.organizationId ?? null, args.packageId); + const state = await this.getEffectiveLock(args.type, args.name, null, args.packageId); const refusal = evaluateLockForWrite(state.lock); if (!refusal) return null; const reason = state.lockReason ?? refusal.reason; @@ -17970,7 +17569,7 @@ export class ObjectStackProtocolImplementation implements audit: { type: args.type, name: args.name, - organizationId: args.organizationId ?? null, + organizationId: null, operation: args.operation, outcome: 'denied', // adr0112-ok: D6b — persisted audit column, its own vocabulary @@ -17999,7 +17598,6 @@ export class ObjectStackProtocolImplementation implements private async assertLockAllowsWrite(args: { type: string; name: string; - organizationId?: string; /** [#21761] See {@link lockWriteRefusal}. */ packageId?: string; operation: 'save' | 'publish' | 'rollback'; @@ -18020,12 +17618,11 @@ export class ObjectStackProtocolImplementation implements private async assertLockAllowsDelete(args: { type: string; name: string; - organizationId?: string; actor?: string; source?: string; requestId?: string; }): Promise { - const state = await this.getEffectiveLock(args.type, args.name, args.organizationId ?? null); + const state = await this.getEffectiveLock(args.type, args.name, null); const refusal = evaluateLockForDelete(state.lock); if (!refusal) return null; const reason = state.lockReason ?? refusal.reason; @@ -18040,7 +17637,7 @@ export class ObjectStackProtocolImplementation implements await this.recordMetadataAudit({ type: args.type, name: args.name, - organizationId: args.organizationId ?? null, + organizationId: null, operation: 'delete', outcome: 'denied', // adr0112-ok: D6b — persisted audit column, its own vocabulary @@ -18077,7 +17674,6 @@ export class ObjectStackProtocolImplementation implements private async recordOptimisticConflictAudit(args: { type: string; name: string; - organizationId?: string | null; operation: 'save' | 'publish' | 'rollback' | 'delete'; actor?: string; source: string; @@ -18168,9 +17764,8 @@ export class ObjectStackProtocolImplementation implements * against ({@link storedHeadAt}), at the read's own scope — its * organization partition and `packageId` — and its lifecycle, or `null` * when no stored row is there. The `/meta` save door builds its address - * from the same three facts (`organizationIdForMetaWrite` has the body of - * `organizationIdForMetaRead`, and `?package=` names the binding on both), - * so a read followed by a save with the served token is accepted, and a + * 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 @@ -18274,7 +17869,6 @@ export class ObjectStackProtocolImplementation implements private static optimisticConflictAuditEntry(args: { type: string; name: string; - organizationId?: string | null; operation: 'save' | 'publish' | 'rollback' | 'delete'; actor?: string; source: string; @@ -18285,7 +17879,7 @@ export class ObjectStackProtocolImplementation implements return { type: args.type, name: args.name, - organizationId: args.organizationId ?? null, + organizationId: null, operation: args.operation, outcome: 'denied', // adr0112-ok: D6b — persisted audit column, its own vocabulary @@ -19921,9 +19515,7 @@ export class ObjectStackProtocolImplementation implements * container and under any of these names is that name's sanctioned * override; a container under its object's name, or under any name of * its own, whose expansion collides with nothing; an overlay of a - * package's own container; #21334's own-name arm; a container whose would-be - * sibling is in another organization (the caller's selection decides, as - * it does for the readers). Rows already stored in a refused shape keep + * package's own container; #21334's own-name arm. Rows already stored in a refused shape keep * their bytes and are served as before; a new save of one, a re-save * included, is refused until its body stops colliding, and the re-savers * that write through this door (`migrateStoredMetadata`, @@ -19944,7 +19536,6 @@ export class ObjectStackProtocolImplementation implements item: unknown, saveName: string, packageId: string | null | undefined, - organizationId: string | undefined, ): Promise<(Error & { code: 'VALIDATION_ERROR'; status: 400 }) | undefined> { if ((PLURAL_TO_SINGULAR[type] ?? type) !== 'view') return undefined; if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined; @@ -19955,7 +19546,9 @@ export class ObjectStackProtocolImplementation implements let records: any[] = []; try { - records = await this.readActiveOverlayRows({ type }, organizationIdForMetaRead(type, organizationId)); + // [ADR-0131 D6] A save lands environment-wide, so its siblings are + // the environment-wide rows. + records = await this.readActiveOverlayRows({ type }, undefined); } catch (error) { // [#5532] The readers' rule: only an unprovisioned store means "no // rows". Any other failure is not answered as "no sibling". @@ -20082,7 +19675,13 @@ export class ObjectStackProtocolImplementation implements // STORED content hash itself (`migrateStoredMetadata`): it reaches the // repository as given. ⛔ No transport sets it — every door builds its // request field by field from named inputs, never by spreading a body. - async saveMetaItem(request: { type: string, name: string, item?: any, organizationId?: string, parentVersion?: string | null, storedParentVersion?: string | null, actor?: string, force?: boolean, mode?: 'draft' | 'publish', packageId?: string | null, source?: string, writeFace?: MetadataWriteFace }) { + async saveMetaItem(request: { type: string, name: string, item?: any, parentVersion?: string | null, storedParentVersion?: string | null, actor?: string, force?: boolean, mode?: 'draft' | 'publish', packageId?: string | null, source?: string, writeFace?: MetadataWriteFace }) { + // [ADR-0131 D6] FIRST, before every other gate and every read: an + // organization-scoped write is refused for every type, draft or + // publish. See {@link organizationScopedWriteRefusal}. + ObjectStackProtocolImplementation.refuseOrganizationScopedWrite( + `Metadata item '${request.type}/${request.name}'`, request, + ); // [commit fd6bdf89f] The ADR-0112 envelope this refusal always owed. Every OTHER // refusal in this method declares `code` AND `status` // (`NOT_OVERRIDABLE`/403, `NOT_CREATABLE`/403, `ITEM_LOCKED`/403, @@ -20312,37 +19911,6 @@ export class ObjectStackProtocolImplementation implements } } - // [#6190] …and the ORG dimension of the same declaration, on the tier - // that never consulted it. Placed HERE — before the topology carve-out - // below, before the destructive diff, before the schema parse — for the - // two reasons #5086 put its own refusal first: the verdict depends on - // nothing but the type and the requested scope, and "refused, not - // refused after writing" is the property the issue was filed about, so - // the gate must precede every path that could persist a row. Draft - // saves are gated identically (the branch is below): a draft is the - // first half of the SECOND minting path this closes, and #4463 D1 - // recorded what happens when only one of the two doors gates. - // See {@link orgScopedWriteRefusal} for the ruling and the shape. - { - const orgRefusal = ObjectStackProtocolImplementation.orgScopedWriteRefusal( - request.type, request.name, request.organizationId, - ); - if (orgRefusal) throw orgRefusal; - } - // An org-scoped change to a form's anonymous intake that the anonymous - // form doors cannot see. Drafts too, so no draft is minted that its - // own promotion would refuse. See {@link anonymousFormIntakeOrgScopeRefusal}. - { - const intakeRefusal = await this.anonymousFormIntakeOrgScopeRefusal({ - type: request.type, - name: request.name, - organizationId: request.organizationId, - body: request.item, - ...(request.packageId ? { packageId: request.packageId } : {}), - }); - if (intakeRefusal) throw intakeRefusal; - } - // [#8184] THE PACKAGE DOOR — the refusal of a write onto an item a // code package ships, on a type with no per-org overlay channel. // The verdict and its full record live in @@ -20409,7 +19977,6 @@ export class ObjectStackProtocolImplementation implements const lockErr = await this.assertLockAllowsWrite({ type: request.type, name: request.name, - ...(request.organizationId ? { organizationId: request.organizationId } : {}), // [#21761] The save's address carries its package, as the read's does. ...(request.packageId ? { packageId: request.packageId } : {}), operation: 'save', @@ -20498,7 +20065,6 @@ export class ObjectStackProtocolImplementation implements const existing = await this.getMetaItem({ type: request.type, name: request.name, - ...(request.organizationId ? { organizationId: request.organizationId } : {}), } as any); const prev = (existing as any)?.item; if (prev) { @@ -20633,7 +20199,7 @@ export class ObjectStackProtocolImplementation implements // `viewKind` onto it. See {@link viewContainerNameCollisionRefusal}. { const containerCollision = await this.viewContainerNameCollisionRefusal( - singularType, request.item, request.name, request.packageId, request.organizationId, + singularType, request.item, request.name, request.packageId, ); if (containerCollision) throw containerCollision; } @@ -20896,7 +20462,7 @@ export class ObjectStackProtocolImplementation implements // [#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: request.organizationId ?? null, + 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 @@ -20913,7 +20479,7 @@ export class ObjectStackProtocolImplementation implements // is absent and not stored as missing. The body judged is unchanged. restoredCredentialPaths: () => this.restoredCredentialPathsFor({ type: singularType, - organizationId: request.organizationId ?? null, + organizationId: null, name: request.name, packageId: request.packageId ?? null, item: gatedItem, @@ -20963,7 +20529,6 @@ export class ObjectStackProtocolImplementation implements type: request.type, name: request.name, state: mode === 'draft' ? 'draft' : 'active', - ...(request.organizationId ? { organizationId: request.organizationId } : {}), body: request.item, }); } @@ -21094,12 +20659,13 @@ export class ObjectStackProtocolImplementation implements singularTypeForRepo, request.packageId, hatchOpen, request.name, ); } - const orgId = request.organizationId ?? null; - const repo = this.getOverlayRepo(orgId); + // [ADR-0131 D6] Environment-wide, always: an organization-scoped + // request was refused at the top of this method. + const repo = this.getOverlayRepo(null); const ref = { type: singularTypeForRepo, name: request.name, - org: orgId ?? 'env', + org: 'env', } as Parameters[0]; let parentVersion: string | null; if (request.storedParentVersion !== undefined) { @@ -21130,7 +20696,7 @@ export class ObjectStackProtocolImplementation implements try { parentVersion = await this.storedParentForToken(ref, request.parentVersion, currentStored); } catch (err: unknown) { - if (err instanceof ConflictError) throw await this.saveConflict(err, request, orgId, writeSource); + if (err instanceof ConflictError) throw await this.saveConflict(err, request, writeSource); throw err; } } @@ -21226,9 +20792,8 @@ export class ObjectStackProtocolImplementation implements name: request.name, item: request.item, packageId: request.packageId ?? null, - // [#6602] The SAME scope the row was just written with — - // a per-org overlay stays out of the shared registry. - organizationId: orgId, + // [#6602] The SAME scope the row was just written with. + organizationId: null, }); await this.ensureObjectStorage(request.type, request.name); } @@ -21236,7 +20801,7 @@ export class ObjectStackProtocolImplementation implements await this.recordMetadataAudit({ type: request.type, name: request.name, - organizationId: orgId, + organizationId: null, operation: 'save', outcome: 'allowed', code: 'ok', @@ -21251,14 +20816,14 @@ export class ObjectStackProtocolImplementation implements type: singularTypeForRepo, name: request.name, state: mode === 'draft' ? 'draft' : 'active', - organizationId: orgId, + organizationId: null, body: request.item, }); this.emitMetadataMutation({ type: singularTypeForRepo, name: request.name, state: mode === 'draft' ? 'draft' : 'active', - organizationId: orgId, + organizationId: null, }); return { success: true, @@ -21316,15 +20881,11 @@ export class ObjectStackProtocolImplementation implements // this path does not already make, and a receipt is not worth // a query — so the verb stays the neutral, true "Saved". message: artifactBacked - ? (orgId - ? `Saved customization overlay (org=${orgId}, state=${mode === 'draft' ? 'draft' : 'active'}) — type=${request.type}, name=${request.name} [seq=${result.seq}]` - : `Saved customization overlay (env-wide, state=${mode === 'draft' ? 'draft' : 'active'}) — type=${request.type}, name=${request.name} [seq=${result.seq}]`) - : (orgId - ? `Saved ${singularTypeForRepo} '${request.name}' (org=${orgId}, state=${mode === 'draft' ? 'draft' : 'active'}) [seq=${result.seq}]` - : `Saved ${singularTypeForRepo} '${request.name}' (env-wide, state=${mode === 'draft' ? 'draft' : 'active'}) [seq=${result.seq}]`), + ? `Saved customization overlay (env-wide, state=${mode === 'draft' ? 'draft' : 'active'}) — type=${request.type}, name=${request.name} [seq=${result.seq}]` + : `Saved ${singularTypeForRepo} '${request.name}' (env-wide, state=${mode === 'draft' ? 'draft' : 'active'}) [seq=${result.seq}]`, }; } catch (err: any) { - if (err instanceof ConflictError) throw await this.saveConflict(err, request, orgId, writeSource); + if (err instanceof ConflictError) throw await this.saveConflict(err, request, writeSource); throw err; } } @@ -21339,7 +20900,6 @@ export class ObjectStackProtocolImplementation implements private async saveConflict( err: ConflictError, request: { type: string; name: string; actor?: string }, - orgId: string | null, writeSource: string, ): Promise { const conflict = await this.metadataConflictRefusal( @@ -21350,7 +20910,6 @@ export class ObjectStackProtocolImplementation implements await this.recordOptimisticConflictAudit({ type: request.type, name: request.name, - organizationId: orgId, operation: 'save', ...(request.actor ? { actor: request.actor } : {}), source: writeSource, @@ -21636,6 +21195,24 @@ export class ObjectStackProtocolImplementation implements continue; } + // [ADR-0131 D6] An organization-scoped row is reported, never + // re-saved: the protocol refuses every organization-scoped write, + // and re-saving the body environment-wide would move it — the + // promotion ceremony's job (ADR-0131 C7), not this pass's. `skipped` + // for the same reason as the guard above: nothing is broken about + // this pass, the row is outside its reach. + if (organizationId !== null) { + record({ + ...base, + outcome: 'skipped', + reason: `the row is stored organization-scoped (organization '${organizationId}'), and ` + + `environment metadata no longer has a per-organization layer (ADR-0131 D6), so this ` + + `pass does not re-save it. The promotion ceremony (ADR-0131 C7) carries it to the ` + + `environment layer; until then it stays in sys_metadata as stored.`, + }); + continue; + } + let body: unknown; try { body = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata; @@ -21806,7 +21383,6 @@ export class ObjectStackProtocolImplementation implements force: true, source: 'migrate-stored', actor: request.actor ?? 'migrate-stored', - ...(organizationId ? { organizationId } : {}), }); record({ ...base, notices: flattened, todos: flattenedTodos, outcome: 'rewritten' }); } catch (e: any) { @@ -21957,7 +21533,6 @@ export class ObjectStackProtocolImplementation implements async publishMetaItem(request: { type: string; name: string; - organizationId?: string; actor?: string; message?: string; /** @@ -22051,6 +21626,11 @@ export class ObjectStackProtocolImplementation implements */ advisories?: RuntimeAuthoringIssue[]; }> { + // [ADR-0131 D6] FIRST: an organization-scoped promotion is refused. + // See {@link organizationScopedWriteRefusal}. + ObjectStackProtocolImplementation.refuseOrganizationScopedWrite( + `Metadata item '${request.type}/${request.name}'`, request, + ); // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. This is the // SEVENTH `/meta` entry point, and until #8769 it was the only one that // did not funnel through the boundary fold — so the URL family @@ -22135,43 +21715,12 @@ export class ObjectStackProtocolImplementation implements // residue drafts the refusal (rather than a promotion) is the ruled // direction, and `deleteMetaItem` stays open to clear them. this.refuseUngrammaticalMetaItemName(request); - // [#10219] Then resolve WHICH SCOPE's draft this publish means. The - // caller states the scope it is IN; the draft may live env-wide. See - // {@link resolveDraftOrgScopeForPublish} — the single-item twin of the - // #3115 rule `publishPackageDrafts` already follows. - // - // Placed after the type fold (the probe must name the canonical stored - // `type`) and before every gate below, so the ADR-0010 lock check, the - // #6190 org-scoped-write refusal and the promote all judge ONE scope — - // the one the row is actually in. Resolving it later would gate against - // a partition the promotion never touches. - // - // [commit c74aefe63] The package dimension rides along under the SAME - // present/absent contract `promoteDraftForPublish` spells as - // `...('packageId' in request ? { packageId: request.packageId ?? null } - // : {})`: an ABSENT key keeps the historical package-agnostic probes, - // a stated one (string, or `null` for the unbound row) makes both - // probes ask the promote's package-exact question. Passing - // `request.packageId` bare would collapse "absent" and - // "present-and-undefined" into one spelling — the coercion trap the - // request type's own TSDoc warns against. - { - const singular = PLURAL_TO_SINGULAR[request.type] ?? request.type; - const resolvedOrgId = await this.resolveDraftOrgScopeForPublish( - singular, request.name, request.organizationId ?? null, - 'packageId' in request ? (request.packageId ?? null) : undefined, - ); - if (resolvedOrgId !== (request.organizationId ?? null)) { - const { organizationId: _requested, ...rest } = request; - request = resolvedOrgId === null ? rest : { ...rest, organizationId: resolvedOrgId }; - } - } // [#8594] The refusal's own row is written HERE, by the route that owns // the (absent) transaction — see `promoteDraftForPublish`'s header. This // site has no transaction of its own, so recording it in the `catch` is // where it always effectively landed; what changed is that the helper no // longer assumes that on behalf of the batch route too. - const { singularType, orgId, advisories, result } = await this.promoteDraftForPublish(request) + const { singularType, advisories, result } = await this.promoteDraftForPublish(request) .catch(async (err: unknown) => { await this.recordPendingDenialAudit(err); throw err; @@ -22198,7 +21747,7 @@ export class ObjectStackProtocolImplementation implements await this.recordMetadataAudit({ type: request.type, name: request.name, - organizationId: orgId, + organizationId: null, operation: 'publish', outcome: 'allowed', code: 'ok', @@ -22230,7 +21779,6 @@ export class ObjectStackProtocolImplementation implements const effects = await this.runPublishSideEffects({ singularType, name: request.name, - orgId, body: result.item.body, packageId: result.packageId, ...(request.actor ? { actor: request.actor } : {}), @@ -22250,7 +21798,7 @@ export class ObjectStackProtocolImplementation implements await this.emitMetaItemPublished({ type: singularType, name: request.name, - organizationId: orgId, + organizationId: null, }); return response; } @@ -22290,13 +21838,12 @@ export class ObjectStackProtocolImplementation implements * again — the pre-#7748 state, not merely a worse one. */ private async promoteDraftForPublish(request: { - type: string; name: string; organizationId?: string; actor?: string; message?: string; + type: string; name: string; actor?: string; message?: string; /** * [#8907] ADR-0048 — the package binding of the draft being promoted, * when the caller listed it under one. Threaded straight into * `repo.promoteDraft` so the promotion resolves the draft under the - * SAME key it was listed by, exactly as `organizationId` above threads - * the draft's own org scope for the #3115 partition analogue. + * SAME key it was listed by. * * `undefined` (any caller with no binding to state) keeps the * historical "match any package" resolution. `null` pins the lookup to @@ -22321,7 +21868,6 @@ export class ObjectStackProtocolImplementation implements pending?: RuntimePendingDeclarations; }): Promise<{ singularType: string; - orgId: string | null; /** * [#9176] The #4463 gate's advisory half for this promotion — the * non-blocking findings `assertRuntimeAuthoringRules` RETURNS (its @@ -22346,24 +21892,10 @@ export class ObjectStackProtocolImplementation implements err.status = 403; throw err; } - // [#6190] The draft→active promotion is the OTHER way an org-scoped row - // of a non-org-overridable type reaches `active` — `publishMetaItem` - // and, behind Studio's "publish whole app", `publishPackageDrafts`. - // `saveMetaItem`'s gate now refuses to MINT such a draft, so what this - // door closes is the promotion of residue that predates the refusal: - // a legacy org-scoped draft row must not be promotable into a fresh - // active phantom. Exactly the #4463 D1 posture — gating one door and - // not the other makes the refusal bypassable by anyone who saves - // `?mode=draft` and then POSTs `/publish`. - { - const orgRefusal = ObjectStackProtocolImplementation.orgScopedWriteRefusal( - request.type, request.name, request.organizationId, - ); - if (orgRefusal) throw orgRefusal; - } await this.ensureOverlayIndex(); - const orgId = request.organizationId ?? null; - const repo = this.getOverlayRepo(orgId); + // [ADR-0131 D6] Environment-wide: every caller refused an + // organization-scoped request before reaching here. + const repo = this.getOverlayRepo(null); // #4463 D1 — the OTHER way a body reaches `active`. `saveMetaItem` // gates a direct active save and deliberately lets every draft through; @@ -22397,7 +21929,7 @@ export class ObjectStackProtocolImplementation implements // exactly as its sibling store reads do. Principal-less, the // engine now refuses it. draftRow = await this.engine.findOne('sys_metadata', { - where: { type: singularType, name: request.name, organization_id: orgId, state: 'draft' }, + where: { type: singularType, name: request.name, organization_id: null, state: 'draft' }, context: { isSystem: true }, }); } catch (error) { @@ -22413,7 +21945,6 @@ export class ObjectStackProtocolImplementation implements const _publishLockRefusal = await this.lockWriteRefusal({ type: request.type, name: request.name, - ...(request.organizationId ? { organizationId: request.organizationId } : {}), // [#21761] The promotion's address carries its package, as the read's does. // [#21934] It is the key resolved above, the one the gate reads the // draft under and the promotion writes under: the caller's stated @@ -22427,7 +21958,7 @@ export class ObjectStackProtocolImplementation implements throw withPendingAudit(_publishLockRefusal.err, _publishLockRefusal.audit); } const draftForGate = await repo.get( - { type: singularType, name: request.name, org: orgId ?? 'env' } as Parameters[0], + { type: singularType, name: request.name, org: 'env' } as Parameters[0], { state: 'draft', ...(draftKey !== undefined ? { packageId: draftKey } : {}) }, ); // [#21470] …and the divergent `name` refusal, on the same body and for @@ -22441,21 +21972,6 @@ export class ObjectStackProtocolImplementation implements const nameRefusal = savedItemNameRefusal(singularType, draftForGate.body, request.name, 'publish'); if (nameRefusal) throw nameRefusal; } - // The promotion half of {@link anonymousFormIntakeOrgScopeRefusal}: a - // draft saved before that refusal existed must not reach `active`. - if (draftForGate) { - // The binding the promoted row is placed by: the key the draft was - // read under above (a container's expansion is placed by it). - const draftPackageId = draftKey; - const intakeRefusal = await this.anonymousFormIntakeOrgScopeRefusal({ - type: singularType, - name: request.name, - organizationId: orgId, - body: draftForGate.body, - ...(draftPackageId ? { packageId: draftPackageId } : {}), - }); - if (intakeRefusal) throw intakeRefusal; - } // [#9176] The gate's return is its advisory half (#4717): captured and // handed out so `publishMetaItem` can attach it to the 2xx this // promotion is about to earn, exactly as `saveMetaItem` attaches its @@ -22467,10 +21983,9 @@ export class ObjectStackProtocolImplementation implements name: request.name, state: 'active', body: draftForGate.body, - // [#6285] Same partition the draft is being promoted in. Without - // it the draft door would be a bypass for this refusal alone, - // which is the exact hole #4463 D1 closed for the other 26. - organizationId: orgId, + // [#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 @@ -22523,7 +22038,7 @@ export class ObjectStackProtocolImplementation implements const ref = { type: singularType, name: request.name, - org: orgId ?? 'env', + org: 'env', } as Parameters[0]; // [#20312] ADR-0080 §5: `requires` is derived from the source at save, // and this promotion is the second way a body reaches `active`. The @@ -22565,7 +22080,7 @@ export class ObjectStackProtocolImplementation implements // conflict instead of being promoted unjudged. expectedDraftHash: draftForGate ? draftForGate.hash : null, }); - return { singularType, orgId, advisories: runtimeAdvisories, result }; + return { singularType, advisories: runtimeAdvisories, result }; } catch (err: any) { if (err instanceof ConflictError) { // [#21207] Keyed values or none in the text and attributes. @@ -22585,7 +22100,6 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocolImplementation.optimisticConflictAuditEntry({ type: request.type, name: request.name, - organizationId: orgId, operation: 'publish', ...(request.actor ? { actor: request.actor } : {}), source: 'protocol.publishMetaItem', @@ -22612,7 +22126,6 @@ export class ObjectStackProtocolImplementation implements private async runPublishSideEffects(args: { singularType: string; name: string; - orgId: string | null; body: unknown; packageId: string | null; actor?: string; @@ -22637,8 +22150,8 @@ export class ObjectStackProtocolImplementation implements name: args.name, item: args.body, packageId: args.packageId, - // [#6602] The promoted draft carries the org it was drafted in. - organizationId: args.orgId, + // [#6602] The promoted draft is environment-wide (ADR-0131 D6). + organizationId: null, }); // Create the object's table now so it's CRUD-able without a restart. // @@ -22668,7 +22181,7 @@ export class ObjectStackProtocolImplementation implements // lands data, not just metadata. The body is already in hand from // the promote — no read-back, so no org-scope resolution pitfalls. if (args.singularType === 'seed' && !args.skipSeedApply) { - out.seedApplied = await this.applySeedBodies([args.body], args.orgId); + out.seedApplied = await this.applySeedBodies([args.body]); } // Publish-time materializer (ADR-0086 P2): project the published body // into its data-plane row (e.g. `permission` → `sys_permission_set` @@ -22683,7 +22196,7 @@ export class ObjectStackProtocolImplementation implements out.materializeApplied = await materializer({ body: args.body, packageId: args.packageId, - organizationId: args.orgId, + organizationId: null, actor: args.actor ?? 'system', }); } catch (e: any) { @@ -22719,7 +22232,7 @@ export class ObjectStackProtocolImplementation implements type: args.singularType, name: args.name, state: 'active', - organizationId: args.orgId, + organizationId: null, body: args.body, }); if (publishProjection) out.projectionApplied = publishProjection; @@ -22727,7 +22240,7 @@ export class ObjectStackProtocolImplementation implements type: args.singularType, name: args.name, state: 'active', - organizationId: args.orgId, + organizationId: null, }); return out; } @@ -22742,7 +22255,6 @@ export class ObjectStackProtocolImplementation implements */ private async applySeedBodies( bodies: unknown[], - organizationId: string | null, ): Promise<{ success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[]; issues?: Array<{ path: string; message: string; code?: string | undefined }>; @@ -22761,11 +22273,7 @@ export class ObjectStackProtocolImplementation implements // metadata reads so no kernel service lookup is required. const metadataAdapter = { getObject: async (name: string) => { - const wrapper: any = await (this as any).getMetaItem({ - type: 'object', - name, - ...(organizationId ? { organizationId } : {}), - }); + const wrapper: any = await (this as any).getMetaItem({ type: 'object', name }); return wrapper?.item ?? wrapper ?? null; }, }; @@ -22785,7 +22293,9 @@ export class ObjectStackProtocolImplementation implements config: { defaultMode: 'upsert', multiPass: true, - ...(organizationId ? { organizationId } : {}), + // [ADR-0131 D6, §12] No caller organization: a seed dataset + // names the organization it populates itself, and the + // loader derives the owner only under `single` (D9). }, }); if (!parsedRequest.success) throw seedRequestValidationError(parsedRequest.error.issues); @@ -23049,7 +22559,6 @@ export class ObjectStackProtocolImplementation implements */ async publishPackageDrafts(request: { packageId: string; - organizationId?: string; actor?: string; /** ADR-0067 — commit message (for AI turns: the user's instruction). */ message?: string; @@ -23123,9 +22632,17 @@ export class ObjectStackProtocolImplementation implements /** ADR-0067 — id of the commit this publish recorded (absent if nothing published). */ commitId?: string; }> { + // [ADR-0131 D6] FIRST: an organization-scoped package publish is + // refused. See {@link organizationScopedWriteRefusal}. + ObjectStackProtocolImplementation.refuseOrganizationScopedWrite( + `Package '${request.packageId}'`, request, + ); await this.ensureOverlayIndex(); - const orgId = request.organizationId ?? null; - const repo = this.getOverlayRepo(orgId); + // Environment-wide drafts only: the environment repository lists + // `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 drafts = await repo.listDrafts({ packageId: request.packageId }); // Runtime enforcement of the package namespace-prefix rule (ADR-0028 @@ -23163,17 +22680,6 @@ export class ObjectStackProtocolImplementation implements name: string; error: string; code: PreflightViolationCode; - /** - * [#8595] The DRAFT's own scope, captured at detection — the same - * rule the promoted rows follow (`PromotedDraft.draftOrgId`) and for - * the same reason: `listDrafts` surfaces env-wide drafts - * (`organization_id IS NULL`) to a non-null-org caller, so an audit - * row keyed on the caller's active org would record the refusal - * against a partition the item never lived in. Internal to this - * method — deliberately NOT part of `failed[]`, which is a wire - * shape; the projection at the refusal site drops it. - */ - organizationId: string | null; }> = []; if (pkgNamespace) { for (const d of drafts) { @@ -23185,7 +22691,6 @@ export class ObjectStackProtocolImplementation implements name: d.name, error: err, code: 'NAMESPACE_PREFIX', - organizationId: d.organizationId ?? null, }); } } @@ -23242,7 +22747,6 @@ export class ObjectStackProtocolImplementation implements + `POST /meta/_migrate-stored does NOT rewrite a stored type spelling — it canonicalizes ` + `bodies, and reports rows of this class as 'skipped' with that same reason.`, code: 'STORED_TYPE_NOT_CANONICAL', - organizationId: d.organizationId ?? null, }); } @@ -23342,8 +22846,7 @@ export class ObjectStackProtocolImplementation implements // `failed[].error`, where it is the actionable fact. type: canonicalMetaType(v.type), name: v.name, - // The draft's OWN scope — see the violation type above. - organizationId: v.organizationId, + organizationId: null, operation: 'publish', outcome: 'denied', // The violation's own verdict, in the audit column's @@ -23506,17 +23009,6 @@ export class ObjectStackProtocolImplementation implements * then carries no `advisories` key at all. */ advisories: RuntimeAuthoringIssue[]; - /** - * [#8400] The scope the draft was PROMOTED IN — `d.organizationId`, - * not the request's active org. `listDrafts` surfaces env-wide - * (`organization_id IS NULL`) drafts to a non-null-org caller and - * the promote above targets the draft's own scope (#3115), so the - * audit row must be keyed the same way or it records the publish - * against a partition the active row never entered. Captured here - * rather than re-derived in Phase 2 because `d` is narrowed to - * `{ type, name }` by the type above. - */ - draftOrgId: string | null; }; const promoted: PromotedDraft[] = []; // (assigned inside the transaction closure — keep the wide type) @@ -23533,23 +23025,12 @@ export class ObjectStackProtocolImplementation implements await inTxn(async () => { for (const d of ordered) { try { - // Promote each draft in the scope `listDrafts` surfaced - // it from (#3115). Studio/package authoring writes the - // draft env-wide (`organization_id = NULL`) while the - // publishing session may carry a non-null active org; - // `listDrafts` includes those env-wide rows via its `$or`, - // so the promote MUST target the draft's own org or it - // 404s (`no_draft`) on a row it can never match. - const draftOrgId = d.organizationId ?? null; if (d.type === 'seed') { // Capture the body BEFORE promote (the draft row is - // deleted by the promote, and a post-publish read-back - // has org-scope resolution pitfalls — reading the - // draft is unambiguous). Read from the draft's own - // scope, not the request's active org. - const seedRepo = this.getOverlayRepo(draftOrgId); - const ref = { type: d.type, name: d.name, org: draftOrgId ?? 'env' } as unknown as Parameters[0]; - const draft = await seedRepo.get(ref, { state: 'draft' }); + // deleted by the promote, and reading the draft is + // unambiguous). + const ref = { type: d.type, name: d.name, org: 'env' } as unknown as Parameters[0]; + const draft = await repo.get(ref, { state: 'draft' }); if (draft?.body) seedBodies.push(draft.body); } const { singularType, advisories, result } = await this.promoteDraftForPublish({ @@ -23576,10 +23057,8 @@ export class ObjectStackProtocolImplementation implements // refuses if it is not canonical. type: canonicalMetaType(d.type), name: d.name, - ...(draftOrgId ? { organizationId: draftOrgId } : {}), // [#8907] Promote each draft under the PACKAGE - // `listDrafts` surfaced it from, for the same reason - // `draftOrgId` above threads its org: ADR-0048 keys + // `listDrafts` surfaced it from: ADR-0048 keys // overlay rows by `(org, type, name, package_id)`, // so with two packages holding drafts for one // `(type, name)` a promote that omits the package @@ -23605,7 +23084,6 @@ export class ObjectStackProtocolImplementation implements version: result.version, seq: result.seq, advisories, - draftOrgId, }); if (typeof result.seq === 'number') publishedSeqs.push(result.seq); } catch (e: unknown) { @@ -23623,7 +23101,6 @@ export class ObjectStackProtocolImplementation implements if (promoted.length > 0) { const promotedKeys = new Set(promoted.map((p) => `${p.d.type}/${p.d.name}`)); commit = await this.recordPackageCommit({ - orgId, packageId: request.packageId, operation: 'apply', ...(request.message ? { message: request.message } : {}), @@ -23734,7 +23211,7 @@ export class ObjectStackProtocolImplementation implements // (`__batchItem`), so its `type` is the STORED spelling. type: canonicalMetaType(causal.type), name: causal.name, - organizationId: causal.organizationId ?? null, + organizationId: null, operation: 'publish', outcome: 'denied', // adr0112-ok: D6b — persisted audit column, its own @@ -23829,8 +23306,7 @@ export class ObjectStackProtocolImplementation implements // #8858 as a provable no-op). type: canonicalMetaType(p.d.type), name: p.d.name, - // The draft's OWN scope — see `PromotedDraft.draftOrgId`. - organizationId: p.draftOrgId, + organizationId: null, operation: 'publish', outcome: 'allowed', code: 'ok', @@ -23857,7 +23333,6 @@ export class ObjectStackProtocolImplementation implements const eff = await this.runPublishSideEffects({ singularType: p.singularType, name: p.d.name, - orgId, body: p.body, packageId: p.packageId, ...(request.actor ? { actor: request.actor } : {}), @@ -23904,7 +23379,7 @@ export class ObjectStackProtocolImplementation implements } } - const seedApplied = seedBodies.length > 0 ? await this.applySeedBodies(seedBodies, orgId) : undefined; + const seedApplied = seedBodies.length > 0 ? await this.applySeedBodies(seedBodies) : undefined; // ADR-0038 L3: exercise what was just published — one real read per // artifact — so "Published!" can never again mean "and silently @@ -23919,16 +23394,12 @@ export class ObjectStackProtocolImplementation implements probes = await runBuildProbes({ engine: this.engine as any, getItem: async (type, name) => { - const wrapper: any = await (this as any).getMetaItem({ - type, - name, - ...(orgId ? { organizationId: orgId } : {}), - }); + const wrapper: any = await (this as any).getMetaItem({ type, name }); return wrapper?.item ?? wrapper ?? undefined; }, published, ...(analytics && typeof analytics.queryDataset === 'function' ? { analytics } : {}), - organizationId: orgId, + organizationId: null, }); } catch { probes = undefined; @@ -23996,7 +23467,6 @@ export class ObjectStackProtocolImplementation implements */ async discardPackageDrafts(request: { packageId: string; - organizationId?: string; actor?: string; }): Promise<{ success: boolean; @@ -24005,9 +23475,12 @@ export class ObjectStackProtocolImplementation implements discarded: Array<{ type: string; name: string }>; failed: Array<{ type: string; name: string; error: string; code?: string }>; }> { + // [ADR-0131 D6] FIRST: an organization-scoped discard is refused. + ObjectStackProtocolImplementation.refuseOrganizationScopedWrite( + `Package '${request.packageId}'`, request, + ); await this.ensureOverlayIndex(); - const orgId = request.organizationId ?? null; - const repo = this.getOverlayRepo(orgId); + const repo = this.getOverlayRepo(null); const drafts = await repo.listDrafts({ packageId: request.packageId }); const discarded: Array<{ type: string; name: string }> = []; @@ -24042,24 +23515,19 @@ export class ObjectStackProtocolImplementation implements } /** - * Discard ONE pending draft in the scope it lives in — the per-draft step - * {@link discardPackageDrafts} and {@link revertStoredPackage} share, so - * the scope rule below has one home. + * Discard ONE pending draft — the per-draft step + * {@link discardPackageDrafts} and {@link revertStoredPackage} share. The + * draft is environment-wide: both list through the environment + * repository (ADR-0131 D6). */ private async discardDraftInItsScope( - draft: { type: string; name: string; organizationId: string | null }, + draft: { type: string; name: string }, actor: string | undefined, ): Promise { - // Discard the draft in the scope it lives in (#3115). Like - // publish, `listDrafts` surfaces env-wide drafts to a non-null - // active org via `$or`; deleting under the request's active org - // would silently no-op on those env-wide rows. - const draftOrgId = draft.organizationId ?? null; await this.deleteMetaItem({ type: draft.type, name: draft.name, state: 'draft', - ...(draftOrgId ? { organizationId: draftOrgId } : {}), ...(actor ? { actor } : {}), }); } @@ -24090,7 +23558,7 @@ export class ObjectStackProtocolImplementation implements * published state. * * Membership is the package's rows as {@link SysMetadataRepository.listDrafts} - * reads them — `package_id`, the caller's organization plus env-wide — and + * reads them — `package_id`, environment-wide (ADR-0131 D6) — and * the active rows are read through the same predicate, * {@link packageScopedRowWhere}, never a `where` of this method's own. * @@ -24116,21 +23584,23 @@ export class ObjectStackProtocolImplementation implements */ async revertStoredPackage(request: { packageId: string; - organizationId?: string; actor?: string; }): Promise<{ stored: boolean; discarded: Array<{ type: string; name: string }>; }> { + // [ADR-0131 D6] FIRST: an organization-scoped revert is refused. + ObjectStackProtocolImplementation.refuseOrganizationScopedWrite( + `Package '${request.packageId}'`, request, + ); await this.ensureOverlayIndex(); - const orgId = request.organizationId ?? null; let drafts: Awaited>; let publishedRows: unknown[]; try { - drafts = await this.getOverlayRepo(orgId).listDrafts({ packageId: request.packageId }); + drafts = await this.getOverlayRepo(null).listDrafts({ packageId: request.packageId }); // [#21911] The explicit system opt-in — see findServedOverlayRow. publishedRows = (await this.engine.find('sys_metadata', { - where: packageScopedRowWhere(orgId, 'active', { packageId: request.packageId }), + where: packageScopedRowWhere(null, 'active', { packageId: request.packageId }), limit: 1, context: { isSystem: true }, })) as unknown[]; @@ -24173,7 +23643,7 @@ export class ObjectStackProtocolImplementation implements * * [#21276] The steps, in order. Nothing durable happens before step 4, so * a refusal at any of steps 1–4 leaves everything as it was: - * 1. the tenant-scope refusals (`TENANT_SCOPE_REQUIRED`) — pure; + * 1. the retired-key refusal (`INVALID_REQUEST`) — pure; * 2. the `sys_metadata` read — a read; a failure is thrown; * 3. the registry's uninstall refusal (another package extends an object * this one owns, ADR-0029), asked through @@ -24189,119 +23659,29 @@ export class ObjectStackProtocolImplementation implements * 7. the uninstall cleanups — each refusal is reported in `cleanups[]`. */ async deletePackage(request: DeletePackageRequest): Promise { - // [#7780] A cross-tenant uninstall must be DECLARED, never inferred from - // an absent parameter. Maintainer ruling (2026-08-12): - // 跨租户卸载必须显式声明,缺省缺参永远不等于「全部租户」. - // - // Before this gate, `{ packageId }` with no org matched EVERY - // organization's rows — measured during #7705 at 5 of 5 deleted, - // including a foreign org's. The two doors disagreed on which semantic - // that was: the direct-mount REST registrar - // (`packages/rest/src/package-routes.ts`) passes no org and got the - // cross-tenant read, while the dispatcher twin - // (`packages/runtime/src/domains/packages.ts`) resolves one and got the - // org-scoped read. Nobody chose that split; it fell out of a missing - // argument. - // - // Why a flag and not a convention: `resolveActiveOrganizationId` - // (#4127) is entirely `catch`-wrapped, so ANY throw on the auth seam - // returns `undefined`. An accidental org-less call and a deliberate - // env-wide one are byte-identical at the call site, and the widest - // possible reading of a destructive operation is the one that must - // never be reachable by accident. `allTenants: true` is the carrier - // that makes the two distinguishable. - // - // ⛔ NOT narrowed to `organization_id IS NULL` — #7705 proved that - // revives the orphaned-row defect on the other door. The remedy here is - // explicitness, not narrowing: with the flag, the no-org branch stays - // package-wide exactly as it was. - // - // Mirrors the `force: true` / `DESTRUCTIVE_CHANGE` opt-in this same - // class already uses for `saveMetaItem` — refuse, name the remedy in the - // message, and carry a ledger-declared code plus an explicit status. - // Two ways to violate ONE contract — "the uninstall's tenant scope must be - // readable off the request" — so both answer in the same family, with the - // same code and status, and each names the parameters that produced it. - // - // (a) CONTRADICTORY. `organizationId` says "this tenant", `allTenants` - // says "every tenant". Rejecting beats picking a winner, because both - // silent resolutions are worse than a refusal: resolving narrow-first - // makes `allTenants: true` silently INERT (the caller believes they - // asked for a cross-tenant uninstall and quietly gets a scoped one, - // discovering it only when the rows they expected gone are still - // there); resolving explicit-first silently IGNORES a named - // organization and deletes every tenant's rows — the original defect - // wearing a flag. Rejecting is also the only reading that stays correct - // when a request is COMPOSED from two places (a resolver supplying the - // org, config supplying the flag), which is exactly the accidental - // composition `resolveActiveOrganizationId` makes real. - if (request.organizationId && request.allTenants === true) { - const err = new Error( - `Refusing to uninstall '${request.packageId}':` - + ` organizationId ('${request.organizationId}') and allTenants: true are mutually exclusive —` - + ` one scopes the uninstall to a single tenant, the other clears every tenant's rows.` - + ` — pass organizationId alone to scope it, or allTenants: true alone to confirm the cross-tenant uninstall.` - ); - (err as any).code = 'TENANT_SCOPE_REQUIRED'; - (err as any).status = 400; - throw err; - } - // (b) UNDECLARED. Note `!== true`: an explicit `allTenants: false` lands - // here with absent, deliberately. `false` is not a request for - // cross-tenant semantics, so it cannot authorise them — only the - // affirmative `true` does. - if (!request.organizationId && request.allTenants !== true) { - const err = new Error( - `Refusing to uninstall '${request.packageId}' with no organization scope:` - + ` an uninstall that names neither an organization nor an explicit cross-tenant intent would delete` - + ` EVERY organization's rows for this package.` - + ` — pass organizationId to scope it, or allTenants: true to confirm the cross-tenant uninstall.` - ); - (err as any).code = 'TENANT_SCOPE_REQUIRED'; - (err as any).status = 400; - throw err; + // [ADR-0131 D6/D12] An uninstall is environment-wide by construction: + // every `sys_metadata` row bound to the package, in this environment, + // whatever organization a legacy row was stored under. The + // `organizationId` / `allTenants` request keys that once chose between + // one organization's rows and every organization's (#7780) name + // nothing now, and retire: a request still carrying either is refused + // whole, before anything is read, rather than read past — the caller + // believes the key still scopes something. Who may uninstall is the + // package door's operator gate, not a request key. + for (const retired of ['organizationId', 'allTenants'] as const) { + if (Object.prototype.hasOwnProperty.call(request, retired)) { + const err = new Error( + `Refusing to uninstall '${request.packageId}': the '${retired}' request key is retired. ` + + `An uninstall is environment-wide — it removes every row bound to the package in this ` + + `environment — so no organization scope is stated or confirmed. Retry without '${retired}'. ` + + `See docs/adr/0131-total-organization-ownership-no-null-organization-id.md.` + ); + (err as any).code = 'INVALID_REQUEST'; + (err as any).status = 400; + throw err; + } } const where: Record = { package_id: request.packageId }; - // [#7705] Surface BOTH org-scoped rows and env-wide (`organization_id - // IS NULL`) rows to an org-scoped uninstall. A strict - // `organization_id = ` equality silently dropped every env-wide - // row, and env-wide is where a package's metadata normally LANDS: the - // REST `PUT /meta/:type/:name` save path does not thread the session's - // active org, and AI-authored metadata is written env-wide too. So an - // uninstall issued by a session that HAS an active org (the dispatcher - // door, `packages/runtime/src/domains/packages.ts`, is the one that - // resolves and passes `organizationId`) selected only the handful of - // rows that happened to be org-scoped and left the rest behind — - // reporting `deletedCount` > 0 and `success: true` while the package's - // rows demonstrably survived (the orphaned-uninstall bug). - // - // Same defect and same remedy as the #3115 "orphaned draft" bug one - // file over ({@link SysMetadataRepository.listDrafts}), and the shape - // is deliberately identical to it. The driver's own implicit tenant - // wall already reads this way (`field = :tenant OR field IS NULL`, - // #2734); only author-supplied predicates are strict, which is what - // made this silent. - // - // The no-org branch is deliberately NOT narrowed to `organization_id - // IS NULL`: the other door of this route (the direct-mount REST - // registrar, `packages/rest/src/package-routes.ts`) passes no - // `organizationId` at all, and restricting it to env-wide rows would - // orphan every org-scoped row — the same bug, re-created on the other - // door. Absent an org, a full uninstall stays package-wide — and since - // #7780 that branch is reachable only with `allTenants: true`, so the - // width is now something a caller ASKED for rather than something a - // missing argument selected. - // - // There is no tie-break for "both supplied" because that combination - // never reaches here — it is refused above. A destructive operation - // whose scope is stated twice, contradictorily, has no reading that is - // safe to guess. - if (request.organizationId) { - where.$or = [ - { organization_id: request.organizationId }, - { organization_id: null }, - ]; - } // [#8136] This read is the uninstall's FIRST database touch, and until // now it sat outside every `try` in this method — the per-item `catch` // below wraps only the `deleteMetaItem` loop. So a driver failure here @@ -24413,14 +23793,17 @@ export class ObjectStackProtocolImplementation implements for (const row of ordered) { const state: 'active' | 'draft' = row.state === 'draft' ? 'draft' : 'active'; try { - await this.deleteMetaItem({ - type: row.type, - name: row.name, - state, - ...(row.organization_id ? { organizationId: row.organization_id } : {}), - ...(request.actor ? { actor: request.actor } : {}), - ...(dropStorage ? { dropStorage: true } : {}), - }); + if (row.organization_id) { + await this.removeLegacyOrganizationRowOnUninstall(row, state, request.actor); + } else { + await this.deleteMetaItem({ + type: row.type, + name: row.name, + state, + ...(request.actor ? { actor: request.actor } : {}), + ...(dropStorage ? { dropStorage: true } : {}), + }); + } deleted.push({ type: row.type, name: row.name, state }); } catch (e: any) { // [#8136] NO filter here, deliberately, and this comment is why @@ -24504,6 +23887,54 @@ export class ObjectStackProtocolImplementation implements }; } + /** + * [ADR-0131 D6/D12] Remove ONE organization-scoped row stored before the + * per-organization overlay axis retired, as part of uninstalling the + * package it is bound to. The uninstall is environment-wide, so the + * package's legacy rows leave with it rather than being stranded for the + * promotion ceremony (ADR-0131 C7) to carry into the environment layer + * for a package that is gone. + * + * The one organization-scoped write left in this class, and it only + * REMOVES: reachable from {@link deletePackage} alone, never from a + * request (every write verb refuses an organization-scoped request, + * {@link organizationScopedWriteRefusal}). Through the repository, so the + * removal is transactional and leaves its history tombstone, like every + * other delete. A legacy row is presentational (only the five + * formerly org-overridable types could be stored per organization), so + * there is no table to tear down and no registry entry to retire — + * boot hydration never registered one. + */ + private async removeLegacyOrganizationRowOnUninstall( + row: { type: string; name: string; organization_id: string }, + state: 'active' | 'draft', + actor: string | undefined, + ): Promise { + const type = PLURAL_TO_SINGULAR[row.type] ?? row.type; + const repo = this.getOverlayRepo(row.organization_id); + const ref = { type, name: row.name, org: row.organization_id } as Parameters[0]; + const current = await repo.get(ref, { state }); + if (!current) return; + await repo.delete(ref, { + parentVersion: current.hash, + actor: actor ?? null, + source: 'protocol.deletePackage', + intent: 'runtime-only', + state, + }); + await this.recordMetadataAudit({ + type, + name: row.name, + organizationId: null, + operation: 'delete', + outcome: 'allowed', + code: 'ok', + ...(actor ? { actor } : {}), + source: 'protocol.deletePackage', + note: `${state}; legacy organization-scoped row (organization '${row.organization_id}') removed with its package`, + }); + } + /** * ADR-0070 D4 — duplicate a writable base into a NEW package (the Airtable * "duplicate base" gesture). Clones every ACTIVE item the source owns into @@ -24519,7 +23950,6 @@ export class ObjectStackProtocolImplementation implements targetPackageId: string; targetName?: string; targetNamespace?: string; - organizationId?: string; actor?: string; }): Promise<{ success: boolean; @@ -24529,6 +23959,10 @@ export class ObjectStackProtocolImplementation implements copied: Array<{ type: string; name: string }>; failed: Array<{ type: string; name: string; error: string }>; }> { + // [ADR-0131 D6] FIRST: an organization-scoped duplicate is refused. + ObjectStackProtocolImplementation.refuseOrganizationScopedWrite( + `Package '${request.targetPackageId}'`, request, + ); // [#19417] ⭐ THE TARGET ID IS PARSED BEFORE ANYTHING IS MINTED — same // declaration, same surfaced sentence, one key over. // @@ -24625,100 +24059,19 @@ export class ObjectStackProtocolImplementation implements ); } - const where: Record = { package_id: request.sourcePackageId, state: 'active' }; - // [#7819 tier 2] Copy the source's env-wide (`organization_id IS NULL`) - // rows too, not just the ones this org happens to own — the same `$or` - // {@link deletePackage} (#7705) and {@link listCommits} (#7779) carry. - // Unlike the tier-1 sites this really is plain scan scoping (`where` is - // keyed on package + state, not on `id`), so the family remedy applies - // without their authorization question. - // - // Measured on a real driver before the fix: a source package holding one - // env-wide row and one org-scoped row duplicated by an org caller - // answered `{success: true, copiedCount: 1, failedCount: 0}` — a PARTIAL - // copy reported as a whole one, because `organization_id = ` matches - // no NULL column. The mixed state is ordinary, not contrived: a publish - // made before an active org was selected lands its `sys_metadata` row - // env-wide (`saveMetaItem` writes `organization_id = NULL`), and - // `resolveActiveOrganizationId` yields `undefined` for such a session - // *and* for any throw on the auth seam. - // - // The sharper consequence is the rename map below, which is built ONLY - // from the rows this scan returns. With the env-wide OBJECT rows missing - // it came out empty, so a copied view was renamed `iojn2_list` while its - // `data.object` still pointed at the SOURCE package's `iojn_widget` — a - // duplicate silently wired back to the base it was cloned from, reporting - // success. An all-env-wide source degraded differently and just as - // quietly: `{success: false, copiedCount: 0, failedCount: 0}`, nothing - // copied and nothing named as failed. - // - // The no-org branch is deliberately NOT narrowed to `organization_id IS - // NULL`, exactly as #7705 / #7779 / tier 1 left theirs: that door copies - // every scope today, and restricting it to env-wide rows would drop every - // org-scoped row from the copy — the same bug pointed the other way. - if (request.organizationId) { - where.$or = [ - { organization_id: request.organizationId }, - { organization_id: null }, - ]; - } + // [ADR-0131 D6] The source's ENVIRONMENT rows — the ones every `/meta` + // read serves. A legacy organization-scoped row is served by none of + // them and waits for the promotion ceremony (ADR-0131 C7); copying it + // would write its body environment-wide under the new package, a + // promotion this verb has no business performing, and — beside the + // environment row of the same item — land two bodies on one target key. + const where: Record = { + package_id: request.sourcePackageId, + state: 'active', + organization_id: null, + }; // [#21911] The explicit system opt-in — see findServedOverlayRow. - const scanned = (await this.engine.find('sys_metadata', { where, context: { isSystem: true } })) as any[]; - - // [#7819 tier 2] ADR-0005 overlay precedence — the caller's OWN org - // shadows env-wide ({@link resolveMetaItemOrgScope} states the same rule - // for history lineages). Widening the scan makes a collision newly - // possible that could not occur while it was a strict equality: one item - // can now appear TWICE, as an env-wide row PLUS this org's overlay of it. - // Every copy is written under `request.organizationId`, so both would - // land on the same target key — overlay uniqueness is - // `(type, name, organization_id, COALESCE(package_id, ''))` — and which - // body survived would be decided by driver row order. Keep the org - // overlay: it is what this caller already reads everywhere else. - let rows = scanned; - if (request.organizationId) { - const byKey = new Map(); - for (const row of scanned) { - // [#7932] …and for a bundled type the slot is - // `(type, name, discriminator)`. Same shape #7774 gave - // {@link metaItemKey}: the discriminator is appended ONLY - // when the type declares one, so every undiscriminated type - // keeps a byte-identical two-component key and this - // change's blast radius is provable rather than argued. - // - // Within ONE org the collapse cannot happen — - // `sys_metadata`'s overlay uniqueness is - // `(type, name, organization_id, package_id)` and the table - // has no locale column, so one org cannot hold two rows - // differing only by body locale. Across the two tiers it - // can, and this scan is the one place they meet: an - // env-wide `auth.welcome` customized in `en-US` and THIS - // org's `zh-CN` customization are two different members of - // one bundle (`EmailTemplateDefinitionSchema` resolves a - // template by `(name, locale)`), and keying them together - // let the org row displace the env-wide one — the - // duplicate then shipped one locale of a two-locale - // customization, reporting success. Precedence is unchanged - // where it was ever meaningful: an org row still wins over - // the env-wide row of the SAME member. - // - // The discriminator is read off the RAW stored body rather - // than the converted one, which is safe because no ADR-0087 - // conversion entry touches `email_template` — re-checked - // against `packages/spec/src/conversions/registry.ts`, - // whose surfaces are flow/page/object/view/app/… and never - // this type. - const disc = storedRowDiscriminator(String(row?.type), row); - const base = `${row?.type}\u0000${row?.name}`; - const key = disc === undefined ? base : `${base}\u0000${disc}`; - const kept = byKey.get(key); - const keptIsEnvWide = kept != null && (kept.organization_id ?? null) === null; - if (kept == null || (keptIsEnvWide && (row?.organization_id ?? null) !== null)) { - byKey.set(key, row); - } - } - rows = [...byKey.values()]; - } + const rows = (await this.engine.find('sys_metadata', { where, context: { isSystem: true } })) as any[]; // Map only OBJECT names that carry the source namespace prefix; views/etc. // are renamed by the same prefix swap and reference-rewritten via the map. @@ -24860,36 +24213,6 @@ export class ObjectStackProtocolImplementation implements } const rewritten = deepRewrite(item); if (rewritten && typeof rewritten === 'object' && !Array.isArray(rewritten)) rewritten.name = newName; - // [#7819 tier 2] The copy lands in the SCOPE OF THE ROW IT CAME - // FROM, not the request's — the same rule #7559 gave `revertCommit` - // ({@link resolveMetaItemOrgScope}) for the same reason, now that - // widening the scan above means this loop, too, processes a batch - // that "legitimately mixes an env-wide artifact with an org - // overlay". - // - // Not cosmetic: without it the read fix alone cannot produce a - // working duplicate. Stamping the request's org on every copy is - // REFUSED for any type the metadata-type registry declares - // `allowOrgOverride=false` — `object` among them — with - // `NOT_OVERRIDABLE`, because boot hydration loads env-wide rows - // only and an org-scoped `object` row would vanish on the next - // restart (ADR-0005, #6190). Since an `object` therefore CANNOT - // exist org-scoped, every object row in a source package is - // env-wide, and an org-scoped `duplicatePackage` could not copy a - // single one: before this card the strict equality hid them, and - // with only the scan widened they would land in `failed[]` - // instead. Objects being what a base is mostly made of, ADR-0070 - // D4's "duplicate base" gesture was structurally unable to - // duplicate a base whenever an org was active. - // - // Scoped to the org-scoped door alone. With no `organizationId` on - // the request the scan returns every organization's rows and each - // copy is written env-wide exactly as before — that door's - // behaviour is deliberately left byte-identical, as this card - // leaves all of its no-org branches. - const copyOrgId: string | null = request.organizationId - ? ((row?.organization_id ?? null) as string | null) - : null; try { await this.saveMetaItem({ type: row.type, @@ -24905,7 +24228,6 @@ export class ObjectStackProtocolImplementation implements // duplicate-AGAIN workflow, and its `?force=true` default // would name a parameter this door does not accept. writeFace: 'package-duplicate', - ...(copyOrgId ? { organizationId: copyOrgId } : {}), ...(request.actor ? { actor: request.actor } : {}), }); copied.push({ type: row.type, name: newName }); @@ -24960,7 +24282,6 @@ export class ObjectStackProtocolImplementation implements */ async reassignOrphanedMetadata(request: { targetPackageId: string; - organizationId?: string; actor?: string; }): Promise<{ success: boolean; @@ -24968,46 +24289,19 @@ export class ObjectStackProtocolImplementation implements reassigned: Array<{ type: string; name: string }>; targetPackageId: string; }> { - const where: Record = {}; - // [#7819 tier 2] See env-wide (`organization_id IS NULL`) orphans too. - // This is the sharper member of the family, because FINDING ORPHANS IS - // THE ENTIRE PURPOSE of this method: a class of orphan it structurally - // cannot see is not a partial answer, it is a wrong one. Measured on a - // real driver before the fix — two orphans, one env-wide and one - // org-scoped, adopted by an org caller: `{success: true, - // reassignedCount: 1}`, with the env-wide orphan left at - // `package_id = null` and nothing reporting that it was skipped. - // - // Not a legacy-only population, which is what makes this live rather - // than latent. The docstring above calls orphans a pre-package-first - // residue, and ADR-0070 D1 does reject NEW orphans that name a - // read-only package (`WRITABLE_PACKAGE_REQUIRED`) — but a - // `saveMetaItem` that names NO package at all still succeeds today and - // lands `package_id = null, organization_id = null`, i.e. the current - // write path mints exactly the orphan this scan could not see. - // - // ADR-0070 D5 settles the scope question this widening raises (an - // org-scoped caller now rebinds rows every org can see): the unit is - // explicitly the ENVIRONMENT — "bulk-assign legacy orphans to a default - // base named for the environment", completing when "an environment has - // no orphans", in a deployment model whose own words are "there is no - // per-org overlay dimension here… the relevant axis is code package vs - // writable base, not 'org'". Under that model every orphan is env-wide, - // so the strict equality made this method inert for an org-scoped - // caller in precisely the deployment it was designed for. - // - // ⛔ The no-org branch stays `{}` — deliberately un-narrowed, and this - // is the exposure the card flagged as worst: that door already scans - // EVERY organization's rows. Narrowing it to `organization_id IS NULL` - // would re-create this same bug pointed the other way. Whether that - // door should be that wide is #7780's open product question, which is - // a maintainer call and explicitly NOT decided here. - if (request.organizationId) { - where.$or = [ - { organization_id: request.organizationId }, - { organization_id: null }, - ]; - } + // [ADR-0131 D6] FIRST: an organization-scoped adoption is refused. + ObjectStackProtocolImplementation.refuseOrganizationScopedWrite( + `Package '${request.targetPackageId}'`, request, + ); + // The ENVIRONMENT's orphans: ADR-0070 D5 makes the environment the unit + // ("bulk-assign legacy orphans to a default base named for the + // environment"), and since ADR-0131 D6 every new orphan is + // environment-wide (`saveMetaItem` writes `organization_id = NULL`). A + // legacy organization-scoped row is left as it is stored — rebinding + // it would be an organization-scoped write; the promotion ceremony + // (ADR-0131 C7) carries it to the environment layer, where a later + // adoption sees it. + const where: Record = { organization_id: null }; // [#21911] The explicit system opt-in — see findServedOverlayRow. const rows = (await this.engine.find('sys_metadata', { where, context: { isSystem: true } })) as any[]; const orphans = rows.filter( @@ -25148,7 +24442,6 @@ export class ObjectStackProtocolImplementation implements * `isMissingTableError` predicate rather than a hand-rolled code test. */ private async recordPackageCommit(args: { - orgId: string | null; packageId: string; operation: 'apply' | 'revert'; message?: string; @@ -25175,7 +24468,8 @@ export class ObjectStackProtocolImplementation implements ...(args.eventSeqEnd !== undefined ? { event_seq_end: args.eventSeqEnd } : {}), items: JSON.stringify(args.items), item_count: args.items.length, - organization_id: args.orgId, + // [ADR-0131 D6/D7] The commit ledger is environment-level. + organization_id: null, created_at: new Date().toISOString(), }); return { commitId }; @@ -25412,7 +24706,6 @@ export class ObjectStackProtocolImplementation implements */ async revertCommit(request: { commitId: string; - organizationId?: string; actor?: string; }): Promise<{ success: boolean; @@ -25422,55 +24715,12 @@ export class ObjectStackProtocolImplementation implements failed: Array<{ type: string; name: string; error: string; code?: string }>; revertCommitId?: string; }> { + // [ADR-0131 D6] FIRST: an organization-scoped revert is refused. + ObjectStackProtocolImplementation.refuseOrganizationScopedWrite( + `Commit '${request.commitId}'`, request, + ); await this.ensureOverlayIndex(); - const orgId = request.organizationId ?? null; const where: Record = { id: request.commitId }; - // [#7819] Resolve BOTH org-scoped and env-wide (`organization_id IS - // NULL`) commit rows for an org-scoped caller — the same defect and - // remedy as the sibling {@link listCommits} (#7779) and {@link - // deletePackage} (#7705). `organization_id = ` matches no NULL - // column, so this answered `COMMIT_NOT_FOUND` (404) for a row that - // demonstrably exists and that the SAME caller's `listCommits` - // returns. - // - // ⚠️ This site is NOT the family's plain scan-scoping, and the `$or` - // was chosen over the two alternatives rather than copied. `where` is - // keyed on `id`, so the predicate reads like an AUTHORIZATION filter - // layered on a unique key. Measured against the only door, it is not - // one: authorization on `POST /packages/:id/commits/:commitId/revert` - // is `requireManageMetadata`, checked before this call; the - // `organizationId` that arrives is the session's *active org - // selection* from `resolveActiveOrganizationId`, whose body is - // entirely `catch`-wrapped and answers `undefined` on any auth-seam - // throw — and `undefined` omits this predicate, which is the WIDEST - // reading (every organization's commits). A boundary that fails OPEN - // is not a boundary, so there is no authz here to make precise; that - // rules out "keep it but distinguish 'not yours' from 'no such - // commit'". Dropping the predicate outright is defensible on an id - // lookup, but it would newly let an org caller revert ANOTHER - // organization's commit by id — a widening this card never asked for. - // The `$or` admits the env-wide rows and refuses that one. - // - // The body already agreed with this reading before the lookup did: - // #7559 made each item resolve its scope FROM THE ROW ({@link - // resolveMetaItemOrgScope}) precisely because "a batch legitimately - // mixes an env-wide artifact with an org overlay", so the loop below - // processes env-wide items for an org caller while the lookup above - // refused to hand them over. {@link rollbackToPackageCommit} made the - // contradiction self-evident: since #7814 it plans from `listCommits` - // (org + env-wide) and fed each id straight back into this lookup. - // - // The no-org branch is deliberately NOT narrowed to `organization_id - // IS NULL`, exactly as #7705 and #7779 left theirs: the direct-mount - // REST registrar passes no `organizationId` at all, and restricting - // that door to env-wide rows would make every org-scoped commit - // unrevertable — the same bug pointed the other way. - if (request.organizationId) { - where.$or = [ - { organization_id: request.organizationId }, - { 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. @@ -25481,6 +24731,16 @@ export class ObjectStackProtocolImplementation implements err.status = 404; throw err; } + // [ADR-0131 D6] A commit recorded in a legacy organization's layer + // describes organization-scoped rows; reverting it would write them. + { + const legacy = ObjectStackProtocolImplementation.organizationScopedWriteRefusal( + `Commit '${request.commitId}'`, + row.organization_id, + 'It records a change to that organization\'s legacy layer, which no write reaches any more.', + ); + if (legacy) throw legacy; + } const items = this.parseCommitItems(row.items); // #4556 — threaded into repo.put/delete → `recorded_by`; NULL when the // revert carries no human actor. @@ -25614,20 +24874,9 @@ export class ObjectStackProtocolImplementation implements continue; } - // [#7559] PER ITEM, and from the ROW rather than from the request — - // the same shape {@link publishPackageDrafts} already uses when it - // promotes each draft in the draft's OWN scope and captures - // `prevVersion` there. A batch legitimately mixes an env-wide - // artifact with an org overlay, so a hoisted `orgId` has to pick one - // and be wrong about the other — which is exactly how a commit whose - // items are env-wide answered `VERSION_NOT_FOUND` for every item - // when reverted by a caller with an active org. See - // {@link resolveMetaItemOrgScope} for the measurement. - const itemOrgId = await this.resolveMetaItemOrgScope( - PLURAL_TO_SINGULAR[it.type] ?? it.type, - it.name, - orgId, - ); + // [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 ref = { type: it.type, name: it.name, org: itemOrgId ?? 'env' } as unknown as Parameters[0]; try { @@ -25919,40 +25168,9 @@ export class ObjectStackProtocolImplementation implements } } - // Record the revert as its own commit (append-only history). - // - // [#7860] The scope of the commit being REVERTED, not the request's — - // the same rule #7559 gave this function's items ({@link - // resolveMetaItemOrgScope}) and #7819 tier 2 gave {@link - // duplicatePackage}'s copies, now applied to the commit RECORD that - // documents them. `packageId` on this very call is already read off - // `row`; the org was the one field still taken from whoever asked. - // - // Reachable only since #7819 tier 1: before it, the lookup above - // answered COMMIT_NOT_FOUND for an env-wide row, so an org caller - // could not reach this line with a mismatched scope at all. - // - // The invariant it restores: a revert commit is visible to exactly - // the readers who can see the commit it reverts. Measured on a real - // driver, both directions were incoherent without it — - // - // env-wide commit reverted by an org caller: the revert row was - // stamped with that org, so a DIFFERENT org's `listCommits` showed - // the env-wide `apply` with no compensation anywhere after it — - // while the artifact really was removed env-wide (the items revert - // in the ROW's scope), i.e. the effect was global and the record - // private. That is the reporting defect this card was opened to - // measure, and it is not cosmetic: {@link rollbackToPackageCommit} - // plans from `listCommits`. - // - // org-scoped commit reverted by the no-org REST door: the revert - // row was stamped env-wide, so every OTHER org read a dangling - // `Revert: …` entry whose `parentCommitId` names a commit that - // door cannot see. - // - // Both collapse to one line because both are the same mismatch. + // Record the revert as its own commit (append-only history), + // environment-wide like the commit it reverts (ADR-0131 D6/D7). const revertCommit = await this.recordPackageCommit({ - orgId: (row.organization_id ?? null) as string | null, packageId: row.package_id, operation: 'revert', message: `Revert: ${row.message ?? request.commitId}`, @@ -25983,28 +25201,17 @@ export class ObjectStackProtocolImplementation implements */ async rollbackToPackageCommit(request: { commitId: string; - organizationId?: string; actor?: string; }): Promise<{ success: boolean; revertedCommits: string[]; failed: Array<{ commitId: string; error: string }>; }> { + // [ADR-0131 D6] FIRST: an organization-scoped rollback is refused. + ObjectStackProtocolImplementation.refuseOrganizationScopedWrite( + `Commit '${request.commitId}'`, request, + ); const where: Record = { id: request.commitId }; - // [#7819] Same widening as the {@link revertCommit} lookup above, and - // for the sharper reason: this function PLANS from {@link listCommits}, - // which since #7814 returns org-scoped and env-wide rows alike to an - // org caller. With the strict equality here, an org-scoped rollback - // whose TARGET happened to be recorded env-wide answered 404 before it - // planned anything at all — for a commit the caller's own timeline had - // just listed. The rationale for the `$or` over the alternatives, and - // for leaving the no-org branch un-narrowed, is stated in full there. - if (request.organizationId) { - where.$or = [ - { organization_id: request.organizationId }, - { 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. @@ -26015,10 +25222,7 @@ export class ObjectStackProtocolImplementation implements err.status = 404; throw err; } - const all = await this.listCommits({ - packageId: target.package_id, - ...(request.organizationId ? { organizationId: request.organizationId } : {}), - }); + const all = await this.listCommits({ packageId: target.package_id }); // listCommits is newest-first; revert every `apply` commit strictly newer // than the target (by created_at). Revert commits are skipped (their // effect is already captured by re-reverting the apply they undid). @@ -26042,7 +25246,6 @@ export class ObjectStackProtocolImplementation implements try { await this.revertCommit({ commitId: c.id, - ...(request.organizationId ? { organizationId: request.organizationId } : {}), ...(request.actor ? { actor: request.actor } : {}), }); revertedCommits.push(c.id); @@ -26081,7 +25284,6 @@ export class ObjectStackProtocolImplementation implements type: string; name: string; toVersion: number; - organizationId?: string; actor?: string; message?: string; }): Promise<{ @@ -26091,6 +25293,11 @@ export class ObjectStackProtocolImplementation implements restoredFromVersion: number; message?: string; }> { + // [ADR-0131 D6] FIRST: an organization-scoped rollback is refused. + // See {@link organizationScopedWriteRefusal}. + ObjectStackProtocolImplementation.refuseOrganizationScopedWrite( + `Metadata item '${request.type}/${request.name}'`, request, + ); if (!Number.isFinite(request.toVersion) || request.toVersion < 1) { const err: any = new Error( `rollbackMetaItem requires a positive integer 'toVersion' (got ${request.toVersion}).`, @@ -26168,25 +25375,15 @@ export class ObjectStackProtocolImplementation implements const _rollbackLockErr = await this.assertLockAllowsWrite({ type: request.type, name: request.name, - ...(request.organizationId ? { organizationId: request.organizationId } : {}), operation: 'rollback', ...(request.actor ? { actor: request.actor } : {}), source: 'protocol.rollbackMetaItem', }); if (_rollbackLockErr) throw _rollbackLockErr; await this.ensureOverlayIndex(); - // [#7559] The scope the item's lineage actually lives in, not the - // caller's active org. Measured on `origin/main`: an env-wide `view` - // rolled back by a caller with an active org threw `VERSION_NOT_FOUND` - // (404) at exactly the version its own history endpoint lists, while - // the identical call with no active org succeeded — the same - // disagreement {@link revertCommit} showed, one caller over. See - // {@link resolveMetaItemOrgScope}. - const orgId = await this.resolveMetaItemOrgScope( - singularType, - request.name, - request.organizationId ?? null, - ); + // [ADR-0131 D6] The environment's lineage: an organization-scoped + // request was refused above. + const orgId = null; const repo = this.getOverlayRepo(orgId); const artifactBacked = this.isArtifactBacked(singularType, request.name); const intent: 'override-artifact' | 'runtime-only' = artifactBacked @@ -26306,7 +25503,6 @@ export class ObjectStackProtocolImplementation implements await this.recordOptimisticConflictAudit({ type: request.type, name: request.name, - organizationId: orgId, operation: 'rollback', ...(request.actor ? { actor: request.actor } : {}), source: 'protocol.rollbackMetaItem', @@ -26656,7 +25852,6 @@ export class ObjectStackProtocolImplementation implements async deleteMetaItem(request: { type: string; name: string; - organizationId?: string; parentVersion?: string | null; actor?: string; state?: 'active' | 'draft'; @@ -26675,6 +25870,11 @@ export class ObjectStackProtocolImplementation implements /** [ADR-0094] Outcome of the awaited mutation projector, when one is registered. */ projectionApplied?: MutationProjectionOutcome; }> { + // [ADR-0131 D6] FIRST: an organization-scoped delete is refused. + // See {@link organizationScopedWriteRefusal}. + ObjectStackProtocolImplementation.refuseOrganizationScopedWrite( + `Metadata item '${request.type}/${request.name}'`, request, + ); // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. Without it // the authorization tier (`isOverlayAllowed` / `isArtifactBacked`) and // the registry heal (`restoreArtifactRegistryView`) read the caller's @@ -26777,7 +25977,6 @@ export class ObjectStackProtocolImplementation implements const lockErr = await this.assertLockAllowsDelete({ type: request.type, name: request.name, - ...(request.organizationId ? { organizationId: request.organizationId } : {}), ...(request.actor ? { actor: request.actor } : {}), source: 'protocol.deleteMetaItem', }); @@ -26820,7 +26019,8 @@ export class ObjectStackProtocolImplementation implements // undefined) take the legacy raw-engine path below — the repository's // `assertAllowed()` whitelist would 403 those deletes. if (useRepoPath) { - const orgId = request.organizationId ?? null; + // [ADR-0131 D6] Environment-wide: refused above otherwise. + const orgId = null; const repo = this.getOverlayRepo(orgId); const ref = { type: singularTypeForRepo, @@ -26998,7 +26198,6 @@ export class ObjectStackProtocolImplementation implements await this.recordOptimisticConflictAudit({ type: request.type, name: request.name, - organizationId: orgId, operation: 'delete', ...(request.actor ? { actor: request.actor } : {}), source: 'protocol.deleteMetaItem', @@ -27066,7 +26265,7 @@ export class ObjectStackProtocolImplementation implements const scopedWhere: Record = { type: request.type, name: request.name, - organization_id: request.organizationId ?? null, + organization_id: null, }; try { @@ -27105,7 +26304,7 @@ export class ObjectStackProtocolImplementation implements await this.restoreArtifactRegistryView( request.type, request.name, - request.organizationId ?? null, + null, ); } @@ -27120,7 +26319,7 @@ export class ObjectStackProtocolImplementation implements type: singularTypeForRepo, name: request.name, state: 'deleted', - organizationId: request.organizationId ?? null, + organizationId: null, }); // [#14179] A real deletion announces itself on the ONE choke @@ -27136,7 +26335,7 @@ export class ObjectStackProtocolImplementation implements type: singularTypeForRepo, name: request.name, state: 'deleted', - organizationId: request.organizationId ?? null, + organizationId: null, }); return { @@ -27587,20 +26786,14 @@ export class ObjectStackProtocolImplementation implements * `getMetaTypes()` synthesises `allowOrgOverride: false` for it, so * "not per-org overridable" is its correct reading here. * - * ── THE DIVERGENCE FROM THE REFUSAL IS DELIBERATE ── - * - * {@link orgScopedWriteRefusal} keys off the STATIC registry and - * returns `null` for exactly this family (its "Statically-declared - * types only" bullet); this audit keys off the LIVE set and reports it. - * The two sets are meant to differ, and a future reader should not - * "fix" one to match the other. The asymmetry is this file's own stated - * posture, three bullets up in that method: *warning is free and should - * be maximal; refusing removes a capability*. Widening the refusal - * would extend the 2026-08-08 ruling — reasoned over the 27 declared - * entries — onto a surface nobody measured; widening the warning costs - * an operator one more segment on a line that already exists. Same - * reasoning by which this method ignores `OS_METADATA_WRITABLE` while - * the refusal honours it. Ruled on #6992, scoped to the diagnostic. + * ── 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 diff --git a/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts b/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts index 046361058c6..02350f17f4c 100644 --- a/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts +++ b/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts @@ -11,7 +11,7 @@ * * What this file pins: * - every registered cleanup runs once, with the package id and exactly the - * organization / actor the request carried, and each outcome is reported; + * actor the request carried, and each outcome is reported; * - a cleanup's failure is an outcome, never a throw: a returned * `success: false` keeps its own error, a thrown undeclared fault is * reported with the withheld fallback sentence, never its driver text; @@ -42,17 +42,17 @@ function makeProtocol() { } describe('#21490: runUninstallCleanups — the uninstall-cleanup registry\'s one runner', () => { - it('runs every registered cleanup once, with the package id, organization and actor it was given, and reports each outcome', async () => { + it('runs every registered cleanup once, with the package id and actor it was given, and reports each outcome', async () => { const protocol = makeProtocol(); const first = vi.fn(async () => ({ success: true, removed: 3 })); const second = vi.fn(async () => ({ success: true, removed: 0 })); protocol.registerUninstallCleanup('security.package-permissions', first); protocol.registerUninstallCleanup('another.cleanup', second); - const outcomes = await protocol.runUninstallCleanups({ packageId: PACKAGE_ID, organizationId: 'org_1', actor: 'usr_1' }); + const outcomes = await protocol.runUninstallCleanups({ packageId: PACKAGE_ID, actor: 'usr_1' }); - expect(first.mock.calls).toEqual([[{ packageId: PACKAGE_ID, organizationId: 'org_1', actor: 'usr_1' }]]); - expect(second.mock.calls).toEqual([[{ packageId: PACKAGE_ID, organizationId: 'org_1', actor: 'usr_1' }]]); + expect(first.mock.calls).toEqual([[{ packageId: PACKAGE_ID, actor: 'usr_1' }]]); + expect(second.mock.calls).toEqual([[{ packageId: PACKAGE_ID, actor: 'usr_1' }]]); expect(outcomes).toEqual([ { name: 'security.package-permissions', success: true, removed: 3 }, { name: 'another.cleanup', success: true, removed: 0 }, @@ -95,7 +95,7 @@ describe('#21490: runUninstallCleanups — the uninstall-cleanup registry\'s one const protocol = makeProtocol(); protocol.registerUninstallCleanup('security.package-permissions', async () => ({ success: true, removed: 4 })); const runner = vi.spyOn(protocol, 'runUninstallCleanups'); - const request = { packageId: PACKAGE_ID, allTenants: true as const, actor: 'usr_1' }; + const request = { packageId: PACKAGE_ID, actor: 'usr_1' }; const res = await protocol.deletePackage(request); diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.ts b/packages/metadata-protocol/src/runtime-authoring-gate.ts index 0875866b09c..b57e89c3575 100644 --- a/packages/metadata-protocol/src/runtime-authoring-gate.ts +++ b/packages/metadata-protocol/src/runtime-authoring-gate.ts @@ -301,15 +301,18 @@ function declaresOrganization(config: AnyRec): boolean { * answered by ONE authority (`postureEnforcesWall(resolveTenancyPosture())`, * ADR-0105 D1) rather than re-derived here. * - * All five limbs must hold; each one's negation is a legitimate publish: + * All four limbs must hold; each one's negation is a legitimate publish: * * | limb | negation passes because | * |------|-------------------------| * | walled posture | `single` has no organization partition to land outside of | - * | platform-level write | an org-scoped row already carries its organization | * | schedule binding | every other trigger resolves a user or a record, so #6153 stamps | * | has `create_record` | nothing is born, so nothing is born unpartitioned | * | no `fields.organization_id` | the author answered the question | + * + * [ADR-0131 D6] Every write is platform-level now — the protocol refuses an + * organization-scoped one — so the former fifth limb ("an org-scoped row + * already carries its organization") always holds and is gone. */ export function findPlatformScheduleOrgGaps(args: { /** Singular metadata type of the item being written. */ @@ -318,16 +321,10 @@ export function findPlatformScheduleOrgGaps(args: { name: string; /** The body as it will be persisted. */ body: unknown; - /** - * The organization partition this write lands in — `saveMetaItem`'s - * `organizationId`. Absent/null IS the platform-level write. - */ - organizationId?: string | null; /** `postureEnforcesWall(resolveTenancyPosture())`, read by the caller. */ orgWallEnforced: boolean; }): RuntimeAuthoringIssue[] { if (!args.orgWallEnforced) return []; - if (args.organizationId != null) return []; if (args.type !== 'flow') return []; if (!isRec(args.body)) return []; @@ -359,8 +356,8 @@ export function findPlatformScheduleOrgGaps(args: { + `the one place the answer can come from for a ` + `scheduled run. A NULL ${ORGANIZATION_FIELD} is not merely untidy: an ` + `(${ORGANIZATION_FIELD}, …) unique index does not constrain across NULL and org-scoped ` - + `queries never see the row. Alternatively, publish this flow into an organization, or ` - + `give it a trigger that resolves one.`, + + `queries never see the row. Alternatively, give it a trigger that ` + + `resolves one.`, }); } @@ -970,15 +967,6 @@ export function evaluateRuntimeAuthoringGate(args: { * this card was forbidden to build. */ packageScope?: RuntimePackageScope; - /** - * [#6285] The organization partition this write lands in — `saveMetaItem`'s - * `organizationId`, absent/null for a platform-level (environment) write. - * - * One of the two inputs #6155 Q3=A adds. It was always in `saveMetaItem`'s - * hand and simply never travelled this far, which is why the guardrail - * could not be written before. - */ - organizationId?: string | null; /** * [#6285] Does this deployment enforce an organization wall — * `postureEnforcesWall(resolveTenancyPosture())` (ADR-0105 D1)? @@ -1074,7 +1062,6 @@ export function evaluateRuntimeAuthoringGate(args: { type: args.type, name: args.name, body: args.body, - ...(args.organizationId !== undefined ? { organizationId: args.organizationId } : {}), orgWallEnforced: args.orgWallEnforced === true, }); 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 4ea1ae98c64..fb4a685694e 100644 --- a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts +++ b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts @@ -33,6 +33,7 @@ import { expandViewContainer, isAggregatedViewContainer, ViewSchema } from '@obj import { MetadataPlugin } from '@objectstack/metadata'; import { savedItemNameRefusal } from '@objectstack/metadata/view-container-name'; import { ObjectStackProtocolImplementation } from './index.js'; +import { SysMetadataRepository } from './sys-metadata-repository.js'; interface Row { id: string; type: string; name: string; organization_id: string | null; @@ -103,6 +104,17 @@ function makeStubEngine() { return { engine, rows, registered }; } +/** + * [ADR-0131 D6] The save door refuses every organization-scoped write, so an + * organization-scoped view row — one stored before that release, which the + * reads still serve — is planted at rest through the repository. + */ +async function plantOrgRow(engine: any, organizationId: string, name: string, item: unknown): Promise<{ success: true }> { + await new SysMetadataRepository({ engine, organizationId, orgLabel: organizationId }) + .put({ type: 'view', name } as any, item, { parentVersion: null, actor: null }); + return { success: true }; +} + /** The card's repro body: a `defineView` container, as `defineView` emits it. */ const leadContainer = { list: { @@ -304,9 +316,7 @@ describe('#13407 org-scoped and environment-scoped runtime containers are served const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); - await protocol.saveMetaItem({ - type: 'view', name: 'crm_lead', item: leadContainer, organizationId: 'org_acme', - }); + await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); const list: any = await protocol.getMetaItems({ type: 'view', organizationId: 'org_acme' } as any); const served = switcherMatches(list.items, 'crm_lead').map((v: any) => v.name).sort(); @@ -316,17 +326,10 @@ describe('#13407 org-scoped and environment-scoped runtime containers are served it('POSITIVE CONTROL: a pre-existing independent ViewItem for the same object is still served alongside the newly-expanded container', async () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); - await protocol.saveMetaItem({ - type: 'view', name: 'crm_lead', item: leadContainer, organizationId: 'org_acme', - }); - await protocol.saveMetaItem({ - type: 'view', - name: 'crm_lead.mine', - item: { - name: 'crm_lead.mine', object: 'crm_lead', viewKind: 'list', label: 'My Leads', - config: { type: 'grid', data: { provider: 'object', object: 'crm_lead' }, columns: [{ field: 'name' }] }, - }, - organizationId: 'org_acme', + await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); + await plantOrgRow(engine, 'org_acme', 'crm_lead.mine', { + name: 'crm_lead.mine', object: 'crm_lead', viewKind: 'list', label: 'My Leads', + config: { type: 'grid', data: { provider: 'object', object: 'crm_lead' }, columns: [{ field: 'name' }] }, }); const list: any = await protocol.getMetaItems({ type: 'view', organizationId: 'org_acme' } as any); @@ -340,9 +343,7 @@ describe('#13407 org-scoped and environment-scoped runtime containers are served it('ORG ISOLATION HELD: a container authored in org_acme is invisible reading as org_globex, and visible reading as org_acme', async () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); - await protocol.saveMetaItem({ - type: 'view', name: 'crm_lead', item: leadContainer, organizationId: 'org_acme', - }); + await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); const globex: any = await protocol.getMetaItems({ type: 'view', organizationId: 'org_globex' } as any); expect(switcherMatches(globex.items, 'crm_lead')).toEqual([]); @@ -354,9 +355,7 @@ describe('#13407 org-scoped and environment-scoped runtime containers are served it('the isolation-safe path never registers an org-scoped expansion into the shared SchemaRegistry', async () => { const { engine, registered } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); - await protocol.saveMetaItem({ - type: 'view', name: 'crm_lead', item: leadContainer, organizationId: 'org_acme', - }); + await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); await protocol.getMetaItems({ type: 'view', organizationId: 'org_acme' } as any); // The RESPONSE carries the expansion (re-confirmed above); the SHARED @@ -383,9 +382,7 @@ describe('#13407 org-scoped and environment-scoped runtime containers are served it('a DIFFERENT object in the SAME org still serves nothing for it', async () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); - await protocol.saveMetaItem({ - type: 'view', name: 'crm_lead', item: leadContainer, organizationId: 'org_acme', - }); + await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); const list: any = await protocol.getMetaItems({ type: 'view', organizationId: 'org_acme' } as any); expect(switcherMatches(list.items, 'crm_account')).toEqual([]); }); @@ -637,11 +634,11 @@ describe('#21334 a container on another package\'s object never takes that packa { arm: 'package-less, environment-wide', packageId: undefined, organizationId: undefined, ownPackage: undefined }, { arm: 'package-less, organization-scoped', packageId: undefined, organizationId: ORG, ownPackage: undefined }, ] as const; - const save = (protocol: Protocol, name: string, item: unknown, c: (typeof CONTAINERS)[number]) => - protocol.saveMetaItem({ + 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({ type: 'view', name, item, ...(c.packageId ? { packageId: c.packageId } : {}), - ...scoped(c.organizationId), } as any); for (const [kernel, environmentId] of KERNELS) { @@ -904,7 +901,9 @@ describe('#21334 a container on another package\'s object never takes that packa list: { label: 'Customized', type: 'grid', data, columns: [{ field: 'title' }] }, listViews: { in_progress: { label: 'Customized In Progress', type: 'grid', data, columns: [{ field: 'title' }] } }, }; - await protocol.saveMetaItem({ type: 'view', name: TASK, item: overlay, ...scoped(organizationId) } as any); + await (organizationId + ? plantOrgRow((protocol as any).engine, organizationId, TASK, overlay) + : protocol.saveMetaItem({ type: 'view', name: TASK, item: overlay } as any)); const served = await objectDoor(protocol, organizationId); for (const name of [DEFAULT, `${TASK}.in_progress`]) { @@ -981,8 +980,9 @@ describe('#21334 a container on another package\'s object never takes that packa }; /** The control: a name the container expands that has no row of its own. */ const ROWLESS = `${TASK}.in_progress`; - const saveView = (protocol: Protocol, name: string, item: unknown, organizationId?: string) => - protocol.saveMetaItem({ type: 'view', name, item, ...scoped(organizationId) } as any); + const saveView = (protocol: Protocol, name: string, item: unknown, organizationId?: string) => (organizationId + ? plantOrgRow((protocol as any).engine, organizationId, name, item) + : protocol.saveMetaItem({ type: 'view', name, item } as any)); /** The two doors answer `name` with one item, and that item is the one `expectItem` names. */ const expectBothDoors = async ( protocol: Protocol, name: string, organizationId: string | undefined, expectItem: (v: any) => void, @@ -1243,7 +1243,9 @@ 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]) { + // [ADR-0131 D6] Environment-wide only: an organization-scoped save is refused NOT_OVERRIDABLE / 403 + // before this check (`protocol.org-scoped-write-refused.test.ts`). + for (const organizationId of [undefined]) { const scope = organizationId ? 'organization-scoped' : 'environment-wide'; for (const [kind, m] of Object.entries(MEMBER_CASES)) { it(`${scope}, member ${kind}: a container saved under ${m.shadows}, a name its own expansion produces, is refused VALIDATION_ERROR / 400; nothing is stored or registered`, async () => { @@ -1318,7 +1320,7 @@ describe('#21334 a container on another package\'s object never takes that packa * P2b below, this block's own #21334 cases) — so only the name another * stored container of the same object expands to is refused. * - * The ruling's three pins, on both kernels and both scopes: the measured + * The ruling's three pins, on both kernels (environment-wide, ADR-0131 D6): the measured * save is refused (every member kind the first container can expand the * name from, the card's own pair, draft mode, a body with no `name`, a * `form`-only body, and a sibling on another package's object); a @@ -1396,7 +1398,9 @@ 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]) { + // [ADR-0131 D6] Environment-wide only: an organization-scoped save is refused NOT_OVERRIDABLE / 403 + // before this check (`protocol.org-scoped-write-refused.test.ts`). + for (const organizationId of [undefined]) { const scope = organizationId ? 'organization-scoped' : 'environment-wide'; for (const [kind, member] of Object.entries(FIRST_CASES)) { @@ -1504,24 +1508,6 @@ describe('#21334 a container on another package\'s object never takes that packa expectNothingWritten(rows, registry, [LEAD], PIPELINE); await expectServed(protocol, PIPELINE, undefined, 'Lead Pipeline'); }); - - it('an environment-wide sibling is in an organization caller\'s selection: that caller\'s save under its expanded name is refused', async () => { - const { protocol, rows, registry } = showcaseHarness(environmentId); - await saved(saveIn(protocol, LEAD, { name: LEAD, object: LEAD, listViews: { pipeline: leadList('Lead Pipeline') } })); - const PIPELINE = `${LEAD}.pipeline`; - expectRefused(await refusalOf(saveIn(protocol, PIPELINE, second(PIPELINE), ORG)), PIPELINE, LEAD); - expectNothingWritten(rows, registry, [LEAD], PIPELINE); - await expectServed(protocol, PIPELINE, ORG, 'Lead Pipeline'); - }); - - it('CONTROL — another organization\'s container is not this caller\'s sibling: the save is judged by the caller\'s own selection, as the readers judge', async () => { - const { protocol } = showcaseHarness(environmentId); - await saved(saveIn(protocol, LEAD, { name: LEAD, object: LEAD, listViews: { pipeline: leadList('Lead Pipeline') } }, 'org_globex')); - const PIPELINE = `${LEAD}.pipeline`; - await saved(saveIn(protocol, PIPELINE, second(PIPELINE), ORG)); - // The other organization still gets its own container's view, on both doors. - await expectServed(protocol, PIPELINE, 'org_globex', 'Lead Pipeline'); - }); }); } }); @@ -1552,7 +1538,6 @@ describe('#21334 a container on another package\'s object never takes that packa */ describe('#21639 the save door\'s one collision predicate — the enumeration pin', () => { const LEAD = 'crm_lead'; - const OTHER_ORG = 'org_globex'; const leadData = { provider: 'object', object: LEAD }; const leadList = (label: string) => ({ label, type: 'grid', data: leadData, columns: [{ field: 'name' }] }); const taskList = (label: string) => ({ label, type: 'grid', data, columns: [{ field: 'title' }] }); @@ -1765,16 +1750,6 @@ describe('#21334 a container on another package\'s object never takes that packa serves: [{ object: TASK, name: `${TASK}.${OWN}`, label: 'Probe' }, { object: TASK, name: DEFAULT, label: 'All Tasks' }], }, }, - { - shape: 'a container saved under the expanded name of ANOTHER organization\'s container', - scopes: ['organization-scoped'], - given: [{ name: LEAD, item: storedLead, organizationId: OTHER_ORG }], - save: { name: `${LEAD}.pipeline`, item: { object: LEAD, list: leadList('Mine') } }, - allowed: { - because: 'the caller\'s selection decides, as it does for the readers: another organization\'s row is not in it', - serves: [{ object: LEAD, name: `${LEAD}.default`, label: 'Mine' }], - }, - }, { shape: 'an environment-wide container saved under the expanded name of an organization\'s container', scopes: ['environment-wide'], @@ -1841,6 +1816,7 @@ describe('#21334 a container on another package\'s object never takes that packa const writeIn = (protocol: Protocol, write: Write, callerOrganizationId: string | undefined, mode?: 'draft' | 'publish') => { const organizationId = write.organizationId === undefined ? callerOrganizationId : (write.organizationId ?? undefined); + if (organizationId) return plantOrgRow((protocol as any).engine, organizationId, write.name, write.item); return protocol.saveMetaItem({ type: 'view', name: write.name, item: write.item, ...(write.packageId ? { packageId: write.packageId } : {}), @@ -1865,7 +1841,9 @@ 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]) { + // [ADR-0131 D6] Environment-wide only: an organization-scoped save is refused NOT_OVERRIDABLE / 403 + // before this check (`protocol.org-scoped-write-refused.test.ts`). + for (const organizationId of [undefined]) { const scope: Scope = organizationId ? 'organization-scoped' : 'environment-wide'; for (const row of SHAPES) { if (row.scopes && !row.scopes.includes(scope)) continue; diff --git a/packages/objectql/src/delete-meta-response-conformance.test.ts b/packages/objectql/src/delete-meta-response-conformance.test.ts index c584ea2d230..2f35d6499f6 100644 --- a/packages/objectql/src/delete-meta-response-conformance.test.ts +++ b/packages/objectql/src/delete-meta-response-conformance.test.ts @@ -168,8 +168,6 @@ const viewBody = (label: string) => ({ name: 'cases', type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list', }); -const ORG = 'org_x'; - /** Keys the producer emitted that the schema refused to carry through. */ function strippedKeys(raw: Record): string[] { const parsed = DeleteMetaItemResponseSchema.parse(raw) as Record; @@ -180,11 +178,11 @@ describe('deleteMetaItem response conforms to DeleteMetaItemResponseSchema (#131 it('repository path, row deleted: parses green, strips nothing, and carries seq', async () => { const { p } = await makeProtocol(); await (p as any).saveMetaItem({ - type: 'view', name: 'cases', organizationId: ORG, item: viewBody('A'), + type: 'view', name: 'cases', item: viewBody('A'), }); const raw: any = await (p as any).deleteMetaItem({ - type: 'view', name: 'cases', organizationId: ORG, + type: 'view', name: 'cases', }); // The assertion that was red before the declaration: `seq` rode the @@ -205,14 +203,14 @@ describe('deleteMetaItem response conforms to DeleteMetaItemResponseSchema (#131 it('with an ADR-0094 projector registered: projectionApplied is carried through', async () => { const { p } = await makeProtocol(); await (p as any).saveMetaItem({ - type: 'view', name: 'cases', organizationId: ORG, item: viewBody('P'), + type: 'view', name: 'cases', item: viewBody('P'), }); // Registered AFTER the save so the save's own projection is not what // this case reads — the delete's is. (p as any).registerMutationProjector('view', async () => { throw new Error('boom-from-projector'); }); const raw: any = await (p as any).deleteMetaItem({ - type: 'view', name: 'cases', organizationId: ORG, + type: 'view', name: 'cases', }); expect(Object.keys(raw)).toContain('projectionApplied'); @@ -229,11 +227,11 @@ describe('deleteMetaItem response conforms to DeleteMetaItemResponseSchema (#131 it('no projector registered → projectionApplied is absent, which is why it is optional', async () => { const { p } = await makeProtocol(); await (p as any).saveMetaItem({ - type: 'view', name: 'cases', organizationId: ORG, item: viewBody('N'), + type: 'view', name: 'cases', item: viewBody('N'), }); const raw: any = await (p as any).deleteMetaItem({ - type: 'view', name: 'cases', organizationId: ORG, + type: 'view', name: 'cases', }); expect(raw.projectionApplied).toBeUndefined(); @@ -245,7 +243,7 @@ describe('deleteMetaItem response conforms to DeleteMetaItemResponseSchema (#131 const { p } = await makeProtocol(); const raw: any = await (p as any).deleteMetaItem({ - type: 'view', name: 'never_written', organizationId: ORG, + type: 'view', name: 'never_written', }); // This is the branch that makes `seq` optional rather than required. @@ -263,10 +261,10 @@ describe('deleteMetaItem response conforms to DeleteMetaItemResponseSchema (#131 it('seq really is the history event sequence: it advances across the item\'s writes', async () => { const { p } = await makeProtocol(); const saved: any = await (p as any).saveMetaItem({ - type: 'view', name: 'cases', organizationId: ORG, item: viewBody('S'), + type: 'view', name: 'cases', item: viewBody('S'), }); const raw: any = await (p as any).deleteMetaItem({ - type: 'view', name: 'cases', organizationId: ORG, + type: 'view', name: 'cases', }); // The delete's tombstone event comes after the save's write event on diff --git a/packages/objectql/src/overlay-precedence.test.ts b/packages/objectql/src/overlay-precedence.test.ts index 9e2346f69f8..7fd31176129 100644 --- a/packages/objectql/src/overlay-precedence.test.ts +++ b/packages/objectql/src/overlay-precedence.test.ts @@ -131,12 +131,14 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => { // ── allowed types: pure render-time, safe per-org override ── describe('allowed (allowOrgOverride: true) — must accept', () => { + // [ADR-0131 D6] These writes used to pass `organizationId: 'org_alpha'`; + // every org-scoped write is now refused (403 NOT_OVERRIDABLE), so they + // run environment-wide, as the runtime-creatable loop below already does. it('accepts view', async () => { const result = await protocol.saveMetaItem({ type: 'view', name: 'case_grid', item: validView, - organizationId: 'org_alpha', }); expect(result.success).toBe(true); }); @@ -146,7 +148,6 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => { type: 'dashboard', name: 'sales_overview', item: validDashboard, - organizationId: 'org_alpha', }); expect(result.success).toBe(true); }); @@ -158,7 +159,6 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => { type: 'report', name: 'monthly_revenue', item: validReport, - organizationId: 'org_alpha', }); expect(result.success).toBe(true); }); @@ -168,7 +168,6 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => { type: 'email_template', name: 'welcome', item: { name: 'welcome', label: 'Welcome', subject: 'Hi', bodyHtml: '

Hello

' }, - organizationId: 'org_alpha', }); expect(result.success).toBe(true); }); @@ -178,7 +177,6 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => { type: 'views', name: 'case_grid', item: validView, - organizationId: 'org_alpha', }); expect(result.success).toBe(true); }); @@ -229,7 +227,6 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => { type, name: item.name, item, - organizationId: 'org_alpha', }), ).rejects.toMatchObject({ code: expect.stringMatching(/^(NOT_OVERRIDABLE|NOT_CREATABLE)$/), diff --git a/packages/objectql/src/protocol-commit-history.test.ts b/packages/objectql/src/protocol-commit-history.test.ts index 1c7108d7529..15689f4ce4c 100644 --- a/packages/objectql/src/protocol-commit-history.test.ts +++ b/packages/objectql/src/protocol-commit-history.test.ts @@ -291,10 +291,9 @@ const APP_PKG = 'app.myapp'; * reimplementation of them, so any assertion whose SUBJECT is the org predicate * would be measuring this function rather than the protocol. No case in this * file has that subject (which is precisely why this file could never see the - * #7705/#7779/#7819 family), and the operator's real behaviour against a real - * driver — whether `organization_id = 'org'` matches a NULL column — is pinned - * on a real engine in `packages/runtime/src/package-revert-commit-org-scope. - * integration.test.ts`. Keep it that way: do not add org-scoping cases here. + * #7705/#7779/#7819 family). [ADR-0131 D6] Every write — commits included — is + * environment-wide now and an org-scoped revert is refused, so there is no + * org-scoping case left to add here. */ const matchesWhere = (r: Record, w: Record): boolean => { if (!w || typeof w !== 'object') return true; @@ -1048,46 +1047,6 @@ describe('#6621 — revertCommit RESTORE limb refreshes the registry', () => { // THE LINE THAT WAS RED: pre-fix the registry still served 'Renamed'. expect(registryViewLabel(registry, 'myapp_case_grid')).toBe('Cases'); }); - - /** - * [#6602] The org dimension, INHERITED rather than re-decided here. ADR-0005: - * only env-wide rows enter the process-wide SchemaRegistry, and PR #6779 made - * `organizationId` a REQUIRED argument of the write-through so no caller can - * forget to say which it is. This limb passes the row's own org, so an - * org-scoped revert persists and stays out of the shared registry. - * - * Direction note (measured, not assumed): this case is green BEFORE the fix - * as well — pre-fix nothing was written through at all, so "the shared - * registry is untouched" was true for the wrong reason. It cannot go red by - * removing the write-through; what it goes red on is the write-through - * passing anything other than the row's own org, which is the mistake the - * required parameter exists to prevent. - */ - it('an ORG-scoped revert persists and still never reaches the process-wide registry', async () => { - const { protocol, rows, registry } = makeRealRepoHarness([applyCommit({ - id: 'cmt_reg_org', - package_id: APP_PKG, - organization_id: 'org_a', - items: [{ type: 'view', name: 'myapp_case_grid', existedBefore: true, prevVersion: 1 }], - created_at: '2026-08-08T00:00:02.000Z', - })], { controlPlane: true }); - await protocol.saveMetaItem({ - type: 'view', name: 'myapp_case_grid', organizationId: 'org_a', packageId: APP_PKG, item: gridBody('Cases'), - }); - await protocol.saveMetaItem({ - type: 'view', name: 'myapp_case_grid', organizationId: 'org_a', packageId: APP_PKG, item: gridBody('Renamed'), - }); - expect(registryViewLabel(registry, 'myapp_case_grid')).toBeNull(); - - const res = await protocol.revertCommit({ commitId: 'cmt_reg_org', organizationId: 'org_a' }); - - expect(res.failed).toEqual([]); - expect(res.revertedCount).toBe(1); - const stored = Array.from(rows.values()).filter((r) => r.name === 'myapp_case_grid'); - expect(stored[0].organization_id).toBe('org_a'); - expect(JSON.parse(stored[0].metadata).label).toBe('Cases'); - expect(registryViewLabel(registry, 'myapp_case_grid')).toBeNull(); - }); }); /** @@ -1216,41 +1175,6 @@ describe('#6621 — revertCommit SOFT-REMOVE limb heals the registry, like delet expect(registryShapeFor(viaRevert.registry, 'object', 'myapp_invoice')) .toEqual(registryShapeFor(viaDelete.registry, 'object', 'myapp_invoice')); }); - - /** - * [#6602] The org gate on this limb is ASYMMETRIC with the write-through's - * object branch, on purpose: only an env-wide revert may mutate the registry - * every org in this process shares. An org-scoped row never entered it, so - * healing on its behalf would retire or un-shadow the ENV-WIDE row's entry — - * a per-org undo breaking every other org. Register wide, retire narrow. - */ - it('an ORG-scoped soft-remove leaves the env-wide registry entry alone', async () => { - const { protocol, rows, registry } = makeRealRepoHarness([applyCommit({ - id: 'cmt_reg_new_org', - package_id: APP_PKG, - organization_id: 'org_a', - items: [{ type: 'view', name: 'myapp_case_grid', existedBefore: false, prevVersion: null }], - created_at: '2026-08-08T00:00:02.000Z', - })], { controlPlane: true }); - // The env-wide row is what the shared registry holds (ADR-0005). - await protocol.saveMetaItem({ - type: 'view', name: 'myapp_case_grid', packageId: APP_PKG, item: gridBody('EnvWide'), - }); - // …and org A authored its own overlay of the same name. - await protocol.saveMetaItem({ - type: 'view', name: 'myapp_case_grid', organizationId: 'org_a', packageId: APP_PKG, item: gridBody('OrgA'), - }); - expect(registryViewLabel(registry, 'myapp_case_grid')).toBe('EnvWide'); - - const res = await protocol.revertCommit({ commitId: 'cmt_reg_new_org', organizationId: 'org_a' }); - - expect(res.failed).toEqual([]); - // Org A's row really went away… - expect(Array.from(rows.values()).filter((r) => r.name === 'myapp_case_grid' && r.organization_id === 'org_a')) - .toHaveLength(0); - // …and the env-wide entry every other org reads is untouched. - expect(registryViewLabel(registry, 'myapp_case_grid')).toBe('EnvWide'); - }); }); /** diff --git a/packages/objectql/src/protocol-lock-enforcement.test.ts b/packages/objectql/src/protocol-lock-enforcement.test.ts index d99c0b56667..aaf59601893 100644 --- a/packages/objectql/src/protocol-lock-enforcement.test.ts +++ b/packages/objectql/src/protocol-lock-enforcement.test.ts @@ -88,12 +88,11 @@ describe('ADR-0010 L3 lock enforcement — artifact-backed item', () => { const save = await protocol.saveMetaItem({ type: 'view', name: 'case_grid', item: validView, - organizationId: 'org_alpha', }); expect(save.success).toBe(true); const del = await protocol.deleteMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', }); expect(del.success).toBe(true); }); @@ -104,12 +103,11 @@ describe('ADR-0010 L3 lock enforcement — artifact-backed item', () => { const save = await protocol.saveMetaItem({ type: 'view', name: 'case_grid', item: validView, - organizationId: 'org_alpha', }); expect(save.success).toBe(true); await expect(protocol.deleteMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', })).rejects.toMatchObject({ code: 'ITEM_LOCKED', status: 403, @@ -122,14 +120,13 @@ describe('ADR-0010 L3 lock enforcement — artifact-backed item', () => { await expect(protocol.saveMetaItem({ type: 'view', name: 'case_grid', item: validView, - organizationId: 'org_alpha', })).rejects.toMatchObject({ code: 'ITEM_LOCKED', status: 403, }); const del = await protocol.deleteMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', }); expect(del.success).toBe(true); }); @@ -140,19 +137,18 @@ describe('ADR-0010 L3 lock enforcement — artifact-backed item', () => { await expect(protocol.saveMetaItem({ type: 'view', name: 'case_grid', item: validView, - organizationId: 'org_alpha', })).rejects.toMatchObject({ code: 'ITEM_LOCKED', status: 403 }); await expect(protocol.deleteMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', })).rejects.toMatchObject({ code: 'ITEM_LOCKED', status: 403 }); await expect(protocol.publishMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', })).rejects.toMatchObject({ code: 'ITEM_LOCKED', status: 403 }); await expect(protocol.rollbackMetaItem({ - type: 'view', name: 'case_grid', toVersion: 1, organizationId: 'org_alpha', + type: 'view', name: 'case_grid', toVersion: 1, })).rejects.toMatchObject({ code: 'ITEM_LOCKED', status: 403 }); }); }); @@ -204,7 +200,6 @@ describe('ADR-0010 L3 lock enforcement — audit trail', () => { await expect(protocol.saveMetaItem({ type: 'view', name: 'case_grid', item: validView, - organizationId: 'org_alpha', actor: 'user_42', })).rejects.toMatchObject({ code: 'ITEM_LOCKED' }); @@ -234,7 +229,6 @@ describe('ADR-0010 L3 lock enforcement — audit trail', () => { const result = await protocol.saveMetaItem({ type: 'view', name: 'case_grid', item: validView, - organizationId: 'org_alpha', actor: 'user_42', }); expect(result.success).toBe(true); diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index 878a49716d4..c7f2007f93d 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -62,32 +62,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { // ═══════════════════════════════════════════════════════════════ describe('per-organization overlay isolation', () => { - it('saveMetaItem persists organization_id when provided', async () => { - // [#6190] Re-spelled from `app` to `view`. The claim — an org-scoped - // save stamps `organization_id` on the row — is unchanged, but since - // the 2026-08-08 ruling only types that DECLARE a per-org channel may - // carry one, and `app` rolled back to `allowOrgOverride: false` in - // commit ee58392e1. `view` is the whitelisted specimen, so this now measures the - // stamping on a row the platform can actually read back. - mockEngine.findOne.mockResolvedValue(null); - await protocol.saveMetaItem({ - type: 'view', - name: 'test_grid', - item: sampleView, - organizationId: 'org_alpha', - }); - expect(mockEngine.findOne).toHaveBeenCalledWith('sys_metadata', { - // ADR-0048 — a package-less save scopes the upsert lookup to the - // GLOBAL row (package_id IS NULL), not any package's row. - where: { type: 'view', name: 'test_grid', organization_id: 'org_alpha', state: 'active', package_id: null }, - // [#21911] The platform store read carries the explicit system opt-in. - context: { isSystem: true }, - }); - expect(mockEngine.insert).toHaveBeenCalledWith('sys_metadata', expect.objectContaining({ - organization_id: 'org_alpha', - }), expect.anything()); - }); - // [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, @@ -368,12 +342,11 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { // case above: the package dimension this pins is untouched, but the // ORG dimension now requires a type that declares a per-org channel. await protocol.saveMetaItem({ - type: 'view', name: 'test_grid', item: sampleView, - organizationId: 'org_alpha', packageId: 'com.acme.beta', + type: 'view', name: 'test_grid', item: sampleView, packageId: 'com.acme.beta', }); expect(mockEngine.findOne).toHaveBeenCalledWith('sys_metadata', { - where: { type: 'view', name: 'test_grid', organization_id: 'org_alpha', state: 'active', package_id: 'com.acme.beta' }, + where: { type: 'view', name: 'test_grid', organization_id: null, state: 'active', package_id: 'com.acme.beta' }, // [#21911] The platform store read carries the explicit system opt-in. context: { isSystem: true }, }); @@ -428,12 +401,12 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { registry.registerItem('view', { ...sampleView }, 'name', 'com.acme.showcase'); const result = await protocol.saveMetaItem({ - type: 'view', name: 'test_grid', item: sampleView, organizationId: 'org_alpha', + type: 'view', name: 'test_grid', item: sampleView, }); expect(result.success).toBe(true); expect(result.message).toMatch( - /^Saved customization overlay \(org=org_alpha, state=active\) — type=view, name=test_grid \[seq=\d+\]$/, + /^Saved customization overlay \(env-wide, state=active\) — type=view, name=test_grid \[seq=\d+\]$/, ); }); @@ -1520,7 +1493,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { // the provenance gate's and no other gate's: the door also // refuses a hook with no `body`. item: { name: 'shipped_hook', object: 'case', events: ['beforeInsert'], body: { language: 'js', source: 'return;' } }, - organizationId: 'org_alpha', }), ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', @@ -1609,7 +1581,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { type: 'agent', name: 'my_agent', item: { name: 'my_agent', label: 'My Agent' }, - organizationId: 'org_alpha', }), ).rejects.toMatchObject({ code: 'NOT_CREATABLE', @@ -1688,7 +1659,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { type: 'rag_pipeline', name: 'my_pipeline', item: { name: 'my_pipeline', label: 'Test' }, - organizationId: 'org_alpha', }); expect(result.success).toBe(true); @@ -1712,7 +1682,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { type: 'policy', name: 'my_policy', item: { name: 'my_policy', label: 'Test' }, - organizationId: 'org_alpha', }), ).rejects.toMatchObject({ code: 'INVALID_REQUEST', @@ -1728,7 +1697,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { type: 'policy', name: 'my_policy', item: { name: 'my_policy', label: 'Test' }, - organizationId: 'org_alpha', }), ).rejects.toThrow(/'policy' is not a metadata type/); @@ -1762,7 +1730,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { triggers: ['create'], url: 'https://e.example/x', }, - organizationId: 'org_alpha', }); expect(result.success).toBe(true); @@ -1781,7 +1748,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { type: 'webhook', name: 'my_webhook', item: { name: 'my_webhook', url: 'https://e.example/x', events: ['x.created'] }, - organizationId: 'org_alpha', }), ).rejects.toMatchObject({ code: 'INVALID_METADATA', @@ -1796,7 +1762,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { type: 'connector', name: 'my_connector', item: { name: 'my_connector', label: 'My Connector', type: 'api' }, - organizationId: 'org_alpha', }); expect(result.success).toBe(true); @@ -1820,7 +1785,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { provider: 'github', authentication: { type: 'basic', username: 'u', password: 'p' }, }, - organizationId: 'org_alpha', }), ).rejects.toMatchObject({ code: 'INVALID_METADATA', @@ -1841,7 +1805,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { condition: "record.department == 'Sales'", sharedWith: { type: 'team', value: 'sales' }, }, - organizationId: 'org_alpha', }); expect(result.success).toBe(true); @@ -1863,7 +1826,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { type: 'criteria', sharedWith: { type: 'team', value: 'sales' }, }, - organizationId: 'org_alpha', }), ).rejects.toMatchObject({ code: 'INVALID_METADATA', @@ -1897,7 +1859,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { triggers: ['create'], url: 'https://example.com/hook', }, - organizationId: 'org_alpha', }); expect(result.success).toBe(true); @@ -1914,7 +1875,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { type: 'webhook', name: 'my_hook', item: { name: 'my_hook', label: 'Test' }, - organizationId: 'org_alpha', }), ).rejects.toMatchObject({ code: 'INVALID_METADATA', @@ -1930,7 +1890,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { type: 'theme', name: 'my_theme', item: { name: 'my_theme', label: 'My Theme', colors: { primary: '#3b82f6' } }, - organizationId: 'org_alpha', }), ).rejects.toMatchObject({ code: 'INVALID_REQUEST', @@ -1950,7 +1909,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { measures: { count: { label: 'Count', type: 'count', sql: '*' } }, dimensions: { stage: { label: 'Stage', type: 'string', sql: 'stage' } }, }, - organizationId: 'org_alpha', }); expect(result.success).toBe(true); @@ -1968,7 +1926,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { type: 'analytics_cube', name: 'orders', item: { name: 'orders', table: 'orders' }, - organizationId: 'org_alpha', }), ).rejects.toMatchObject({ code: 'INVALID_METADATA', @@ -2050,7 +2007,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { type: 'view', name: 'case_grid', item: { ...viewBase, columns: [{ field: 'name' }, { field: 'status' }] }, - organizationId: 'org_alpha', }); expect(result.success).toBe(true); }); 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 a90c39fcd57..da624a0aeb3 100644 --- a/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts +++ b/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts @@ -192,15 +192,17 @@ describe('#6602 — WRITE seam: applyRegistryWriteThrough refuses org-scoped row }); it('an ORG-scoped view write does not reach the process-wide registry (PROBE P3)', async () => { - const saved = await protocol.saveMetaItem({ - type: 'view', - name: 'org_grid', - item: viewBody('org_grid', 'Org A grid'), - organizationId: ORG_A, - }); - expect(saved.success).toBe(true); - // The row IS persisted — this fix closes a registry leak, never a write. - expect(engine.getRows().some((r: any) => r.name === 'org_grid' && r.organization_id === ORG_A)).toBe(true); + // [ADR-0131 D6] The org-scoped write is now refused outright, for every + // type, so the leak is closed one layer earlier and no row is persisted. + await expect( + protocol.saveMetaItem({ + type: 'view', + name: 'org_grid', + item: viewBody('org_grid', 'Org A grid'), + organizationId: ORG_A, + } as any), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(engine.getRows().some((r: any) => r.name === 'org_grid')).toBe(false); // Pre-fix: the body sits here under the PLAIN key, visible to every // registry-direct reader in the process. @@ -227,7 +229,7 @@ describe('#6602 — WRITE seam: applyRegistryWriteThrough refuses org-scoped row name: 'org_sweep', item: flowBody('org_sweep', 'Org A sweep'), organizationId: ORG_A, - }), + } as any), ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); expect(registry.getItem('flow', 'org_sweep')).toBeUndefined(); @@ -254,12 +256,14 @@ describe('#6602 — WRITE seam: applyRegistryWriteThrough refuses org-scoped row name: 'shared_grid', item: viewBody('shared_grid', 'Env grid'), }); - await protocol.saveMetaItem({ - type: 'view', - name: 'shared_grid', - item: viewBody('shared_grid', 'Org A grid'), - organizationId: ORG_A, - }); + await expect( + protocol.saveMetaItem({ + type: 'view', + name: 'shared_grid', + item: viewBody('shared_grid', 'Org A grid'), + organizationId: ORG_A, + } as any), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); // Pre-fix the org body OVERWROTE the env-wide plain-key entry, so // every org (and the control plane) started reading org A's body. expect((registry.getItem('view', 'shared_grid') as any)?.label).toBe('Env grid'); @@ -359,18 +363,6 @@ describe('#6602 — the disclosure shape, end to end', () => { protocol = new ObjectStackProtocolImplementation(engine); }); - it("org B's listing never contains org A's item — write then list", async () => { - await protocol.saveMetaItem({ - type: 'view', - name: 'org_a_only', - item: viewBody('org_a_only', 'Org A only'), - organizationId: ORG_A, - }); - - const listedB = await protocol.getMetaItems({ type: 'view', organizationId: ORG_B }); - expect(namesOf(listedB as any)).not.toContain('org_a_only'); - }); - it("org B's listing never contains org A's item — org A LISTS first", async () => { // The seam the write-side fix alone would not have closed: one // org-scoped listing call used to graft org A's body, and every @@ -431,19 +423,6 @@ describe('#6602 — the on-demand per-org read still serves org readers', () => const forOrgB: any = await protocol.getMetaItem({ type: 'view', name: 'shared_grid', organizationId: ORG_B }); expect(forOrgB.item.label).toBe('Env grid'); }); - - it('a write-then-read round trip works for an org author (write seam, read seam)', async () => { - await protocol.saveMetaItem({ - type: 'view', - name: 'org_grid', - item: viewBody('org_grid', 'Org A grid'), - organizationId: ORG_A, - }); - - const got: any = await protocol.getMetaItem({ type: 'view', name: 'org_grid', organizationId: ORG_A }); - expect(got.item.label).toBe('Org A grid'); - expect(registry.getItem('view', 'org_grid')).toBeUndefined(); - }); }); describe('#6602 — the delete chain needs no re-keying under this fix', () => { @@ -469,33 +448,6 @@ describe('#6602 — the delete chain needs no re-keying under this fix', () => { await protocol.deleteMetaItem({ type: 'view', name: 'env_grid' }); expect(registry.getItem('view', 'env_grid')).toBeUndefined(); }); - - it('an org-scoped delete has no plain-key entry of its own to retire', async () => { - // The argument for leaving `restoreArtifactRegistryView` alone: the - // delete chain is `(type, name)`-addressed and org-blind, but with both - // entry seams refusing org rows there is nothing org-scoped in the - // registry for it to mis-address. - // - // [#6780] TRUE OF THIS CASE, AND ONLY THIS CASE — measured later, and - // the correction is the next describe block. The name here is org A's - // alone, so the plain key really is empty and the org-blind heal has - // nothing to hit. Give the name an ENV-WIDE row as well and the same - // heal addresses that row's entry instead: `(type, name)` cannot tell - // the two apart, so "no entry of its own" was never "no entry". The - // fix keeps this file's conclusion (no org-scoped registry keys) and - // adds the missing half (an org-scoped delete may not heal at all). - await protocol.saveMetaItem({ - type: 'view', - name: 'org_grid', - item: viewBody('org_grid', 'Org A grid'), - organizationId: ORG_A, - }); - expect(registry.getItem('view', 'org_grid')).toBeUndefined(); - - const deleted = await protocol.deleteMetaItem({ type: 'view', name: 'org_grid', organizationId: ORG_A }); - expect(deleted.success).toBe(true); - expect(registry.getItem('view', 'org_grid')).toBeUndefined(); - }); }); /** @@ -598,44 +550,20 @@ describe('#6780 — the registry heal is org-gated: an org DELETE never evicts t it("org A deleting its OWN overlay leaves the env-wide entry standing (the card's sequence)", async () => { await protocol.saveMetaItem({ type: 'view', name: 'shared_grid', item: viewBody('shared_grid', 'Env grid') }); - await protocol.saveMetaItem({ - type: 'view', name: 'shared_grid', item: viewBody('shared_grid', 'Org A grid'), organizationId: ORG_A, - }); - // #6602 holding: the org write never reached the shared registry. + engine.plant(metaRow('view', viewBody('shared_grid', 'Org A grid'), ORG_A)); + // #6602 holding: the org row never reached the shared registry. expect((registry.getItem('view', 'shared_grid') as any)?.label).toBe('Env grid'); - await protocol.deleteMetaItem({ type: 'view', name: 'shared_grid', organizationId: ORG_A }); + // [ADR-0131 D6] An org-scoped delete is now refused before any read. + await expect( + protocol.deleteMetaItem({ type: 'view', name: 'shared_grid', organizationId: ORG_A } as any), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); // Pre-fix this read was `undefined` — the whole defect, in one line. expect(registry.getItem('view', 'shared_grid')).toBeDefined(); expect((registry.getItem('view', 'shared_grid') as any)?.label).toBe('Env grid'); }); - it('the same delete still removes the ORG ROW — row-level behaviour is untouched', async () => { - // The control that keeps the case above from passing for the wrong - // reason: a "fix" that skipped the delete entirely would also leave the - // env-wide entry standing. The reset must still reset. - await protocol.saveMetaItem({ type: 'view', name: 'shared_grid', item: viewBody('shared_grid', 'Env grid') }); - await protocol.saveMetaItem({ - type: 'view', name: 'shared_grid', item: viewBody('shared_grid', 'Org A grid'), organizationId: ORG_A, - }); - const beforeDelete: any = await protocol.getMetaItem({ - type: 'view', name: 'shared_grid', organizationId: ORG_A, - }); - expect(beforeDelete.item.label).toBe('Org A grid'); - - const deleted = await protocol.deleteMetaItem({ type: 'view', name: 'shared_grid', organizationId: ORG_A }); - - expect(deleted.success).toBe(true); - expect(deleted.reset).toBe(true); - // Org A now falls through to the env-wide body — ADR-0005's "reset to - // default", which is what the org author actually asked for. - const afterDelete: any = await protocol.getMetaItem({ - type: 'view', name: 'shared_grid', organizationId: ORG_A, - }); - expect(afterDelete.item.label).toBe('Env grid'); - }); - it('the SELF-HEAL branch respects the same scope — a no-op org DELETE is inert', async () => { // The cheapest eviction door of the three, and the one a gate on the // delete-ful branch alone would have left open: org A has no overlay @@ -646,9 +574,9 @@ describe('#6780 — the registry heal is org-gated: an org DELETE never evicts t await protocol.saveMetaItem({ type: 'view', name: 'shared_grid', item: viewBody('shared_grid', 'Env grid') }); expect((registry.getItem('view', 'shared_grid') as any)?.label).toBe('Env grid'); - const deleted = await protocol.deleteMetaItem({ type: 'view', name: 'shared_grid', organizationId: ORG_A }); - - expect(deleted.reset).toBe(false); + await expect( + protocol.deleteMetaItem({ type: 'view', name: 'shared_grid', organizationId: ORG_A } as any), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); expect((registry.getItem('view', 'shared_grid') as any)?.label).toBe('Env grid'); }); @@ -662,7 +590,9 @@ describe('#6780 — the registry heal is org-gated: an org DELETE never evicts t }); expect((registry.getItem('view', 'shared_grid') as any)?.label).toBe('Env grid'); - await protocol.deleteMetaItem({ type: 'view', name: 'shared_grid', organizationId: ORG_A }); + await expect( + protocol.deleteMetaItem({ type: 'view', name: 'shared_grid', organizationId: ORG_A } as any), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); expect((registry.getItem('view', 'shared_grid') as any)?.label).toBe('Env grid'); // The artifact is still there under its composite key, unharmed. @@ -697,9 +627,7 @@ describe('#6780 — the registry heal is org-gated: an org DELETE never evicts t // reason to leave the env-wide entry stale. Scope is read from the // DELETE, never from what else happens to be stored. await protocol.saveMetaItem({ type: 'view', name: 'shared_grid', item: viewBody('shared_grid', 'Env grid') }); - await protocol.saveMetaItem({ - type: 'view', name: 'shared_grid', item: viewBody('shared_grid', 'Org A grid'), organizationId: ORG_A, - }); + engine.plant(metaRow('view', viewBody('shared_grid', 'Org A grid'), ORG_A)); await protocol.deleteMetaItem({ type: 'view', name: 'shared_grid' }); diff --git a/packages/objectql/src/protocol-package-lifecycle.test.ts b/packages/objectql/src/protocol-package-lifecycle.test.ts index e9f297522dc..356338251a1 100644 --- a/packages/objectql/src/protocol-package-lifecycle.test.ts +++ b/packages/objectql/src/protocol-package-lifecycle.test.ts @@ -77,7 +77,7 @@ describe('protocol.deletePackage', () => { { type: 'object', name: 'course', state: 'draft', organization_id: null }, { type: 'view', name: 'course_list', state: 'active', organization_id: null }, ]); - const res = await protocol.deletePackage({ packageId: 'app.edu', allTenants: true }); + const res = await protocol.deletePackage({ packageId: 'app.edu' }); expect(res).toMatchObject({ success: true, deletedCount: 3, failedCount: 0 }); const calls = deleteMetaItem.mock.calls.map((c) => c[0] as any); @@ -92,13 +92,13 @@ describe('protocol.deletePackage', () => { const { protocol, deleteMetaItem } = makeProtocol([ { type: 'object', name: 'course', state: 'active', organization_id: null }, ]); - await protocol.deletePackage({ packageId: 'app.edu', allTenants: true, keepData: true }); + await protocol.deletePackage({ packageId: 'app.edu', keepData: true }); expect((deleteMetaItem.mock.calls[0][0] as any)).not.toHaveProperty('dropStorage'); }); it('empty package → deletedCount 0, success false', async () => { const { protocol, deleteMetaItem } = makeProtocol([]); - const res = await protocol.deletePackage({ packageId: 'app.empty', allTenants: true }); + const res = await protocol.deletePackage({ packageId: 'app.empty' }); expect(deleteMetaItem).not.toHaveBeenCalled(); expect(res).toMatchObject({ success: false, deletedCount: 0 }); }); diff --git a/packages/objectql/src/protocol-publish-canonical-fold.test.ts b/packages/objectql/src/protocol-publish-canonical-fold.test.ts index 3b28e2808e5..6a7d73d1948 100644 --- a/packages/objectql/src/protocol-publish-canonical-fold.test.ts +++ b/packages/objectql/src/protocol-publish-canonical-fold.test.ts @@ -273,8 +273,6 @@ const viewBody = (label: string) => ({ name: 'case_grid', type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list', }); -const ORG = 'org_x'; - // ═══════════════════════════════════════════════════════════════════════════ // A — a MANIFEST-ABSENT type: the plural URL spelling resolves the same row // ═══════════════════════════════════════════════════════════════════════════ @@ -288,12 +286,12 @@ describe('#8769 · publish addressed with a manifest-absent plural resolves the it('`translations` promotes the draft written under `translation`', async () => { const { protocol, rows } = await makeProtocol(); await (protocol as any).saveMetaItem({ - type: 'translation', name: 'zh_cn', organizationId: ORG, + type: 'translation', name: 'zh_cn', item: translationBody('A'), mode: 'draft', }); const receipt: any = await (protocol as any).publishMetaItem({ - type: 'translations', name: 'zh_cn', organizationId: ORG, + type: 'translations', name: 'zh_cn', }); expect(receipt.success).toBe(true); @@ -310,12 +308,12 @@ describe('#8769 · publish addressed with a manifest-absent plural resolves the it('CONTROL — the canonical spelling still publishes (a fold was added, not a lookup loosened)', async () => { const { protocol, rows } = await makeProtocol(); await (protocol as any).saveMetaItem({ - type: 'translation', name: 'zh_cn', organizationId: ORG, + type: 'translation', name: 'zh_cn', item: translationBody('A'), mode: 'draft', }); const receipt: any = await (protocol as any).publishMetaItem({ - type: 'translation', name: 'zh_cn', organizationId: ORG, + type: 'translation', name: 'zh_cn', }); expect(receipt.success).toBe(true); @@ -330,12 +328,12 @@ describe('#8769 · publish addressed with a manifest-absent plural resolves the // `metaUrlSpellingRefusal` documents) and must still miss. const { protocol } = await makeProtocol(); await (protocol as any).saveMetaItem({ - type: 'translation', name: 'zh_cn', organizationId: ORG, + type: 'translation', name: 'zh_cn', item: translationBody('A'), mode: 'draft', }); const refusal = await refusalOf(() => (protocol as any).publishMetaItem({ - type: 'translationz', name: 'zh_cn', organizationId: ORG, + type: 'translationz', name: 'zh_cn', })); expect(refusal.code).toBe('NO_DRAFT'); expect(refusal.status).toBe(404); @@ -359,7 +357,7 @@ describe('#8769 · the draftability gate judges the real registry entry, not the const { protocol } = await makeProtocol(); const refusal = await refusalOf(() => (protocol as any).publishMetaItem({ - type: 'fields', name: 'showcase_task.title', organizationId: ORG, + type: 'fields', name: 'showcase_task.title', })); expect(refusal.code).toBe('NOT_OVERRIDABLE'); @@ -374,7 +372,7 @@ describe('#8769 · the draftability gate judges the real registry entry, not the const { protocol } = await makeProtocol(); const refusal = await refusalOf(() => (protocol as any).publishMetaItem({ - type: 'field', name: 'showcase_task.title', organizationId: ORG, + type: 'field', name: 'showcase_task.title', })); expect(refusal.code).toBe('NOT_OVERRIDABLE'); @@ -405,11 +403,11 @@ describe('#8769 · a plural-addressed publish cannot address around the overlay async function stageLockedActiveWithPendingDraft() { const { protocol, rows } = await makeProtocol(); await (protocol as any).saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: ORG, item: viewBody('v1'), mode: 'draft', + type: 'view', name: 'case_grid', item: viewBody('v1'), mode: 'draft', }); - await (protocol as any).publishMetaItem({ type: 'view', name: 'case_grid', organizationId: ORG }); + await (protocol as any).publishMetaItem({ type: 'view', name: 'case_grid' }); await (protocol as any).saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: ORG, item: viewBody('v2'), + type: 'view', name: 'case_grid', item: viewBody('v2'), mode: 'draft', force: true, }); @@ -426,7 +424,7 @@ describe('#8769 · a plural-addressed publish cannot address around the overlay const { protocol } = await stageLockedActiveWithPendingDraft(); const refusal = await refusalOf(() => (protocol as any).publishMetaItem({ - type: 'view', name: 'case_grid', organizationId: ORG, + type: 'view', name: 'case_grid', })); expect(refusal.code).toBe('ITEM_LOCKED'); @@ -437,7 +435,7 @@ describe('#8769 · a plural-addressed publish cannot address around the overlay const { protocol, rows } = await stageLockedActiveWithPendingDraft(); const refusal = await refusalOf(() => (protocol as any).publishMetaItem({ - type: 'views', name: 'case_grid', organizationId: ORG, + type: 'views', name: 'case_grid', })); expect(refusal.code).toBe('ITEM_LOCKED'); @@ -482,14 +480,14 @@ describe('#8819 · a plural-addressed rollback cannot address around the overlay async function stageLockedActiveOverRestorableHistory(opts: { locked: boolean }) { const { protocol, rows, historyRows } = await makeProtocol(); await (protocol as any).saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: ORG, item: viewBody('v1'), mode: 'draft', + type: 'view', name: 'case_grid', item: viewBody('v1'), mode: 'draft', }); - await (protocol as any).publishMetaItem({ type: 'view', name: 'case_grid', organizationId: ORG }); + await (protocol as any).publishMetaItem({ type: 'view', name: 'case_grid' }); await (protocol as any).saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: ORG, item: viewBody('v2'), + type: 'view', name: 'case_grid', item: viewBody('v2'), mode: 'draft', force: true, }); - await (protocol as any).publishMetaItem({ type: 'view', name: 'case_grid', organizationId: ORG }); + await (protocol as any).publishMetaItem({ type: 'view', name: 'case_grid' }); const activeRow = rows().find((r) => r.type === 'view' && r.state === 'active'); expect(activeRow, 'fixture: an active row must exist to carry the lock').toBeTruthy(); @@ -520,7 +518,7 @@ describe('#8819 · a plural-addressed rollback cannot address around the overlay const { protocol, toVersion } = await stageLockedActiveOverRestorableHistory({ locked: true }); const refusal = await refusalOf(() => (protocol as any).rollbackMetaItem({ - type: 'view', name: 'case_grid', organizationId: ORG, toVersion, + type: 'view', name: 'case_grid', toVersion, })); expect(refusal.code).toBe('ITEM_LOCKED'); @@ -532,7 +530,7 @@ describe('#8819 · a plural-addressed rollback cannot address around the overlay await stageLockedActiveOverRestorableHistory({ locked: true }); const refusal = await refusalOf(() => (protocol as any).rollbackMetaItem({ - type: 'views', name: 'case_grid', organizationId: ORG, toVersion, + type: 'views', name: 'case_grid', toVersion, })); expect(refusal.code).toBe('ITEM_LOCKED'); @@ -556,7 +554,7 @@ describe('#8819 · a plural-addressed rollback cannot address around the overlay await stageLockedActiveOverRestorableHistory({ locked: false }); const receipt: any = await (protocol as any).rollbackMetaItem({ - type: 'views', name: 'case_grid', organizationId: ORG, toVersion, + type: 'views', name: 'case_grid', toVersion, }); expect(receipt.success).toBe(true); diff --git a/packages/objectql/src/protocol-publish-package-drafts.test.ts b/packages/objectql/src/protocol-publish-package-drafts.test.ts index cf6b4c3bbc6..51464247b89 100644 --- a/packages/objectql/src/protocol-publish-package-drafts.test.ts +++ b/packages/objectql/src/protocol-publish-package-drafts.test.ts @@ -492,7 +492,7 @@ describe('protocol.publishMetaItem — seed self-apply', () => { const body = { object: 'project', records: [{ name: 'Apollo' }] }; const { protocol, applySeedBodies } = makePublishable(body); const res = await protocol.publishMetaItem({ type: 'seed', name: 'project_sample' }); - expect(applySeedBodies).toHaveBeenCalledWith([body], null); + expect(applySeedBodies).toHaveBeenCalledWith([body]); expect(res.seedApplied).toEqual({ success: true, inserted: 3, updated: 0 }); expect(res.success).toBe(true); }); diff --git a/packages/objectql/src/protocol-publish-rollback.test.ts b/packages/objectql/src/protocol-publish-rollback.test.ts index 8b14beb2155..0a8eb9e4b68 100644 --- a/packages/objectql/src/protocol-publish-rollback.test.ts +++ b/packages/objectql/src/protocol-publish-rollback.test.ts @@ -177,11 +177,11 @@ describe('publishMetaItem / rollbackMetaItem / diffMetaItem', () => { const { engine, rows } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); await protocol.saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', item: sampleBody('Published'), }); await protocol.saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', item: sampleBody('Pending'), mode: 'draft', }); @@ -201,15 +201,15 @@ describe('publishMetaItem / rollbackMetaItem / diffMetaItem', () => { const { engine, rows } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); await protocol.saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', item: sampleBody('v1'), }); await protocol.saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', item: sampleBody('v2-draft'), mode: 'draft', }); const result = await protocol.publishMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', actor: 'admin', + type: 'view', name: 'case_grid', actor: 'admin', }); expect((result as any).success).toBe(true); // Only the active row remains. @@ -223,12 +223,12 @@ describe('publishMetaItem / rollbackMetaItem / diffMetaItem', () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); await protocol.saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', item: sampleBody('v1'), }); await expect( protocol.publishMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', actor: 'admin', + type: 'view', name: 'case_grid', actor: 'admin', }), ).rejects.toMatchObject({ code: 'NO_DRAFT', status: 404 }); }); @@ -242,7 +242,7 @@ describe('publishMetaItem / rollbackMetaItem / diffMetaItem', () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); await protocol.saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', item: sampleBody('v1'), // `mode` defaults to publish, so this writes the active row; // no draft row exists after this save. @@ -267,12 +267,12 @@ describe('publishMetaItem / rollbackMetaItem / diffMetaItem', () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); await protocol.saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', item: sampleBody('v1'), }); // Write a draft row in addition to the active row. await protocol.saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', item: sampleBody('v2-draft'), mode: 'draft', } as any); @@ -291,15 +291,15 @@ describe('publishMetaItem / rollbackMetaItem / diffMetaItem', () => { const { engine, historyRows } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); await protocol.saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', item: sampleBody('v1'), }); await protocol.saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', item: sampleBody('v2'), }); const result = await protocol.rollbackMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', toVersion: 1, actor: 'admin', }); expect((result as any).success).toBe(true); @@ -317,12 +317,12 @@ describe('publishMetaItem / rollbackMetaItem / diffMetaItem', () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); await protocol.saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', item: sampleBody('v1'), }); await expect( protocol.rollbackMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', toVersion: 99, actor: 'admin', }), ).rejects.toMatchObject({ code: 'VERSION_NOT_FOUND', status: 404 }); @@ -332,15 +332,15 @@ describe('publishMetaItem / rollbackMetaItem / diffMetaItem', () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); await protocol.saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', item: { name: 'case_grid', type: 'grid', label: 'A', columns: ['id'], object: 'case', viewKind: 'list' }, // [#7741] the inline arm requires the object binding pair }); await protocol.saveMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', item: { name: 'case_grid', type: 'grid', label: 'B', columns: ['id', 'title'], rowHeight: 'compact', extra: 1, object: 'case', viewKind: 'list' }, // [#7741] the inline arm requires the object binding pair }); const diff = await protocol.diffMetaItem({ - type: 'view', name: 'case_grid', organizationId: 'org_alpha', + type: 'view', name: 'case_grid', fromVersion: 1, toVersion: 2, }); // [#20051] Re-judged at stage (iv): the added key is a DECLARED one diff --git a/packages/objectql/src/protocol-recorded-by-null.test.ts b/packages/objectql/src/protocol-recorded-by-null.test.ts index 467bb358af6..c6d6de56de9 100644 --- a/packages/objectql/src/protocol-recorded-by-null.test.ts +++ b/packages/objectql/src/protocol-recorded-by-null.test.ts @@ -192,7 +192,7 @@ describe('#4556 — protocol write paths store NULL, not the sentinel string', ( let protocol: ObjectStackProtocolImplementation; const historyRows = async () => - (await engine.find('sys_metadata_history', { where: { organization_id: 'org_x' } })) as any[]; + (await engine.find('sys_metadata_history', { where: { organization_id: null } })) as any[]; beforeEach(async () => { engine = new ObjectQL(); @@ -208,7 +208,7 @@ describe('#4556 — protocol write paths store NULL, not the sentinel string', ( it('saveMetaItem with NO actor lands recorded_by = NULL', async () => { await protocol.saveMetaItem({ - type: 'view', name: 'cases', organizationId: 'org_x', item: viewBody('A'), + type: 'view', name: 'cases', item: viewBody('A'), }); const rows = await historyRows(); @@ -221,7 +221,7 @@ describe('#4556 — protocol write paths store NULL, not the sentinel string', ( it('saveMetaItem WITH an actor still stores that user id', async () => { await protocol.saveMetaItem({ - type: 'view', name: 'cases', organizationId: 'org_x', item: viewBody('A'), actor: 'usr_alice', + type: 'view', name: 'cases', item: viewBody('A'), actor: 'usr_alice', }); const rows = await historyRows(); expect(rows[0].recorded_by).toBe('usr_alice'); @@ -229,9 +229,9 @@ describe('#4556 — protocol write paths store NULL, not the sentinel string', ( it('deleteMetaItem with NO actor writes a tombstone with recorded_by = NULL', async () => { await protocol.saveMetaItem({ - type: 'view', name: 'cases', organizationId: 'org_x', item: viewBody('A'), actor: 'usr_alice', + type: 'view', name: 'cases', item: viewBody('A'), actor: 'usr_alice', }); - await protocol.deleteMetaItem({ type: 'view', name: 'cases', organizationId: 'org_x' }); + await protocol.deleteMetaItem({ type: 'view', name: 'cases' }); const tombstone = (await historyRows()).find((h) => h.operation_type === 'delete'); expect(tombstone).toBeDefined(); @@ -241,9 +241,9 @@ describe('#4556 — protocol write paths store NULL, not the sentinel string', ( it('publishMetaItem with NO actor records the publish event with recorded_by = NULL', async () => { await protocol.saveMetaItem({ - type: 'view', name: 'cases', organizationId: 'org_x', item: viewBody('draft'), mode: 'draft', + type: 'view', name: 'cases', item: viewBody('draft'), mode: 'draft', }); - await protocol.publishMetaItem({ type: 'view', name: 'cases', organizationId: 'org_x' }); + await protocol.publishMetaItem({ type: 'view', name: 'cases' }); const publishRow = (await historyRows()).find((h) => h.operation_type === 'publish'); expect(publishRow).toBeDefined(); @@ -254,13 +254,13 @@ describe('#4556 — protocol write paths store NULL, not the sentinel string', ( it('no history row on ANY path carries a value that is not a sys_user id', async () => { // The three authoring paths #4441 was bitten by: create, publish, delete. await protocol.saveMetaItem({ - type: 'view', name: 'a', organizationId: 'org_x', item: viewBody('a', 'a'), mode: 'draft', + type: 'view', name: 'a', item: viewBody('a', 'a'), mode: 'draft', }); - await protocol.publishMetaItem({ type: 'view', name: 'a', organizationId: 'org_x' }); + await protocol.publishMetaItem({ type: 'view', name: 'a' }); await protocol.saveMetaItem({ - type: 'view', name: 'b', organizationId: 'org_x', item: viewBody('b', 'b'), actor: 'usr_alice', + type: 'view', name: 'b', item: viewBody('b', 'b'), actor: 'usr_alice', }); - await protocol.deleteMetaItem({ type: 'view', name: 'b', organizationId: 'org_x' }); + await protocol.deleteMetaItem({ type: 'view', name: 'b' }); const rows = await historyRows(); expect(rows.length).toBeGreaterThan(0); @@ -295,7 +295,7 @@ describe('#4556 — protocol write paths store NULL, not the sentinel string', ( // field was target-less, so the guard skipped it whether or not the // exemption existed. Delete the exemption now and this goes red. await protocol.saveMetaItem({ - type: 'view', name: 'cases', organizationId: 'org_x', item: viewBody('A'), actor: 'usr_not_a_row', + type: 'view', name: 'cases', item: viewBody('A'), actor: 'usr_not_a_row', }); const rows = await historyRows(); diff --git a/packages/objectql/src/protocol-registry-shadow.test.ts b/packages/objectql/src/protocol-registry-shadow.test.ts index 14e1c86da7c..f546dbc7fc0 100644 --- a/packages/objectql/src/protocol-registry-shadow.test.ts +++ b/packages/objectql/src/protocol-registry-shadow.test.ts @@ -371,12 +371,12 @@ describe('registry shadow — scoped-kernel lock enforcement is shadow-immune', // shadow-immunity stays pinned on the delete, which the #6960 // carve-out (an `app` overlay merges at read) carries to the lock. await expect(protocol.saveMetaItem({ - type: 'app', name: 'setup', organizationId: 'org_a', + type: 'app', name: 'setup', item: { ...overlayBody }, })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); await expect(protocol.deleteMetaItem({ - type: 'app', name: 'setup', organizationId: 'org_a', + type: 'app', name: 'setup', })).rejects.toMatchObject({ code: 'ITEM_LOCKED', status: 403 }); }); }); diff --git a/packages/objectql/src/protocol-revert-org-scope.test.ts b/packages/objectql/src/protocol-revert-org-scope.test.ts index e6b10cfc3b4..1479593819e 100644 --- a/packages/objectql/src/protocol-revert-org-scope.test.ts +++ b/packages/objectql/src/protocol-revert-org-scope.test.ts @@ -40,6 +40,11 @@ * asserts `code` AND `status` (ADR-0112) — a bare `rejects.toThrow()` is green * against an implementation that throws a naked `Error`, so it proves nothing * about the envelope. + * + * [ADR-0131 D6] Every protocol write is now environment-wide and an + * organization-scoped write request is refused (403 NOT_OVERRIDABLE), so the + * round trip below is driven with no organization on the write verbs; the + * writer/reader key agreement it pins is the env-wide one. */ import { describe, it, expect, beforeEach } from 'vitest'; @@ -218,22 +223,21 @@ describe('#7559 — the revert reads the history row under the key the writer st }; /** - * The card's reproduction: two publishes of an env-wide draft, driven by a - * caller carrying an active org — which is every console request, since - * `resolveActiveOrganizationId` puts one on all of them. + * The card's reproduction: two publishes of an env-wide draft. [ADR-0131 D6] + * The publish carries no organization any more (an org-scoped one is refused). */ const publishTwiceEnvWideAsOrg = async () => { await protocol.saveMetaItem({ type: 'view', name: 'cases', item: viewBody('A'), packageId: PKG, mode: 'draft', }); await protocol.publishPackageDrafts({ - packageId: PKG, organizationId: ORG, message: 'publish 1', + packageId: PKG, message: 'publish 1', }); await protocol.saveMetaItem({ type: 'view', name: 'cases', item: viewBody('B'), packageId: PKG, mode: 'draft', }); const p2 = await protocol.publishPackageDrafts({ - packageId: PKG, organizationId: ORG, message: 'publish 2', + packageId: PKG, message: 'publish 2', }); return p2; }; @@ -243,7 +247,7 @@ describe('#7559 — the revert reads the history row under the key the writer st // everything, and asserting the restored BODY so it cannot pass by // reverting to the wrong version. - it('revertCommit restores the pre-commit BODY for an env-wide item when the caller has an active org', async () => { + it('revertCommit restores the pre-commit BODY for an env-wide item', async () => { const p2 = await publishTwiceEnvWideAsOrg(); expect(p2.success).toBe(true); expect(p2.commitId).toBeTruthy(); @@ -251,7 +255,7 @@ describe('#7559 — the revert reads the history row under the key the writer st expect(await activeBody(null)).toMatchObject({ label: 'B' }); const res = await protocol.revertCommit({ - commitId: p2.commitId!, organizationId: ORG, + commitId: p2.commitId!, }); expect(res.failed).toEqual([]); @@ -284,12 +288,12 @@ describe('#7559 — the revert reads the history row under the key the writer st expect(Object.keys(hist[0])).not.toContain('package_id'); }); - it('rollbackMetaItem — the sibling item-level revert — restores the same env-wide item for an org caller', async () => { + it('rollbackMetaItem — the sibling item-level revert — restores the same env-wide item', async () => { await publishTwiceEnvWideAsOrg(); expect(await activeBody(null)).toMatchObject({ label: 'B' }); const res = await protocol.rollbackMetaItem({ - type: 'view', name: 'cases', toVersion: 2, organizationId: ORG, + type: 'view', name: 'cases', toVersion: 2, }); expect(res.success).toBe(true); @@ -297,33 +301,6 @@ describe('#7559 — the revert reads the history row under the key the writer st expect(await activeBody(null)).toMatchObject({ label: 'A' }); }); - it('an ORG-SCOPED item is still reverted in its OWN scope, not redirected env-wide', async () => { - // The control for the resolution's precedence: an org that has its own - // overlay row must keep reverting that row. Without it, "fall back to - // env-wide" could pass every test above while quietly hijacking the - // org-scoped case that already worked. - await protocol.saveMetaItem({ - type: 'view', name: 'cases', item: viewBody('ORG-A'), - packageId: PKG, mode: 'draft', organizationId: ORG, - }); - await protocol.publishPackageDrafts({ packageId: PKG, organizationId: ORG }); - await protocol.saveMetaItem({ - type: 'view', name: 'cases', item: viewBody('ORG-B'), - packageId: PKG, mode: 'draft', organizationId: ORG, - }); - const p2 = await protocol.publishPackageDrafts({ packageId: PKG, organizationId: ORG }); - - const res = await protocol.revertCommit({ - commitId: p2.commitId!, organizationId: ORG, - }); - - expect(res.failed).toEqual([]); - expect(res.success).toBe(true); - expect(await activeBody(ORG)).toMatchObject({ label: 'ORG-A' }); - // Nothing was written into the env-wide scope on this org's behalf. - expect(await activeBody(null)).toBeNull(); - }); - // ── REFUSALS — `code` AND `status`, never a bare throw ──────────────── it('a version that genuinely has no history row is still refused, with the ADR-0112 envelope', async () => { @@ -333,7 +310,7 @@ describe('#7559 — the revert reads the history row under the key the writer st await expect( protocol.rollbackMetaItem({ - type: 'view', name: 'cases', toVersion: 99, organizationId: ORG, + type: 'view', name: 'cases', toVersion: 99, }), ).rejects.toMatchObject({ code: 'VERSION_NOT_FOUND', status: 404 }); }); @@ -343,14 +320,14 @@ describe('#7559 — the revert reads the history row under the key the writer st // Hand-write a commit whose plan points at a version nobody ever wrote. await engine.insert('sys_metadata_commit', { id: 'cmt_bogus', package_id: PKG, operation: 'apply', - organization_id: ORG, item_count: 1, + organization_id: null, item_count: 1, items: JSON.stringify([ { type: 'view', name: 'cases', existedBefore: true, prevVersion: 99 }, ]), created_at: '2026-08-11T00:00:00.000Z', }); - const res = await protocol.revertCommit({ commitId: 'cmt_bogus', organizationId: ORG }); + const res = await protocol.revertCommit({ commitId: 'cmt_bogus' }); expect(res.success).toBe(false); expect(res.failedCount).toBe(1); @@ -361,7 +338,7 @@ describe('#7559 — the revert reads the history row under the key the writer st it('an unknown commit id is refused with COMMIT_NOT_FOUND / 404', async () => { await expect( - protocol.revertCommit({ commitId: 'cmt_nope', organizationId: ORG }), + protocol.revertCommit({ commitId: 'cmt_nope' }), ).rejects.toMatchObject({ code: 'COMMIT_NOT_FOUND', status: 404 }); }); }); diff --git a/packages/objectql/src/protocol-save-meta-repo-path-real-engine.test.ts b/packages/objectql/src/protocol-save-meta-repo-path-real-engine.test.ts index b59eaa6379c..3886a5f08e5 100644 --- a/packages/objectql/src/protocol-save-meta-repo-path-real-engine.test.ts +++ b/packages/objectql/src/protocol-save-meta-repo-path-real-engine.test.ts @@ -157,16 +157,16 @@ describe('saveMetaItem — repository write path against real ObjectQL (PR-10d.4 it('insert → update writes the second body and bumps version (id-based update on real engine)', async () => { await protocol.saveMetaItem({ - type: 'view', name: 'cases', organizationId: 'org_x', + type: 'view', name: 'cases', item: viewBody('A'), }); await protocol.saveMetaItem({ - type: 'view', name: 'cases', organizationId: 'org_x', + type: 'view', name: 'cases', item: viewBody('B'), }); const rows = await engine.find('sys_metadata', { - where: { type: 'view', organization_id: 'org_x' }, + where: { type: 'view', organization_id: null }, }); expect(rows.length).toBe(1); const row = rows[0] as any; @@ -177,20 +177,20 @@ describe('saveMetaItem — repository write path against real ObjectQL (PR-10d.4 it('stale parentVersion → 409 metadata_conflict; stored body unchanged', async () => { await protocol.saveMetaItem({ - type: 'view', name: 'cases', organizationId: 'org_x', + type: 'view', name: 'cases', item: viewBody('Original'), }); await expect( protocol.saveMetaItem({ - type: 'view', name: 'cases', organizationId: 'org_x', + type: 'view', name: 'cases', item: viewBody('Should not land'), parentVersion: 'sha256:stale', }), ).rejects.toMatchObject({ code: 'METADATA_CONFLICT', status: 409 }); const rows = await engine.find('sys_metadata', { - where: { type: 'view', organization_id: 'org_x' }, + where: { type: 'view', organization_id: null }, }); const body = JSON.parse((rows[0] as any).metadata); expect(body.label).toBe('Original'); @@ -198,11 +198,11 @@ describe('saveMetaItem — repository write path against real ObjectQL (PR-10d.4 it('checksum column holds the full sha256: hash (71 chars)', async () => { await protocol.saveMetaItem({ - type: 'view', name: 'cases', organizationId: 'org_x', + type: 'view', name: 'cases', item: viewBody('A'), }); const rows = await engine.find('sys_metadata', { - where: { type: 'view', organization_id: 'org_x' }, + where: { type: 'view', organization_id: null }, }); const checksum = (rows[0] as any).checksum as string; expect(checksum).toMatch(/^sha256:[a-f0-9]{64}$/); @@ -211,11 +211,11 @@ describe('saveMetaItem — repository write path against real ObjectQL (PR-10d.4 it('plural type "views" is normalized to singular and stored as "view"', async () => { await protocol.saveMetaItem({ - type: 'views', name: 'cases', organizationId: 'org_x', + type: 'views', name: 'cases', item: viewBody('Plural'), }); const rows = await engine.find('sys_metadata', { - where: { type: 'view', organization_id: 'org_x' }, + where: { type: 'view', organization_id: null }, }); expect(rows.length).toBe(1); }); @@ -259,13 +259,13 @@ describe('deleteMetaItem — repository write path against real ObjectQL (PR-10d it('deletes the overlay row AND appends a delete tombstone to sys_metadata_history', async () => { const save = await protocol.saveMetaItem({ - type: 'view', name: 'cases', organizationId: 'org_x', + type: 'view', name: 'cases', item: viewBody('A'), actor: 'alice', }); expect((save as any).seq).toBe(1); const result = await protocol.deleteMetaItem({ - type: 'view', name: 'cases', organizationId: 'org_x', actor: 'alice', + type: 'view', name: 'cases', actor: 'alice', }); expect(result.success).toBe(true); expect(result.reset).toBe(true); @@ -273,13 +273,13 @@ describe('deleteMetaItem — repository write path against real ObjectQL (PR-10d // sys_metadata row gone const rows = await engine.find('sys_metadata', { - where: { type: 'view', organization_id: 'org_x' }, + where: { type: 'view', organization_id: null }, }); expect(rows.length).toBe(0); // sys_metadata_history has a create + a delete tombstone const history = await engine.find('sys_metadata_history', { - where: { type: 'view', name: 'cases', organization_id: 'org_x' }, + where: { type: 'view', name: 'cases', organization_id: null }, }); expect(history.length).toBe(2); const ops = history.map((h: any) => h.operation_type).sort(); @@ -296,24 +296,24 @@ describe('deleteMetaItem — repository write path against real ObjectQL (PR-10d it('returns reset=false (no history write) when no overlay exists', async () => { const result = await protocol.deleteMetaItem({ - type: 'view', name: 'never_existed', organizationId: 'org_x', + type: 'view', name: 'never_existed', }); expect(result.success).toBe(true); expect(result.reset).toBe(false); expect(result.seq).toBeUndefined(); const history = await engine.find('sys_metadata_history', { - where: { organization_id: 'org_x' }, + where: { organization_id: null }, }); expect(history.length).toBe(0); }); it('plural type "views" is normalized to singular for the tombstone', async () => { await protocol.saveMetaItem({ - type: 'view', name: 'cases', organizationId: 'org_x', item: viewBody('A'), + type: 'view', name: 'cases', item: viewBody('A'), }); await protocol.deleteMetaItem({ - type: 'views', name: 'cases', organizationId: 'org_x', + type: 'views', name: 'cases', }); const tombstone = await engine.findOne('sys_metadata_history', { where: { name: 'cases', operation_type: 'delete' }, diff --git a/packages/objectql/src/protocol-save-meta-repo-path.test.ts b/packages/objectql/src/protocol-save-meta-repo-path.test.ts index d34dc28c68a..9269088d793 100644 --- a/packages/objectql/src/protocol-save-meta-repo-path.test.ts +++ b/packages/objectql/src/protocol-save-meta-repo-path.test.ts @@ -125,7 +125,6 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { const result = await protocol.saveMetaItem({ type: 'view', name: 'case_grid', - organizationId: 'org_alpha', item: { name: 'case_grid', type: 'grid', label: 'Cases', columns: ['id', 'title'], object: 'case', viewKind: 'list' }, }); expect(result.success).toBe(true); @@ -142,7 +141,6 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { const result = await protocol.saveMetaItem({ type: 'view', name: 'case_grid', - organizationId: 'org_alpha', item: body, }); expect(result.success).toBe(true); @@ -157,11 +155,11 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { const { engine, rows } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); const r1 = await protocol.saveMetaItem({ - type: 'view', name: 'v', organizationId: 'org', + type: 'view', name: 'v', item: { name: 'v', type: 'grid', label: 'A', columns: ['id'], object: 'case', viewKind: 'list' }, }); const r2 = await protocol.saveMetaItem({ - type: 'view', name: 'v', organizationId: 'org', + type: 'view', name: 'v', item: { name: 'v', type: 'grid', label: 'B', columns: ['id'], object: 'case', viewKind: 'list' }, }); expect((r1 as any).seq).toBe(1); @@ -175,13 +173,13 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { const protocol = new ObjectStackProtocolImplementation(engine); // First write establishes a HEAD. await protocol.saveMetaItem({ - type: 'view', name: 'v', organizationId: 'org', + type: 'view', name: 'v', item: { name: 'v', type: 'grid', label: 'A', columns: ['id'], object: 'case', viewKind: 'list' }, }); // Second write with an explicit stale parentVersion → conflict. await expect( protocol.saveMetaItem({ - type: 'view', name: 'v', organizationId: 'org', + type: 'view', name: 'v', item: { name: 'v', type: 'grid', label: 'B', columns: ['id'], object: 'case', viewKind: 'list' }, parentVersion: 'sha256:notTheCurrentHead', }), @@ -196,10 +194,10 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { const protocol = new ObjectStackProtocolImplementation(engine); const body = { name: 'v', type: 'grid', label: 'A', columns: ['id'], object: 'case', viewKind: 'list' }; const r1 = await protocol.saveMetaItem({ - type: 'view', name: 'v', organizationId: 'org', item: body, + type: 'view', name: 'v', item: body, }); const r2 = await protocol.saveMetaItem({ - type: 'view', name: 'v', organizationId: 'org', item: body, + type: 'view', name: 'v', item: body, }); // No new seq allocated for an identical body. expect((r1 as any).seq).toBe(1); @@ -208,23 +206,6 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { expect(rows.size).toBe(1); }); - it('env-wide overlays (organizationId omitted) use a separate repo bucket', async () => { - const { engine, rows } = makeStubEngine(); - const protocol = new ObjectStackProtocolImplementation(engine); - await protocol.saveMetaItem({ - type: 'view', name: 'v', - item: { name: 'v', type: 'grid', label: 'env-wide', columns: ['id'], object: 'case', viewKind: 'list' }, - }); - await protocol.saveMetaItem({ - type: 'view', name: 'v', organizationId: 'org_alpha', - item: { name: 'v', type: 'grid', label: 'org_alpha', columns: ['id'], object: 'case', viewKind: 'list' }, - }); - // Two rows: one with organization_id=null, one with org_alpha. - expect(rows.size).toBe(2); - const orgs = Array.from(rows.values()).map((r) => r.organization_id).sort(); - expect(orgs).toEqual([null, 'org_alpha']); - }); - it('plural type (e.g. "views") is normalized to singular before the repo gate (rubber-duck #5)', async () => { const { engine, rows } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); @@ -233,7 +214,6 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { const result = await protocol.saveMetaItem({ type: 'views', name: 'case_grid', - organizationId: 'org', item: { name: 'case_grid', type: 'grid', label: 'OK', columns: ['id'], object: 'case', viewKind: 'list' }, }); expect(result.success).toBe(true); @@ -255,7 +235,6 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { protocol.saveMetaItem({ type: 'report', name: 'accounts_by_industry_type', - organizationId: 'org', item: { type: 'report', name: 'accounts_by_industry_type', @@ -278,7 +257,6 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { await protocol.saveMetaItem({ type: 'view', name: 'cases', - organizationId: 'org_x', item: { name: 'cases', type: 'grid', label: 'Original', columns: ['id'], object: 'case', viewKind: 'list' }, }); const beforeBody = (Array.from(rows.values())[0] as any).metadata; @@ -288,7 +266,6 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { protocol.saveMetaItem({ type: 'view', name: 'cases', - organizationId: 'org_x', item: { name: 'cases', type: 'grid', label: 'Mutated (should not land)', columns: ['id'], object: 'case', viewKind: 'list' }, parentVersion: 'sha256:stale', }), @@ -399,7 +376,6 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { await protocol.saveMetaItem({ type: 'view', name: 'case_grid', - organizationId: 'org_alpha', packageId: 'app.objectstack.hotcrm', mode: 'draft', item: { name: 'case_grid', type: 'grid', label: 'Cases (org overlay)', columns: ['id', 'title'], object: 'case', viewKind: 'list' }, @@ -429,7 +405,6 @@ describe('#16225 a `sys_metadata` read is not answered from the journal tables', const result = await protocol.saveMetaItem({ type: 'view', name: 'case_grid', - organizationId: 'org_alpha', item: { name: 'case_grid', type: 'grid', label: 'Cases', columns: ['id', 'title'], object: 'case', viewKind: 'list', @@ -461,7 +436,7 @@ describe('#16225 a `sys_metadata` read is not answered from the journal tables', // `case_grid`. `checksum` and `state` are written by the store leg // alone; a journal row carries neither. const stored = await engine.find('sys_metadata', { - where: { type: 'view', organization_id: 'org_alpha' }, + where: { type: 'view', organization_id: null }, }); expect( stored.map((r: Row) => r.name), diff --git a/packages/objectql/src/publish-meta-response-conformance.test.ts b/packages/objectql/src/publish-meta-response-conformance.test.ts index 370b0a16ff6..6effd87df12 100644 --- a/packages/objectql/src/publish-meta-response-conformance.test.ts +++ b/packages/objectql/src/publish-meta-response-conformance.test.ts @@ -171,8 +171,6 @@ const viewBody = (label: string) => ({ name: 'cases', type: 'grid', label, colum */ const seedBody = { object: 'sys_metadata', records: [{ name: 'row_a', type: 'view' }] }; -const ORG = 'org_x'; - /** * Stage a draft and promote it — the two-step the REST pair * `PUT /:type/:name?mode=draft` + `POST /:type/:name/publish` spells. There is @@ -187,12 +185,10 @@ async function stageAndPublish( const type = opts.type ?? 'view'; const name = opts.name ?? 'cases'; const item = opts.item ?? viewBody('A'); - // `org: null` writes env-wide. Not a stylistic choice: ADR-0005 /#6190 refuse - // an org-scoped write for a type whose registry entry says - // `allowOrgOverride: false`, and `seed` is one — the row would not survive a - // restart, so the platform declines to mint it. The seed cases below are - // therefore env-wide, which is the only channel that type has. - const scope = opts.org === undefined ? ORG : opts.org; + // Env-wide by default: [ADR-0131 D6] every organization-scoped write is + // refused (403 NOT_OVERRIDABLE), so environment-wide is the only channel + // any type has. + const scope = opts.org === undefined ? null : opts.org; const orgArg = scope === null ? {} : { organizationId: scope }; await (p as any).saveMetaItem({ type, name, ...orgArg, item, mode: 'draft' }); return (p as any).publishMetaItem({ type, name, ...orgArg }); @@ -349,7 +345,7 @@ describe('publishMetaItem response conforms to PublishMetaItemResponseSchema (#7 // stay required. const p = await makeProtocol(); await expect( - (p as any).publishMetaItem({ type: 'agent', name: 'helper', organizationId: ORG }), + (p as any).publishMetaItem({ type: 'agent', name: 'helper' }), ).rejects.toMatchObject({ status: 403 }); }); }); diff --git a/packages/objectql/src/save-meta-response-conformance.test.ts b/packages/objectql/src/save-meta-response-conformance.test.ts index 5a5e587a538..396be49b064 100644 --- a/packages/objectql/src/save-meta-response-conformance.test.ts +++ b/packages/objectql/src/save-meta-response-conformance.test.ts @@ -178,7 +178,7 @@ describe('saveMetaItem response conforms to SaveMetaItemResponseSchema (#5745)', it('publish-mode save: parses green and strips nothing', async () => { const p = await makeProtocol(); const raw: any = await p.saveMetaItem({ - type: 'view', name: 'cases', organizationId: 'org_x', item: viewBody('A'), + type: 'view', name: 'cases', item: viewBody('A'), }); expect(strippedKeys(raw)).toEqual([]); @@ -195,7 +195,7 @@ describe('saveMetaItem response conforms to SaveMetaItemResponseSchema (#5745)', it('draft-mode save: state is "draft" and still strips nothing', async () => { const p = await makeProtocol(); const raw: any = await p.saveMetaItem({ - type: 'view', name: 'cases', organizationId: 'org_x', item: viewBody('D'), mode: 'draft', + type: 'view', name: 'cases', item: viewBody('D'), mode: 'draft', }); expect(strippedKeys(raw)).toEqual([]); @@ -207,7 +207,7 @@ describe('saveMetaItem response conforms to SaveMetaItemResponseSchema (#5745)', p.registerMutationProjector('view', async () => { throw new Error('boom-from-projector'); }); const raw: any = await p.saveMetaItem({ - type: 'view', name: 'cases', organizationId: 'org_x', item: viewBody('P'), + type: 'view', name: 'cases', item: viewBody('P'), }); expect(Object.keys(raw)).toContain('projectionApplied'); @@ -222,7 +222,7 @@ describe('saveMetaItem response conforms to SaveMetaItemResponseSchema (#5745)', it('no projector registered → projectionApplied is absent, which is why it alone is optional', async () => { const p = await makeProtocol(); const raw: any = await p.saveMetaItem({ - type: 'view', name: 'cases', organizationId: 'org_x', item: viewBody('N'), + type: 'view', name: 'cases', item: viewBody('N'), }); expect(raw.projectionApplied).toBeUndefined(); @@ -245,7 +245,7 @@ describe('saveMetaItem response conforms to SaveMetaItemResponseSchema (#5745)', // fields can stay required. const p = await makeProtocol(); await expect( - p.saveMetaItem({ type: 'agent', name: 'helper', organizationId: 'org_x', item: { name: 'helper' } }), + p.saveMetaItem({ type: 'agent', name: 'helper', item: { name: 'helper' } }), ).rejects.toMatchObject({ code: 'NOT_CREATABLE', status: 403 }); }); }); @@ -431,7 +431,7 @@ describe('saveMetaItem carries the runtime authoring gate\'s advisories (#4717 it('GUARD (green either way): a clean view save is untouched by the new field', async () => { const p = await makeProtocol(); const raw: any = await p.saveMetaItem({ - type: 'view', name: 'cases', organizationId: 'org_x', item: viewBody('A'), + type: 'view', name: 'cases', item: viewBody('A'), }); expect('advisories' in raw).toBe(false); diff --git a/packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts b/packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts index e557789ed98..4841a0e8783 100644 --- a/packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts +++ b/packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts @@ -147,7 +147,7 @@ describe('dogfood: the package is the authoring & delete unit (ADR-0070 D3/D4)', // 6. DELETE-CASCADE — deleting the base removes every item it owns; the base // becomes empty. This is the answer to "a pile of loose metadata, how do I // delete it?" — operate on the whole base. - const del = await protocol.deletePackage({ packageId: BASE, allTenants: true }); + const del = await protocol.deletePackage({ packageId: BASE }); expect(del.deletedCount).toBeGreaterThan(0); expect(del.failedCount).toBe(0); expect(await ownedNames(ql, BASE)).not.toContain(OBJ); diff --git a/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts index 220c4d8aa75..ba6d80c2543 100644 --- a/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts @@ -15,8 +15,8 @@ // its withdrawals, fail-closed, until ADR-0131 C7 carries those rows to the // environment layer (triage ruling Q3 A on the retirement card). No door can // write such a row any more, so this file PLANTS legacy organization rows -// straight through the protocol, the way a door wrote them before the -// retirement, and pins: +// at rest in `sys_metadata`, as a door left them before the retirement, and +// pins: // // - a legacy organization overlay that keeps the form open does not survive // an environment withdrawal: both anonymous doors answer @@ -42,8 +42,6 @@ describe('showcase: a public form withdrawal at any metadata layer holds', () => let ql: any; let published: Record; let organizationId: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let protocol: any; let probeSeq = 0; /** Both anonymous doors, plus how many rows a submit with a unique marker left. */ @@ -82,10 +80,17 @@ describe('showcase: a public form withdrawal at any metadata layer holds', () => const plantLegacyOrgOverlay = async (allowAnonymous: boolean) => { const item = structuredClone(published); item.config.sharing.allowAnonymous = allowAnonymous; - const result = await protocol.saveMetaItem({ - type: 'view', name: 'showcase_inquiry.contact', item, organizationId, - }); - expect(String(result?.message ?? ''), JSON.stringify(result)).toContain(`org=${organizationId}`); + const at = { type: 'view', name: 'showcase_inquiry.contact', organization_id: organizationId }; + const now = new Date().toISOString(); + const [existing] = await ql.find('sys_metadata', { where: { ...at, state: 'active' }, context: SYS }); + if (existing) { + await ql.update('sys_metadata', { metadata: JSON.stringify(item), updated_at: now }, { where: { id: existing.id }, context: SYS }); + } else { + await ql.insert('sys_metadata', { + ...at, package_id: null, state: 'active', version: 1, checksum: null, + created_at: now, updated_at: now, metadata: JSON.stringify(item), + }, { context: SYS }); + } }; const saved = async (allowAnonymous: boolean) => { @@ -110,7 +115,6 @@ describe('showcase: a public form withdrawal at any metadata layer holds', () => const orgs = await ql.find('sys_organization', { fields: ['id'], limit: 2, context: SYS }); expect(orgs, 'the showcase boot holds exactly one organization').toHaveLength(1); organizationId = orgs[0].id; - protocol = await stack.kernel.getServiceAsync('protocol'); }, 120_000); afterAll(async () => { diff --git a/packages/rest/src/package-door-5xx-message-sanitization.test.ts b/packages/rest/src/package-door-5xx-message-sanitization.test.ts index 680811a94c2..5537b3a4138 100644 --- a/packages/rest/src/package-door-5xx-message-sanitization.test.ts +++ b/packages/rest/src/package-door-5xx-message-sanitization.test.ts @@ -355,14 +355,14 @@ describe('[#8086] a 5xx that does NOT look like a leak passes through unchanged' describe('[#8086] a 4xx message is never withheld, even when it trips the predicate', () => { const FOUR_XX: Array<{ name: string; error: unknown; status: number; code: string }> = [ { - name: "the protocol's TENANT_SCOPE_REQUIRED refusal, wording that trips the predicate", + name: "the protocol's INVALID_REQUEST refusal, wording that trips the predicate", error: thrown( - "[tenant_scope_required] Refusing to uninstall 'com.acme.crm': foreign key rows in sys_metadata " - + 'would be orphaned — pass organizationId to scope it, or allTenants: true to confirm.', - { status: 400, code: 'TENANT_SCOPE_REQUIRED' }, + "Refusing to uninstall 'com.acme.crm': the 'allTenants' request key is retired — foreign key rows " + + 'in sys_metadata are removed environment-wide. Retry without allTenants.', + { status: 400, code: 'INVALID_REQUEST' }, ), status: 400, - code: 'TENANT_SCOPE_REQUIRED', + code: 'INVALID_REQUEST', }, { name: 'the established 409 DESTRUCTIVE_CHANGE, naming the tables it would drop', diff --git a/packages/rest/src/package-door-declared-code.test.ts b/packages/rest/src/package-door-declared-code.test.ts index 99d25f63922..0c00bccbee5 100644 --- a/packages/rest/src/package-door-declared-code.test.ts +++ b/packages/rest/src/package-door-declared-code.test.ts @@ -436,9 +436,9 @@ describe('[#12405] `declaredCode` is ABSENT unless the demote actually happened' }, { name: 'a REGISTERED standard-catalog code, same rule', - error: thrown('package scope is required', { status: 400, code: 'TENANT_SCOPE_REQUIRED' }), + error: thrown('a retired request key', { status: 400, code: 'INVALID_REQUEST' }), status: 400, - code: 'TENANT_SCOPE_REQUIRED', + code: 'INVALID_REQUEST', }, { name: 'a producer that declared NO code has nothing to declare', diff --git a/packages/rest/src/package-door-user-message.test.ts b/packages/rest/src/package-door-user-message.test.ts index 18baafa9846..95e6ed0b970 100644 --- a/packages/rest/src/package-door-user-message.test.ts +++ b/packages/rest/src/package-door-user-message.test.ts @@ -57,7 +57,7 @@ * `code`/`status`/`cause` survive) and absorb per-item and cleanup throws into * `failed[]`/`cleanups[]`, which carry no such channel. Same for * `declaredCode`: every in-tree throw that escapes these seams spells a - * REGISTERED code (`TENANT_SCOPE_REQUIRED`, `SERVICE_UNAVAILABLE`) or none. + * REGISTERED code (`INVALID_REQUEST`, `SERVICE_UNAVAILABLE`) or none. * * That is not a reason to withhold either channel, and the ruling that says so * is the honest cost of this door being **composed rather than closed**: diff --git a/packages/rest/src/package-routes-coded-error-mapping.test.ts b/packages/rest/src/package-routes-coded-error-mapping.test.ts index 0d9efeb4402..e8ecaee154d 100644 --- a/packages/rest/src/package-routes-coded-error-mapping.test.ts +++ b/packages/rest/src/package-routes-coded-error-mapping.test.ts @@ -206,9 +206,9 @@ describe('#8016 — a coded refusal keeps its status AND its code on every packa const REFUSALS: Array<{ name: string; error: unknown; status: number; code: string }> = [ { name: 'a coded 4xx (`status`)', - error: thrown('Package scope is required', { status: 400, code: 'TENANT_SCOPE_REQUIRED' }), + error: thrown('A retired request key', { status: 400, code: 'INVALID_REQUEST' }), status: 400, - code: 'TENANT_SCOPE_REQUIRED', + code: 'INVALID_REQUEST', }, { name: 'the established 409 (`status`)', @@ -299,7 +299,7 @@ describe('#8016 — a coded refusal keeps its status AND its code on every packa */ describe('#8016 — the wire answer IS the shared mapping, not a second copy of it', () => { const SHAPES: unknown[] = [ - thrown('coded 4xx', { status: 400, code: 'TENANT_SCOPE_REQUIRED' }), + thrown('coded 4xx', { status: 400, code: 'INVALID_REQUEST' }), thrown('coded 409', { status: 409, code: 'DESTRUCTIVE_CHANGE' }), thrown('statusCode spelling', { statusCode: 403, code: 'PERMISSION_DENIED' }), thrown('a record-validation failure', { name: 'ValidationError', code: 'VALIDATION_FAILED', fields: [] }), diff --git a/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts b/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts index eafe296f587..9a451605e2a 100644 --- a/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts +++ b/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts @@ -21,7 +21,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; -import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { ObjectStackProtocolImplementation, SysMetadataRepository } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server.js'; const META = '/api/v1/meta'; @@ -313,9 +313,16 @@ function boot() { return { rows, historyRows, - /** A LEGACY organization-scoped write, as a door made one before ADR-0131 D6. */ - plantLegacyOrgRow: (type: string, name: string, label = MARKER, organizationId = ORG_A) => - protocol.saveMetaItem({ type, name, item: bodyFor(type, name, label), organizationId }), + /** + * A LEGACY organization-scoped write, as a door made one before ADR-0131 D6, + * planted at rest — the protocol now refuses it (`403 NOT_OVERRIDABLE`). + */ + plantLegacyOrgRow: async (type: string, name: string, label = MARKER, organizationId = ORG_A) => { + const repo = new SysMetadataRepository({ engine, organizationId, orgLabel: organizationId }); + const ref = { org: organizationId, type, name } as Parameters[0]; + const head = await repo.get(ref); + return repo.put(ref, bodyFor(type, name, label), { parentVersion: head?.hash ?? null, actor: null }); + }, as(tenantId: string | undefined) { session = tenantId === undefined ? { userId: 'u1', systemPermissions: ['manage_metadata'] } diff --git a/packages/rest/src/rest-server-meta-read-org-scope.test.ts b/packages/rest/src/rest-server-meta-read-org-scope.test.ts index fb539c641b6..7d06bb77125 100644 --- a/packages/rest/src/rest-server-meta-read-org-scope.test.ts +++ b/packages/rest/src/rest-server-meta-read-org-scope.test.ts @@ -26,7 +26,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; -import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { ObjectStackProtocolImplementation, SysMetadataRepository } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server.js'; const META = '/api/v1/meta'; @@ -336,11 +336,16 @@ function boot() { rows, historyRows, /** - * Write a LEGACY organization-scoped row the way a door did before - * ADR-0131 D6: straight through the protocol, naming the organization. + * Plant a LEGACY organization-scoped row (and its change log) at rest, + * as a door wrote it before ADR-0131 D6 — the protocol now refuses an + * organization-scoped write (`403 NOT_OVERRIDABLE`). */ - plantLegacyOrgRow: (type: string, name: string, label = LEGACY_MARKER, organizationId = ORG_A) => - protocol.saveMetaItem({ type, name, item: bodyFor(type, name, label), organizationId }), + plantLegacyOrgRow: async (type: string, name: string, label = LEGACY_MARKER, organizationId = ORG_A) => { + const repo = new SysMetadataRepository({ engine, organizationId, orgLabel: organizationId }); + const ref = { org: organizationId, type, name } as Parameters[0]; + const head = await repo.get(ref); + return repo.put(ref, bodyFor(type, name, label), { parentVersion: head?.hash ?? null, actor: null }); + }, as(tenantId: string | undefined) { session = tenantId === undefined ? { userId: 'u1', systemPermissions: ['manage_metadata'] } 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 5015e8ee23f..3735fbd8da4 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 @@ -93,20 +93,34 @@ const viewBody = (name: string) => ({ }); /** - * Three saves of ONE view name through the real `saveMetaItem` write path — - * two tenant overlays and one env-wide package write. Rows are seeded by the - * production writer, not hand-inserted, so the stamps under test are the - * stamps production produces. + * Three audit-bearing rows on ONE view name: two tenant overlays and one + * env-wide package write. + * + * [ADR-0131 D6] The protocol now refuses every organization-scoped write, so + * the two tenant overlays — and the audit rows that recorded them — are + * planted AT REST as the legacy rows a release before D6 left behind, shaped + * as `SysMetadataRepository.put` and `recordMetadataAudit` wrote them. The + * env-wide row is still written by the production writer (`saveMetaItem`), + * so the discriminating control keeps the stamp production produces. The + * organization-scoped READ this file pins is S5's to re-premise. */ -async function seedThreeOrgs(protocol: any) { - await protocol.saveMetaItem({ - type: 'view', name: NAME, item: viewBody(NAME), - organizationId: ORG_A, actor: ACTOR_A, source: 'studio', - }); - await protocol.saveMetaItem({ - type: 'view', name: NAME, item: viewBody(NAME), - organizationId: ORG_B, actor: ACTOR_B, source: 'studio', - }); +/** One legacy organization-scoped save, at rest: its overlay row and its audit row. */ +async function plantLegacyOrgSave(engine: any, org: string, name: string, actor: string) { + await engine.insert('sys_metadata', { + type: 'view', name, organization_id: org, package_id: null, state: 'active', + metadata: JSON.stringify(viewBody(name)), checksum: `legacy_${org}_${name}`, version: 1, + }, { context: { isSystem: true } }); + await engine.insert('sys_metadata_audit', { + occurred_at: new Date().toISOString(), actor, source: 'studio', + type: 'view', name, organization_id: org, + operation: 'save', outcome: 'allowed', code: 'ok', + lock_state: 'none', lock_overridden: false, note: 'active', + }, { context: { isSystem: true } }); +} + +async function seedThreeOrgs(protocol: any, engine: any) { + await plantLegacyOrgSave(engine, ORG_A, NAME, ACTOR_A); + await plantLegacyOrgSave(engine, ORG_B, NAME, ACTOR_B); await protocol.saveMetaItem({ type: 'view', name: NAME, item: viewBody(NAME), actor: ACTOR_ENV, source: 'package', @@ -118,7 +132,7 @@ const actorsOf = (result: any) => (result.events as any[]).map((e) => e.actor).s describe('#8747 auditMetaItem organization scope (real engine + real SqlDriver)', () => { it('seeds three organizations onto one (type, name) — the precondition the scope is judged against', async () => { const { engine, protocol } = await boot(); - await seedThreeOrgs(protocol); + await seedThreeOrgs(protocol, engine); const raw = (await engine.find('sys_metadata_audit', { where: {} })) as any[]; const stamps = raw @@ -136,8 +150,8 @@ 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 () => { - const { protocol } = await boot(); - await seedThreeOrgs(protocol); + const { engine, protocol } = await boot(); + await seedThreeOrgs(protocol, engine); const result = await (protocol as any).auditMetaItem({ type: 'view', name: NAME, organizationId: ORG_A, @@ -156,8 +170,8 @@ describe('#8747 auditMetaItem organization scope (real engine + real SqlDriver)' }); it('is symmetric — org_beta sees its own rows plus env-wide, never org_alpha', async () => { - const { protocol } = await boot(); - await seedThreeOrgs(protocol); + const { engine, protocol } = await boot(); + await seedThreeOrgs(protocol, engine); const actors = actorsOf(await (protocol as any).auditMetaItem({ type: 'view', name: NAME, organizationId: ORG_B, @@ -168,8 +182,8 @@ describe('#8747 auditMetaItem organization scope (real engine + real SqlDriver)' }); it('an organization with no rows of its own still sees the env-wide rows, and only those', async () => { - const { protocol } = await boot(); - await seedThreeOrgs(protocol); + const { engine, protocol } = await boot(); + await seedThreeOrgs(protocol, engine); // A tenant that has never overlaid this item must still see the package // install that put it there — and nobody else's overlays. @@ -181,8 +195,8 @@ describe('#8747 auditMetaItem organization scope (real engine + real SqlDriver)' }); it('an org-less read is fail-closed: env-wide rows only, never every tenant\'s', async () => { - const { protocol } = await boot(); - await seedThreeOrgs(protocol); + const { engine, protocol } = await boot(); + await seedThreeOrgs(protocol, engine); // This is the exact call shape that leaked before the fix — the production // route omitted `organizationId` entirely. It must no longer be a skeleton @@ -198,8 +212,8 @@ describe('#8747 auditMetaItem organization scope (real engine + real SqlDriver)' }); it('an explicit organizationId: null reads env-wide rows, same as omitting it', async () => { - const { protocol } = await boot(); - await seedThreeOrgs(protocol); + const { engine, protocol } = await boot(); + await seedThreeOrgs(protocol, engine); const actors = actorsOf(await (protocol as any).auditMetaItem({ type: 'view', name: NAME, organizationId: null, @@ -209,12 +223,9 @@ describe('#8747 auditMetaItem organization scope (real engine + real SqlDriver)' }); it('scoping does not disturb the (type, name) key — a different item is still excluded', async () => { - const { protocol } = await boot(); - await seedThreeOrgs(protocol); - await (protocol as any).saveMetaItem({ - type: 'view', name: 'other_grid', item: viewBody('other_grid'), - organizationId: ORG_A, actor: 'carol@alpha.example', source: 'studio', - }); + const { engine, protocol } = await boot(); + await seedThreeOrgs(protocol, engine); + 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, 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 54f3ae5a227..60aed30139d 100644 --- a/packages/runtime/src/domains/domain-protocol-handle-typing.test.ts +++ b/packages/runtime/src/domains/domain-protocol-handle-typing.test.ts @@ -94,7 +94,6 @@ function declaredKeysCompile( type: 'app', name: 'crm_console', item: { _unpublished: false }, - organizationId: 'org_1', writeFace: 'meta-dispatch', packageId: 'crm', }), diff --git a/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts b/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts index f152f160112..dcefec280a7 100644 --- a/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts +++ b/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts @@ -14,8 +14,8 @@ * `org_alpha` — operator-authored, and not revoked by the removal — went on * conferring `manage_metadata` with no organization boundary left on it. The * gate passed: `PATCH /packages/:id/disable` switched the package off for the - * whole environment (200), and `DELETE /packages/:id` reached the door's own - * organization check (400 `TENANT_SCOPE_REQUIRED`) instead of the gate's 403. + * whole environment (200), and `DELETE /packages/:id` got past the gate + * instead of being refused its 403. * * The same held for a set the left organization bound to one of its own * POSITIONS: with no tenant the position read is installation-wide, so the left @@ -32,8 +32,9 @@ * runs the REAL identity resolution (`resolveRequestScope` → * `resolveExecutionContext` → `resolveAuthzContext`) under an `isolated` * posture, over a real `SchemaRegistry` holding the package, and a `protocol` - * double that refuses an organization-less `deletePackage` the way - * `@objectstack/metadata-protocol` does. `@objectstack/core` resolves to its + * double that refuses a `deletePackage` carrying the retired `organizationId` / + * `allTenants` keys the way `@objectstack/metadata-protocol` does (ADR-0131 D6). + * `@objectstack/core` resolves to its * source here (this package's vitest alias), so the resolver under test is the * one in this checkout. */ @@ -138,9 +139,9 @@ function rig() { const protocol = { deletePackage: async (req: any) => { deleteRequests.push({ ...req }); - if (!req?.organizationId && req?.allTenants !== true) { - throw Object.assign(new Error('Refusing to uninstall with no organization scope.'), { - code: 'TENANT_SCOPE_REQUIRED', status: 400, + if ('organizationId' in (req ?? {}) || 'allTenants' in (req ?? {})) { + throw Object.assign(new Error('organizationId / allTenants are retired request keys.'), { + code: 'INVALID_REQUEST', status: 400, }); } return { success: true, deletedCount: 0, failedCount: 0, deleted: [], failed: [], cleanups: [] }; @@ -250,7 +251,7 @@ describe('[#20515] what the rule leaves unchanged', () => { const r = rig(); const answer = await r.call('DELETE', 'member', `/packages/${PKG}`); expect(answer.status).toBe(200); - expect(r.deleteRequests).toEqual([{ packageId: PKG, organizationId: ALPHA }]); + expect(r.deleteRequests).toEqual([{ packageId: PKG }]); }); it('CONTROL · the same current member passes PATCH /packages/:id/disable: 200, and the package is switched off', async () => { @@ -263,13 +264,14 @@ describe('[#20515] what the rule leaves unchanged', () => { const r = rig(); const answer = await r.call('DELETE', 'posmember', `/packages/${PKG}`); expect(answer.status).toBe(200); - expect(r.deleteRequests).toEqual([{ packageId: PKG, organizationId: ALPHA }]); + expect(r.deleteRequests).toEqual([{ packageId: PKG }]); }); - it('a GLOBAL grant still passes the gate for the removed member — the door\'s own organization check answers, not the gate', async () => { + it('a GLOBAL grant still passes the gate for the removed member — the uninstall proceeds, naming no organization', async () => { const r = rig(); const answer = await r.call('DELETE', 'global', `/packages/${PKG}`); - expect({ status: answer.status, code: answer.code }).toEqual({ status: 400, code: 'TENANT_SCOPE_REQUIRED' }); + expect(answer.status).toBe(200); + expect(r.deleteRequests).toEqual([{ packageId: PKG }]); // …and the disable door, which asks no organization, lands — the // positive control that makes `switchedOff` false above mean something. const d = rig(); @@ -280,8 +282,7 @@ describe('[#20515] what the rule leaves unchanged', () => { it('platform-admin standing (unscoped admin_full_access) passes with org_alpha active, and with no organization at all', async () => { expect((await rig().call('DELETE', 'admin', `/packages/${PKG}`)).status).toBe(200); expect((await rig().call('PATCH', 'admin', `/packages/${PKG}/disable`)).status).toBe(200); - const orgless = await rig().call('DELETE', 'admin_orgless', `/packages/${PKG}`); - expect({ status: orgless.status, code: orgless.code }).toEqual({ status: 400, code: 'TENANT_SCOPE_REQUIRED' }); + expect((await rig().call('DELETE', 'admin_orgless', `/packages/${PKG}`)).status).toBe(200); expect((await rig().call('PATCH', 'admin_orgless', `/packages/${PKG}/disable`)).status).toBe(200); }); 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 e9896d22360..8fb0bca9a14 100644 --- a/packages/runtime/src/domains/packages-protocol-handle-typing.test.ts +++ b/packages/runtime/src/domains/packages-protocol-handle-typing.test.ts @@ -81,7 +81,6 @@ function declaredKeysCompile(protocol: PackagesDomainProtocol) { name: 'crm_console', item: { _unpublished: false }, packageId: 'crm', - organizationId: 'org_1', actor: 'u_publisher', }), // `applyPublishedSeeds`' seed body read-back, both attempts. 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 eff1411a6ae..f68e616d48d 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 @@ -387,15 +387,13 @@ describe('#15068 · 0 · the publish-then-read path really runs', () => { expect(served?.item?.records).toHaveLength(2); }); - it('the caller\'s organization really reaches this request', async () => { + it('[ADR-0131 D6] the caller\'s organization does NOT reach this request', async () => { const { publishRequest } = await publishThenRead({ activeOrganizationId: ORG }); - // `applyPublishedSeeds` receives the SAME binding this route handed - // `publishPackageDrafts` — one `resolveActiveOrganizationId` call - // serves both. So an org here is what put the ladder on its two-rung - // branch: without this control every measurement below could be of the - // one-rung branch and would prove nothing. - expect(publishRequest()?.organizationId).toBe(ORG); + // The door threads no organization into `publishPackageDrafts` (nor + // into `applyPublishedSeeds`), even with one active. + expect(publishRequest()).toEqual({ packageId: PKG }); + expect(publishRequest()).not.toHaveProperty('organizationId'); }); }); diff --git a/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts b/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts index 0ad2f03076a..78faea3e18c 100644 --- a/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts +++ b/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts @@ -1,8 +1,14 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#20492] `DELETE /packages/:id` refuses an uninstall that names no - * organization BEFORE it touches the running registry. + * [#20492] `DELETE /packages/:id` refused an uninstall that names no + * organization BEFORE it touched the running registry. + * + * [ADR-0131 D6/D12] That refusal is RETIRED: an uninstall is environment-wide + * by construction and names no organization, so every caller who passes the + * operator gate — with or without an active organization — uninstalls, and + * `deletePackage` is handed no organization. The history below is kept for + * the rig's sake. * * ## The defect * @@ -25,14 +31,13 @@ * `SchemaRegistry` holding the package and one object it owns, so "the package * left the process" is read off the registry itself and through the door's own * `GET`. The `protocol` double keeps the package's stored rows, records every - * `deletePackage` request, and refuses an org-less one the way - * `@objectstack/metadata-protocol`'s `deletePackage` does (its request type: - * "Omitted together with `allTenants` ⇒ refused"). + * `deletePackage` request, and refuses one carrying the retired + * `organizationId` / `allTenants` keys the way + * `@objectstack/metadata-protocol`'s `deletePackage` does. * - * Two refused populations, both measured reaching the old half-applied state: - * a member removed from the organization whose session still names it (the - * resolver drops the claim), and a caller who never selected an organization. - * The control is a current member, who uninstalls exactly as before. + * Three populations: a current member, a member removed from the organization + * whose session still names it (the resolver drops the claim), and a caller who + * never selected an organization. All three uninstall the same way. */ import { mkdtempSync, rmSync } from 'node:fs'; @@ -140,9 +145,9 @@ function rig(opts: { persistedHalf?: boolean } = {}): Rig { const protocol = { deletePackage: async (req: any) => { deleteRequests.push({ ...req }); - if (!req?.organizationId && req?.allTenants !== true) { - throw Object.assign(new Error('Refusing to uninstall with no organization scope.'), { - code: 'TENANT_SCOPE_REQUIRED', status: 400, + if ('organizationId' in (req ?? {}) || 'allTenants' in (req ?? {})) { + throw Object.assign(new Error('organizationId / allTenants are retired request keys.'), { + code: 'INVALID_REQUEST', status: 400, }); } const deleted = rows.splice(0); @@ -220,80 +225,30 @@ let warnSpy: ReturnType; beforeEach(() => { warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); afterEach(() => { warnSpy.mockRestore(); }); -// ── The subject: a refused uninstall changes nothing ────────────────────────── +// ── The subject: every operator uninstalls, naming no organization ─────────── -const REFUSED: Array<[Who, string]> = [ +const CALLERS: Array<[Who, string]> = [ + ['member', 'a current member'], ['exmember', 'a member removed from the organization, whose session still names it'], ['orgless', 'a caller who never selected an organization'], ]; -describe('[#20492] DELETE /packages/:id with no organization is refused before the registry is touched', () => { - for (const [who, label] of REFUSED) { - it(`${label}: answered 400 TENANT_SCOPE_REQUIRED by the door, and deletePackage is never asked`, async () => { - const r = rig(); - const answer = await r.call('DELETE', who, `/packages/${PKG}`); - expect({ status: answer.status, code: answer.code, httpStatus: answer.body?.error?.httpStatus }) - .toEqual({ status: 400, code: 'TENANT_SCOPE_REQUIRED', httpStatus: 400 }); - expect(r.deleteRequests).toEqual([]); - }); - - it(`${label}: afterwards the package is still served, listed and registered with its object, and its stored rows are untouched`, async () => { +describe('[ADR-0131 D6] DELETE /packages/:id names no organization, whoever asks', () => { + for (const [who, label] of CALLERS) { + it(`${label}: 200; the package and its object leave the registry, GET answers 404, and deletePackage is handed no organization`, async () => { const r = rig(); expect(await observed(r), 'precondition: the package is installed').toEqual(UNTOUCHED); - await r.call('DELETE', who, `/packages/${PKG}`); - expect(await observed(r)).toEqual(UNTOUCHED); + const answer = await r.call('DELETE', who, `/packages/${PKG}`); + expect(answer.status).toBe(200); + expect(answer.body?.data).toMatchObject({ packageId: PKG, success: true, registryRemoved: true }); + expect(r.deleteRequests).toEqual([{ packageId: PKG }]); + expect(await observed(r)).toEqual({ + detailStatus: 404, + listed: false, + inRegistry: false, + objectRegistered: false, + storedRows: [], + }); }); } - - it('the rig really drops the removed member\'s claim — that arm is not a session that never presented one', async () => { - const r = rig(); - await r.call('DELETE', 'exmember', `/packages/${PKG}`); - const dropped = warnSpy.mock.calls.map((args: unknown[]) => String(args[0])) - .filter((line: string) => line.includes('Session organization claim dropped')); - expect(dropped.length).toBeGreaterThan(0); - expect(dropped[0]).toContain(`organization=${ALPHA}`); - }); -}); - -// ── The control: a member with an organization uninstalls as before ─────────── - -describe('[#20492] control: a current member uninstalls exactly as before', () => { - it('200; the package and its object leave the registry, GET answers 404, and deletePackage removes the rows in that organization', async () => { - const r = rig(); - const answer = await r.call('DELETE', 'member', `/packages/${PKG}`); - expect(answer.status).toBe(200); - expect(answer.body?.data).toMatchObject({ packageId: PKG, success: true, registryRemoved: true }); - expect(r.deleteRequests).toEqual([{ packageId: PKG, organizationId: ALPHA }]); - expect(await observed(r)).toEqual({ - detailStatus: 404, - listed: false, - inRegistry: false, - objectRegistered: false, - storedRows: [], - }); - }); -}); - -// ── The mirror's reach: exactly the refusal the persisted half would give ───── - -describe('[#20492] the door mirrors the persisted refusal — no wider, no narrower', () => { - it('a host with no persisted half (no deletePackage) has no refusal to mirror: the org-less uninstall proceeds as before', async () => { - const r = rig({ persistedHalf: false }); - const answer = await r.call('DELETE', 'orgless', `/packages/${PKG}`); - expect(answer.status).toBe(200); - expect(r.registry.getPackage(PKG)).toBeUndefined(); - }); - - it('no isSystem bypass — the protocol refuses an org-less uninstall whoever asks, so the door does too, before the registry', async () => { - const r = rig(); - const registry = r.registry; - const get = (n: string) => (n === 'objectql' ? { registry } : n === 'protocol' ? { deletePackage: vi.fn() } : null); - const d = new HttpDispatcher({ context: { getService: get } } as any); - const res = await d.handlePackages(`/${PKG}`, 'DELETE', undefined, {}, { - request: {}, executionContext: { isSystem: true }, - } as any); - expect({ status: res.response?.status, code: (res.response?.body as any)?.error?.code }) - .toEqual({ status: 400, code: 'TENANT_SCOPE_REQUIRED' }); - expect(registry.getPackage(PKG)).toBeDefined(); - }); }); diff --git a/packages/runtime/src/domains/packages-vetted-org-source.test.ts b/packages/runtime/src/domains/packages-vetted-org-source.test.ts index 1ad4a1cb144..5b515e608c9 100644 --- a/packages/runtime/src/domains/packages-vetted-org-source.test.ts +++ b/packages/runtime/src/domains/packages-vetted-org-source.test.ts @@ -47,8 +47,12 @@ * `organizationId` names (env-wide when none). Every partition's names carry * its organization, so a read of organization A's rows is visible in the * answer and a write to them is visible in the store. `deletePackage` mirrors - * the real protocol's refusal of an uninstall that names no organization - * (`400 TENANT_SCOPE_REQUIRED`, `metadata-protocol` `deletePackage`). + * the real protocol's refusal of the retired `organizationId` / `allTenants` + * request keys (`400 INVALID_REQUEST`, `metadata-protocol` `deletePackage`). + * + * [ADR-0131 D6] The doors now thread NO organization into any protocol verb, + * vetted or not, so every caller — current member, ex-member, switched + * ex-member — reaches the env-wide partition and never an organization's. */ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; @@ -144,13 +148,12 @@ function protocolDouble() { duplicatePackage: async (req: any) => ({ success: true, copied: [...record('duplicatePackage', req).rows] }), deletePackage: async (req: any) => { state.calls.push({ verb: 'deletePackage', organizationId: req?.organizationId }); - if (!req?.organizationId && req?.allTenants !== true) { - throw Object.assign(new Error('Refusing to uninstall with no organization scope.'), { - code: 'TENANT_SCOPE_REQUIRED', status: 400, + if ('organizationId' in (req ?? {}) || 'allTenants' in (req ?? {})) { + throw Object.assign(new Error('organizationId / allTenants are retired request keys.'), { + code: 'INVALID_REQUEST', status: 400, }); } - // An org-scoped uninstall reaches the org's rows AND the env-wide ones (#7705). - const deleted = [...state.store[scopeOf(req)].rows.splice(0), ...state.store[ENV_WIDE].rows.splice(0)]; + const deleted = state.store[ENV_WIDE].rows.splice(0); return { success: true, deletedCount: deleted.length, failedCount: 0, deleted }; }, getMetaItems: async (req: any) => { @@ -363,20 +366,6 @@ const organizationsCarried = () => [...new Set(state.calls.map((c) => c.organiza describe('[#20477] controls: the rig can tell the organizations apart', () => { for (const [transport, entry] of TRANSPORTS) { for (const door of DOORS) { - it(`${transport} · ${door.name}: a CURRENT member reaches their own organization's partition`, async () => { - const answer = await entry()(door.method, 'member', door.path, door.body); - expect(state.calls.map((c) => c.verb)).toContain(door.verb); - expect(organizationsCarried()).toEqual([ALPHA]); - const { wrote, read } = touched(ALPHA, 'alpha', answer); - expect(wrote || read, `${door.name} -> ${answer.status} ${JSON.stringify(answer.body)}`).toBe(true); - }); - - it(`${transport} · ${door.name}: the ex-member, switched to an organization they ARE in, reaches that one and never the left one`, async () => { - const answer = await entry()(door.method, 'exmember_beta', door.path, door.body); - expect(organizationsCarried()).toEqual([BETA]); - expect(touched(ALPHA, 'alpha', answer)).toEqual({ wrote: false, read: false }); - }); - it(`${transport} · ${door.name}: an anonymous caller is refused before any protocol call, as before`, async () => { const answer = await entry()(door.method, 'anonymous', door.path, door.body); expect({ status: answer.status, code: answer.code }).toEqual({ status: ANONYMOUS_DENY_STATUS, code: ANONYMOUS_DENY_CODE }); @@ -396,31 +385,19 @@ describe('[#20477] controls: the rig can tell the organizations apart', () => { // ── The subject: a claim the resolver dropped scopes nothing ────────────────── -describe('[#20477] a session claim the resolver DROPPED reaches no organization on any /packages door', () => { +describe('[#20477][ADR-0131 D6] no session claim reaches an organization on any /packages door', () => { for (const [transport, entry] of TRANSPORTS) { - // [#20492] The uninstall door is pinned on its own, below: it refuses a - // caller with no organization BEFORE the protocol is asked at all, so - // there is no protocol call for this generic pin to read. - for (const door of DOORS.filter((d) => d.verb !== 'deletePackage')) { - it(`${transport} · ${door.name}: the protocol is handed no organization, and the left organization's rows are neither read nor written`, async () => { - const answer = await entry()(door.method, 'exmember', door.path, door.body); - expect(state.calls.map((c) => c.verb)).toContain(door.verb); - expect(organizationsCarried()).toEqual([undefined]); - expect(touched(ALPHA, 'alpha', answer), `${door.name} -> ${answer.status} ${JSON.stringify(answer.body)}`) - .toEqual({ wrote: false, read: false }); - }); + for (const who of ['member', 'exmember', 'exmember_beta'] as const) { + for (const door of DOORS) { + it(`${transport} · ${who} · ${door.name}: the protocol is handed no organization, and no organization's rows are read or written`, async () => { + const answer = await entry()(door.method, who, door.path, door.body); + expect(state.calls.map((c) => c.verb)).toContain(door.verb); + expect(organizationsCarried()).toEqual([undefined]); + expect(touched(ALPHA, 'alpha', answer), `${door.name} -> ${answer.status} ${JSON.stringify(answer.body)}`) + .toEqual({ wrote: false, read: false }); + expect(touched(BETA, 'beta', answer)).toEqual({ wrote: false, read: false }); + }); + } } - - // [#20492] The refusal is the door's own now, taken before the registry - // is touched: the protocol is never handed the org-less request. The - // registry half of "nothing changed" is pinned in - // `packages-uninstall-refuse-before-mutate.test.ts` (this rig's - // registry cannot uninstall anything). - it(`${transport} · DELETE /packages/:id: the org-less uninstall is refused by the door before the protocol is asked, and nothing is deleted`, async () => { - const answer = await entry()('DELETE', 'exmember', `/packages/${PKG}`); - expect({ status: answer.status, code: answer.code }).toEqual({ status: 400, code: 'TENANT_SCOPE_REQUIRED' }); - expect(state.calls).toEqual([]); - expect(state.store).toEqual(seedStore()); - }); } }); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index a42fab09b1e..7706d7a055c 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -429,70 +429,6 @@ function requireWritablePackage( }; } -/** - * [#20492] `DELETE /packages/:id` — the ORGANIZATION-SCOPE refusal of the - * persisted delete, asked by the door before anything is mutated. - * - * ## The measurement - * - * A caller holding `manage_metadata` with no active organization sent - * `DELETE /api/v1/packages/:id` through `dispatch()` and was answered - * `400 TENANT_SCOPE_REQUIRED` — yet the package had ALREADY left the running - * registry (`GET /packages/:id` 200 before, 404 after; the listing empty), and - * its stored rows were kept. The door ran `registry.uninstallPackage(id)` first - * and reached `deletePackage`'s refusal second. A refused request had changed - * the process for everyone it serves, until a restart re-seeded the registry. - * - * ## The rule it mirrors - * - * `deletePackage` (`@objectstack/metadata-protocol`) refuses an uninstall - * whose request names neither `organizationId` nor `allTenants: true` — its - * declared request type says so ("Omitted together with `allTenants` ⇒ - * refused"). This door never sends `allTenants`, so the request it builds is - * refused exactly when the caller's vetted organization is absent. That - * absence is the whole condition: nothing about the package or its rows enters - * it, so the door can decide it up front, from the SAME value it hands the - * protocol. - * - * ## Shape - * - * Same code and status as the protocol's refusal — `TENANT_SCOPE_REQUIRED`, - * `400` — so no caller sees a second vocabulary for one condition. The sentence - * is the door's own, for the reason {@link requireWritablePackage}'s is: the - * protocol's remedy ("pass organizationId … or allTenants: true") names request - * keys an HTTP caller cannot send. What this caller can do is select an - * organization; and what the door can now truthfully add is that nothing - * changed. - * - * ⛔ No `isSystem` bypass: the protocol refuses an org-less uninstall whoever - * asks, so a mirror that exempted anyone would disagree with it. Returns a - * refusal result to short-circuit on, or `null` to proceed. Callers MUST run - * it before `uninstallPackage`, and only when `deletePackage` will run. - * - * [#21276] The ordering above is the one this refusal was written against. - * Since then the door withdraws nothing before `deletePackage` answers: the - * registry withdrawal and the disable-record clear both follow it, so a - * refusal from the store leaves the running process untouched as well. - */ -function requireUninstallOrganizationScope( - deps: DomainHandlerDeps, - id: string, - organizationId: string | undefined, -): HttpDispatcherResult | null { - if (organizationId) return null; - return { - handled: true, - response: deps.error( - `Refusing to uninstall '${id}' with no organization scope: this request carries no active ` - + `organization, and an uninstall that names none would delete every organization's rows for ` - + `this package. Nothing was changed — select an organization you are a member of as your ` - + `active organization, then retry.`, - 400, - { code: 'TENANT_SCOPE_REQUIRED', packageId: id }, - ), - }; -} - /** * [#14451] `POST /packages/:id/duplicate` — the SOURCE must be a BASE. * @@ -1491,10 +1427,8 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin const protocol = await resolveProtocol(deps, _context); if (protocol && typeof protocol.publishPackageDrafts === 'function') { try { - const organizationId = await deps.resolveActiveOrganizationId(_context); const result = await protocol.publishPackageDrafts({ packageId: id, - ...(organizationId ? { organizationId } : {}), ...(body?.actor ? { actor: body.actor } : {}), }); // Publishing a `seed` draft is what actually loads its @@ -1513,7 +1447,6 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (seedNames.length > 0) { (result as any).seedApplied = await applyPublishedSeeds(deps, seedNames, - organizationId, _context, ); } @@ -1619,8 +1552,8 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // `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. The `organizationId` - // this route still hands that call is dropped at the gate. + // `allowOrgOverride: false` in commit ee58392e1. [ADR-0131 D6] + // This route hands it no organization at all any more. // // ⛔ Dropping it is the REPAIR, not an oversight to undo. // An org-scoped `app` row is an unhydratable phantom — @@ -1640,7 +1573,6 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin const appsRes = await protocol.getMetaItems({ type: 'app', packageId: id, - ...(organizationId ? { organizationId } : {}), }); const apps: any[] = Array.isArray(appsRes) ? appsRes @@ -1817,10 +1749,8 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin const protocol = await resolveProtocol(deps, _context); if (protocol && typeof protocol.discardPackageDrafts === 'function') { try { - const organizationId = await deps.resolveActiveOrganizationId(_context); const result = await protocol.discardPackageDrafts({ packageId: id, - ...(organizationId ? { organizationId } : {}), ...(body?.actor ? { actor: body.actor } : {}), }); return { handled: true, response: deps.success(result) }; @@ -1840,10 +1770,8 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin const protocol = await resolveProtocol(deps, _context); if (protocol && typeof protocol.listCommits === 'function') { try { - const organizationId = await deps.resolveActiveOrganizationId(_context); const commits = await protocol.listCommits({ packageId: id, - ...(organizationId ? { organizationId } : {}), }); return { handled: true, response: deps.success({ commits }) }; } catch (e: any) { @@ -1862,10 +1790,8 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin const protocol = await resolveProtocol(deps, _context); if (protocol && typeof protocol.revertCommit === 'function') { try { - const organizationId = await deps.resolveActiveOrganizationId(_context); const result = await protocol.revertCommit({ commitId, - ...(organizationId ? { organizationId } : {}), ...(body?.actor ? { actor: body.actor } : {}), }); return { handled: true, response: deps.success(result) }; @@ -1886,10 +1812,8 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin return { handled: true, response: deps.error('Body { commitId } is required', 400) }; } try { - const organizationId = await deps.resolveActiveOrganizationId(_context); const result = await protocol.rollbackToPackageCommit({ commitId: String(body.commitId), - ...(organizationId ? { organizationId } : {}), ...(body?.actor ? { actor: body.actor } : {}), }); return { handled: true, response: deps.success(result) }; @@ -1918,10 +1842,8 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin const protocol = await resolveProtocol(deps, _context); if (protocol && typeof protocol.revertStoredPackage === 'function') { try { - const organizationId = await deps.resolveActiveOrganizationId(_context); const stored = await protocol.revertStoredPackage({ packageId: id, - ...(organizationId ? { organizationId } : {}), }); if (stored.stored) return { handled: true, response: deps.success({ success: true }) }; } catch (e: any) { @@ -1967,10 +1889,8 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin return { handled: true, response: deps.error('Orphan adoption not supported', 501) }; } try { - const organizationId = await deps.resolveActiveOrganizationId(_context); const result = await protocol.reassignOrphanedMetadata({ targetPackageId: id, - ...(organizationId ? { organizationId } : {}), ...(body?.actor ? { actor: body.actor } : {}), }); return { handled: true, response: deps.success(result) }; @@ -2007,13 +1927,11 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // loop, so a refusal any later still leaves the empty shell behind. const notABase = requireDuplicableSource(deps, qlService, id); if (notABase) return notABase; try { - const organizationId = await deps.resolveActiveOrganizationId(_context); const result = await protocol.duplicatePackage({ sourcePackageId: id, targetPackageId, ...(typeof body?.targetName === 'string' ? { targetName: body.targetName } : {}), ...(typeof body?.targetNamespace === 'string' ? { targetNamespace: body.targetNamespace } : {}), - ...(organizationId ? { organizationId } : {}), ...(body?.actor ? { actor: body.actor } : {}), }); return { handled: true, response: deps.success(result) }; @@ -2118,28 +2036,10 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // listing (and with it every object the package registers) until // the next restart. const readOnly = requireWritablePackage(deps, qlService, id, 'delete'); if (readOnly) return readOnly; - // [#20492] The persisted delete's organization-scope refusal, taken - // HERE, before `uninstallPackage` — the same "refuse before you - // mutate" ordering as the gate above. `deletePackage` refuses an - // uninstall that names no organization, and it used to be asked - // only AFTER the registry had already dropped the package: the - // caller got `400 TENANT_SCOPE_REQUIRED` while the package and every - // object it registers had left the running process for everyone it - // serves, with the stored rows still saying it was installed. - // - // ONE organization read, and it is the value handed to - // `deletePackage` below, so this check and the protocol's cannot - // disagree. Asked only when the persisted half will run: with no - // `deletePackage` there is no refusal to mirror, and the in-memory - // uninstall proceeds exactly as before. ⛔ Not a compensating - // re-install after the fact — nothing is touched before this. - // The protocol keeps its own refusal as the second line. + // [ADR-0131 D6/D12] No organization: an uninstall is environment-wide + // by construction, and who may uninstall is the operator gate above. const protocol = await resolveProtocol(deps, _context); - const organizationId = await deps.resolveActiveOrganizationId(_context); const persists = Boolean(protocol && typeof protocol.deletePackage === 'function'); - if (persists) { - const unscoped = requireUninstallOrganizationScope(deps, id, organizationId); if (unscoped) return unscoped; - } // [#21276] Existence is READ here, never acted on. This line used to // be `registry.uninstallPackage(id)`, and the disable-record clear @@ -2185,15 +2085,13 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // the name, and registrants carrying no `deletePackage` are real in-tree. // A capability question, asked as a capability probe — not a cast. // - // [#20492] `protocol` and `organizationId` are the ones resolved above, - // before the registry was touched: the organization this request - // carries is the one the scope check already read. + // [#20492] `protocol` is the one resolved above, before the registry + // was touched. if (protocol && typeof protocol.deletePackage === 'function') { try { const keepData = query?.keepData === 'true' || query?.keepData === '1'; persisted = await protocol.deletePackage({ packageId: id, - ...(organizationId ? { organizationId } : {}), ...(keepData ? { keepData: true } : {}), }); } catch (e: any) { @@ -2360,7 +2258,8 @@ context: HttpProtocolContext, const protocol = await resolveProtocol(deps, context); if (!protocol || typeof protocol.getMetaItems !== 'function') return null; - const organizationId = await deps.resolveActiveOrganizationId(context); + // [ADR-0131 D6] No organization: a package's manifest is its environment + // rows (and its code), the same answer for every caller. // Provenance / overlay-bookkeeping keys that must never leak into a // portable manifest. Stripped at top level only — nested field bodies @@ -2394,7 +2293,7 @@ context: HttpProtocolContext, // getMetaItems applies the packageId filter at the // registry/overlay query level, so the returned items are // already scoped to this package — no client-side re-filter. - const res = await protocol.getMetaItems({ type: singular, packageId, organizationId }); + const res = await protocol.getMetaItems({ type: singular, packageId }); items = Array.isArray(res?.items) ? res.items : []; } catch { // Unknown/unsupported type for this runtime — skip. @@ -2438,7 +2337,8 @@ context: HttpProtocolContext, * Apply just-published `seed` metadata: load each seed's rows into its * target object so publishing a seed draft makes the data live (the runtime * counterpart to staging it). Reads each seed body via the protocol, then - * runs the {@link SeedLoaderService} for the active org. Best-effort and + * runs the {@link SeedLoaderService} — with no caller organization: a seed + * dataset names the organization it populates (ADR-0131 D9, §12). Best-effort and * idempotent (upsert) — callers must never let this fail the publish. * * Lives at the runtime layer (not in the objectql publish primitive) @@ -2448,7 +2348,6 @@ context: HttpProtocolContext, async function applyPublishedSeeds( deps: DomainHandlerDeps, names: string[], -organizationId: string | undefined, _context: HttpProtocolContext, ): Promise<{ success: boolean; inserted?: number; updated?: number; errors?: unknown[]; error?: string }> { // [#4127] `protocol` keeps its `any` — no written contract, so this is where @@ -2574,7 +2473,6 @@ _context: HttpProtocolContext, config: { defaultMode: 'upsert', multiPass: true, - ...(organizationId ? { organizationId } : {}), }, }); if (!parsedRequest.success) throw seedRequestValidationError(parsedRequest.error.issues); diff --git a/packages/runtime/src/package-door-error-parity.test.ts b/packages/runtime/src/package-door-error-parity.test.ts index a0ff2230a90..8cd2c22a456 100644 --- a/packages/runtime/src/package-door-error-parity.test.ts +++ b/packages/runtime/src/package-door-error-parity.test.ts @@ -98,7 +98,7 @@ function thrown(message: string, carried: Record): Error { * code outside the declared vocabulary, and a genuinely unexpected fault. */ const SHAPES: Array<{ name: string; error: unknown }> = [ - { name: 'a coded 4xx (`status`)', error: thrown('scope required', { status: 400, code: 'TENANT_SCOPE_REQUIRED' }) }, + { name: 'a coded 4xx (`status`)', error: thrown('invalid request', { status: 400, code: 'INVALID_REQUEST' }) }, { name: 'the established 409', error: thrown('would drop data', { status: 409, code: 'DESTRUCTIVE_CHANGE' }) }, { name: 'a coded 4xx spelled `statusCode`', error: thrown('locked', { statusCode: 409, code: 'RECORD_LOCKED' }) }, { diff --git a/packages/runtime/src/package-duplicate-adopt-org-scope.integration.test.ts b/packages/runtime/src/package-duplicate-adopt-org-scope.integration.test.ts index 6d675722691..2f5ae965c99 100644 --- a/packages/runtime/src/package-duplicate-adopt-org-scope.integration.test.ts +++ b/packages/runtime/src/package-duplicate-adopt-org-scope.integration.test.ts @@ -78,7 +78,7 @@ import { * an org at all (`packages/qa/dogfood/.../package-first-authoring.dogfood.test.ts`), * which is exactly why none of them could see this family. This file lives in * `packages/runtime` for the same reason its tier-1 sibling - * `package-revert-commit-org-scope.integration.test.ts` does: `metadata-protocol` + * the org-scoped revert-commit integration suite (retired with ADR-0131 D6) does: `metadata-protocol` * cannot import `objectql` (dependency cycle). * * ⚠️ For the next author: this suite resolves `@objectstack/metadata-protocol` @@ -91,8 +91,6 @@ const SRC = 'app.iojn'; const DST = 'app.iojn2'; const OTHER_PKG = 'app.other'; const PLATFORM_PKG = '@objectstack/platform-objects'; -const ACTIVE_ORG = 'org_active'; -const OTHER_ORG = 'org_other'; let cleanup: Array<() => void> = []; afterEach(() => { @@ -155,13 +153,14 @@ const objectBody = (name: string) => ({ }); /** - * Publish one item. Omitting `organizationId` reproduces exactly what the - * dispatcher sends when the session has no active organization — and lands the - * row env-wide. + * Publish one item, env-wide. [ADR-0131 D6] Every write is env-wide now — the + * protocol refuses an organization-scoped one, and `duplicatePackage` / + * `reassignOrphanedMetadata` take no organization — so the org-scoped cases + * this suite once carried are retired; what remains is what holds env-wide. */ async function publish( p: any, - args: { type: 'view' | 'object'; name: string; packageId: string; organizationId?: string; item?: unknown }, + args: { type: 'view' | 'object'; name: string; packageId: string; item?: unknown }, ): Promise { const res = await p.saveMetaItem({ type: args.type, @@ -169,7 +168,6 @@ async function publish( item: args.item ?? (args.type === 'object' ? objectBody(args.name) : viewBody(args.name)), packageId: args.packageId, mode: 'publish', - ...(args.organizationId ? { organizationId: args.organizationId } : {}), }); expect(res?.success ?? true).toBeTruthy(); } @@ -190,50 +188,6 @@ const namesIn = (rows: Array>, pkg: string): string[] => rows.filter((r) => r.pkg === pkg).map((r) => r.name).sort(); describe('#7819 tier 2 — duplicatePackage must copy the source’s env-wide rows', () => { - it('the premise, measured: a no-org publish really does land an env-wide row', async () => { - const { engine, protocol } = await boot(); - await publish(protocol as any, { type: 'view', name: 'iojn_env', packageId: SRC }); - await publish(protocol as any, { - type: 'view', name: 'iojn_own', packageId: SRC, organizationId: ACTIVE_ORG, - }); - - // Straight out of SQLite: the column really is NULL on the first row, so - // the strict equality this card removes really had nothing to match. - expect((await rowsOf(engine)).map((r) => ({ name: r.name, org: r.org }))).toEqual([ - { name: 'iojn_env', org: null }, - { name: 'iojn_own', org: ACTIVE_ORG }, - ]); - }); - - it('copies BOTH the env-wide and the org-scoped rows (was: a partial copy reporting success)', async () => { - const { engine, protocol } = await boot(); - const p = protocol as any; - await publish(p, { type: 'view', name: 'iojn_env', packageId: SRC }); - await publish(p, { type: 'view', name: 'iojn_own', packageId: SRC, organizationId: ACTIVE_ORG }); - - const res = await p.duplicatePackage({ - sourcePackageId: SRC, targetPackageId: DST, targetNamespace: 'iojn2', - organizationId: ACTIVE_ORG, - }); - - // MEASURED BEFORE THE FIX: `{success: true, copiedCount: 1, failedCount: 0}` - // with only `iojn2_own` present — the env-wide row silently absent from a - // copy that reported itself complete. The CONSEQUENCE, not the call: both - // items exist in the duplicate. - expect(res.failed).toEqual([]); - expect(res.success).toBe(true); - expect(res.copiedCount).toBe(2); - - // Each copy lands in the scope of the row it came from, not the request's - // — see the object case below for why that is load-bearing rather than - // tidy. - const copied = (await rowsOf(engine)).filter((r) => r.pkg === DST); - expect(copied.map((r) => ({ name: r.name, org: r.org }))).toEqual([ - { name: 'iojn2_env', org: null }, - { name: 'iojn2_own', org: ACTIVE_ORG }, - ]); - }); - it('rewrites references INTO the copy — the rename map is built from the scan (the sharper defect)', async () => { const { engine, protocol } = await boot(); const p = protocol as any; @@ -241,13 +195,12 @@ describe('#7819 tier 2 — duplicatePackage must copy the source’s env-wide ro // the view that references it published after. await publish(p, { type: 'object', name: 'iojn_widget', packageId: SRC }); await publish(p, { - type: 'view', name: 'iojn_list', packageId: SRC, organizationId: ACTIVE_ORG, + type: 'view', name: 'iojn_list', packageId: SRC, item: viewBody('iojn_list', 'List', 'iojn_widget'), }); const res = await p.duplicatePackage({ sourcePackageId: SRC, targetPackageId: DST, targetNamespace: 'iojn2', - organizationId: ACTIVE_ORG, }); // MEASURED BEFORE THE FIX: `{success: true, copiedCount: 1}` — the env-wide @@ -272,95 +225,28 @@ describe('#7819 tier 2 — duplicatePackage must copy the source’s env-wide ro const copied = (await rowsOf(engine)).filter((r) => r.pkg === DST); expect(copied.map((r) => ({ name: r.name, org: r.org })).sort((a, b) => a.name.localeCompare(b.name))) .toEqual([ - { name: 'iojn2_list', org: ACTIVE_ORG }, + { name: 'iojn2_list', org: null }, { name: 'iojn2_widget', org: null }, ]); const list = copied.find((r) => r.name === 'iojn2_list'); expect(list?.body?.data?.object).toBe('iojn2_widget'); }); - it('the caller’s own org SHADOWS env-wide when both scopes carry the same item', async () => { - const { engine, protocol } = await boot(); - const p = protocol as any; - // A collision that could not occur while the scan was a strict equality, - // and therefore a hazard this fix introduces rather than inherits: one item - // present twice. Both copies would be written under the SAME target key - // (`type, name, organization_id, COALESCE(package_id, '')`), so without the - // precedence rule the surviving body would be decided by driver row order. - // ADR-0005 order — the caller's own org shadows env-wide — is what - // `resolveMetaItemOrgScope` applies to history lineages, and what this - // caller already reads everywhere else. - await publish(p, { - type: 'view', name: 'iojn_dup', packageId: SRC, - item: viewBody('iojn_dup', 'ENV BODY'), - }); - await publish(p, { - type: 'view', name: 'iojn_dup', packageId: SRC, organizationId: ACTIVE_ORG, - item: viewBody('iojn_dup', 'ORG BODY'), - }); - - const res = await p.duplicatePackage({ - sourcePackageId: SRC, targetPackageId: DST, targetNamespace: 'iojn2', - organizationId: ACTIVE_ORG, - }); - - expect(res.copiedCount).toBe(1); - const copied = (await rowsOf(engine)).filter((r) => r.pkg === DST); - expect(copied).toHaveLength(1); - expect(copied[0]?.body?.label).toBe('ORG BODY'); - }); - - it('does NOT copy another organization’s rows', async () => { - const { engine, protocol } = await boot(); - const p = protocol as any; - await publish(p, { type: 'view', name: 'iojn_own', packageId: SRC, organizationId: ACTIVE_ORG }); - await publish(p, { type: 'view', name: 'iojn_foreign', packageId: SRC, organizationId: OTHER_ORG }); - - // The direction that must not widen. `$or` admits env-wide rows and refuses - // this one; dropping the predicate outright would have copied it. - const res = await p.duplicatePackage({ - sourcePackageId: SRC, targetPackageId: DST, targetNamespace: 'iojn2', - organizationId: ACTIVE_ORG, - }); - - expect(res.copiedCount).toBe(1); - expect(namesIn(await rowsOf(engine), DST)).toEqual(['iojn2_own']); - }); - it('does NOT reach into another package’s rows', async () => { const { engine, protocol } = await boot(); const p = protocol as any; - await publish(p, { type: 'view', name: 'iojn_own', packageId: SRC, organizationId: ACTIVE_ORG }); + await publish(p, { type: 'view', name: 'iojn_own', packageId: SRC }); // Env-wide AND in a different package, so it clears the org filter the fix // widened and is refused by the package one alone. await publish(p, { type: 'view', name: 'iojn_elsewhere', packageId: OTHER_PKG }); const res = await p.duplicatePackage({ sourcePackageId: SRC, targetPackageId: DST, targetNamespace: 'iojn2', - organizationId: ACTIVE_ORG, }); expect(res.copiedCount).toBe(1); expect(namesIn(await rowsOf(engine), DST)).toEqual(['iojn2_own']); }); - - it('a caller with NO org still copies org-scoped rows (the other door, un-narrowed)', async () => { - const { engine, protocol } = await boot(); - const p = protocol as any; - await publish(p, { type: 'view', name: 'iojn_env', packageId: SRC }); - await publish(p, { type: 'view', name: 'iojn_own', packageId: SRC, organizationId: ACTIVE_ORG }); - - // The no-org branch is deliberately NOT narrowed to `organization_id IS - // NULL`, exactly as #7705 / #7779 / tier 1 left theirs. Narrowing it would - // drop every org-scoped row from this door's copy — the same bug pointed - // the other way. - const res = await p.duplicatePackage({ - sourcePackageId: SRC, targetPackageId: DST, targetNamespace: 'iojn2', - }); - - expect(res.success).toBe(true); - expect(namesIn(await rowsOf(engine), DST)).toEqual(['iojn2_env', 'iojn2_own']); - }); }); describe('#7819 tier 2 — reassignOrphanedMetadata must see env-wide orphans', () => { @@ -387,52 +273,6 @@ describe('#7819 tier 2 — reassignOrphanedMetadata must see env-wide orphans', ]); }); - it('adopts the env-wide orphan as well as its own (was: structurally invisible)', async () => { - const { engine, protocol } = await boot(); - const p = protocol as any; - await publish(p, { type: 'view', name: 'iojn_owned', packageId: SRC, organizationId: ACTIVE_ORG }); - // Two orphans, one per scope. The env-wide one is minted through the real - // write path pinned above, not hand-inserted. - await p.saveMetaItem({ type: 'view', name: 'orph_env', item: viewBody('orph_env'), mode: 'publish' }); - await p.saveMetaItem({ - type: 'view', name: 'orph_own', item: viewBody('orph_own'), mode: 'publish', - organizationId: ACTIVE_ORG, - }); - - const res = await p.reassignOrphanedMetadata({ - targetPackageId: DST, organizationId: ACTIVE_ORG, - }); - - // MEASURED BEFORE THE FIX: `{success: true, reassignedCount: 1}` — only - // `orph_own` moved, with the env-wide orphan left at `package_id = null` - // and nothing reporting that it had been skipped. Finding orphans is this - // method's entire purpose, so a class of orphan it cannot see is a wrong - // answer rather than a partial one. - expect(res.reassignedCount).toBe(2); - expect(res.reassigned.map((r: any) => r.name).sort()).toEqual(['orph_env', 'orph_own']); - expect(namesIn(await rowsOf(engine), DST)).toEqual(['orph_env', 'orph_own']); - }); - - it('does NOT adopt another organization’s orphans', async () => { - const { engine, protocol } = await boot(); - const p = protocol as any; - await p.saveMetaItem({ type: 'view', name: 'orph_env', item: viewBody('orph_env'), mode: 'publish' }); - await p.saveMetaItem({ - type: 'view', name: 'orph_foreign', item: viewBody('orph_foreign'), mode: 'publish', - organizationId: OTHER_ORG, - }); - - const res = await p.reassignOrphanedMetadata({ - targetPackageId: DST, organizationId: ACTIVE_ORG, - }); - - // The direction that must not widen. - expect(res.reassigned.map((r: any) => r.name)).toEqual(['orph_env']); - const rows = await rowsOf(engine); - expect(namesIn(rows, DST)).toEqual(['orph_env']); - expect(rows.find((r) => r.name === 'orph_foreign')?.pkg).toBeNull(); - }); - it('leaves OWNED rows alone — an env-wide row bound to a real package is not an orphan', async () => { const { engine, protocol } = await boot(); const p = protocol as any; @@ -443,33 +283,10 @@ describe('#7819 tier 2 — reassignOrphanedMetadata must see env-wide orphans', await p.saveMetaItem({ type: 'view', name: 'orph_env', item: viewBody('orph_env'), mode: 'publish' }); const res = await p.reassignOrphanedMetadata({ - targetPackageId: DST, organizationId: ACTIVE_ORG, + targetPackageId: DST, }); expect(res.reassigned.map((r: any) => r.name)).toEqual(['orph_env']); expect((await rowsOf(engine)).find((r) => r.name === 'iojn_env')?.pkg).toBe(SRC); }); - - it('a caller with NO org still adopts every scope’s orphans (the widest door, un-narrowed)', async () => { - const { engine, protocol } = await boot(); - const p = protocol as any; - await p.saveMetaItem({ type: 'view', name: 'orph_env', item: viewBody('orph_env'), mode: 'publish' }); - await p.saveMetaItem({ - type: 'view', name: 'orph_a', item: viewBody('orph_a'), mode: 'publish', organizationId: ACTIVE_ORG, - }); - await p.saveMetaItem({ - type: 'view', name: 'orph_b', item: viewBody('orph_b'), mode: 'publish', organizationId: OTHER_ORG, - }); - - // ⛔ `where` stays `{}` for this door — the card named it the family's worst - // exposure precisely because it already scans EVERY organization's rows, - // and narrowing it to `organization_id IS NULL` would re-create this bug - // pointed the other way. This case pins the behaviour as it stands so the - // door cannot drift silently; whether it SHOULD be this wide is #7780's - // open product question, which is a maintainer call and not decided here. - const res = await p.reassignOrphanedMetadata({ targetPackageId: DST }); - - expect(res.reassignedCount).toBe(3); - expect(namesIn(await rowsOf(engine), DST)).toEqual(['orph_a', 'orph_b', 'orph_env']); - }); }); 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 30daf234e3d..af0b1abafd4 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 @@ -185,10 +185,10 @@ const viewBody = (name: string, label: string) => ({ }); /** - * Author one draft and publish it, which is what records ONE commit row. The - * commit's `organization_id` is the PUBLISH REQUEST's org (`?? null`), so - * omitting `organizationId` here reproduces exactly what the dispatcher sends - * when the session has no active organization. + * Author one draft and publish it, which is what records ONE commit row, + * env-wide. [ADR-0131 D6] The protocol refuses an organization-scoped publish, + * so an `organizationId` here plants a LEGACY organization commit: the + * env-wide commit row is re-stamped at rest, the way an older release left it. */ async function publishOne( protocol: any, @@ -203,11 +203,18 @@ async function publishOne( }); const res = await protocol.publishPackageDrafts({ packageId: args.packageId, - ...(args.organizationId ? { organizationId: args.organizationId } : {}), message: args.message, }); expect(res.success).toBe(true); expect(res.commitId).toBeTruthy(); + if (args.organizationId) { + // `isSystem`: the column is `readonly`, and only a system write may set it. + await protocol.engine.update( + 'sys_metadata_commit', + { id: res.commitId, organization_id: args.organizationId }, + { context: { isSystem: true } }, + ); + } return res.commitId as string; } @@ -360,8 +367,10 @@ describe('#7779 — org-scoped listCommits must not hide env-wide commit rows', // commit (asserted above) AND can now act on it — the only combination // under which an org-scoped rollback past an env-wide publish does what it // reports. Its own negative directions live in the sibling - // `package-revert-commit-org-scope.integration.test.ts`. - const rollback = await p.rollbackToPackageCommit({ commitId: c1, organizationId: ACTIVE_ORG }); + // the org-scoped revert-commit integration suite (retired with ADR-0131 D6). + // [ADR-0131 D6] The rollback itself is environment-wide now: an + // organization-scoped one is refused, so it names none. + const rollback = await p.rollbackToPackageCommit({ commitId: c1 }); expect(rollback.failed).toEqual([]); expect(rollback.success).toBe(true); expect(rollback.revertedCommits).toEqual([c2]); diff --git a/packages/runtime/src/package-revert-commit-attribution-org-scope.integration.test.ts b/packages/runtime/src/package-revert-commit-attribution-org-scope.integration.test.ts deleted file mode 100644 index 52b13a69c0e..00000000000 --- a/packages/runtime/src/package-revert-commit-attribution-org-scope.integration.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// Real-engine regression for #7860 — `protocol.revertCommit` recorded its -// compensating commit under the REQUEST's organization -// (`recordPackageCommit({ orgId: request.organizationId ?? null })`) rather -// than under the scope of the commit it was reverting. - -import { describe, it, expect, afterEach } from 'vitest'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqlDriver } from '@objectstack/driver-sql'; -import { - captureExpectedReadRefusals, - type ExpectedReadRefusalCapture, -} from './expected-read-refusal-noise.js'; -import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; -import { - SysMetadataObject, - SysMetadataHistoryObject, - SysMetadataAuditObject, - SysMetadataCommitObject, -} from '@objectstack/metadata-core'; - -/** - * The invariant, stated once: a revert commit is visible to exactly the - * readers who can see the commit it reverts. - * - * --------------------------------------------------------------------------- - * Why this was a REPORTING defect and not a design question - * --------------------------------------------------------------------------- - * The card (#7860) was filed explicitly NOT as a defect — the behaviour is - * self-consistent for the caller who performed the revert, and it asked for a - * measurement first: after an org-scoped revert of an env-wide commit, what - * does a DIFFERENT organization's `listCommits` show, and what does the no-org - * (direct-mount REST) door see? Measured here, on a real driver, before any - * edit: - * - * actor (`org_active`) → [revert, apply] coherent - * different org → [apply] the env-wide publish, with NO - * compensation anywhere after it - * no-org REST → [revert, apply] coherent - * - * The middle row is the defect, and what makes it more than cosmetic is the - * artifact state measured alongside it: `sys_metadata` held NO row for the - * reverted view afterwards. `revertCommit` resolves each item's scope from the - * ROW (#7559), so the artifact really was removed ENV-WIDE — the effect is - * global while the record documenting it was private. A reader in another - * organization saw an `apply` commit that was never compensated, for an - * artifact that had in fact already been withdrawn underneath it. And since - * #7814, `rollbackToPackageCommit` PLANS from `listCommits`, so this list is - * not merely an observability surface. - * - * The mirror direction is the same mismatch pointed the other way and is - * pinned below: a no-org caller reverting an ORG-SCOPED commit stamped the - * revert env-wide, so every other organization read a dangling `Revert: …` - * whose `parentCommitId` names a commit that door cannot see. - * - * --------------------------------------------------------------------------- - * Why a REAL engine and a REAL driver - * --------------------------------------------------------------------------- - * Every assertion here turns on whether `organization_id = 'org'` matches a - * NULL column — a property of the driver's SQL, not of a stub's `filter()`. - * The suites in this family that stub `engine.find` are structurally unable to - * see any of it. This file seeds through the REAL publish path, exactly as its - * siblings `package-revert-commit-org-scope.integration.test.ts` (#7819) and - * `package-list-commits-org-scope.integration.test.ts` (#7814), and lives in - * `packages/runtime` for the same reason they do: `metadata-protocol` cannot - * import `objectql` (dependency cycle). - * - * ⚠️ These suites resolve `metadata-protocol` through its `dist` while stack - * traces are source-mapped back to `src`, so an edited-but-unbuilt `src` looks - * like it is running while the old bytes execute. Every number above was taken - * with a rebuild between measurements. - * - * --------------------------------------------------------------------------- - * Reachability - * --------------------------------------------------------------------------- - * Only since #7819 tier 1. Before it the target lookup answered - * `COMMIT_NOT_FOUND` (404) for an env-wide row, so an org-scoped caller could - * not reach the attribution line with a mismatched scope at all. - */ - -const PKG = 'com.repro.attrib'; -const PLATFORM_PKG = '@objectstack/platform-objects'; -const ACTIVE_ORG = 'org_active'; -const OTHER_ORG = 'org_other'; - -/** - * [commit 13a6cb4ad] Every publish this fixture makes runs the metadata-protocol build - * probes (`metadata-protocol/src/build-probes.ts`), and the views it publishes - * are bound to the placeholder object `anything` — the #7741 inline arm - * requires an object binding pair, and nothing here creates that table. The - * probe reads it, catches, and files a `view_read_failed` publish issue this - * suite does not read (it asserts `res.success`), but the driver and the engine - * each log the read on the way out. Withheld and asserted rather than muted; - * `expected-read-refusal-noise.ts` says why. - */ -// [#21516] The engine now refuses a name its registry does not hold before any driver, so -// this read no longer reaches the driver and nothing above is logged; the pin asserts that. -const UNBOUND_PROBE_OBJECT = 'anything'; - -let cleanup: Array<() => void> = []; - -/** [commit 13a6cb4ad] The expected-noise capture belonging to the latest `boot()`. */ -let noise: ExpectedReadRefusalCapture | null = null; -afterEach(() => { - for (const c of cleanup) c(); - cleanup = []; - // [commit 13a6cb4ad] The capture is a PIN, not a mute — asserted after teardown so a - // failure here can never leave an engine running. Every test in this file publishes at - // least once, so the probe fires for each of them: this holds for a single - // `-t` run as well as for the whole file. - // [#21516] Quiet by construction now: the engine refuses a name its registry does not - // hold before any driver, so the declared refusal no longer occurs. The capture stays - // declared (a returning read is still withheld and counted) and this asserts nothing was. - expect(noise?.tablesSeen() ?? ['no capture was installed']).toEqual([]); - noise = null; -}); - -/** REAL ObjectQL wired to a REAL SqlDriver over on-disk better-sqlite3. */ -async function boot() { - const dir = mkdtempSync(join(tmpdir(), 'os-7860-')); - cleanup.push(() => rmSync(dir, { recursive: true, force: true })); - - const driver = new SqlDriver({ - client: 'better-sqlite3', - connection: { filename: join(dir, 'data.sqlite') }, - useNullAsDefault: true, - }); - const objects = [ - SysMetadataObject, - SysMetadataHistoryObject, - SysMetadataAuditObject, - SysMetadataCommitObject, - ] as any[]; - // [commit 13a6cb4ad] Installed before the driver runs a statement and before the - // engine issues a read — the two sinks the expected refusal travels out on. - noise = captureExpectedReadRefusals([UNBOUND_PROBE_OBJECT]); - noise.captureDriver(driver); - await driver.initObjects(objects); - - const engine = new ObjectQL(); - noise.captureEngine(engine); - engine.registerDriver(driver as any, true); - await engine.init(); - for (const o of objects) engine.registry.registerObject(o, PLATFORM_PKG); - cleanup.push(() => { void engine.destroy(); }); - - // `'package-author'` is the control-plane assembly's channel — the #4463 - // runtime authoring gate would otherwise refuse the seeding saves below. - const protocol = new ObjectStackProtocolImplementation( - engine as any, undefined, undefined, 'package-author', - ); - return { engine, protocol }; -} - -const viewBody = (name: string) => ({ - name, - label: name, - type: 'grid', - object: 'anything', // [#7741] the inline arm requires the object binding pair - viewKind: 'list', - data: { provider: 'object', object: 'anything' }, - columns: ['id'], -}); - -/** - * Author one draft and publish it — what records ONE commit row. The commit's - * `organization_id` is the PUBLISH REQUEST's org (`?? null`), so omitting - * `organizationId` reproduces exactly what the dispatcher sends when the - * session has no active organization. - */ -async function publishOne( - protocol: any, - args: { view: string; packageId: string; organizationId?: string; message: string }, -): Promise { - await protocol.saveMetaItem({ - type: 'view', - name: args.view, - item: viewBody(args.view), - packageId: args.packageId, - mode: 'draft', - }); - const res = await protocol.publishPackageDrafts({ - packageId: args.packageId, - ...(args.organizationId ? { organizationId: args.organizationId } : {}), - message: args.message, - }); - expect(res.success).toBe(true); - expect(res.commitId).toBeTruthy(); - return res.commitId as string; -} - -const ops = (commits: any[]) => commits.map((c) => c.operation); - -describe('#7860 — a revert commit is attributed to what it reverted, not to who asked', () => { - it('an org caller reverting an ENV-WIDE commit records the revert env-wide', async () => { - const { engine, protocol } = await boot(); - const p = protocol as any; - const envWide = await publishOne(p, { - view: 'attr_env', packageId: PKG, message: 'env-wide publish', - }); - - const result = await p.revertCommit({ commitId: envWide, organizationId: ACTIVE_ORG }); - expect(result.success).toBe(true); - - // Straight out of SQLite. Pre-fix this row carried `org_active`. - const rows = (await engine.find('sys_metadata_commit', { where: {} })) as any[]; - expect(rows.map((r) => ({ op: r.operation, org: r.organization_id ?? null }))).toEqual([ - { op: 'apply', org: null }, - { op: 'revert', org: null }, - ]); - }); - - it('a DIFFERENT organization sees the compensation, not a bare uncompensated publish', async () => { - const { engine, protocol } = await boot(); - const p = protocol as any; - const envWide = await publishOne(p, { - view: 'attr_env', packageId: PKG, message: 'env-wide publish', - }); - await p.revertCommit({ commitId: envWide, organizationId: ACTIVE_ORG }); - - // THE defect this card was opened to measure. Pre-fix: `['apply']` — the - // env-wide publish alone, with nothing recording that it was undone. - const asOther = await p.listCommits({ packageId: PKG, organizationId: OTHER_ORG }); - expect(ops(asOther)).toEqual(['revert', 'apply']); - expect(asOther[0].parentCommitId).toBe(envWide); - - // Why the omission mattered: the artifact really is gone ENV-WIDE (items - // revert in the ROW's scope, #7559), so the reader above was being shown - // an `apply` that had already been withdrawn underneath it. - const meta = (await engine.find('sys_metadata', { where: { name: 'attr_env' } })) as any[]; - expect(meta).toEqual([]); - }); - - it('the actor and the no-org REST door keep the timeline they already had', async () => { - const { protocol } = await boot(); - const p = protocol as any; - const envWide = await publishOne(p, { - view: 'attr_env', packageId: PKG, message: 'env-wide publish', - }); - await p.revertCommit({ commitId: envWide, organizationId: ACTIVE_ORG }); - - // Both were coherent BEFORE the fix and must stay so after it — the change - // may only ADD the missing reader, never trade one blind spot for another. - expect(ops(await p.listCommits({ packageId: PKG, organizationId: ACTIVE_ORG }))) - .toEqual(['revert', 'apply']); - expect(ops(await p.listCommits({ packageId: PKG }))).toEqual(['revert', 'apply']); - }); - - it('mirror — the no-org door reverting an ORG-SCOPED commit records the revert in THAT org', async () => { - const { engine, protocol } = await boot(); - const p = protocol as any; - const owned = await publishOne(p, { - view: 'attr_owned', packageId: PKG, organizationId: ACTIVE_ORG, message: 'org_active publish', - }); - - const result = await p.revertCommit({ commitId: owned }); - expect(result.success).toBe(true); - - const rows = (await engine.find('sys_metadata_commit', { where: {} })) as any[]; - expect(rows.map((r) => ({ op: r.operation, org: r.organization_id ?? null }))).toEqual([ - { op: 'apply', org: ACTIVE_ORG }, - { op: 'revert', org: ACTIVE_ORG }, - ]); - - // Pre-fix the revert was stamped env-wide, so an unrelated organization - // read a DANGLING `Revert: …` whose parent it cannot see. The owning org - // and the no-org door still see the pair. - expect(await p.listCommits({ packageId: PKG, organizationId: OTHER_ORG })).toEqual([]); - expect(ops(await p.listCommits({ packageId: PKG, organizationId: ACTIVE_ORG }))) - .toEqual(['revert', 'apply']); - expect(ops(await p.listCommits({ packageId: PKG }))).toEqual(['revert', 'apply']); - }); -}); diff --git a/packages/runtime/src/package-revert-commit-org-scope.integration.test.ts b/packages/runtime/src/package-revert-commit-org-scope.integration.test.ts deleted file mode 100644 index dc64e68b8ee..00000000000 --- a/packages/runtime/src/package-revert-commit-org-scope.integration.test.ts +++ /dev/null @@ -1,390 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// Real-engine regression for #7819 tier 1 — `protocol.revertCommit` and -// `protocol.rollbackToPackageCommit`'s target lookup each resolved their -// target commit with a strict `organization_id` equality, so an org-scoped -// caller got `COMMIT_NOT_FOUND` (404) for a commit row that demonstrably -// exists and that the very same caller's `listCommits` hands back. - -import { describe, it, expect, afterEach } from 'vitest'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqlDriver } from '@objectstack/driver-sql'; -import { - captureExpectedReadRefusals, - type ExpectedReadRefusalCapture, -} from './expected-read-refusal-noise.js'; -import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; -import { - SysMetadataObject, - SysMetadataHistoryObject, - SysMetadataAuditObject, - SysMetadataCommitObject, -} from '@objectstack/metadata-core'; - -/** - * The mechanism, and why the remedy here is NOT self-evidently the family's. - * - * Both sites carried the predicate the rest of this family carried: - * - * const where = { id: request.commitId }; - * if (request.organizationId) where.organization_id = request.organizationId; - * - * `organization_id = 'org'` matches no row whose column is NULL, so an - * org-scoped caller could not resolve any commit recorded env-wide. - * - * --------------------------------------------------------------------------- - * Why this site needed a decision the earlier cards did not face - * --------------------------------------------------------------------------- - * `where` is keyed on `id` — a primary-key lookup. The org predicate is not - * scoping a scan the way {@link listCommits} (#7779) or {@link deletePackage} - * (#7705) were; it reads like an authorization filter layered on a unique key. - * If it WERE one, widening it would be widening an authorization boundary, and - * "consistent with the family" would be the wrong reason to do it. - * - * Measured against the only door, it is not one: - * - * 1. Authorization on both routes is `requireManageMetadata(deps, _context)` - * in `packages/runtime/src/domains/packages.ts`, checked BEFORE the - * protocol is called. The org never gates the call. - * 2. The `organizationId` those routes pass comes from - * `resolveActiveOrganizationId` (`packages/runtime/src/http-dispatcher.ts`), - * which reads the session's `activeOrganizationId` — a "which org am I - * looking at" selection — and whose whole body is `catch`-wrapped so ANY - * throw on the auth seam answers `undefined`. - * 3. `undefined` omits the predicate entirely, which is the WIDEST reading: - * every organization's commits. A boundary that fails OPEN is not a - * boundary. That single fact rules out reading these lines as authz, and - * with it rules out remedy 3 (keep the check, distinguish "not yours" - * from "no such commit") — there is no authz here to make precise. - * - * So the choice was between mirroring the family's `$or` and dropping the - * predicate outright. The `$or` is what landed, because dropping it would let - * an org-scoped caller revert ANOTHER organization's commit by id — a genuine - * widening this card never asked for — while the `$or` refuses exactly that - * (pinned below) and admits only the env-wide rows. - * - * --------------------------------------------------------------------------- - * The decisive in-code evidence: the body already accepts what the lookup refused - * --------------------------------------------------------------------------- - * #7559 changed `revertCommit` to resolve each item's scope FROM THE ROW rather - * than from the request ({@link resolveMetaItemOrgScope}), with the in-code - * rationale that "a batch legitimately mixes an env-wide artifact with an org - * overlay". Verified here rather than taken on faith: that helper answers - * `null` — env scope — for an item whose history is env-wide, even when the - * request carries an org. The design therefore already accepts an org caller - * operating on env-wide artifacts; a target lookup that refuses the same row - * contradicted the body that would have processed it. - * - * `rollbackToPackageCommit` closes the argument. It derives its work list from - * `listCommits`, which since #7814 returns org-scoped AND env-wide rows to an - * org caller — then fed each id straight back into a lookup that refused half - * of them. One function contradicted itself inside a single call. - * - * --------------------------------------------------------------------------- - * Why a REAL engine and a REAL driver - * --------------------------------------------------------------------------- - * The question is whether `organization_id = 'org'` matches a NULL column, a - * property of the driver's SQL rather than of a stub's `filter()`. Both - * `deletePackage` suites and the ADR-0067 commit-history suites stub - * `engine.find`, which is precisely why none of them could see any member of - * this family. This file seeds through the REAL publish path, exactly as its - * sibling `package-list-commits-org-scope.integration.test.ts` (#7814) does, - * and for the same reason it lives in `packages/runtime`: `metadata-protocol` - * cannot import `objectql` (dependency cycle). - * - * --------------------------------------------------------------------------- - * Measured BEFORE the fix, on this branch's base - * --------------------------------------------------------------------------- - * Every positive case below was run against the unfixed protocol first: - * `revertCommit` on the env-wide commit threw `COMMIT_NOT_FOUND` / 404, and - * `rollbackToPackageCommit` answered `{success: false, failed: [c2]}` — the - * state the sibling suite pinned as KNOWN-INCOMPLETE and handed to this card. - * Reverse verification (restoring the strict equality after the fix) was - * predicted to turn exactly the positive cases red and leave both negative - * directions and the no-org door green, since strict equality is NARROWER than - * the `$or`; measured on revert: exactly that. Numbers in the changeset. - */ - -const PKG = 'com.repro.revert'; -const OTHER_PKG = 'com.other.revert'; -const PLATFORM_PKG = '@objectstack/platform-objects'; -const ACTIVE_ORG = 'org_active'; -const OTHER_ORG = 'org_other'; - -/** - * [commit 13a6cb4ad] Every publish this fixture makes runs the metadata-protocol build - * probes (`metadata-protocol/src/build-probes.ts`), and the views it publishes - * are bound to the placeholder object `anything` — the #7741 inline arm - * requires an object binding pair, and nothing here creates that table. The - * probe reads it, catches, and files a `view_read_failed` publish issue this - * suite does not read (it asserts `res.success`), but the driver and the engine - * each log the read on the way out. Withheld and asserted rather than muted; - * `expected-read-refusal-noise.ts` says why. - */ -// [#21516] The engine now refuses a name its registry does not hold before any driver, so -// this read no longer reaches the driver and nothing above is logged; the pin asserts that. -const UNBOUND_PROBE_OBJECT = 'anything'; - -let cleanup: Array<() => void> = []; - -/** [commit 13a6cb4ad] The expected-noise capture belonging to the latest `boot()`. */ -let noise: ExpectedReadRefusalCapture | null = null; -afterEach(() => { - for (const c of cleanup) c(); - cleanup = []; - // [commit 13a6cb4ad] The capture is a PIN, not a mute — asserted after teardown so a - // failure here can never leave an engine running. Every test in this file publishes at - // least once, so the probe fires for each of them: this holds for a single - // `-t` run as well as for the whole file. - // [#21516] Quiet by construction now: the engine refuses a name its registry does not - // hold before any driver, so the declared refusal no longer occurs. The capture stays - // declared (a returning read is still withheld and counted) and this asserts nothing was. - expect(noise?.tablesSeen() ?? ['no capture was installed']).toEqual([]); - noise = null; -}); - -/** REAL ObjectQL wired to a REAL SqlDriver over on-disk better-sqlite3. */ -async function boot() { - const dir = mkdtempSync(join(tmpdir(), 'os-7819-')); - cleanup.push(() => rmSync(dir, { recursive: true, force: true })); - - const driver = new SqlDriver({ - client: 'better-sqlite3', - connection: { filename: join(dir, 'data.sqlite') }, - useNullAsDefault: true, - }); - // `sys_metadata_commit` holds the rows under test; the other three are what - // the real publish path writes through on its way to recording a commit, - // and what the revert then reads back and rewrites. - const objects = [ - SysMetadataObject, - SysMetadataHistoryObject, - SysMetadataAuditObject, - SysMetadataCommitObject, - ] as any[]; - // [commit 13a6cb4ad] Installed before the driver runs a statement and before the - // engine issues a read — the two sinks the expected refusal travels out on. - noise = captureExpectedReadRefusals([UNBOUND_PROBE_OBJECT]); - noise.captureDriver(driver); - await driver.initObjects(objects); - - const engine = new ObjectQL(); - noise.captureEngine(engine); - engine.registerDriver(driver as any, true); - await engine.init(); - // `registerObject(schema, packageId)` — the second argument is REQUIRED. - // Registered under the PLATFORM package, never under `PKG`, so the tables - // cannot be torn out from under the assertions that read them back. - for (const o of objects) engine.registry.registerObject(o, PLATFORM_PKG); - cleanup.push(() => { void engine.destroy(); }); - - // `'package-author'` is the genuine control-plane assembly's channel — the - // #4463 runtime authoring gate is for environment-channel writes and would - // otherwise refuse the seeding saves below. - const protocol = new ObjectStackProtocolImplementation( - engine as any, undefined, undefined, 'package-author', - ); - return { engine, protocol }; -} - -const viewBody = (name: string) => ({ - name, - label: name, - type: 'grid', - object: 'anything', // [#7741] the inline arm requires the object binding pair - viewKind: 'list', - data: { provider: 'object', object: 'anything' }, - columns: ['id'], -}); - -/** - * Author one draft and publish it, which is what records ONE commit row. The - * commit's `organization_id` is the PUBLISH REQUEST's org (`?? null`), so - * omitting `organizationId` reproduces exactly what the dispatcher sends when - * the session has no active organization. - */ -async function publishOne( - protocol: any, - args: { view: string; packageId: string; organizationId?: string; message: string }, -): Promise { - await protocol.saveMetaItem({ - type: 'view', - name: args.view, - item: viewBody(args.view), - packageId: args.packageId, - mode: 'draft', - }); - const res = await protocol.publishPackageDrafts({ - packageId: args.packageId, - ...(args.organizationId ? { organizationId: args.organizationId } : {}), - message: args.message, - }); - expect(res.success).toBe(true); - expect(res.commitId).toBeTruthy(); - return res.commitId as string; -} - -/** Distinct `created_at` values — the timeline is sorted by that ISO string. */ -const tick = () => new Promise((r) => setTimeout(r, 5)); - -/** - * ADR-0112 refusal shape. A bare `toThrow()` goes green against an - * implementation that throws anything at all, including a bare `Error`, so - * every refusal below is asserted on `code` AND `status` — the pair the - * dispatcher's `errorFromThrown` turns into the HTTP answer. - */ -async function expectRefusal(run: () => Promise, code: string, status: number) { - const err = await run().then( - () => { throw new Error(`expected ${code} (${status}), but the call resolved`); }, - (e: any) => e, - ); - expect({ code: err?.code, status: err?.status }).toEqual({ code, status }); -} - -describe('#7819 tier 1 — an org-scoped caller must be able to revert an env-wide commit', () => { - it('a no-org publish really does record an env-wide commit row (the premise, measured)', async () => { - const { engine, protocol } = await boot(); - const id = await publishOne(protocol as any, { - view: 'revert_env', packageId: PKG, message: 'env-wide publish', - }); - - // Straight out of SQLite: the column really is NULL, so the strict - // equality this card removes really had nothing to match. - const rows = (await engine.find('sys_metadata_commit', { where: {} })) as any[]; - expect(rows.map((r) => ({ id: r.id, org: r.organization_id ?? null }))).toEqual([ - { id, org: null }, - ]); - }); - - it('revertCommit resolves an env-wide commit for an org caller (was: COMMIT_NOT_FOUND 404)', async () => { - const { protocol } = await boot(); - const p = protocol as any; - const envWide = await publishOne(p, { - view: 'revert_env', packageId: PKG, message: 'env-wide publish', - }); - - const result = await p.revertCommit({ commitId: envWide, organizationId: ACTIVE_ORG }); - - // The CONSEQUENCE, not the call: the artifact the env-wide commit created - // is gone. Before the fix this line was never reached — the lookup threw - // 404 for a row the caller's own `listCommits` returns. - expect(result.success).toBe(true); - expect(result.reverted).toEqual([ - { type: 'view', name: 'revert_env', action: 'removed' }, - ]); - expect(result.failed).toEqual([]); - }); - - it('revertCommit still REFUSES another organization’s commit', async () => { - const { protocol } = await boot(); - const p = protocol as any; - const foreign = await publishOne(p, { - view: 'revert_foreign', packageId: PKG, organizationId: OTHER_ORG, message: 'other-org publish', - }); - - // The direction that must not widen. Dropping the predicate outright — - // remedy 2, defensible on an id lookup — would have made this resolve, so - // this case is what chose the `$or` over it. - await expectRefusal( - () => p.revertCommit({ commitId: foreign, organizationId: ACTIVE_ORG }), - 'COMMIT_NOT_FOUND', - 404, - ); - }); - - it('a caller with NO org can still revert an org-scoped commit (the other door)', async () => { - const { protocol } = await boot(); - const p = protocol as any; - const own = await publishOne(p, { - view: 'revert_own', packageId: PKG, organizationId: ACTIVE_ORG, message: 'own-org publish', - }); - - // The no-org branch is deliberately left un-narrowed, exactly as #7705 and - // #7779 left theirs. Narrowing it to `organization_id IS NULL` would hide - // every org-scoped commit from this door — the same bug pointed the other - // way. The direct-mount REST registrar passes no `organizationId` at all. - const result = await p.revertCommit({ commitId: own }); - expect(result.success).toBe(true); - expect(result.reverted.map((r: any) => r.name)).toEqual(['revert_own']); - }); - - it('rollbackToPackageCommit resolves an env-wide TARGET for an org caller', async () => { - const { protocol } = await boot(); - const p = protocol as any; - // The target itself env-wide, with a strictly newer org-scoped commit to - // undo — the mirror image of the handoff case, exercising the second of - // the two sites in isolation. Pre-fix the TARGET lookup threw 404 before - // any planning happened. - const envTarget = await publishOne(p, { - view: 'roll_env_target', packageId: PKG, message: 'env-wide target', - }); - await tick(); - await publishOne(p, { - view: 'roll_newer', packageId: PKG, organizationId: ACTIVE_ORG, message: 'newer', - }); - - const rollback = await p.rollbackToPackageCommit({ - commitId: envTarget, organizationId: ACTIVE_ORG, - }); - - expect(rollback.failed).toEqual([]); - expect(rollback.success).toBe(true); - expect(rollback.revertedCommits).toHaveLength(1); - }); - - it('rollbackToPackageCommit still REFUSES another organization’s target commit', async () => { - const { protocol } = await boot(); - const p = protocol as any; - const foreign = await publishOne(p, { - view: 'roll_foreign', packageId: PKG, organizationId: OTHER_ORG, message: 'other-org target', - }); - - await expectRefusal( - () => p.rollbackToPackageCommit({ commitId: foreign, organizationId: ACTIVE_ORG }), - 'COMMIT_NOT_FOUND', - 404, - ); - }); - - it('rollbackToPackageCommit does NOT reach into another package’s commits', async () => { - const { protocol } = await boot(); - const p = protocol as any; - const target = await publishOne(p, { - view: 'roll_target', packageId: PKG, organizationId: ACTIVE_ORG, message: 'target', - }); - await tick(); - // Strictly NEWER than the target and env-wide, so it clears both filters - // the planner applies except the package one. If widening the target - // lookup had leaked package scope, this commit would be reverted too. - const otherPkg = await publishOne(p, { - view: 'roll_other_pkg', packageId: OTHER_PKG, message: 'other package, newer', - }); - - const rollback = await p.rollbackToPackageCommit({ - commitId: target, organizationId: ACTIVE_ORG, - }); - - expect(rollback.success).toBe(true); - expect(rollback.revertedCommits).toEqual([]); - expect(rollback.revertedCommits).not.toContain(otherPkg); - }); - - it('a caller with NO org can still roll back to an org-scoped target (the other door)', async () => { - const { protocol } = await boot(); - const p = protocol as any; - const target = await publishOne(p, { - view: 'roll_own_target', packageId: PKG, organizationId: ACTIVE_ORG, message: 'own-org target', - }); - await tick(); - await publishOne(p, { view: 'roll_after', packageId: PKG, message: 'after' }); - - const rollback = await p.rollbackToPackageCommit({ commitId: target }); - - expect(rollback.failed).toEqual([]); - expect(rollback.success).toBe(true); - expect(rollback.revertedCommits).toHaveLength(1); - }); -}); 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 9a517d3c764..90ef048dead 100644 --- a/packages/runtime/src/package-revert-stored-members.integration.test.ts +++ b/packages/runtime/src/package-revert-stored-members.integration.test.ts @@ -52,7 +52,7 @@ import { * double of either would pin the double. It lives in `packages/runtime` * because the door does, and because `metadata-protocol` cannot import * `objectql` (dependency cycle) — the same reason its sibling - * `package-revert-commit-org-scope.integration.test.ts` lives here. + * the org-scoped revert-commit integration suite (retired with ADR-0131 D6) lives here. */ /** A Studio-authored, writable package. */ @@ -81,7 +81,7 @@ const OTHER_ORG = 'org_other'; * Every publish this fixture makes runs the metadata-protocol build probes, and * the views it publishes are bound to the placeholder object `anything`, which * nothing here creates — the sibling suite's arrangement and its reason - * (`package-revert-commit-org-scope.integration.test.ts`). The engine refuses a + * (the org-scoped revert-commit integration suite (retired with ADR-0131 D6)). The engine refuses a * name its registry does not hold before any driver, so nothing is withheld; * the capture stays declared and asserts that. */ @@ -167,7 +167,7 @@ const viewBody = (name: string, label: string) => ({ async function draftSave( protocol: any, - args: { view: string; label: string; packageId: string; organizationId?: string }, + args: { view: string; label: string; packageId: string }, ): Promise { await protocol.saveMetaItem({ type: 'view', @@ -175,7 +175,6 @@ async function draftSave( item: viewBody(args.view, args.label), packageId: args.packageId, mode: 'draft', - ...(args.organizationId ? { organizationId: args.organizationId } : {}), }); } @@ -261,11 +260,14 @@ describe('#22090 POST /packages/:id/revert on a Studio-authored package', () => }); it('another organization’s draft of the package is neither read nor discarded', async () => { - const { protocol, revert } = await boot(); + const { engine, protocol, revert } = await boot(); await draftSave(protocol, { view: 'repairs_board', label: 'Board v1', packageId: STUDIO_PKG }); await publish(protocol, STUDIO_PKG); - await draftSave(protocol, { - view: 'repairs_board', label: 'Other org overlay (draft)', packageId: STUDIO_PKG, organizationId: OTHER_ORG, + // [ADR-0131 D6] The protocol refuses an organization-scoped save, so the + // other organization's draft is a LEGACY row, planted at rest. + await engine.insert('sys_metadata', { + type: 'view', name: 'repairs_board', organization_id: OTHER_ORG, package_id: STUDIO_PKG, state: 'draft', + metadata: JSON.stringify(viewBody('repairs_board', 'Other org overlay (draft)')), checksum: 'sha256:legacy', version: 1, }); const answer = await revert(STUDIO_PKG, ACTIVE_ORG); diff --git a/packages/runtime/src/package-uninstall-org-scope.integration.test.ts b/packages/runtime/src/package-uninstall-org-scope.integration.test.ts index 7c565a4eb97..63597c9dd61 100644 --- a/packages/runtime/src/package-uninstall-org-scope.integration.test.ts +++ b/packages/runtime/src/package-uninstall-org-scope.integration.test.ts @@ -149,26 +149,33 @@ const viewBody = (name: string) => ({ * history the real uninstall reads back. Views (not objects) so the assertions * stay on row survival rather than on physical-table teardown, which * `deleteMetaItem` handles separately and which #7705 is not about. + * + * [ADR-0131 D6] The protocol refuses every organization-scoped write, so the + * legacy organization rows are planted AT REST, the way an older release left + * them. */ -async function seed(protocol: any) { - const save = (name: string, packageId: string, organizationId?: string) => +async function seed(protocol: any, engine: any) { + const save = (name: string, packageId: string) => protocol.saveMetaItem({ type: 'view', name, item: viewBody(name), packageId, - ...(organizationId ? { organizationId } : {}), + }); + const plantLegacy = (name: string, organizationId: string) => + engine.insert('sys_metadata', { + type: 'view', name, organization_id: organizationId, package_id: PKG, state: 'active', + metadata: JSON.stringify(viewBody(name)), checksum: 'sha256:legacy', version: 1, }); // The suspected — and confirmed — miss: env-wide rows, `organization_id IS NULL`. await save('reprob_a', PKG); await save('reprob_b', PKG); await save('reprob_v', PKG); - // Same package, the caller's OWN org: the only rows the strict equality found. - await save('reprob_own', PKG, ACTIVE_ORG); - // Negative 1 — same package, ANOTHER org. Must survive an org-scoped uninstall. - await save('reprob_foreign', PKG, OTHER_ORG); - // Negative 2 — ANOTHER package, env-wide. Must survive either way. + // Same package, legacy organization rows. + await plantLegacy('reprob_own', ACTIVE_ORG); + await plantLegacy('reprob_foreign', OTHER_ORG); + // Negative — ANOTHER package, env-wide. Must survive. await save('other_a', OTHER_PKG); } @@ -183,178 +190,53 @@ const namesFor = async (engine: any, packageId: string): Promise => { return rows.map((r) => r.name).sort(); }; -describe('#7705 — org-scoped uninstall must not orphan env-wide sys_metadata rows', () => { - it('removes the env-wide rows too, and counts them (was: found 1 of 4, left 3 orphaned)', async () => { +const SEEDED = [ + `other_a[${OTHER_PKG},ENV]`, + `reprob_a[${PKG},ENV]`, + `reprob_b[${PKG},ENV]`, + `reprob_foreign[${PKG},${OTHER_ORG}]`, + `reprob_own[${PKG},${ACTIVE_ORG}]`, + `reprob_v[${PKG},ENV]`, +]; + +describe('#7705 · ADR-0131 D6 — an uninstall is package-wide and orphans no sys_metadata row', () => { + it('removes the env-wide rows AND the legacy organization rows, counts them, and leaves another package alone', async () => { const { engine, protocol } = await boot(); - await seed(protocol); + await seed(protocol, engine); // Precondition: the rows the uninstall is supposed to remove really exist, // so a passing assertion below cannot be the vacuous "nothing was there". - expect(await namesFor(engine, PKG)).toEqual( - ['reprob_a', 'reprob_b', 'reprob_foreign', 'reprob_own', 'reprob_v'], - ); + expect(await survivors(engine)).toEqual(SEEDED); - const res: any = await (protocol as any).deletePackage({ - packageId: PKG, - organizationId: ACTIVE_ORG, - }); - - // The CONSEQUENCE: no row of this package survives in the caller's scope - // (its own org + env-wide). Before the fix `reprob_a`, `reprob_b` and - // `reprob_v` were all still here. - expect(await namesFor(engine, PKG)).toEqual(['reprob_foreign']); + const res: any = await (protocol as any).deletePackage({ packageId: PKG }); - // …and the receipt matches what was actually seeded in that scope — 3 - // env-wide + 1 own-org. It reported 1 before, while claiming success. - expect(res.deletedCount).toBe(4); + expect(await namesFor(engine, PKG)).toEqual([]); + expect(res.deletedCount).toBe(5); expect(res.failedCount).toBe(0); expect(res.success).toBe(true); expect(res.deleted.map((d: any) => d.name).sort()).toEqual( - ['reprob_a', 'reprob_b', 'reprob_own', 'reprob_v'], - ); - - // The complete post-state, so nothing else moved either way. - expect(await survivors(engine)).toEqual([ - `other_a[${OTHER_PKG},ENV]`, - `reprob_foreign[${PKG},${OTHER_ORG}]`, - ]); - }); - - it('does NOT sweep up another organization’s rows', async () => { - const { engine, protocol } = await boot(); - await seed(protocol); - - await (protocol as any).deletePackage({ packageId: PKG, organizationId: ACTIVE_ORG }); - - // `reprob_foreign` belongs to a different tenant and was never in scope. - // Over-widening the predicate to catch the env-wide rows would delete data - // that should have stayed — worse than the bug being closed here. - const rows = (await engine.find('sys_metadata', { - where: { package_id: PKG, organization_id: OTHER_ORG }, - })) as any[]; - expect(rows.map((r: any) => r.name)).toEqual(['reprob_foreign']); - }); - - it('does NOT sweep up another package’s rows', async () => { - const { engine, protocol } = await boot(); - await seed(protocol); - - await (protocol as any).deletePackage({ packageId: PKG, organizationId: ACTIVE_ORG }); - - expect(await namesFor(engine, OTHER_PKG)).toEqual(['other_a']); - }); - - // [#7780] REWRITTEN, not deleted — and the reason the original existed is - // still pinned, in the second half below. - // - // The old single case pinned that an uninstall with NO org "still clears the - // whole package", deliberately, so nobody would narrow the no-org branch to - // `organization_id IS NULL` and re-create the #7705 orphan bug on the other - // door. That branch is still NOT narrowed. What the maintainer ruled - // (2026-08-12, 跨租户卸载必须显式声明,缺省缺参永远不等于「全部租户」) is - // that REACHING it now requires saying so. So the one case becomes the two - // halves of the ruled contract: the refusal, and the declared cross-tenant - // uninstall that still clears exactly what it cleared before. - it('an uninstall with NO org and NO explicit flag is REFUSED, and deletes nothing', async () => { - const { engine, protocol } = await boot(); - await seed(protocol); - - await expect( - (protocol as any).deletePackage({ packageId: PKG }), - ).rejects.toMatchObject({ code: 'TENANT_SCOPE_REQUIRED', status: 400 }); - - // A refusal, not a partial delete: the complete post-state is the seed, - // untouched — including the foreign org's row, which is the row the old - // no-org call took with it. - expect(await survivors(engine)).toEqual([ - `other_a[${OTHER_PKG},ENV]`, - `reprob_a[${PKG},ENV]`, - `reprob_b[${PKG},ENV]`, - `reprob_foreign[${PKG},${OTHER_ORG}]`, - `reprob_own[${PKG},${ACTIVE_ORG}]`, - `reprob_v[${PKG},ENV]`, - ]); - }); - - it('an uninstall with an explicit allTenants clears the whole package (the other door)', async () => { - const { engine, protocol } = await boot(); - await seed(protocol); - - // The direct-mount REST registrar (`packages/rest/src/package-routes.ts`) - // has no organization to resolve — `packages/rest` carries no org plumbing - // at all — so of the two doors the ruling allows it declares - // `allTenants: true`. Narrowing THIS branch to `organization_id IS NULL` - // would orphan every org-scoped row instead, i.e. re-create #7705 on the - // other door. This case pins that the declared branch stays package-wide, - // at exactly the count the pre-#7780 no-org call produced. - const res: any = await (protocol as any).deletePackage({ packageId: PKG, allTenants: true }); - - expect(await namesFor(engine, PKG)).toEqual([]); - expect(res.deletedCount).toBe(5); - expect(await namesFor(engine, OTHER_PKG)).toEqual(['other_a']); - }); - - // [#7780] `organizationId` + `allTenants: true` is CONTRADICTORY, not - // redundant: one says "this tenant", the other says "every tenant". Both - // silent resolutions are worse than a refusal — resolving narrow-first makes - // `allTenants: true` silently inert, resolving explicit-first ignores a named - // organization and deletes every tenant's rows (the original defect wearing a - // flag). It is also the reading that stays correct when a request is COMPOSED - // from two places, which is the accidental composition - // `resolveActiveOrganizationId` makes real. Same code and status as the - // undeclared case: one contract, two ways to violate it. - it('REFUSES when BOTH organizationId and allTenants are supplied, deleting nothing', async () => { - const { engine, protocol } = await boot(); - await seed(protocol); - - await expect( - (protocol as any).deletePackage({ packageId: PKG, organizationId: ACTIVE_ORG, allTenants: true }), - ).rejects.toMatchObject({ code: 'TENANT_SCOPE_REQUIRED', status: 400 }); - - // Names BOTH offending parameters, so the caller is not left guessing which - // pair conflicted. - await expect( - (protocol as any).deletePackage({ packageId: PKG, organizationId: ACTIVE_ORG, allTenants: true }), - ).rejects.toThrow(/organizationId.*mutually exclusive|mutually exclusive.*organizationId/s); - - expect(await namesFor(engine, PKG)).toEqual( - ['reprob_a', 'reprob_b', 'reprob_foreign', 'reprob_own', 'reprob_v'], - ); - }); - - // [#7780] An EXPLICIT `false` is not the same gesture as an absent flag, but - // it must land on the same refusal: `false` is not an affirmative request for - // cross-tenant semantics, so it cannot authorise them. Pinned so that a future - // `!request.allTenants`-style rewrite (which would treat them identically by - // accident rather than by decision) still has to face this case. - it('treats an explicit allTenants:false as undeclared — same 400 as absent', async () => { - const { engine, protocol } = await boot(); - await seed(protocol); - - await expect( - (protocol as any).deletePackage({ packageId: PKG, allTenants: false }), - ).rejects.toMatchObject({ code: 'TENANT_SCOPE_REQUIRED', status: 400 }); - - expect(await namesFor(engine, PKG)).toEqual( ['reprob_a', 'reprob_b', 'reprob_foreign', 'reprob_own', 'reprob_v'], ); + expect(await survivors(engine)).toEqual([`other_a[${OTHER_PKG},ENV]`]); }); - // …while an org-scoped call carrying the same explicit `false` is NOT a - // violation — the org states the scope, and `false` agrees with it. This is - // the row that keeps the refusal from over-firing on a legitimate caller that - // spells its flags out. - it('allows an org-scoped uninstall that spells allTenants:false explicitly', async () => { - const { engine, protocol } = await boot(); - await seed(protocol); - - const res: any = await (protocol as any).deletePackage({ - packageId: PKG, - organizationId: ACTIVE_ORG, - allTenants: false, + // The retired request keys are refused whatever their value — an explicit + // `false` or `undefined` included — and nothing is removed. + for (const keys of [ + { organizationId: ACTIVE_ORG }, + { allTenants: true }, + { allTenants: false }, + { organizationId: ACTIVE_ORG, allTenants: false }, + { allTenants: undefined }, + ]) { + it(`REFUSES a request carrying ${JSON.stringify(Object.keys(keys))}, deleting nothing`, async () => { + const { engine, protocol } = await boot(); + await seed(protocol, engine); + + await expect( + (protocol as any).deletePackage({ packageId: PKG, ...keys }), + ).rejects.toMatchObject({ code: 'INVALID_REQUEST', status: 400 }); + expect(await survivors(engine)).toEqual(SEEDED); }); - - expect(res.deletedCount).toBe(4); - expect(await namesFor(engine, PKG)).toEqual(['reprob_foreign']); - }); + } }); diff --git a/packages/services/service-automation/src/flow-credential-migration.test.ts b/packages/services/service-automation/src/flow-credential-migration.test.ts index 3909c767751..102e717aa6f 100644 --- a/packages/services/service-automation/src/flow-credential-migration.test.ts +++ b/packages/services/service-automation/src/flow-credential-migration.test.ts @@ -151,6 +151,20 @@ describe('[#20790] the stored flow credential move', () => { expect(f.receipts[0]!.advisory).toBe(1); }); + it('[ADR-0131 D6] a legacy organization-scoped row is not re-saved: it is reported NOT_OVERRIDABLE, loudly', async () => { + const f = fakes([ + { name: 'org_one', state: 'active', organization_id: 'org_a', metadata: JSON.stringify(body('org_one', HOOK)) }, + { name: 'env_one', state: 'active', organization_id: null, metadata: JSON.stringify(body('env_one', HOOK)) }, + ]); + const result = await migrateFlowCredentialsIntoChannel(f); + expect(result.found).toBe(2); + expect(result.failed).toEqual([{ flow: 'org_one', state: 'active', code: 'NOT_OVERRIDABLE' }]); + expect(result.migrated).toEqual(['env_one (active)']); + expect(f.saves.map((s) => s.name)).toEqual(['env_one']); + expect(f.logs.some((l) => l.level === 'error' && l.msg.includes("flow 'org_one'"))).toBe(true); + for (const log of f.logs) expect(log.msg).not.toContain(HOOK); + }); + it('finds nothing to move on a store with no credential left — and writes no receipt', async () => { const f = fakes([{ name: 'clean', state: 'active', metadata: JSON.stringify(body('clean')) }]); const result = await migrateFlowCredentialsIntoChannel(f); diff --git a/packages/services/service-automation/src/flow-credential-migration.ts b/packages/services/service-automation/src/flow-credential-migration.ts index 13bdb11f3e2..2e324517812 100644 --- a/packages/services/service-automation/src/flow-credential-migration.ts +++ b/packages/services/service-automation/src/flow-credential-migration.ts @@ -63,7 +63,6 @@ export interface FlowCredentialMigrationProtocol { item: unknown; mode: 'draft' | 'publish'; packageId: string | null; - organizationId?: string; source: string; }): Promise; } @@ -191,9 +190,23 @@ export async function migrateFlowCredentialsIntoChannel(deps: { if (explicit.length === 0) continue; const state = row.state === 'draft' ? 'draft' : 'active'; result.found += 1; - const organizationId = typeof row.organization_id === 'string' && row.organization_id !== '' - ? row.organization_id - : undefined; + // [ADR-0131 D6] A legacy organization-scoped row cannot be re-saved: + // the protocol writes no organization-scoped row, and re-saving it + // environment-wide would move it — the promotion ceremony's job + // (ADR-0131 C7). It is reported as not moved, every run, until then. + if (typeof row.organization_id === 'string' && row.organization_id !== '') { + result.failed.push({ flow: name, state, code: 'NOT_OVERRIDABLE' }); + logger.error( + `[Automation] flow '${name}' (${state}): its stored row is organization-scoped (organization ` + + `'${row.organization_id}'), and no metadata write lands organization-scoped any more, so its ` + + 'credential could not be moved into the write-only flow credential store: that row STILL CARRIES ' + + 'IT IN CLEARTEXT. The promotion ceremony (ADR-0131 C7) carries the row to the environment layer, ' + + 'where the next run moves it. Rotate the credential now.', + undefined, + { flow: name, state, code: 'NOT_OVERRIDABLE' }, + ); + continue; + } try { await protocol.saveMetaItem({ type: FLOW_METADATA_TYPE, @@ -201,7 +214,6 @@ export async function migrateFlowCredentialsIntoChannel(deps: { item: body, mode: state === 'draft' ? 'draft' : 'publish', packageId: typeof row.package_id === 'string' && row.package_id !== '' ? row.package_id : null, - ...(organizationId ? { organizationId } : {}), source: 'migrate-stored', }); result.migrated.push(`${name} (${state})`); diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 60a6ea5d915..93ac9d2a8b0 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -496,7 +496,6 @@ "api/DeleteMetaItemRequest:actor", "api/DeleteMetaItemRequest:dropStorage", "api/DeleteMetaItemRequest:name", - "api/DeleteMetaItemRequest:organizationId", "api/DeleteMetaItemRequest:parentVersion", "api/DeleteMetaItemRequest:state", "api/DeleteMetaItemRequest:type", @@ -1305,7 +1304,6 @@ "api/PublishMetaItemRequest:actor", "api/PublishMetaItemRequest:message", "api/PublishMetaItemRequest:name", - "api/PublishMetaItemRequest:organizationId", "api/PublishMetaItemRequest:packageId", "api/PublishMetaItemRequest:type", "api/PublishMetaItemResponse:advisories", @@ -1547,7 +1545,6 @@ "api/SaveMetaItemRequest:item", "api/SaveMetaItemRequest:mode", "api/SaveMetaItemRequest:name", - "api/SaveMetaItemRequest:organizationId", "api/SaveMetaItemRequest:packageId", "api/SaveMetaItemRequest:parentVersion", "api/SaveMetaItemRequest:type", diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index c8f42c57087..631d556b49b 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -2528,6 +2528,13 @@ "toMajor": 18, "rationale": "ADR-0049 enforce-or-remove; maintainer ruling of 2026-08-14: remove the key, jointly with refusing unknown types at the `/meta` boundary by the static registry. The key was declared, authorable, on the published authorable surface, and documented on four docs pages as THE way a plugin registers a custom metadata type — and read by NOTHING. The only production writer of the manager's type registry is `setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY)` (`packages/metadata/src/plugin.ts`), called exactly once outside tests, and it REPLACES the array outright; nothing ever merged `additionalTypes` into it. Measured against the real `MetadataManager`: declared count == live count (27 == 27), `getRegisteredTypes()` sorted equals the built-in registry sorted. So an author who followed the published instructions wrote the key, got no error, and nothing happened — the same silence trap as the plugin lifecycle's `onInstall` (a documented hook with no invocation site), one level down, in exactly the AI-authoring path (ADR-0033). The joint consequence: with this plugin-declared channel removed, the static registry is the total universe of legal metadata kinds, which makes refuse-by-static-registry at the /meta boundary safe by construction. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections. A metadata-plugin config is neither — `PLURAL_TO_SINGULAR` has no `plugins` entry, so it is not a stack collection member and a conversion would be a transform with no seam that ever runs (the `kernel/Manifest:loading` precedent)." }, + { + "surface": "the organizationId member of the SaveMetaItem, PublishMetaItem and DeleteMetaItem request schemas of @objectstack/spec; and every organization-scoped write the metadata protocol of @objectstack/metadata-protocol accepted: saveMetaItem (draft and publish), publishMetaItem, deleteMetaItem, rollbackMetaItem, revertCommit, rollbackToPackageCommit, publishPackageDrafts, discardPackageDrafts, revertStoredPackage, duplicatePackage and reassignOrphanedMetadata — including the five types that declared allowOrgOverride (view, dashboard, report, translation, email_template) and the OS_METADATA_WRITABLE hatch; and the active organization of the caller publishing a package, which a published seed draft whose records named no organization was loaded into under the group posture", + "replacement": "drop `organizationId` from the request: every metadata write lands environment-wide (`organization_id` NULL), where every organization reads it. The key is stripped at a spec parse and refused at the protocol: a request that still names an organization is refused with 403 `NOT_OVERRIDABLE`, before anything is read or written, and the message names the tenancy posture in force. A seed draft sets `organization_id` on each record (ADR-0131 D12, item 12); under group a seed record that names none is refused at load", + "migrationId": "metadata-write-organization-scope-refused", + "toMajor": 18, + "rationale": "ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by Studio, by the cloud build agent or by an install belongs to the whole deployment. The /meta doors already carry no organization (meta-doors-organization-scope-retired); this is the protocol refusing the same write from every other door — the /packages verbs, the stored-row migrations, a plugin — so no path is left that stamps an organization on a metadata row. The audit and commit ledgers are environment-level too (ADR-0131 D7) and record no organization. Legacy organization-scoped rows are not touched: the stored-metadata migration reports them as skipped, the flow credential move reports them as not moved, and the promotion ceremony (ADR-0131 C7) carries them to the environment layer." + }, { "surface": "The ADR-0087 migration chain and change manifest, imported from the package root @objectstack/spec: MIGRATIONS_BY_MAJOR, MIGRATION_MAJORS, MIGRATION_SUPPORT_FLOOR, RETIRED_KEYS_BY_MAJOR, RETIRED_DEFS_BY_MAJOR, applyMetaMigrations, composeMigrationChain, MigrationFloorError, composeSpecChanges, composeReleaseChanges, the seven change-manifest schemas (SpecChangesSchema, SpecConvertedSchema, SpecMigratedSchema, SpecSurfaceAddSchema, SpecSurfaceRemoveSchema, SpecReleaseChangesSchema, SpecReleaseSurfaceSchema), and the types MigrationStep, MigrationApplication, MigrationChainResult, MigrationHopResult, MigrationTodo, SemanticMigration, SpecChanges, SpecConverted, SpecMigrated, SpecSurfaceAdd, SpecSurfaceRemove, SpecReleaseChanges, SpecReleaseSurface, SurfaceDiff, ReleaseSurfaceDiff and PreviousReleaseRegistries", "replacement": "the same names, unchanged, imported from `@objectstack/spec/migrations` — change the import path and nothing else. The chain, its steps and semantic entries, the retired-key and retired-def tables and the change-manifest schemas are the same objects, and `objectstack migrate meta` replays the same chain. The ADR-0087 conversion layer stays on the package root: `ALL_CONVERSIONS`, `CONVERSIONS_BY_MAJOR`, `applyConversions`, `applyConversionsToFlow`, `applyConversionsToStoredItem`, `collectConversionNotices`, the three `CONVERSION_*_CODE` constants and their types still import from `@objectstack/spec`.", @@ -2633,6 +2640,13 @@ "toMajor": 18, "rationale": "Maintainer ruling of 2026-08-27 on the client SDK's unbound response contracts, sub-question 3A: retire this false declaration first, then author the true one. The schema declared a version rollback — `{ success, restoredVersion?, message? }`, matching its file header \"Rollback a package\" — while the live path it was contract-bound to serves the ADR-0067 commit rollback: a different operation with a different result. Binding it in the SDK would compile and be false (the change that typed the SDK's un-annotated return values left a compile-time guard against exactly that substitution). Zero consumers measured across objectstack, objectui and cloud (the ruling's own survey, re-verified at the retiring PR's base): only its own unit test and that negative guard. A published declaration that outran the implementation is the hazard of response bodies never checked against the schemas that declare them, realised in the opposite direction — not \"no declaration\" but a WRONG one — and it is retired BEFORE the true schema is authored so no window exists in which both claims are published." }, + { + "surface": "the organizationId and allTenants members of the deletePackage request of @objectstack/metadata-protocol (DeletePackageRequest), the two TENANT_SCOPE_REQUIRED refusals of deletePackage, and the organization-scope refusal of DELETE /api/v1/packages/:id on the runtime dispatcher", + "replacement": "call `deletePackage({ packageId })` with neither key: the uninstall removes every row bound to the package in this environment. A request still carrying `organizationId` or `allTenants` is refused with 400 `INVALID_REQUEST` and removes nothing; drop the key and retry. Who may uninstall is the package door's operator gate", + "migrationId": "package-uninstall-environment-wide", + "toMajor": 18, + "rationale": "The guard existed because an uninstall naming no organization once matched every organization's rows, so a cross-tenant uninstall had to be declared (allTenants: true) and a scoped one named (organizationId). ADR-0131 D6 removes that premise: no metadata write lands organization-scoped any more, so a package's rows belong to the environment and an organization names nothing. The HTTP door never sent allTenants, so an operator with no active organization could not uninstall over HTTP at all. The keys are refused rather than ignored, because a caller still sending one believes it scopes the uninstall. Legacy organization-scoped rows bound to the package are removed with it, as the declared cross-tenant uninstall removed them, rather than stranded for the promotion ceremony." + }, { "surface": "PackageVersionSchema.version (`marketplace/package-version.zod.ts`) — the `version` column of a `sys_package_version` row, and through `CreatePackageVersionRequestSchema.version`, which references it, the version a draft release is created with", "replacement": "a SemVer 2.0.0 string matching `SEMVER_2_0_0_VERSION_PATTERN` (`kernel/version-grammar.ts`). Two changes, opposite in direction. ⭐ WIDER: suffix identifiers may now carry either ASCII case, because SemVer 2.0.0 is case-preserving — `1.0.0-Beta.1` and `1.0.0+Build.5` are accepted where this key used to demand lowercase, and the plugin boot path has always accepted them. ⛔ NARROWER: the forms the standard forbids are refused — `01.1.1` (§2), `1.0.0-0123` and `1.0.0-alpha..1` (§9), `1.0.0+.` (§10).", @@ -6124,6 +6138,13 @@ "toMajor": 18, "rationale": "ADR-0049 enforce-or-remove; maintainer ruling of 2026-08-14: remove the key, jointly with refusing unknown types at the `/meta` boundary by the static registry. The key was declared, authorable, on the published authorable surface, and documented on four docs pages as THE way a plugin registers a custom metadata type — and read by NOTHING. The only production writer of the manager's type registry is `setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY)` (`packages/metadata/src/plugin.ts`), called exactly once outside tests, and it REPLACES the array outright; nothing ever merged `additionalTypes` into it. Measured against the real `MetadataManager`: declared count == live count (27 == 27), `getRegisteredTypes()` sorted equals the built-in registry sorted. So an author who followed the published instructions wrote the key, got no error, and nothing happened — the same silence trap as the plugin lifecycle's `onInstall` (a documented hook with no invocation site), one level down, in exactly the AI-authoring path (ADR-0033). The joint consequence: with this plugin-declared channel removed, the static registry is the total universe of legal metadata kinds, which makes refuse-by-static-registry at the /meta boundary safe by construction. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections. A metadata-plugin config is neither — `PLURAL_TO_SINGULAR` has no `plugins` entry, so it is not a stack collection member and a conversion would be a transform with no seam that ever runs (the `kernel/Manifest:loading` precedent)." }, + { + "surface": "the organizationId member of the SaveMetaItem, PublishMetaItem and DeleteMetaItem request schemas of @objectstack/spec; and every organization-scoped write the metadata protocol of @objectstack/metadata-protocol accepted: saveMetaItem (draft and publish), publishMetaItem, deleteMetaItem, rollbackMetaItem, revertCommit, rollbackToPackageCommit, publishPackageDrafts, discardPackageDrafts, revertStoredPackage, duplicatePackage and reassignOrphanedMetadata — including the five types that declared allowOrgOverride (view, dashboard, report, translation, email_template) and the OS_METADATA_WRITABLE hatch; and the active organization of the caller publishing a package, which a published seed draft whose records named no organization was loaded into under the group posture", + "replacement": "drop `organizationId` from the request: every metadata write lands environment-wide (`organization_id` NULL), where every organization reads it. The key is stripped at a spec parse and refused at the protocol: a request that still names an organization is refused with 403 `NOT_OVERRIDABLE`, before anything is read or written, and the message names the tenancy posture in force. A seed draft sets `organization_id` on each record (ADR-0131 D12, item 12); under group a seed record that names none is refused at load", + "migrationId": "metadata-write-organization-scope-refused", + "toMajor": 18, + "rationale": "ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by Studio, by the cloud build agent or by an install belongs to the whole deployment. The /meta doors already carry no organization (meta-doors-organization-scope-retired); this is the protocol refusing the same write from every other door — the /packages verbs, the stored-row migrations, a plugin — so no path is left that stamps an organization on a metadata row. The audit and commit ledgers are environment-level too (ADR-0131 D7) and record no organization. Legacy organization-scoped rows are not touched: the stored-metadata migration reports them as skipped, the flow credential move reports them as not moved, and the promotion ceremony (ADR-0131 C7) carries them to the environment layer." + }, { "surface": "The ADR-0087 migration chain and change manifest, imported from the package root @objectstack/spec: MIGRATIONS_BY_MAJOR, MIGRATION_MAJORS, MIGRATION_SUPPORT_FLOOR, RETIRED_KEYS_BY_MAJOR, RETIRED_DEFS_BY_MAJOR, applyMetaMigrations, composeMigrationChain, MigrationFloorError, composeSpecChanges, composeReleaseChanges, the seven change-manifest schemas (SpecChangesSchema, SpecConvertedSchema, SpecMigratedSchema, SpecSurfaceAddSchema, SpecSurfaceRemoveSchema, SpecReleaseChangesSchema, SpecReleaseSurfaceSchema), and the types MigrationStep, MigrationApplication, MigrationChainResult, MigrationHopResult, MigrationTodo, SemanticMigration, SpecChanges, SpecConverted, SpecMigrated, SpecSurfaceAdd, SpecSurfaceRemove, SpecReleaseChanges, SpecReleaseSurface, SurfaceDiff, ReleaseSurfaceDiff and PreviousReleaseRegistries", "replacement": "the same names, unchanged, imported from `@objectstack/spec/migrations` — change the import path and nothing else. The chain, its steps and semantic entries, the retired-key and retired-def tables and the change-manifest schemas are the same objects, and `objectstack migrate meta` replays the same chain. The ADR-0087 conversion layer stays on the package root: `ALL_CONVERSIONS`, `CONVERSIONS_BY_MAJOR`, `applyConversions`, `applyConversionsToFlow`, `applyConversionsToStoredItem`, `collectConversionNotices`, the three `CONVERSION_*_CODE` constants and their types still import from `@objectstack/spec`.", @@ -6229,6 +6250,13 @@ "toMajor": 18, "rationale": "Maintainer ruling of 2026-08-27 on the client SDK's unbound response contracts, sub-question 3A: retire this false declaration first, then author the true one. The schema declared a version rollback — `{ success, restoredVersion?, message? }`, matching its file header \"Rollback a package\" — while the live path it was contract-bound to serves the ADR-0067 commit rollback: a different operation with a different result. Binding it in the SDK would compile and be false (the change that typed the SDK's un-annotated return values left a compile-time guard against exactly that substitution). Zero consumers measured across objectstack, objectui and cloud (the ruling's own survey, re-verified at the retiring PR's base): only its own unit test and that negative guard. A published declaration that outran the implementation is the hazard of response bodies never checked against the schemas that declare them, realised in the opposite direction — not \"no declaration\" but a WRONG one — and it is retired BEFORE the true schema is authored so no window exists in which both claims are published." }, + { + "surface": "the organizationId and allTenants members of the deletePackage request of @objectstack/metadata-protocol (DeletePackageRequest), the two TENANT_SCOPE_REQUIRED refusals of deletePackage, and the organization-scope refusal of DELETE /api/v1/packages/:id on the runtime dispatcher", + "replacement": "call `deletePackage({ packageId })` with neither key: the uninstall removes every row bound to the package in this environment. A request still carrying `organizationId` or `allTenants` is refused with 400 `INVALID_REQUEST` and removes nothing; drop the key and retry. Who may uninstall is the package door's operator gate", + "migrationId": "package-uninstall-environment-wide", + "toMajor": 18, + "rationale": "The guard existed because an uninstall naming no organization once matched every organization's rows, so a cross-tenant uninstall had to be declared (allTenants: true) and a scoped one named (organizationId). ADR-0131 D6 removes that premise: no metadata write lands organization-scoped any more, so a package's rows belong to the environment and an organization names nothing. The HTTP door never sent allTenants, so an operator with no active organization could not uninstall over HTTP at all. The keys are refused rather than ignored, because a caller still sending one believes it scopes the uninstall. Legacy organization-scoped rows bound to the package are removed with it, as the declared cross-tenant uninstall removed them, rather than stranded for the promotion ceremony." + }, { "surface": "PackageVersionSchema.version (`marketplace/package-version.zod.ts`) — the `version` column of a `sys_package_version` row, and through `CreatePackageVersionRequestSchema.version`, which references it, the version a draft release is created with", "replacement": "a SemVer 2.0.0 string matching `SEMVER_2_0_0_VERSION_PATTERN` (`kernel/version-grammar.ts`). Two changes, opposite in direction. ⭐ WIDER: suffix identifiers may now carry either ASCII case, because SemVer 2.0.0 is case-preserving — `1.0.0-Beta.1` and `1.0.0+Build.5` are accepted where this key used to demand lowercase, and the plugin boot path has always accepted them. ⛔ NARROWER: the forms the standard forbids are refused — `01.1.1` (§2), `1.0.0-0123` and `1.0.0-alpha..1` (§9), `1.0.0+.` (§10).", diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 0f8fd90f3ed..44f58d59262 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -628,7 +628,6 @@ export const ERROR_CODE_LEDGER = { 'REGISTRY_TYPE_NOT_CANONICAL', // [#9111] a SchemaRegistry overlay entry was offered a non-canonical metadata `type` — the mint door asserts, the caller folds 'ROLLED_BACK', // atomic data-batch row was written, then undone by the batch rollback (#4793) 'STORED_TYPE_NOT_CANONICAL', // [#8908] a package draft is stored under a non-canonical metadata type (pre-#7894 second-namespace residue) — refused at the publish pre-flight, batch-atomic; [#9174] also refused on `revertCommit`'s restore limb, per-item on `failed[]`, NOT batch-atomic - 'TENANT_SCOPE_REQUIRED', // [#7780] destructive call named neither an organization nor an explicit cross-tenant intent; needs an explicit opt-in 'UNSUPPORTED_QUERY_PARAM', 'VALIDATION_FAILED', 'VERSION_NOT_FOUND', @@ -1104,6 +1103,13 @@ export const ERROR_CODE_LEDGER = { 'MAPPING_NOT_FOUND', 'NODE_FAILURE', 'NO_EXECUTOR', + // [ADR-0131 D6] The stored-flow credential move (`flow-credential-migration.ts`) + // records a legacy organization-scoped flow row it cannot re-save on its + // report's `failed[]` with the code the metadata protocol refuses such a + // write with. Report data in the `sys_migration` receipt and an `error` log + // line — no HTTP door; registered under `@objectstack/metadata-protocol` + // too, so this row is provenance, not identity. + 'NOT_OVERRIDABLE', 'RESUME_IN_PROGRESS', // duplicate resume refused while the first is running 'RUN_NOT_FOUND', // no suspension for this run id — unresumable for good 'STORE_UNAVAILABLE', // durable suspended-run store unreadable — existence unknown @@ -1873,18 +1879,4 @@ export const PROVENANCE_WAIVERS: readonly ProvenanceWaiver[] = [ 'site, and rows for packages that stamp nothing would be the dead weight this file\'s ' + 'gate refuses.', }, - { - package: '@objectstack/runtime', - code: 'TENANT_SCOPE_REQUIRED', - registeredUnder: '@objectstack/metadata-protocol', - reason: 'The door mirrors the producer\'s refusal; it is not a second emitter. ' + - '`DELETE /packages/:id` (domains/packages.ts, `requireUninstallOrganizationScope`) ' + - 'asks `deletePackage`\'s organization-scope question BEFORE ' + - '`registry.uninstallPackage`, and answers with the code `deletePackage` refuses a ' + - 'scope-less uninstall with (an uninstall across every organization must be declared, ' + - 'never inferred from a missing one), so a refused uninstall changes nothing. The door ' + - 'never sends `allTenants`, so its condition is exactly the producer\'s "no ' + - 'organization"; the protocol keeps its own refusal as the second line and stays the ' + - 'registered emitter.', - }, ]; diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index f0acadc5a96..443ad278942 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -1802,8 +1802,8 @@ describe('PublishMetaItemRequestSchema mirrors the implementation parameter type // request shape stayed undeclared — the half-declared door. Maintainer // ruling 2026-08-22 (option B): declare the request and the interface // member. The measure is the implementation's parameter type in - // `@objectstack/metadata-protocol` — `{ type, name, organizationId?, - // actor?, message?, packageId? }` — and the REST door's actual reads; + // `@objectstack/metadata-protocol` — `{ type, name, actor?, message?, + // packageId? }` — and the REST door's actual reads; // nothing else is declared because nothing else is enforced. // As in the #9726 / commit 2a29caa53 blocks above, accept-pins assert the parsed VALUE: // this is a non-strict object, so `success` alone is exactly the @@ -1814,7 +1814,6 @@ describe('PublishMetaItemRequestSchema mirrors the implementation parameter type it('accepts the full request and PRESERVES every member through parse', () => { const full = { ...base, - organizationId: 'org_alpha', actor: 'admin', message: 'publish from designer', packageId: 'pkg_crm', @@ -1850,8 +1849,20 @@ describe('PublishMetaItemRequestSchema mirrors the implementation parameter type expect(PublishMetaItemRequestSchema.safeParse({ ...base, packageId: 42 }).success).toBe(false); }); - it('the three optional strings stay optional and reject non-strings — values, not bags', () => { - for (const key of ['organizationId', 'actor', 'message'] as const) { + it('[ADR-0131 D6] a retired organizationId is STRIPPED at parse — the protocol, not the schema, refuses it', () => { + // The schema is not `.strict()`: the key retired with the per-organization + // overlay axis is dropped at parse, and an organization-scoped write is + // refused 403 NOT_OVERRIDABLE by the protocol itself (its identity pin, + // `protocol.org-scoped-write-refused.test.ts` in @objectstack/metadata-protocol). + const result = PublishMetaItemRequestSchema.safeParse({ ...base, organizationId: 'org_alpha' }); + expect(result.success).toBe(true); + if (result.success) { + expect('organizationId' in (result.data as object)).toBe(false); + } + }); + + it('the two optional strings stay optional and reject non-strings — values, not bags', () => { + for (const key of ['actor', 'message'] as const) { const absent = PublishMetaItemRequestSchema.safeParse(base); expect(absent.success).toBe(true); if (absent.success) { @@ -2266,8 +2277,7 @@ describe('DeleteMetaItemRequestSchema declares the contract members the reset do // which is why the call-site cast could not come off (TS2353 on six keys, // the opposite half of the publish door's TS2339). The measure is the // implementation's parameter type in `@objectstack/metadata-protocol` — - // `{ type, name, organizationId?, parentVersion?, actor?, state?, - // dropStorage? }` — and the REST door's actual sends. As in the sibling + // `{ type, name, parentVersion?, actor?, state?, dropStorage? }` — and the REST door's actual sends. As in the sibling // 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. @@ -2277,7 +2287,6 @@ describe('DeleteMetaItemRequestSchema declares the contract members the reset do it('accepts the full request and PRESERVES every member through parse', () => { const full = { ...base, - organizationId: 'org_alpha', parentVersion: 'sha256:abc123', actor: 'admin@objectos.ai', state: 'draft', @@ -2296,8 +2305,20 @@ describe('DeleteMetaItemRequestSchema declares the contract members the reset do expect(DeleteMetaItemRequestSchema.safeParse({ name: 'account_list' }).success).toBe(false); }); - it('the three optional strings stay optional and reject non-strings — values, not bags', () => { - for (const key of ['organizationId', 'parentVersion', 'actor'] as const) { + it('[ADR-0131 D6] a retired organizationId is STRIPPED at parse — the protocol, not the schema, refuses it', () => { + // The schema is not `.strict()`: the key retired with the per-organization + // overlay axis is dropped at parse, and an organization-scoped write is + // refused 403 NOT_OVERRIDABLE by the protocol itself (its identity pin, + // `protocol.org-scoped-write-refused.test.ts` in @objectstack/metadata-protocol). + const result = DeleteMetaItemRequestSchema.safeParse({ ...base, organizationId: 'org_alpha' }); + expect(result.success).toBe(true); + if (result.success) { + expect('organizationId' in (result.data as object)).toBe(false); + } + }); + + it('the two optional strings stay optional and reject non-strings — values, not bags', () => { + for (const key of ['parentVersion', 'actor'] as const) { const absent = DeleteMetaItemRequestSchema.safeParse(base); expect(absent.success).toBe(true); if (absent.success) { @@ -2351,7 +2372,6 @@ describe('MetadataProtocol.deleteMetaItem types against the caught-up request sc const good: DeleteMetaItemRequest = { type: 'view', name: 'account_list', - organizationId: 'org_alpha', parentVersion: 'sha256:abc123', actor: 'admin', state: 'draft', @@ -2361,6 +2381,9 @@ describe('MetadataProtocol.deleteMetaItem types against the caught-up request sc // @ts-expect-error `environmentId` is transport-level (commit 2a29caa53) — not a declared request member; the REST door layers it on via TransportScopedMetaRequest. const withEnv: DeleteMetaItemRequest = { type: 'view', name: 'account_list', environmentId: 'env_a' }; expect(withEnv.name).toBe('account_list'); + // @ts-expect-error `organizationId` is retired (ADR-0131 D6): no metadata write is organization-scoped. + const withOrg: DeleteMetaItemRequest = { type: 'view', name: 'account_list', organizationId: 'org_alpha' }; + expect(withOrg.name).toBe('account_list'); // @ts-expect-error an undeclared (here: misspelt) key is refused at the call shape. const misspelt: DeleteMetaItemRequest = { type: 'view', name: 'account_list', dropstorage: true }; expect(misspelt.name).toBe('account_list'); @@ -2378,8 +2401,8 @@ describe('SaveMetaItemRequestSchema declares the contract members the save door // call-site cast could not come off (TS2353 on every undeclared key, pure // request-shape smuggling, never feature detection). The measure is the // implementation's parameter type in `@objectstack/metadata-protocol` — - // `{ type, name, item?, organizationId?, parentVersion?, actor?, force?, - // mode?, packageId?, source?, writeFace? }` — and the REST door's actual + // `{ type, name, item?, parentVersion?, actor?, force?, mode?, packageId?, + // source?, writeFace? }` — and the REST door's actual // sends. As in the sibling 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. @@ -2393,7 +2416,6 @@ describe('SaveMetaItemRequestSchema declares the contract members the save door it('accepts the full request and PRESERVES every member through parse', () => { const full = { ...base, - organizationId: 'org_alpha', parentVersion: 'sha256:abc123', actor: 'admin@objectos.ai', force: true, @@ -2424,8 +2446,20 @@ describe('SaveMetaItemRequestSchema declares the contract members the save door expect(SaveMetaItemRequestSchema.safeParse({ type: 'view', name: 'account_list', item: null }).success).toBe(true); }); - it('the two optional strings stay optional and reject non-strings — values, not bags', () => { - for (const key of ['organizationId', 'actor'] as const) { + it('[ADR-0131 D6] a retired organizationId is STRIPPED at parse — the protocol, not the schema, refuses it', () => { + // The schema is not `.strict()`: the key retired with the per-organization + // overlay axis is dropped at parse, and an organization-scoped write is + // refused 403 NOT_OVERRIDABLE by the protocol itself (its identity pin, + // `protocol.org-scoped-write-refused.test.ts` in @objectstack/metadata-protocol). + const result = SaveMetaItemRequestSchema.safeParse({ ...base, organizationId: 'org_alpha' }); + expect(result.success).toBe(true); + if (result.success) { + expect('organizationId' in (result.data as object)).toBe(false); + } + }); + + it('the optional string stays optional and rejects non-strings — a value, not a bag', () => { + for (const key of ['actor'] as const) { const absent = SaveMetaItemRequestSchema.safeParse(base); expect(absent.success).toBe(true); if (absent.success) { @@ -2531,7 +2565,6 @@ describe('MetadataProtocol.saveMetaItem types against the caught-up request sche type: 'view', name: 'account_list', item: { label: 'Account list' }, - organizationId: 'org_alpha', parentVersion: null, actor: 'admin', force: true, @@ -2543,6 +2576,9 @@ describe('MetadataProtocol.saveMetaItem types against the caught-up request sche // @ts-expect-error `environmentId` is transport-level (commit 2a29caa53) — not a declared request member; the REST door layers it on via TransportScopedMetaRequest. const withEnv: SaveMetaItemRequest = { type: 'view', name: 'account_list', environmentId: 'env_a' }; expect(withEnv.name).toBe('account_list'); + // @ts-expect-error `organizationId` is retired (ADR-0131 D6): no metadata write is organization-scoped. + const withOrg: SaveMetaItemRequest = { type: 'view', name: 'account_list', item: {}, organizationId: 'org_alpha' }; + expect(withOrg.name).toBe('account_list'); // @ts-expect-error `source` is implementation-internal provenance — no producer on this contract sends it, and the REST layer never reads it off the wire. const withSource: SaveMetaItemRequest = { type: 'view', name: 'account_list', source: 'studio' }; expect(withSource.name).toBe('account_list'); diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index f0014f92274..ff35c73e3e5 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -668,15 +668,6 @@ export const SaveMetaItemRequestSchema = lazySchema(() => z.object({ + 'the publish door.', ), item: z.unknown().describe('Metadata item definition'), - organizationId: z.string().optional().describe( - 'Organization (tenant) scope for the write. Load-bearing, not advisory: ' - + 'it selects the overlay partition (ADR-0005) the row lands in — an ' - + 'org-scoped save writes that tenant\'s own overlay, while an org-less ' - + 'save writes the environment-wide row every tenant reads — and it is ' - + 'the scope stamped on the write\'s audit row. An org-scoped write of a ' - + 'type whose registry entry declares `allowOrgOverride: false` is ' - + 'refused (403). Absent = environment-wide.', - ), parentVersion: z.string().nullable().optional().describe( 'ADR-0008 optimistic-concurrency pin: the version token the caller ' + 'believes is current (on the REST door, the `If-Match` request ' @@ -908,12 +899,6 @@ export const PublishMetaItemRequestSchema = lazySchema(() => z.object({ + '(`crm_lead`, `crm_lead.pipeline`). The promotion door enforces the ' + 'same grammar as `saveMetaItem`.', ), - organizationId: z.string().optional().describe( - 'Organization (tenant) scope for the promotion. The implementation resolves ' - + 'the draft through the org partition (ADR-0005), so a draft ' - + 'authored org-scoped must be published under the same scope or the lookup ' - + 'answers 404 `NO_DRAFT`. Absent = environment-wide.', - ), actor: z.string().optional().describe( 'Identity recorded on the `op=\'publish\'` history event. On the REST door ' + 'this is the request\'s authenticated identity (one producer) — ' @@ -1371,14 +1356,6 @@ export const PublishPackageDraftsResponseSchema = lazySchema(() => z.object({ export const DeleteMetaItemRequestSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name'), name: z.string().describe('Item name'), - organizationId: z.string().optional().describe( - 'Organization (tenant) scope for the reset. Load-bearing, not ' - + 'advisory: it selects the ADR-0005 overlay partition, so it decides ' - + 'WHICH row the reset destroys — an org-scoped delete removes that ' - + 'tenant\'s own overlay, while an org-less delete reaches the ' - + 'environment-wide row and would blank the item for every tenant. ' - + 'Absent = environment-wide.', - ), parentVersion: z.string().optional().describe( 'ADR-0008 optimistic-concurrency pin: the version token the caller ' + 'believes is current (on the REST door, the `If-Match` request header). ' diff --git a/packages/spec/src/migrations/entries/semantic/18.metadata-write-organization-scope-refused.ts b/packages/spec/src/migrations/entries/semantic/18.metadata-write-organization-scope-refused.ts new file mode 100644 index 00000000000..4e76b8aa5ca --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.metadata-write-organization-scope-refused.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// 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 +// behind it is deleted. Registered because a caller that still names an +// organization is refused where it was accepted, and because the +// `organizationId` key leaves three declared request shapes. +export const entry: SemanticMigration = { + id: 'metadata-write-organization-scope-refused', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the organizationId member of the SaveMetaItem, PublishMetaItem and DeleteMetaItem request ' + + 'schemas of @objectstack/spec; and every organization-scoped write the metadata protocol of ' + + '@objectstack/metadata-protocol accepted: saveMetaItem (draft and publish), publishMetaItem, ' + + 'deleteMetaItem, rollbackMetaItem, revertCommit, rollbackToPackageCommit, publishPackageDrafts, ' + + 'discardPackageDrafts, revertStoredPackage, duplicatePackage and reassignOrphanedMetadata — ' + + 'including the five types that declared allowOrgOverride (view, dashboard, report, translation, ' + + 'email_template) and the OS_METADATA_WRITABLE hatch; and the active organization of the caller ' + + 'publishing a package, which a published seed draft whose records named no organization was ' + + 'loaded into under the group posture', + replacement: + 'drop `organizationId` from the request: every metadata write lands environment-wide ' + + '(`organization_id` NULL), where every organization reads it. The key is stripped at a spec ' + + 'parse and refused at the protocol: a request that still names an organization is refused with ' + + '403 `NOT_OVERRIDABLE`, before anything is read or written, and the message names the tenancy ' + + 'posture in force. A seed draft sets `organization_id` on each record (ADR-0131 D12, item 12); ' + + 'under group a seed record that names none is refused at load', + reason: + 'ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by Studio, ' + + 'by the cloud build agent or by an install belongs to the whole deployment. The /meta doors ' + + 'already carry no organization (meta-doors-organization-scope-retired); this is the protocol ' + + 'refusing the same write from every other door — the /packages verbs, the stored-row ' + + 'migrations, a plugin — so no path is left that stamps an organization on a metadata row. ' + + 'The audit and commit ledgers are environment-level too (ADR-0131 D7) and record no ' + + 'organization. Legacy organization-scoped rows are not touched: the stored-metadata migration ' + + 'reports them as skipped, the flow credential move reports them as not moved, and the ' + + 'promotion ceremony (ADR-0131 C7) carries them to the environment layer.', + acceptanceCriteria: + 'A protocol write naming an organization — a saveMetaItem of a view with organizationId set, a ' + + 'publishPackageDrafts or a revertCommit with one — answers 403 NOT_OVERRIDABLE and writes ' + + 'nothing, for every metadata type and every tenancy posture; the same call without the key ' + + 'succeeds and stores organization_id NULL. POST /meta/_migrate-stored reports each ' + + 'organization-scoped row as skipped, naming the promotion ceremony, and re-saves none. A ' + + 'commit recorded in a legacy organization layer is refused by revertCommit with the same code, ' + + 'and duplicatePackage and reassignOrphanedMetadata copy or adopt the environment rows only. ' + + 'Remove organizationId from any typed SaveMetaItem / PublishMetaItem / DeleteMetaItem ' + + 'request literal: the key no longer type-checks.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.package-uninstall-environment-wide.ts b/packages/spec/src/migrations/entries/semantic/18.package-uninstall-environment-wide.ts new file mode 100644 index 00000000000..b4c23d72fc2 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.package-uninstall-environment-wide.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0131 D6/D12 (C5, stage S4) — the ruled retirement of the uninstall's +// organization-scope guard: once every package-owned metadata row is +// environment-wide, an uninstall is environment-wide by construction and an +// organization names nothing. Retires what `package-uninstall-explicit-all-tenants` +// (protocol 17) introduced. +export const entry: SemanticMigration = { + id: 'package-uninstall-environment-wide', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the organizationId and allTenants members of the deletePackage request of ' + + '@objectstack/metadata-protocol (DeletePackageRequest), the two TENANT_SCOPE_REQUIRED refusals ' + + 'of deletePackage, and the organization-scope refusal of DELETE /api/v1/packages/:id on the ' + + 'runtime dispatcher', + replacement: + 'call `deletePackage({ packageId })` with neither key: the uninstall removes every row bound to ' + + 'the package in this environment. A request still carrying `organizationId` or `allTenants` ' + + 'is refused with 400 `INVALID_REQUEST` and removes nothing; drop the key and retry. Who may ' + + 'uninstall is the package door\'s operator gate', + reason: + 'The guard existed because an uninstall naming no organization once matched every ' + + 'organization\'s rows, so a cross-tenant uninstall had to be declared (allTenants: true) and a ' + + 'scoped one named (organizationId). ADR-0131 D6 removes that premise: no metadata write lands ' + + 'organization-scoped any more, so a package\'s rows belong to the environment and an ' + + 'organization names nothing. The HTTP door never sent allTenants, so an operator with no active ' + + 'organization could not uninstall over HTTP at all. The keys are refused rather than ignored, ' + + 'because a caller still sending one believes it scopes the uninstall. Legacy ' + + 'organization-scoped rows bound to the package are removed with it, as the declared ' + + 'cross-tenant uninstall removed them, rather than stranded for the promotion ceremony.', + acceptanceCriteria: + 'DELETE /api/v1/packages/:id by a manage_metadata caller with no active organization succeeds ' + + 'and removes every sys_metadata row bound to the package, environment-wide and legacy ' + + 'organization-scoped alike; the same call with an active organization behaves identically. A ' + + 'deletePackage request carrying organizationId or allTenants (true or false) answers 400 ' + + 'INVALID_REQUEST and changes nothing. No response carries TENANT_SCOPE_REQUIRED. Remove both ' + + 'keys from every deletePackage caller, and any deploy script that passed allTenants: true.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 1aab53bca80..f396500d2ca 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -16283,6 +16283,54 @@ const step18: MigrationStep = { + 'set stays exactly `DEFAULT_METADATA_TYPE_REGISTRY` plus item-population growth, ' + 'before and after.', }, + // 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 + // behind it is deleted. Registered because a caller that still names an + // organization is refused where it was accepted, and because the + // `organizationId` key leaves three declared request shapes. + { + id: 'metadata-write-organization-scope-refused', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the organizationId member of the SaveMetaItem, PublishMetaItem and DeleteMetaItem request ' + + 'schemas of @objectstack/spec; and every organization-scoped write the metadata protocol of ' + + '@objectstack/metadata-protocol accepted: saveMetaItem (draft and publish), publishMetaItem, ' + + 'deleteMetaItem, rollbackMetaItem, revertCommit, rollbackToPackageCommit, publishPackageDrafts, ' + + 'discardPackageDrafts, revertStoredPackage, duplicatePackage and reassignOrphanedMetadata — ' + + 'including the five types that declared allowOrgOverride (view, dashboard, report, translation, ' + + 'email_template) and the OS_METADATA_WRITABLE hatch; and the active organization of the caller ' + + 'publishing a package, which a published seed draft whose records named no organization was ' + + 'loaded into under the group posture', + replacement: + 'drop `organizationId` from the request: every metadata write lands environment-wide ' + + '(`organization_id` NULL), where every organization reads it. The key is stripped at a spec ' + + 'parse and refused at the protocol: a request that still names an organization is refused with ' + + '403 `NOT_OVERRIDABLE`, before anything is read or written, and the message names the tenancy ' + + 'posture in force. A seed draft sets `organization_id` on each record (ADR-0131 D12, item 12); ' + + 'under group a seed record that names none is refused at load', + reason: + 'ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by Studio, ' + + 'by the cloud build agent or by an install belongs to the whole deployment. The /meta doors ' + + 'already carry no organization (meta-doors-organization-scope-retired); this is the protocol ' + + 'refusing the same write from every other door — the /packages verbs, the stored-row ' + + 'migrations, a plugin — so no path is left that stamps an organization on a metadata row. ' + + 'The audit and commit ledgers are environment-level too (ADR-0131 D7) and record no ' + + 'organization. Legacy organization-scoped rows are not touched: the stored-metadata migration ' + + 'reports them as skipped, the flow credential move reports them as not moved, and the ' + + 'promotion ceremony (ADR-0131 C7) carries them to the environment layer.', + acceptanceCriteria: + 'A protocol write naming an organization — a saveMetaItem of a view with organizationId set, a ' + + 'publishPackageDrafts or a revertCommit with one — answers 403 NOT_OVERRIDABLE and writes ' + + 'nothing, for every metadata type and every tenancy posture; the same call without the key ' + + 'succeeds and stores organization_id NULL. POST /meta/_migrate-stored reports each ' + + 'organization-scoped row as skipped, naming the promotion ceremony, and re-saves none. A ' + + 'commit recorded in a legacy organization layer is refused by revertCommit with the same code, ' + + 'and duplicatePackage and reassignOrphanedMetadata copy or adopt the environment rows only. ' + + 'Remove organizationId from any typed SaveMetaItem / PublishMetaItem / DeleteMetaItem ' + + 'request literal: the key no longer type-checks.', + }, // The ADR-0087 D3/D4 surface leaves the package root for its own subpath, so the // migration registry's text stops riding in every bundle of the root entry. The // conversion layer (D2) stays on the root: the authoring funnel reads it at run time. @@ -16964,6 +17012,43 @@ const step18: MigrationStep = { + 'bytes before and after — the retirement removes a false claim, not ' + 'behaviour.', }, + // ADR-0131 D6/D12 (C5, stage S4) — the ruled retirement of the uninstall's + // organization-scope guard: once every package-owned metadata row is + // environment-wide, an uninstall is environment-wide by construction and an + // organization names nothing. Retires what `package-uninstall-explicit-all-tenants` + // (protocol 17) introduced. + { + id: 'package-uninstall-environment-wide', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the organizationId and allTenants members of the deletePackage request of ' + + '@objectstack/metadata-protocol (DeletePackageRequest), the two TENANT_SCOPE_REQUIRED refusals ' + + 'of deletePackage, and the organization-scope refusal of DELETE /api/v1/packages/:id on the ' + + 'runtime dispatcher', + replacement: + 'call `deletePackage({ packageId })` with neither key: the uninstall removes every row bound to ' + + 'the package in this environment. A request still carrying `organizationId` or `allTenants` ' + + 'is refused with 400 `INVALID_REQUEST` and removes nothing; drop the key and retry. Who may ' + + 'uninstall is the package door\'s operator gate', + reason: + 'The guard existed because an uninstall naming no organization once matched every ' + + 'organization\'s rows, so a cross-tenant uninstall had to be declared (allTenants: true) and a ' + + 'scoped one named (organizationId). ADR-0131 D6 removes that premise: no metadata write lands ' + + 'organization-scoped any more, so a package\'s rows belong to the environment and an ' + + 'organization names nothing. The HTTP door never sent allTenants, so an operator with no active ' + + 'organization could not uninstall over HTTP at all. The keys are refused rather than ignored, ' + + 'because a caller still sending one believes it scopes the uninstall. Legacy ' + + 'organization-scoped rows bound to the package are removed with it, as the declared ' + + 'cross-tenant uninstall removed them, rather than stranded for the promotion ceremony.', + acceptanceCriteria: + 'DELETE /api/v1/packages/:id by a manage_metadata caller with no active organization succeeds ' + + 'and removes every sys_metadata row bound to the package, environment-wide and legacy ' + + 'organization-scoped alike; the same call with an active organization behaves identically. A ' + + 'deletePackage request carrying organizationId or allTenants (true or false) answers 400 ' + + 'INVALID_REQUEST and changes nothing. No response carries TENANT_SCOPE_REQUIRED. Remove both ' + + 'keys from every deletePackage caller, and any deploy script that passed allTenants: true.', + }, // The published release row's half of the version canon. It moves in BOTH // directions at once — gaining uppercase identifiers, losing the degenerate // forms — which is why the prescription below has to state each separately diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index dc661e98bd8..d0f97cc1a89 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -2402,7 +2402,7 @@ class StackSingleAppViolationError extends StackRefusalError { * omits `hierarchy-security` — {@link validateHierarchyScopeCapability}, the * declared-capability class that fails CLOSED. Spelled `_REQUIRED` like the * ledger's other "a declaration is owed and absent" refusals - * (`TENANT_SCOPE_REQUIRED`, `WRITABLE_PACKAGE_REQUIRED`). + * (`WRITABLE_PACKAGE_REQUIRED`). */ class StackHierarchyScopeCapabilityRequiredError extends StackRefusalError { readonly code = 'STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED';