diff --git a/.changeset/15206-meta-doors-environment-only.md b/.changeset/15206-meta-doors-environment-only.md new file mode 100644 index 00000000000..2f69ab79e38 --- /dev/null +++ b/.changeset/15206-meta-doors-environment-only.md @@ -0,0 +1,38 @@ +--- +'@objectstack/metadata-core': minor +'@objectstack/rest': minor +'@objectstack/runtime': minor +'@objectstack/plugin-email': minor +'@objectstack/spec': minor +--- + +feat(metadata-core,rest,runtime,plugin-email,spec)!: the `/meta` doors carry no organization; organization-admin metadata authoring closes and `manage_org_presentation` retires (ADR-0131 D6) + +Clause-②: no (narrowing) + + + +**BREAKING**, 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: environment metadata written by Studio belongs to the whole deployment. The `/meta` doors of both transports (`@objectstack/rest` and the runtime dispatcher's `/meta` branch) used to thread the caller's active organization into writes and reads of the five `allowOrgOverride: true` types (`view`, `dashboard`, `report`, `translation`, `email_template`). They no longer do, for any type, and the read and the write flip together. + +**What changes.** + +- **Writes land environment-wide.** `PUT`, `DELETE`, `POST …/publish` and `POST …/rollback` on `/api/v1/meta/:type/:name` hand the protocol no organization, so the row and its audit and history rows carry `organization_id` NULL, whatever the caller's active organization. +- **Reads are environment → code.** The item read (both cache arms), the list, the layered view (`/layers` and `?layers=true`), `/published`, `?state=draft`, `GET /meta/_drafts`, `/history`, `/diff`, `/audit` (the environment rows, `organizationId: null`), `GET /meta/diagnostics` and `/references` name no organization. +- **An organization admin's metadata write is refused.** `metaWriteCapabilityVerdict` admits `isSystem` or `manage_metadata` only. A caller holding `manage_org_presentation` and not `manage_metadata` is answered `403` on all four item doors (`FORBIDDEN` on REST, `PERMISSION_DENIED` on the dispatcher) with `… requires the \`manage_metadata\` capability.`, for every type and whatever its active organization. +- **`manage_org_presentation` retires** from `PLATFORM_CAPABILITIES` (`@objectstack/spec/security`). A permission set naming it still parses and loads, and its other grants still apply; the grant itself admits nothing. The `sys_capability` row seeded for it earlier is not pruned (the seeder upserts only); an operator may delete it in Setup. +- **The email-template boot sweep** (`@objectstack/plugin-email`) reads the effective templates environment → code, as the door serves them; it no longer reads in the Default Organization. + +**What moves for consumers.** + +- FROM `import { organizationIdForMetaWrite } from '@objectstack/metadata-core'` TO nothing: a `/meta` write carries no organization. Delete the call and the `organizationId` it fed. +- FROM `import { ORG_PRESENTATION_AUTHORING_CAPABILITY } from '@objectstack/metadata-core'` TO nothing: delete the import. +- FROM `metaWriteCapabilityVerdict({ isSystem, systemPermissions, canonicalType, activeOrganizationId, operation })` TO `metaWriteCapabilityVerdict({ isSystem, systemPermissions, operation })`: drop the two members. +- FROM `import { metaReadOrganizationId } from '@objectstack/rest'` TO nothing: a `/meta` read carries no organization. `metaCallerOrganizationId` stays. +- FROM `bootstrapEffectiveEmailTemplates(engine, metadataService, { protocol, tenancy })` TO `{ protocol }`: the `tenancy` source is gone. +- A permission set granting `manage_org_presentation`: grant `manage_metadata` to whoever must author those five types, and delete the stale grant. + +**What a deployment observes.** An overlay row an earlier release stored under an organization stays in `sys_metadata` untouched, and is no longer served by any `/meta` read or projected by the email-template sweep until the promotion ceremony (ADR-0131 C7) carries it to the environment layer. Under the `single` posture that is every earlier Studio save of the five types, because it was filed under the Default Organization: on the `/meta` doors such an edit reads as reverted to the environment or code definition. Re-save the item in Studio to make the edit live on the `/meta` doors now. Public forms are the exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay for the form's body, while a withdrawal in either layer closes the form, fail-closed. So a legacy organization overlay of a public form keeps serving its body there: a Studio re-save of that body (an environment row) does not change the body the public form serves, and a Studio withdrawal (an environment row) still closes it. + +**What does not change.** Saves of every other type were already environment-wide. Flow saves, the capability gate's answer for `manage_metadata` holders and `isSystem`, the protocol's own organization-scoped refusals, and the `/packages` doors are untouched by this change. diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index e5e3270300f..cdac13d1c9b 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -106,6 +106,8 @@ later ships. See [ADR-0070](https://github.com/objectstack-ai/objectstack/blob/m In shared-database multi-tenancy, **most metadata types must not be per-org customizable** — overriding them would break the physical schema. The whitelist lives in **one** place: `MetadataTypeRegistryEntry.allowOrgOverride` in `packages/spec/src/kernel/metadata-plugin.zod.ts`. +> **ADR-0131 D6 — the per-organization overlay axis is retired.** The `/meta` doors (REST and the runtime dispatcher) carry no organization into a metadata write or read: a Studio or `PUT /api/v1/meta/...` write of the five ✅ types below lands **environment-wide** (`organization_id` NULL) and is served to every organization, and an organization admin holding only the retired `manage_org_presentation` capability is refused (`403`). An overlay row an earlier release stored under an organization — under the `single` posture, every Studio save of these types, filed under the Default Organization — is not served by any `/meta` read until the promotion ceremony (ADR-0131 C7) carries it to the environment layer; re-save the item in Studio to make the edit live on the `/meta` doors now. Public forms are the exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay for the form's body, while a withdrawal in either layer closes the form, fail-closed. So a legacy organization overlay of a public form keeps serving its body there: a Studio re-save of that body (an environment row) does not change the body the public form serves, and a Studio withdrawal (an environment row) still closes it. The table still records which types take an overlay of a shipped item. + | Type | `allowOrgOverride` | Rationale | | :--- | :---: | :--- | | `view`, `dashboard`, `report`, `email_template`, `translation` | ✅ | Pure rendering / render-time content. Per-org customization is safe. | @@ -228,7 +230,7 @@ The hash is `sha256:` + 64-hex of a canonical JSON serialization of the body, wi |:---|:---|:---| | `defineView(...)` / `defineFlow(...)` / any source file → compiled into `dist/objectstack.json` | ❌ Never. Loaded into the in-memory registry on boot; refreshed via HMR in dev. | ❌ The artifact's own version history *is* Git. The metadata layer does not duplicate it. | | Editing a `.json` under `//.json` (FS overlay, e.g. `/view/case_grid.json`) | ❌ FS layer is independent of DB. | ✅ Appended to the change log at `/.objectstack/.log/main.jsonl` by `FileSystemRepository`. | -| Studio inline edit, or `PUT /api/v1/meta/...` (REST) on an `allowOrgOverride: true` type | ✅ Written by `SysMetadataRepository.put()` as an **overlay row** scoped to `organization_id`. | ✅ Appended to `sys_metadata_history` (per-org `event_seq`) in the **same transaction** as the `sys_metadata` write. No-op puts (identical hash) skip the history row entirely. | +| Studio inline edit, or `PUT /api/v1/meta/...` (REST) on an `allowOrgOverride: true` type | ✅ Written by `SysMetadataRepository.put()` as an **overlay row**, environment-wide (`organization_id` NULL) since ADR-0131 D6. | ✅ Appended to `sys_metadata_history` in the **same transaction** as the `sys_metadata` write. No-op puts (identical hash) skip the history row entirely. | | Deploying a new build (new `dist/objectstack.json`) | ❌ The artifact is loaded into memory, not synced into `sys_metadata`. | ❌ Use Git tags / your deployment platform's release log; that's where artifact "version history" lives. | ### Why artifact never enters the database diff --git a/content/docs/kernel/contracts/metadata-service.mdx b/content/docs/kernel/contracts/metadata-service.mdx index 9a81a2ebd69..9505a0325b0 100644 --- a/content/docs/kernel/contracts/metadata-service.mdx +++ b/content/docs/kernel/contracts/metadata-service.mdx @@ -401,14 +401,26 @@ const views = await metadataService.listViews('account'); const dashboard = await metadataService.get('dashboard', 'sales_overview'); ``` -### Org-Level Customization - -Per-org view customization rides ADR-0005's metadata overlay (opt-in per type -via `allowOrgOverride`, `view` among the overlay types): an org-scoped write -through the REST meta doors stores a `sys_metadata` row, and the layered read -returns `code` / `overlay` / `effective` for it. The per-user, per-field patch -overlay a previous revision of this page taught here was removed in #13135 — -it was never served by any route. +### Environment Customization + +View customization rides ADR-0005's metadata overlay (opt-in per type via +`allowOrgOverride`, `view` among the overlay types): a write through the +`/meta` doors stores a `sys_metadata` row, and the layered read returns +`code` / `overlay` / `effective` for it. Since ADR-0131 D6 the per-organization +overlay axis is retired: the doors carry no organization, so the overlay is +**environment-wide** (`organization_id` NULL) and served to every organization +of the deployment, whatever the author's active organization. An overlay row an +earlier release stored under an organization is not served by any `/meta` read +until the promotion ceremony (ADR-0131 C7) carries it to the environment layer. +Public forms are the exception: until that ceremony the anonymous form doors +read a form `view` in the Default Organization and prefer its overlay for the +form's body, while a withdrawal in either layer closes the form, fail-closed. +So a legacy organization overlay of a public form keeps serving its body there: +a Studio re-save of that body (an environment row) does not change the body the +public form serves, and a Studio withdrawal (an environment row) still closes +it. The per-user, +per-field patch overlay a previous revision of this page taught here was +removed in #13135 — it was never served by any route. ### Permission-Based UI Filtering diff --git a/content/docs/protocol/objectui/concept.mdx b/content/docs/protocol/objectui/concept.mdx index d2d7ae3b3ac..3314a823166 100644 --- a/content/docs/protocol/objectui/concept.mdx +++ b/content/docs/protocol/objectui/concept.mdx @@ -391,7 +391,7 @@ ObjectUI merges **3 layers** of configuration to produce the final layout: ↓ Merge ┌─────────────────────────────────────────────────────┐ │ Layer 2: Admin Configuration │ -│ - Org overlay: a full FormView write, not a diff │ +│ - Env overlay: a full FormView write, not a diff │ │ - Branding: Logo, colors, theme │ └────────────────┬────────────────────────────────────┘ ↓ Merge diff --git a/content/docs/ui/create-vs-edit-form.mdx b/content/docs/ui/create-vs-edit-form.mdx index 60048c83275..09239c08cfb 100644 --- a/content/docs/ui/create-vs-edit-form.mdx +++ b/content/docs/ui/create-vs-edit-form.mdx @@ -94,7 +94,7 @@ Three layers, each *derive + only store differences* — never "re-list all 40 f ``` 1. Derived default derive(object, 'create' | 'edit') ← free, no authoring 2. Author override formViews.create (sparse patch) ← this recipe; only on real divergence -3. Tenant override org overlay delta (ADR-0005) ← a single org wants its own form +3. Runtime override environment overlay (ADR-0005) ← an admin edits it in Studio; per-org overlays retired (ADR-0131 D6) ``` Welding two independent full forms is the **Salesforce page-layout tax**: add a required field, forget the create form → runtime "missing required field" on create; rename a field → silent drift. Keeping data semantics on the object (never on the form) means a form can only ever drift on *which fields appear* — a flat name list that **reference-integrity diagnostics catch as a hard failure** in the AI loop (ADR-0047 §3.5, ADR-0033). That guardrail is what makes the escape hatch safe to hand to an AI author. diff --git a/packages/metadata-core/src/index.ts b/packages/metadata-core/src/index.ts index b3ad070b1f1..8328313261b 100644 --- a/packages/metadata-core/src/index.ts +++ b/packages/metadata-core/src/index.ts @@ -95,26 +95,18 @@ export * from './object-schema-fls.js'; // `ITEM_KEY_DISCRIMINATORS` from `registry.ts`, so its surface is unchanged. export * from './item-key-discriminators.js'; -// [#6190 / #7018 / #8805] Which metadata WRITES carry the caller's active -// organization — sunk here from `@objectstack/runtime` by the same criterion as -// the FLS projection above, and for a defect of the same shape. The #6190 -// ruling is a decision the CALLER must make (the protocol deliberately REFUSES -// an org-scoped write of a non-overridable type rather than coercing it, so the -// tenancy statement the author made is never silently rewritten) — which means -// every door that writes metadata needs the same predicate. The dispatcher owned -// the only implementation, and `@objectstack/rest` cannot import it: `runtime` -// depends on `rest`, so the reverse edge is a cycle turbo refuses — the exact -// situation this package exists to resolve. `runtime` imports it from here now, -// so its behaviour is unchanged and there is no second copy to drift. +// [#9454 · ADR-0131 D6] The registry-derived per-organization overlay +// predicate (`declaresOrgOverride`) and the organization a protocol READ +// carries (`organizationIdForMetaRead`). The write-side twin retired with the +// per-organization overlay axis: the `/meta` doors carry no organization into +// a metadata write. See the module header for what still reads through it. export * from './meta-write-org-scope.js'; -// [#12702] The capability half of the same decision: which CALLERS a `/meta` -// item write door admits — `manage_metadata` as before, plus the org-scoped -// `manage_org_presentation` for org-overridable types written to the caller's -// own active organization. Sunk here by the same criterion as the scope half -// above: the doors live in `@objectstack/runtime` and `@objectstack/rest`, -// which share no other common home, and the predicate is registry-coupled -// (through `declaresOrgOverride`) so a second copy is forbidden drift. +// [#12702 · ADR-0131 D6] Which CALLERS a `/meta` item write door admits: +// `manage_metadata` (or `isSystem`), on both transports. The org-scoped +// `manage_org_presentation` arm retired with the per-organization overlay +// axis. Sunk here because the doors live in `@objectstack/runtime` and +// `@objectstack/rest`, which share no other common home. export * from './meta-write-capability.js'; // [commit 1408fe385 / #10101] The shared platform-row organization resolver — sunk here diff --git a/packages/metadata-core/src/meta-write-capability.test.ts b/packages/metadata-core/src/meta-write-capability.test.ts index 9f1e606ee9d..eb6a837b27b 100644 --- a/packages/metadata-core/src/meta-write-capability.test.ts +++ b/packages/metadata-core/src/meta-write-capability.test.ts @@ -1,77 +1,39 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#12702] `metaWriteCapabilityVerdict` — which CALLERS a `/meta` item write - * door admits. + * [#12702 · ADR-0131 D6] `metaWriteCapabilityVerdict` — which CALLERS a + * `/meta` item write door admits. * - * The contract under test (maintainer direction 2026-08-27, quoted in #12701): - * `manage_org_presentation` is a SUBSET key beside platform `manage_metadata` - * — it admits a write ONLY for a type whose registry entry declares - * `allowOrgOverride: true` AND a session with an active organization (the very - * organization the door threads). `manage_metadata` and `isSystem` behave - * exactly as before. + * The contract under test: `isSystem` or `manage_metadata`, and nothing else. + * The org-scoped `manage_org_presentation` arm retired with the + * per-organization overlay axis — the doors carry no organization into a + * metadata write, so the arm would have admitted its holders to + * environment-wide authoring. Its holders are refused like any other caller. * - * ## Registry-derived, so the truth table is the REGISTRY's - * - * The tier-A membership cases iterate `DEFAULT_METADATA_TYPE_REGISTRY` rather - * than a hand-written five-type list (Prime Directive #8). The IDENTITY of the - * five org-overridable types is pinned elsewhere, on the protocol's own - * refusal (`protocol.org-scoped-write-refused.test.ts`) — this file pins that - * the verdict MOVES WITH the registry, whatever the registry says. - * - * ## Refusal messages are envelope halves, not the envelope - * - * The verdict returns a message; each DOOR supplies its own status/code - * (REST `403 FORBIDDEN`, dispatcher `403 PERMISSION_DENIED` — pinned in their - * own gate suites). What is pinned HERE about messages: - * - the tier-B sentence is BYTE-IDENTICAL to the pre-#12702 one (the - * platform's most common metadata refusal stays stable — the - * `single`-posture stability half of the card's acceptance); - * - the tier-A sentences name the sanctioned path (both capabilities) and - * never the caller's own grants (#7450: the message varies only on - * request/session-derived facts, so the same request shape answers the - * same sentence whatever the caller holds). + * Refusal messages are envelope halves, not the envelope: each door supplies + * its own status/code (REST `403 FORBIDDEN`, dispatcher `403 + * PERMISSION_DENIED`, pinned in their own gate suites). What is pinned HERE is + * that the sentence names the sanctioned capability and varies only on the + * door's verb, never on what the caller holds (#7450). */ import { describe, it, expect } from 'vitest'; -import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; -import { canonicalMetaUrlType } from '@objectstack/spec/shared'; +import * as metadataCore from './index.js'; import { METADATA_AUTHORING_CAPABILITY, - ORG_PRESENTATION_AUTHORING_CAPABILITY, metaWriteCapabilityVerdict, type MetaWriteOperation, } from './meta-write-capability.js'; -const ORG = 'org_a'; - -/** Shorthand: verdict for a caller shape against a canonical type. */ -function verdict(input: { - isSystem?: boolean; - held?: unknown; - type: string; - org?: string | undefined; - operation?: MetaWriteOperation; -}) { - return metaWriteCapabilityVerdict({ - ...(input.isSystem !== undefined ? { isSystem: input.isSystem } : {}), - systemPermissions: input.held ?? [], - canonicalType: input.type, - activeOrganizationId: input.org, - operation: input.operation ?? 'save', - }); -} +const OPERATIONS: Record = { + save: 'Saving a metadata item', + reset: 'Resetting a metadata item', + publish: 'Publishing a metadata item', + rollback: 'Rolling back a metadata item', +}; describe('#12702 — the declaration cannot drift from the enforcement spelling', () => { - it('`manage_org_presentation` is a curated PLATFORM_CAPABILITIES entry with scope org', () => { - const declared = PLATFORM_CAPABILITIES.find( - (c) => c.name === ORG_PRESENTATION_AUTHORING_CAPABILITY, - ); - expect(declared).toBeDefined(); - expect(declared!.scope).toBe('org'); - }); - it('`manage_metadata` stays the platform-scoped authoring capability', () => { const declared = PLATFORM_CAPABILITIES.find( (c) => c.name === METADATA_AUTHORING_CAPABILITY, @@ -81,120 +43,52 @@ describe('#12702 — the declaration cannot drift from the enforcement spelling' }); }); -describe('#12702 — the unchanged paths: isSystem and manage_metadata', () => { - it('isSystem is admitted unconditionally — tier-B type, no organization', () => { - expect(verdict({ isSystem: true, type: 'object' })).toEqual({ allowed: true }); - }); - - it('manage_metadata is admitted for a tier-B type with no organization (env-wide, as today)', () => { - expect(verdict({ held: ['manage_metadata'], type: 'object' })).toEqual({ allowed: true }); - expect(verdict({ held: ['manage_metadata'], type: 'flow' })).toEqual({ allowed: true }); +describe('ADR-0131 D6 — `manage_org_presentation` retired', () => { + it('is no longer declared, and its constant is no longer exported', () => { + expect(PLATFORM_CAPABILITIES.some((c) => c.name === 'manage_org_presentation')).toBe(false); + expect('ORG_PRESENTATION_AUTHORING_CAPABILITY' in metadataCore).toBe(false); + // Control: the surviving export is visible through the same barrel. + expect('metaWriteCapabilityVerdict' in metadataCore).toBe(true); }); - it('manage_metadata is admitted for a tier-A type with and without an organization', () => { - expect(verdict({ held: ['manage_metadata'], type: 'view' })).toEqual({ allowed: true }); - expect(verdict({ held: ['manage_metadata'], type: 'view', org: ORG })).toEqual({ allowed: true }); - }); -}); - -describe('#12702 — org-scoped tier-A admission, derived from the registry', () => { - // The whole registry, both directions — no hand-written type list. A - // registry entry flipping `allowOrgOverride` moves this table the same day - // with nothing to keep in sync (and the five-type IDENTITY pin in - // metadata-protocol goes red, which is that pin working). - for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { - const expected = entry.allowOrgOverride === true; - it(`'${entry.type}' (allowOrgOverride: ${String(entry.allowOrgOverride ?? false)}) → holder with active org is ${expected ? 'ADMITTED' : 'REFUSED'}`, () => { - const out = verdict({ - held: [ORG_PRESENTATION_AUTHORING_CAPABILITY], - type: entry.type, - org: ORG, + it('a holder of it alone is refused on every door verb, with the plain sentence', () => { + for (const [operation, subject] of Object.entries(OPERATIONS) as Array<[MetaWriteOperation, string]>) { + const v = metaWriteCapabilityVerdict({ + systemPermissions: ['manage_org_presentation'], + operation, + }); + expect(v).toEqual({ + allowed: false, + message: `${subject} requires the \`manage_metadata\` capability.`, }); - expect(out.allowed).toBe(expected); - }); - } - - it('a type with NO registry entry at all (runtime plugin type) is refused — same posture as boot hydration', () => { - const out = verdict({ held: [ORG_PRESENTATION_AUTHORING_CAPABILITY], type: 'agent_tool_custom', org: ORG }); - expect(out.allowed).toBe(false); - }); - - it('the boundary fold composes: a URL-only spelling folded through canonicalMetaUrlType is admitted', () => { - // `email_templates` is a URL-only spelling (`SINGULAR_TO_PLURAL` has no - // manifest key for it — the measurement commit 26f3588fb wrote). The doors fold BEFORE - // asking; this case pins that the folded spelling answers tier-A. - expect(canonicalMetaUrlType('email_templates')).toBe('email_template'); - const out = verdict({ - held: [ORG_PRESENTATION_AUTHORING_CAPABILITY], - type: canonicalMetaUrlType('email_templates'), - org: ORG, - }); - expect(out).toEqual({ allowed: true }); - }); -}); - -describe('#12702 — the walls: env-wide, foreign-scope, and no-capability shapes', () => { - it('tier-A with NO active organization is refused — the write would land env-wide', () => { - const out = verdict({ held: [ORG_PRESENTATION_AUTHORING_CAPABILITY], type: 'view' }); - expect(out.allowed).toBe(false); - if (!out.allowed) { - expect(out.message).toContain('`manage_metadata`'); - expect(out.message).toContain('active organization'); - expect(out.message).toContain('environment-wide'); } }); - - it("an empty-string organization is absent, not an organization (the conservative direction)", () => { - const out = verdict({ held: [ORG_PRESENTATION_AUTHORING_CAPABILITY], type: 'view', org: '' }); - expect(out.allowed).toBe(false); - }); - - it('holding nothing relevant is refused on every tier', () => { - expect(verdict({ held: [], type: 'view', org: ORG }).allowed).toBe(false); - expect(verdict({ held: ['setup.access', 'studio.access'], type: 'view', org: ORG }).allowed).toBe(false); - expect(verdict({ held: ['manage_org_users'], type: 'object', org: ORG }).allowed).toBe(false); - }); - - it('a non-array systemPermissions is nothing held, never a throw (fail closed)', () => { - expect(verdict({ held: undefined, type: 'view', org: ORG }).allowed).toBe(false); - expect(verdict({ held: 'manage_metadata', type: 'view', org: ORG }).allowed).toBe(false); - expect(verdict({ held: { has: () => true }, type: 'view', org: ORG }).allowed).toBe(false); - }); }); -describe('#12702 — refusal sentences (#7450: request-derived, never caller-derived)', () => { - it('tier-B keeps the pre-#12702 sentence BYTE-IDENTICAL — for every caller shape', () => { - const legacy = 'Saving a metadata item requires the `manage_metadata` capability.'; - const noCaps = verdict({ held: [], type: 'object', org: ORG }); - const holder = verdict({ held: [ORG_PRESENTATION_AUTHORING_CAPABILITY], type: 'object', org: ORG }); - expect(noCaps).toEqual({ allowed: false, message: legacy }); - // The SAME sentence for the org-presentation holder: the message varies - // on the request's tier, never on what this caller holds (#7450). - expect(holder).toEqual({ allowed: false, message: legacy }); +describe('metaWriteCapabilityVerdict — who is admitted', () => { + it('admits `isSystem` whatever it holds', () => { + expect(metaWriteCapabilityVerdict({ isSystem: true, operation: 'save' })).toEqual({ allowed: true }); + expect(metaWriteCapabilityVerdict({ isSystem: true, systemPermissions: 'nonsense', operation: 'publish' })) + .toEqual({ allowed: true }); }); - it('tier-A with an active org names BOTH sanctioned paths — identically for every refused caller shape', () => { - const noCaps = verdict({ held: [], type: 'view', org: ORG }); - const unrelated = verdict({ held: ['setup.access'], type: 'view', org: ORG }); - expect(noCaps.allowed).toBe(false); - if (!noCaps.allowed) { - expect(noCaps.message).toContain('`manage_metadata`'); - expect(noCaps.message).toContain('`manage_org_presentation`'); - expect(noCaps.message).toContain('active organization'); + it('admits a `manage_metadata` holder on every verb', () => { + for (const operation of Object.keys(OPERATIONS) as MetaWriteOperation[]) { + expect(metaWriteCapabilityVerdict({ + systemPermissions: ['studio.access', 'manage_metadata'], + operation, + })).toEqual({ allowed: true }); } - expect(unrelated).toEqual(noCaps); }); - it('each door verb keeps its own pre-#12702 tier-B subject', () => { - const subject: Record = { - save: 'Saving a metadata item requires the `manage_metadata` capability.', - reset: 'Resetting a metadata item requires the `manage_metadata` capability.', - publish: 'Publishing a metadata item requires the `manage_metadata` capability.', - rollback: 'Rolling back a metadata item requires the `manage_metadata` capability.', - }; - for (const op of Object.keys(subject) as MetaWriteOperation[]) { - const out = verdict({ held: [], type: 'object', org: ORG, operation: op }); - expect(out).toEqual({ allowed: false, message: subject[op] }); + it('refuses a caller holding neither, and tolerates a non-array grant list as none held', () => { + for (const held of [[], ['studio.access', 'setup.access'], undefined, 'manage_metadata', { manage_metadata: true }]) { + const v = metaWriteCapabilityVerdict({ systemPermissions: held, operation: 'save' }); + expect(v.allowed, `held=${JSON.stringify(held)}`).toBe(false); } }); + + it('`isSystem: false` is not a bypass', () => { + expect(metaWriteCapabilityVerdict({ isSystem: false, operation: 'rollback' }).allowed).toBe(false); + }); }); diff --git a/packages/metadata-core/src/meta-write-capability.ts b/packages/metadata-core/src/meta-write-capability.ts index 7bc1a4abfab..ed62187c0e0 100644 --- a/packages/metadata-core/src/meta-write-capability.ts +++ b/packages/metadata-core/src/meta-write-capability.ts @@ -1,90 +1,55 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#12702] Which CALLERS a `/meta` item write door admits — the capability - * half of the decision whose SCOPE half lives next door in - * `meta-write-org-scope.ts`. - * - * ── The gap this closes ─────────────────────────────────────────────────── - * - * `manage_metadata` (ADR-0066 D1) was the only metadata-authoring capability, - * and it is `scope: 'platform'`: the one key that unlocks a tenant org admin's - * per-org tier-A overlays (view / dashboard / report / translation / - * email_template — ADR-0005) ALSO unlocks env-wide tier-B authoring (flows, - * objects — cross-tenant reach). So a single-DB SaaS operator in a walled - * posture could not let tenants customize presentation at all, even though the - * per-org overlay mechanism under the door is complete. Maintainer direction - * (2026-08-27, quoted verbatim in #12701): tenant org admins get org-scoped - * authoring of exactly the org-overridable types via a dedicated org-scoped - * capability; platform `manage_metadata` behaviour unchanged. + * [#12702 · ADR-0131 D6] Which CALLERS a `/meta` item write door admits. * * ── The contract ────────────────────────────────────────────────────────── * - * `manage_org_presentation` (declared `scope: 'org'` in `PLATFORM_CAPABILITIES`, - * `@objectstack/spec/security`) is a SUBSET key, not a re-keying: + * `manage_metadata` (ADR-0066 D1) is the metadata-authoring capability, and + * `isSystem` bypasses it, matching every other capability gate on the + * platform. Nothing else admits a `/meta` item write. + * + * ── What retired here (ADR-0131 D6, C5 stage S3) ────────────────────────── * - * - `isSystem` and `manage_metadata` behave exactly as before — first, and - * unconditionally. - * - `manage_org_presentation` admits a write ONLY when BOTH hold: - * 1. the target type's registry entry declares `allowOrgOverride: true` - * ({@link declaresOrgOverride} — the SAME registry-derived predicate - * that decides the write's organization scope, so "the types this - * capability reaches" and "the types whose writes carry the caller's - * org" cannot drift; Prime Directive #8: never a hand-written list); - * 2. the session HAS an active organization — which is the organization - * the door will thread via {@link organizationIdForMetaWrite}. No - * active org ⇒ the write would land env-wide (`organization_id NULL`, - * visible to every tenant) ⇒ refused. A foreign organization is not - * expressible on these doors at all: both transports derive the - * organization from the caller's own session (REST `ctx.tenantId`, - * dispatcher `resolveActiveOrganizationId`), never from the request, - * and the save request is built field by field so the body cannot - * smuggle one. + * This predicate used to admit a second, org-scoped capability, + * `manage_org_presentation`: an organization admin's write of an + * org-overridable type (view / dashboard / report / translation / + * email_template), threaded into the admin's own organization's overlay. + * ADR-0131 D6 retires the per-organization overlay axis: the `/meta` doors no + * longer carry an organization into a metadata write, so every admitted write + * lands environment-wide. Keeping that arm would have handed its holders + * environment-wide authoring of those five types, which is a wider reach than + * the capability ever granted. So the arm and the capability retire together + * (its ADR-0087 entry is `manage-org-presentation-retired`), and an + * organization admin's metadata write is refused here like any other + * caller's without `manage_metadata`. * * ── Why the predicate lives HERE ────────────────────────────────────────── * - * Same criterion as `meta-write-org-scope.ts` one module over (#8805): the - * doors live in `@objectstack/runtime` (dispatcher `/meta` PUT) and - * `@objectstack/rest` (PUT / DELETE / publish / rollback), `runtime` depends on - * `rest` so neither can import from the other, and a second copy of a - * registry-coupled predicate is exactly what Prime Directive #8 forbids. This - * package is the one both already depend on. + * The doors live in `@objectstack/runtime` (dispatcher `/meta` PUT) and + * `@objectstack/rest` (PUT / DELETE / publish / rollback); `runtime` depends + * on `rest`, so neither can import from the other, and this package is the + * one both already depend on. One predicate for every door on both + * transports, never a door-local restatement of it. * * ── What deliberately does NOT consult this predicate ───────────────────── * * - `POST /meta/_migrate-stored` (both transports): an install-wide stored- - * metadata rewrite is env-wide by definition, so condition 2 can never - * hold — it stays `manage_metadata`-only. + * metadata rewrite, gated on `manage_metadata` by its own door. * - Every non-`/meta` `manage_metadata` gate (automation flow authoring, - * package management, activation toggles, datasource admin): those are - * tier-B / platform surfaces; the org capability must not reach them. - * - The read path: org-overlay reads are scoped by - * `organizationIdForMetaRead` for EVERY caller class already and carry no - * capability gate (ADR-0106 masking is the read-side posture). + * package management, activation toggles, datasource admin). + * - The read path, which carries no capability gate (ADR-0106 masking is + * the read-side posture). * * ── Refusal messages (#7450) ────────────────────────────────────────────── * * A refusal names the capability that would admit ANY caller and says nothing - * about this one — the message varies only on REQUEST-derived facts (the - * type's registry tier) and the session's scope (active organization present - * or not), never on what the caller holds. For a type with no per-org channel - * the sentence is byte-identical to the pre-#12702 one: `manage_metadata` is - * the whole sanctioned path there, and the common refusal stays stable. + * about this one; the sentence varies only on the door's verb. */ -import { declaresOrgOverride } from './meta-write-org-scope.js'; - /** ADR-0066 D1's platform-wide metadata authoring capability. */ export const METADATA_AUTHORING_CAPABILITY = 'manage_metadata'; -/** - * [#12702] The org-scoped presentation-authoring capability. Declared in - * `PLATFORM_CAPABILITIES` (`@objectstack/spec/security`, `scope: 'org'`); - * `meta-write-capability.test.ts` pins this spelling to that declaration so - * the two cannot drift. - */ -export const ORG_PRESENTATION_AUTHORING_CAPABILITY = 'manage_org_presentation'; - /** * The `/meta` item write doors, by verb family. A closed set on purpose: the * refusal sentence's subject is derived from it, so a new door states its verb @@ -92,7 +57,7 @@ export const ORG_PRESENTATION_AUTHORING_CAPABILITY = 'manage_org_presentation'; */ export type MetaWriteOperation = 'save' | 'reset' | 'publish' | 'rollback'; -/** The refusal sentence's subject, per door. Matches the pre-#12702 wording. */ +/** The refusal sentence's subject, per door. */ const OPERATION_SUBJECT: Record = { save: 'Saving a metadata item', reset: 'Resetting a metadata item', @@ -110,66 +75,19 @@ export type MetaWriteCapabilityVerdict = * transport's status/code envelope: REST answers `403 FORBIDDEN`, the * dispatcher `403 PERMISSION_DENIED` — both pre-existing spellings, pinned in * their own gate suites). - * - * `canonicalType` MUST be the URL segment folded through - * `canonicalMetaUrlType` — the boundary folds, the layers below read the - * canonical singular (`metadata-url-spelling.ts`; the measurement commit 26f3588fb wrote in - * `meta-write-org-scope.ts` is why this is not optional). - * - * `activeOrganizationId` MUST be the same value the door threads into - * {@link organizationIdForMetaWrite} (REST `ctx.tenantId`, dispatcher - * `resolveActiveOrganizationId`) — one resolution feeding authorization AND - * scope, the single-resolution shape the REST doors already carry (commit b5378550e). */ export function metaWriteCapabilityVerdict(input: { isSystem?: boolean; /** The caller's `systemPermissions`; tolerant of a non-array (treated as none held). */ systemPermissions?: unknown; - /** CANONICAL singular metadata type — fold the URL segment BEFORE asking. */ - canonicalType: string; - /** The caller's own active organization — the org the door will thread. */ - activeOrganizationId: string | undefined; operation: MetaWriteOperation; }): MetaWriteCapabilityVerdict { if (input.isSystem === true) return { allowed: true }; - const held = new Set( - Array.isArray(input.systemPermissions) - ? input.systemPermissions.filter((p): p is string => typeof p === 'string') - : [], - ); - if (held.has(METADATA_AUTHORING_CAPABILITY)) return { allowed: true }; - - const orgOverridable = declaresOrgOverride(input.canonicalType); - // '' is treated as absent, exactly as `orgScopedWriteRefusal`'s falsy check - // reads it — the conservative direction (refuse rather than admit). - const scopedToOwnOrg = typeof input.activeOrganizationId === 'string' - && input.activeOrganizationId.length > 0; - - if (held.has(ORG_PRESENTATION_AUTHORING_CAPABILITY) && orgOverridable && scopedToOwnOrg) { - return { allowed: true }; - } - - const subject = OPERATION_SUBJECT[input.operation]; - if (!orgOverridable) { - // Byte-identical to the pre-#12702 sentence: for a type with no - // per-org overlay channel, `manage_metadata` IS the whole sanctioned - // path, and the platform's most common metadata refusal stays stable. - return { - allowed: false, - message: `${subject} requires the \`manage_metadata\` capability.`, - }; - } - if (!scopedToOwnOrg) { - return { - allowed: false, - message: `${subject} requires the \`manage_metadata\` capability. ` - + `\`manage_org_presentation\` admits only a write scoped to the session's active organization, ` - + `and this session has none — the write would land environment-wide.`, - }; - } + const held = Array.isArray(input.systemPermissions) + && input.systemPermissions.includes(METADATA_AUTHORING_CAPABILITY); + if (held) return { allowed: true }; return { allowed: false, - message: `${subject} requires the \`manage_metadata\` capability, or \`manage_org_presentation\` ` - + `for an org-overridable type written org-scoped to the session's active organization.`, + message: `${OPERATION_SUBJECT[input.operation]} requires the \`manage_metadata\` capability.`, }; } diff --git a/packages/metadata-core/src/meta-write-org-scope.test.ts b/packages/metadata-core/src/meta-write-org-scope.test.ts index 4c3bee812f3..f9f676e73c9 100644 --- a/packages/metadata-core/src/meta-write-org-scope.test.ts +++ b/packages/metadata-core/src/meta-write-org-scope.test.ts @@ -10,7 +10,7 @@ // consulted the predicate directly, so those two spellings read and wrote // env-wide while their singular twins were org-scoped. // -// The correction is at the boundary: doors fold through +// The correction is at the boundary: callers fold through // `canonicalMetaUrlType` BEFORE asking. These cases pin BOTH halves: // // 1. the COMPOSED contract (fold → predicate) answers the registry flag @@ -24,10 +24,10 @@ import { describe, it, expect } from 'vitest'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { META_URL_TO_SINGULAR, canonicalMetaUrlType } from '@objectstack/spec/meta-spelling'; +import * as metadataCore from './index.js'; import { declaresOrgOverride, organizationIdForMetaRead, - organizationIdForMetaWrite, } from './meta-write-org-scope.js'; const ORG = 'org_alpha'; @@ -44,10 +44,6 @@ describe('#10340 org scope composed with the boundary fold', () => { // arrives already covered. for (const [spelling, folded] of Object.entries(META_URL_TO_SINGULAR)) { const expected = REGISTRY_FLAG.get(folded) === true ? ORG : undefined; - expect( - organizationIdForMetaWrite(canonicalMetaUrlType(spelling), ORG), - `composed write scope for '${spelling}' (folds to '${folded}')`, - ).toBe(expected); expect( organizationIdForMetaRead(canonicalMetaUrlType(spelling), ORG), `composed read scope for '${spelling}' (folds to '${folded}')`, @@ -79,3 +75,11 @@ describe('#10340 org scope composed with the boundary fold', () => { expect(declaresOrgOverride('emailTemplates')).toBe(true); }); }); + +describe('ADR-0131 D6 — the write-side twin retired', () => { + it('`organizationIdForMetaWrite` is no longer exported: no `/meta` door threads an organization into a write', () => { + expect('organizationIdForMetaWrite' in metadataCore).toBe(false); + // Control: the read gate the protocol still runs is exported from the same barrel. + expect('organizationIdForMetaRead' in metadataCore).toBe(true); + }); +}); diff --git a/packages/metadata-core/src/meta-write-org-scope.ts b/packages/metadata-core/src/meta-write-org-scope.ts index 3c739d5c5bf..17a23e3b87c 100644 --- a/packages/metadata-core/src/meta-write-org-scope.ts +++ b/packages/metadata-core/src/meta-write-org-scope.ts @@ -1,70 +1,32 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#7018 — the #6190 ruling's runtime half] Which metadata WRITES carry the - * session's active organization, and which land env-wide. - * - * ── The defect this closes ──────────────────────────────────────────────── - * - * The dispatcher used to thread `resolveActiveOrganizationId` into - * `protocol.saveMetaItem` **unconditionally**, and - * `SysMetadataRepository.put` stamps `organization_id: this.organizationId` - * whatever the type is. So a session with an active organization minted an - * org-scoped `sys_metadata` row for EVERY type — including the ones the - * registry declares NOT per-org overridable. - * - * Cold boot walks past exactly those rows: `loadMetaFromDb` hydrates - * `organization_id IS NULL` only, and for `allowOrgOverride: true` types that - * is the ADR-0005 design (their overlays are loaded on demand by - * `getMetaItem`/`getMetaItems`). For every other type there is no per-org read - * channel at all, so the row is a **phantom write**: it works for the life of - * the process and is silently absent after the next restart. The measured - * specimens are `flow` (binds its triggers until the restart, then stops - * firing — `@objectstack/metadata-protocol`'s `reportUnhydratableOrgScopedRows` - * warns about precisely this) and `object` (every record 404s). - * - * The maintainer ruling on #6190 (2026-08-09, Option A) is that the runtime - * stops minting them: thread the org only for types that declare - * `allowOrgOverride: true`; otherwise the write lands env-wide — the same row - * a no-active-org session already produces today. - * - * ── Why the STATIC registry flag, and not `isOverlayAllowed` ────────────── - * - * `@objectstack/metadata-protocol` gates the *write authorization* through - * `isOverlayAllowed`, which additionally consults the `OS_METADATA_WRITABLE` - * escape hatch. This predicate deliberately does NOT: it must agree with the - * predicate that decides whether the row is readable again, and boot hydration - * keys off the static registry flag alone. `reportUnhydratableOrgScopedRows` - * already settled the same question on the read side, in its own words: - * - * "Derived from `DEFAULT_METADATA_TYPE_REGISTRY` and NOT from - * `isOverlayAllowed`, because the `OS_METADATA_WRITABLE` escape hatch only - * unlocks the WRITE — an env-unlocked type's org rows are hydrated no more - * than any other's". - * - * An env-unlocked `object` written org-scoped would be the same phantom, so - * the escape hatch unlocks the write and the write still lands env-wide. + * The registry-derived "does this type declare a per-organization overlay?" + * predicate, and the organization a metadata READ inside the protocol carries. + * + * ── ADR-0131 D6: the doors carry no organization (C5 stage S3) ──────────── + * + * The per-organization overlay axis is retired. Neither transport's `/meta` + * doors (`@objectstack/rest`, and the runtime dispatcher's `/meta` branch) + * carry an organization into a metadata write or read any more: every + * Studio-authored write lands environment-wide (`organization_id` NULL), and + * every door read resolves environment → code. The write-side twin that used + * to decide which writes carried the session's organization, + * `organizationIdForMetaWrite`, therefore had no caller left and was deleted. + * + * What stays, and why. `@objectstack/metadata-protocol` still gates the reads + * an in-process caller hands an organization (`getMetaItem`, `getMetaItems`, + * the layered read, history and diff) through {@link organizationIdForMetaRead}. + * The one door that still names an organization is the anonymous form door, + * whose read of the Default Organization's withdrawals stays, fail-closed, + * until the promotion ceremony carries those rows to the environment layer + * (ADR-0131 C7). The protocol's own read narrowing (environment → code + * everywhere) is a later stage of the same retirement, and deletes this + * module with it. * * ⛔ Registry-derived, never a hand-written list (Prime Directive #8): the set - * below is computed from `DEFAULT_METADATA_TYPE_REGISTRY` — the very export - * `ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES` derives from — so a - * registry entry flipping `allowOrgOverride` moves this predicate with it and - * there is nothing to keep in sync by hand. - * - * ── Why this lives in `metadata-core` and not in the dispatcher [#8805] ──── - * - * Because the decision belongs to the CALLER, and there is more than one. - * `@objectstack/metadata-protocol` deliberately does not make it: an org-scoped - * write of a non-overridable type is REFUSED (`NOT_OVERRIDABLE`, 403) rather - * than coerced to env-wide, because option B of the #6190 ruling — silently - * rewriting the tenancy statement the author made — was rejected. So each door - * that writes metadata must decide, before it calls, which organization the - * write carries. The dispatcher was the only door that did; the REST `/meta` - * write doors passed nothing and stamped every `sys_metadata_audit` row - * env-wide, which is #8805. `@objectstack/rest` cannot import the dispatcher's - * copy — `runtime` depends on `rest`, so that edge is a cycle — and a second - * copy of a registry-derived predicate is precisely what the ⛔ above forbids. - * This package is the one both already depend on and that depends on neither. + * below is computed from `DEFAULT_METADATA_TYPE_REGISTRY`, so a registry entry + * flipping `allowOrgOverride` moves this predicate with it. */ import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; @@ -85,8 +47,8 @@ import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared * spellings while storage folded them into an org-scoped type — one item, * two partitions, addressed by spelling. * - * The correction landed at the boundary, not here: the REST `/meta` doors - * fold the segment through `canonicalMetaUrlType` BEFORE the scope decision, + * The correction landed at the boundary, not here: a caller folds the + * segment through `canonicalMetaUrlType` BEFORE the scope decision, * exactly as `metadata-url-spelling.ts` mandates ("folding happens at the * boundary and only there; the layers below keep reading the single * canonical singular"). ⛔ Do not "complete" this set with the URL map — a @@ -112,7 +74,7 @@ const ORG_OVERRIDABLE_TYPES: ReadonlySet = (() => { * dispatcher-era callers — but ⚠️ [commit 26f3588fb] that tolerance is NOT the URL * fold: URL-only spellings (`translations`, `email_templates`) answer * `false` here. A caller holding a raw `/meta/:type` segment must fold it - * through `canonicalMetaUrlType` BEFORE asking, as the REST doors do; see + * through `canonicalMetaUrlType` BEFORE asking; see * `ORG_OVERRIDABLE_TYPES` above for the measurement and for why this * predicate must not grow the URL map itself. * @@ -126,29 +88,13 @@ export function declaresOrgOverride(type: string): boolean { } /** - * The `organizationId` a metadata write of `type` should carry, given the - * session's active organization. - * - * Returns the active org for a type the registry declares per-org overridable - * (today's behaviour, unchanged), and `undefined` — env-wide, the same row a - * no-active-org session produces — for every other type. - */ -export function organizationIdForMetaWrite( - type: string, - activeOrganizationId: string | undefined, -): string | undefined { - if (activeOrganizationId === undefined) return undefined; - return declaresOrgOverride(type) ? activeOrganizationId : undefined; -} - -/** - * [#9454] The read-side twin: the `organizationId` a metadata READ of `type` - * should carry, given the session's active organization. + * [#9454] The `organizationId` a metadata READ of `type` should carry, given + * the caller's organization. * * ── Why a read door has to ask this at all ──────────────────────────────── * - * `organizationIdForMetaWrite` above stops the runtime MINTING org-scoped rows - * for types that have no per-org read channel. It says nothing about serving + * The (since retired) write-side twin stopped the runtime MINTING org-scoped + * rows for types that have no per-org read channel. It says nothing about serving * the rows that types WITH such a channel legitimately produce — and the REST * `/meta` read doors were never told. A `PUT` of an org-overridable type * (`view`, `dashboard`, `report`, `translation`, `email_template`) landed an @@ -172,10 +118,9 @@ export function organizationIdForMetaWrite( * vanishes at the next restart. Gating the read on the same static registry * flag keeps the two sides answering one question. * - * ⇒ This is deliberately the same predicate as the write side, not a parallel - * one: read scope and write scope CANNOT drift, because both are - * {@link declaresOrgOverride}. If a registry entry flips `allowOrgOverride`, - * both doors move together and there is nothing to keep in sync by hand. + * Since ADR-0131 D6 the `/meta` doors hand the protocol no organization, so + * this gate answers `undefined` for every door read; see the module header for + * the in-process callers that still name one. * * Returns the active org for a type the registry declares per-org overridable, * and `undefined` — env-wide, today's behaviour for every read — otherwise. diff --git a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts index e03e64c60ae..5b96e800cb1 100644 --- a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts +++ b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts @@ -458,12 +458,13 @@ describe('declared email templates carrying the `content` alias spelling (#8378) * Every request is recorded, so the organization the sweep read in is * asserted rather than assumed. */ -function layeredProtocol(declared: any[], overlay?: { organizationId: string; items: any[] }) { +function layeredProtocol(declared: any[], overlay?: { organizationId?: string; items: any[] }) { const requests: Array<{ type: string; organizationId?: string }> = []; return { requests, async getMetaItems(request: { type: string; organizationId?: string }) { requests.push({ ...request }); + // `organizationId` absent on both sides is the environment layer. const items = overlay && request.organizationId === overlay.organizationId ? overlay.items : declared; return { type: request.type, items: items.map((i) => ({ ...i, _diagnostics: { valid: true } })) }; }, @@ -488,47 +489,41 @@ function rowProjectedFromOverlay(over: Record = {}): any { } describe('bootstrapDeclaredEmailTemplates — the effective template (#21785)', () => { - it('projects the org-scoped overlay the metadata door serves, read in the default organization', async () => { - // The registry holds ONLY the declaration: boot hydration leaves an - // org-scoped overlay out of it. Reading it is the reverted-on-restart defect. + it('projects the environment overlay the metadata door serves', async () => { + // The registry holds ONLY the declaration; the door's layered list serves + // the overlay. Reading the registry is the reverted-on-restart defect. const engine = new FakeEngine({ rows: { [TABLE]: [rowProjectedFromOverlay()] }, declared: { email_template: [declaredTemplate()] }, }); const protocol = layeredProtocol( [declaredTemplate()], - { organizationId: ORG, items: [declaredTemplate({ subject: OVERLAY_WORDING })] }, + { items: [declaredTemplate({ subject: OVERLAY_WORDING })] }, ); - const result = await bootstrapEffectiveEmailTemplates(engine as any, undefined, { - protocol, - tenancy: { defaultOrgId: async () => ORG }, - }); + const result = await bootstrapEffectiveEmailTemplates(engine as any, undefined, { protocol }); - expect(protocol.requests).toEqual([{ type: 'email_template', organizationId: ORG }]); + expect(protocol.requests).toEqual([{ type: 'email_template' }]); expect(result).toEqual({ seeded: 1, skipped: 0 }); expect(rowsOf(engine)).toHaveLength(1); expect(rowsOf(engine)[0].subject).toBe(OVERLAY_WORDING); }); - it('reads env-wide when the tenancy service names no organization (a walled posture never guesses one)', async () => { + it('[ADR-0131 D6] names no organization: a legacy Default-Organization overlay is not projected', async () => { + // Before the per-organization overlay axis retired, a single-posture + // Studio save landed under the Default Organization and this sweep read + // there. The door no longer serves that row, so neither does the sweep. const engine = new FakeEngine({ rows: { [TABLE]: [rowProjectedFromOverlay()] } }); const protocol = layeredProtocol( [declaredTemplate()], { organizationId: ORG, items: [declaredTemplate({ subject: OVERLAY_WORDING })] }, ); - await bootstrapEffectiveEmailTemplates(engine as any, undefined, { - protocol, - tenancy: { defaultOrgId: async () => null }, - }); + await bootstrapEffectiveEmailTemplates(engine as any, undefined, { protocol }); - // No organization on the request: the tenancy contract named none, so the - // read is env-wide and the declaration is what the row carries. expect(protocol.requests).toEqual([{ type: 'email_template' }]); expect(rowsOf(engine)[0].subject).toBe(PACKAGE_WORDING); }); - it('projects nothing on a failed effective read, never the package layer in its place', async () => { const engine = new FakeEngine({ rows: { [TABLE]: [rowProjectedFromOverlay()] }, @@ -539,10 +534,7 @@ describe('bootstrapDeclaredEmailTemplates — the effective template (#21785)', async getMetaItems(): Promise { throw new Error('sys_metadata read failed'); }, }; - const result = await bootstrapEffectiveEmailTemplates(engine as any, undefined, { - protocol, - tenancy: { defaultOrgId: async () => ORG }, - }, { warn }); + const result = await bootstrapEffectiveEmailTemplates(engine as any, undefined, { protocol }, { warn }); expect(result).toEqual({ seeded: 0, skipped: 0 }); expect(rowsOf(engine)[0].subject).toBe(OVERLAY_WORDING); @@ -561,17 +553,13 @@ describe('bootstrapDeclaredEmailTemplates — the effective template (#21785)', }); const warn = vi.fn(); const protocol = layeredProtocol([], { - organizationId: ORG, items: [ declaredTemplate({ name: 'ops.digest', category: 'notification', subject: 'Overlay digest' }), declaredTemplate({ subject: OVERLAY_WORDING }), ], }); - const result = await bootstrapEffectiveEmailTemplates(engine as any, undefined, { - protocol, - tenancy: { defaultOrgId: async () => ORG }, - }, { warn }); + const result = await bootstrapEffectiveEmailTemplates(engine as any, undefined, { protocol }, { warn }); expect(result).toEqual({ seeded: 0, skipped: 2 }); expect(rowsOf(engine).map((r) => r.subject)).toEqual(['Admin original', 'Data-door wording']); diff --git a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts index 04568c89c62..1e3dab0d4ef 100644 --- a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts +++ b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts @@ -111,8 +111,8 @@ function uid(prefix: string): string { } /** - * The two kernel services the boot sweep reads the EFFECTIVE templates through - * (#21785). Both are optional: a host that registers no `protocol` has no + * The kernel service the boot sweep reads the EFFECTIVE templates through + * (#21785). Optional: a host that registers no `protocol` has no * metadata door, so nothing can overlay a declaration there and the registry * read below is already the effective one. * @@ -123,8 +123,6 @@ function uid(prefix: string): string { export interface EffectiveEmailTemplateSources { /** The `protocol` service. `getMetaItems` is the layered list `GET /meta/email_template` serves. */ protocol?: { getMetaItems(request: GetMetaItemsRequest): Promise }; - /** The `tenancy` service. `defaultOrgId()` is the organization an org-less read resolves in. */ - tenancy?: { defaultOrgId(): Promise }; } /** {@link readDeclared}'s answer when the effective read did not happen. */ @@ -137,32 +135,26 @@ const EFFECTIVE_READ_FAILED = Symbol('email-template-effective-read-failed'); * decomposition parks `stack.emailTemplates`), falling back to the metadata * service. Every read hands back the authoring document itself. * - * ## [#21785] Why the effective read, and in which organization - * - * The registry holds the package's declaration and only the ENV-WIDE overlays - * boot hydration (`loadMetaFromDb`) registers. `email_template` is - * `allowOrgOverride: true`, so an admin saving through `PUT /meta` with an - * active organization — every Studio save on a `single`-posture deployment, - * where the Default Organization is bootstrapped — writes an ORG-SCOPED - * overlay, which hydration deliberately leaves out of the process-wide - * registry. The live path projected that overlay into the sending row at save - * time; this sweep then read the package layer and wrote the package wording - * back on every boot, while `GET /meta/email_template/:name` kept serving the - * admin's wording. Measured on the showcase before the fix: the env-wide - * overlay survived (the registry lists it after the package entry), the - * org-scoped one reverted. + * ## [#21785] Why the effective read + * + * The registry holds the package's declaration and only the overlays boot + * hydration (`loadMetaFromDb`) registered. The live path projects a Studio + * overlay into the sending row at save time; a sweep that read the package + * layer would write the package wording back on every boot while + * `GET /meta/email_template/:name` kept serving the admin's wording. * * So the sweep reads what the door reads — `protocol.getMetaItems`, the - * layered list (org overlay over env-wide overlay over package), one item per - * `(name, locale)` slot — and resolves the organization the way every other - * org-less reader of org-overridable metadata does: `tenancy.defaultOrgId()`, - * as the anonymous form doors read a form (`@objectstack/rest`). That answers - * the Default Organization under `single` (ADR-0131: the organization IS the - * environment there) and `null` whenever a walled posture was requested (the - * tenancy contract never guesses an organization there), where the read is - * env-wide. The sending row stays org-agnostic: template resolution keys on - * `(name, locale)` only, and per-organization template rows are a capability - * no ruling has opened. + * layered list (environment overlay over package), one item per + * `(name, locale)` slot. + * + * [ADR-0131 D6] It names no organization. The per-organization overlay axis + * is retired: every `/meta` write lands environment-wide, and the door reads + * environment → code. This sweep used to read in the Default Organization + * (`tenancy.defaultOrgId()`), because a `single`-posture Studio save landed + * there; such a legacy row is no longer served by the door, so it is no longer + * projected either, until ADR-0131 C7 promotes it to the environment layer. + * The sending row stays org-agnostic: template resolution keys on + * `(name, locale)` only. * * The served items carry read decorations (`_diagnostics`) the strict schema * refuses, so each is passed through the shared `stripReadDecorations` — the @@ -228,10 +220,7 @@ async function readDeclared( const protocol = sources?.protocol; if (typeof protocol?.getMetaItems === 'function') { try { - const organizationId = typeof sources?.tenancy?.defaultOrgId === 'function' - ? await sources.tenancy.defaultOrgId() - : null; - const listed = await protocol.getMetaItems({ type, ...(organizationId ? { organizationId } : {}) }); + const listed = await protocol.getMetaItems({ type }); return listed.items.filter(Boolean).map(stripReadDecorations); } catch (err: any) { logger?.warn?.( diff --git a/packages/plugins/plugin-email/src/email-plugin.ts b/packages/plugins/plugin-email/src/email-plugin.ts index 6c3041ff781..402f00a8212 100644 --- a/packages/plugins/plugin-email/src/email-plugin.ts +++ b/packages/plugins/plugin-email/src/email-plugin.ts @@ -1099,16 +1099,14 @@ export class EmailServicePlugin implements Plugin { try { metadataService = ctx.getService('metadata'); } catch { /* optional */ } // [#21785] The sweep projects the EFFECTIVE template — what `GET /meta` - // serves, an org-scoped Studio overlay included — through the protocol's - // layered list, read in `tenancy.defaultOrgId()`'s organization. Both are - // optional: a host without them has no metadata door to overlay through. + // serves, an environment Studio overlay included — through the protocol's + // layered list. Optional: a host without it has no metadata door to + // overlay through. let protocol: EffectiveEmailTemplateSources['protocol']; try { protocol = ctx.getService('protocol'); } catch { /* optional */ } - let tenancy: EffectiveEmailTemplateSources['tenancy']; - try { tenancy = ctx.getService('tenancy'); } catch { /* optional */ } try { - await bootstrapEffectiveEmailTemplates(engine, metadataService, { protocol, tenancy }, ctx.logger as any); + await bootstrapEffectiveEmailTemplates(engine, metadataService, { protocol }, ctx.logger as any); } catch (err: any) { ctx.logger.warn( 'EmailServicePlugin: declared email-template bootstrap failed (built-in templates still serve): ' diff --git a/packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts b/packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts index 4a9e3aaf174..cc9b079de92 100644 --- a/packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts +++ b/packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts @@ -22,12 +22,17 @@ // leaves out of the registry the sweep used to read. The harness default is an // org-less admin, whose save lands env-wide — and that shape kept the admin's // wording before the fix as well (measured), so a pin booted that way would -// pass against the defect. The first case asserts the overlay really is -// org-scoped, so this file cannot drift into the vacuous shape unnoticed. +// pass against the defect. +// +// [ADR-0131 D6] Since the per-organization overlay axis retired, the metadata +// door carries no organization, so even this org-active admin's save lands +// ENVIRONMENT-WIDE, and the boot sweep reads the environment layer (it names +// no organization either). `orgContext: true` stays: it is the deployment +// shape, and the first case now pins that the save is environment-wide there. // // ## What each case pins // -// - the metadata-door edit is org-scoped and projected at once (preconditions); +// - the metadata-door edit is environment-wide and projected at once (preconditions); // - after a cold boot the sending row, `GET /meta` and a real `sendTemplate` // all carry the admin's wording; // - control: the organization DATA door is closed (ADR-0131 D6, ruling C on @@ -109,7 +114,7 @@ describe('[#21785] a metadata-door email template edit survives a cold boot (sho if (dir) rmSync(dir, { recursive: true, force: true }); }); - it('precondition: the metadata-door edit lands org-scoped and is projected into the sending row at once', async () => { + it('precondition: an org-active admin\'s metadata-door edit lands environment-wide and is projected into the sending row at once', async () => { const [seeded] = await sendingRows(); expect({ subject: seeded?.subject, managed_by: seeded?.managed_by, customized: seeded?.customized }) .toEqual({ subject: PACKAGE_SUBJECT, managed_by: 'package', customized: false }); @@ -125,12 +130,11 @@ describe('[#21785] a metadata-door email template edit survives a cold boot (sho expect(put.status, JSON.stringify(put.json)).toBe(200); expect(put.json?.projectionApplied).toEqual({ success: true }); - // ⛔ Without this the restart below proves nothing: an env-wide overlay - // survived the restart before the fix too. + // [ADR-0131 D6] The door names no organization, whatever the admin's. const ql: any = await stack!.kernel.getServiceAsync('objectql'); const stored = await ql.find('sys_metadata', { where: { type: 'email_template', name: NAME }, context: SYS }); expect(stored).toHaveLength(1); - expect(stored[0].organization_id, 'the overlay is org-scoped').toEqual(expect.any(String)); + expect(stored[0].organization_id ?? null, 'the overlay is environment-wide').toBeNull(); expect((await sendingRows()).map((r: any) => r.subject)).toEqual([ADMIN_SUBJECT]); }); diff --git a/packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts b/packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts index d0b24b84a44..459c37898e2 100644 --- a/packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts +++ b/packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts @@ -10,16 +10,17 @@ // it could not show the published side accepting intake. // // On a walled posture the anonymous form doors read the env-wide form -// definition, so an organization-scoped change to a form's anonymous intake is -// refused at the save door, naming the env-wide save as the remedy. Pinned: +// definition. Before ADR-0131 D6 an administrator with an active organization +// saved an organization overlay, and the save door refused one that changed +// the form's anonymous intake. The per-organization overlay axis is retired: +// the `/meta` doors carry no organization into a write, so that admin's save +// is ENVIRONMENT-WIDE and takes effect on the anonymous doors. Pinned: // -// - the organization-scoped withdrawal answers `403 NOT_OVERRIDABLE` and -// nothing is saved (the organization still reads the published form, and -// both doors still serve it); -// - an organization-scoped edit that leaves the sharing alone still saves; -// - the env-wide withdrawal is accepted and both anonymous doors answer -// `404 FORM_NOT_FOUND`, with no row landing; republishing env-wide restores -// both doors. +// - a withdrawal by an admin WITH an active organization is accepted +// environment-wide and both anonymous doors answer `404 FORM_NOT_FOUND`, +// with no row landing; republishing restores both doors; +// - an edit that leaves the sharing alone lands environment-wide too; +// - the same with no active organization (unchanged). import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { bootStack, type VerifyStack } from '@objectstack/verify'; @@ -168,27 +169,30 @@ describe('walled posture: withdrawing a public form from anonymous intake', () = expect((await read())._diagnostics?.warnings).toBeUndefined(); }); - it('withdrawn in an organization: refused 403 NOT_OVERRIDABLE naming the env-wide save, and nothing is saved', async () => { + it('[ADR-0131 D6] withdrawn by an admin WITH an active organization: accepted environment-wide, both doors 404 and nothing lands; republishing restores them', async () => { await setActive(orgId); - const res = await put(withAnonymous(false)); - expect(res.status, JSON.stringify(res.json)).toBe(403); - const error = (res.json.error ?? res.json) as Record; - expect(error.code ?? res.json.code).toBe('NOT_OVERRIDABLE'); - expect(JSON.stringify(res.json)).toMatch(/Save it env-wide instead/); + const off = await put(withAnonymous(false)); + expect(off.status, JSON.stringify(off.json)).toBe(200); + expect(String(off.json.message ?? '')).toMatch(/env-wide/); + const closed = await probe(); + expect([closed.get, closed.getCode, closed.submit, closed.submitCode]) + .toEqual([404, 'FORM_NOT_FOUND', 404, 'FORM_NOT_FOUND']); + expect(closed.landed).toHaveLength(0); - expect((await read()).config?.sharing?.allowAnonymous, 'the organization still reads the published form').toBe(true); - const p = await probe(); - expect([p.get, p.submit]).toEqual([200, 201]); - expect(p.landed).toHaveLength(1); + const on = await put(withAnonymous(true)); + expect(on.status, JSON.stringify(on.json)).toBe(200); + const open = await probe(); + expect([open.get, open.submit]).toEqual([200, 201]); + expect(open.landed).toHaveLength(1); }); - it('an organization-scoped edit that leaves the sharing alone still saves (control)', async () => { + it('[ADR-0131 D6] an edit by an org-active admin that leaves the sharing alone lands environment-wide (control)', async () => { await setActive(orgId); const body = structuredClone(published); body.label = `${String(published.label ?? 'Intake')} (tenant)`; const res = await put(body); expect(res.status, JSON.stringify(res.json)).toBe(200); - expect(String(res.json.message ?? '')).toContain(`org=${orgId}`); + expect(String(res.json.message ?? '')).toMatch(/env-wide/); }); it('withdrawn env-wide: both doors answer 404 FORM_NOT_FOUND and nothing lands; republishing restores them', async () => { @@ -208,16 +212,18 @@ describe('walled posture: withdrawing a public form from anonymous intake', () = expect(open.landed).toHaveLength(1); }); - it('withdrawn through `sharing.enabled: false` alone: refused org-scoped; env-wide both doors 404 and nothing lands', async () => { + it('withdrawn through `sharing.enabled: false` alone, by an org-active admin and env-wide: both doors 404 and nothing lands', async () => { const withEnabled = (enabled: boolean): Record => { const body = withAnonymous(true); body.config.sharing.enabled = enabled; return body; }; + // [ADR-0131 D6] An org-active admin's withdrawal is environment-wide too. await setActive(orgId); - const refused = await put(withEnabled(false)); - expect(refused.status, JSON.stringify(refused.json)).toBe(403); - expect(JSON.stringify(refused.json)).toMatch(/NOT_OVERRIDABLE/); + const orgActive = await put(withEnabled(false)); + expect(orgActive.status, JSON.stringify(orgActive.json)).toBe(200); + expect(String(orgActive.json.message ?? '')).toMatch(/env-wide/); + expect((await probe()).get).toBe(404); await setActive(null); const off = await put(withEnabled(false)); 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 449ea548bfa..aa9d811b9f2 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 @@ -7,18 +7,25 @@ // // The showcase ships `showcase_inquiry.contact`, a FormView open to anonymous // intake at `/forms/contact-us`. The administrator saves it the way the editor -// does (`PUT /meta/view/...` at their own session), env-wide (no active -// organization) or in their organization (`orgContext: true` gives the admin -// one, and it is the organization the anonymous doors read). Pinned: +// does (`PUT /meta/view/...` at their own session) — and since ADR-0131 D6 +// retired the per-organization overlay axis, that save lands ENVIRONMENT-WIDE +// even when the admin has an active organization. // -// - an organization overlay that keeps the form open does not survive an -// env-wide withdrawal: both anonymous doors answer `404 FORM_NOT_FOUND` -// and nothing lands; -// - an organization-scoped save that would leave it open (a re-save of -// the overlay open from before, or a re-open) is refused -// (`403 NOT_OVERRIDABLE`) and the doors stay closed; -// - withdrawn in the organization while open env-wide: closed; -// - open at both layers (control): both doors accept. +// The anonymous form doors still read the Default Organization's layer for +// its withdrawals, fail-closed, until ADR-0131 C7 carries those rows to the +// environment layer (triage ruling Q3 A on the retirement card). No door can +// write such a row any more, so this file PLANTS legacy organization rows +// straight through the protocol, the way a door wrote 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 +// `404 FORM_NOT_FOUND` and nothing lands; +// - ⭐ a legacy organization WITHDRAWAL still closes the form while it is +// open environment-wide (the read Q3 A keeps); +// - the admin's save with an active organization is environment-wide; +// - open at both layers (control): both doors accept, and the row lands in +// the organization the doors resolve. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; @@ -35,6 +42,8 @@ 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. */ @@ -69,6 +78,16 @@ describe('showcase: a public form withdrawal at any metadata layer holds', () => return { status: res.status, json: (await res.json()) as Record }; }; + /** Plant a LEGACY organization-scoped overlay, as a door wrote one before ADR-0131 D6. */ + const plantLegacyOrgOverlay = async (allowAnonymous: boolean) => { + const item = structuredClone(published); + item.config.sharing.allowAnonymous = allowAnonymous; + const result = await protocol.saveMetaItem({ + type: 'view', name: 'showcase_inquiry.contact', item, organizationId, + }); + expect(String(result?.message ?? ''), JSON.stringify(result)).toContain(`org=${organizationId}`); + }; + const saved = async (allowAnonymous: boolean) => { const r = await save(allowAnonymous); expect(r.status, JSON.stringify(r.json)).toBe(200); @@ -91,48 +110,35 @@ 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 () => { await stack?.stop(); }); - it('PRECONDITION: an organization overlay that keeps the form open is served', async () => { - await scope(organizationId); - expect(await saved(true), 'the save is an organization overlay').toContain(`org=${organizationId}`); + it('PRECONDITION: a legacy organization overlay that keeps the form open is served', async () => { + await plantLegacyOrgOverlay(true); expect(await probe()).toEqual(OPEN); }); - it('withdrawn env-wide beneath an open organization overlay: both doors refuse and nothing lands', async () => { - await scope(null); - expect(await saved(false)).toMatch(/env-wide/); - expect(await probe()).toEqual(CLOSED); - }); - - it('an organization-scoped save that would leave it open is refused, and the doors stay closed', async () => { + it('withdrawn by an admin WITH an active organization: the save is environment-wide, and it closes the form beneath the open legacy overlay', async () => { await scope(organizationId); - // The organization overlay is still open from before the withdrawal: - // re-saving it as it is would leave open a withdrawn form. - const resave = await save(true); - expect(resave.status, JSON.stringify(resave.json)).toBe(403); - expect(resave.json.code ?? resave.json.error?.code).toBe('NOT_OVERRIDABLE'); - // Withdrawing it there is accepted; re-opening it is refused again. - expect((await save(false)).status).toBe(200); - const reopen = await save(true); - expect(reopen.status, JSON.stringify(reopen.json)).toBe(403); - expect(reopen.json.code ?? reopen.json.error?.code).toBe('NOT_OVERRIDABLE'); + expect(await saved(false), 'an org-active admin\'s save is environment-wide (ADR-0131 D6)').toMatch(/env-wide/); expect(await probe()).toEqual(CLOSED); }); - it('withdrawn in the organization while open env-wide: both doors refuse', async () => { + it('⭐ a legacy organization WITHDRAWAL still closes the form while it is open environment-wide (fail-closed until C7)', async () => { await scope(null); expect(await saved(true)).toMatch(/env-wide/); + await plantLegacyOrgOverlay(false); expect(await probe()).toEqual(CLOSED); }); it('open at both layers (control): both doors accept and the row lands in the organization', async () => { await scope(organizationId); - expect(await saved(true)).toContain(`org=${organizationId}`); + expect(await saved(true)).toMatch(/env-wide/); + await plantLegacyOrgOverlay(true); const marker = `layer_probe_${probeSeq + 1}`; expect(await probe()).toEqual(OPEN); const [row] = await ql.find('showcase_inquiry', { where: { name: marker }, context: SYS }); diff --git a/packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts index 99761833b2b..7284f38dc9b 100644 --- a/packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts @@ -19,6 +19,11 @@ // `enabled: false` (absent reads as the schema default, false). Both sides are // pinned: republishing at the same scope restores both doors, and after the // organization republish the row lands in that organization. +// +// [ADR-0131 D6] Since the per-organization overlay axis retired, the save of an +// admin WITH an active organization is environment-wide too: the doors carry +// no organization into a metadata write. The intake row still lands in the +// organization the anonymous form door resolves (the Default Organization). import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; @@ -139,22 +144,24 @@ describe('showcase: withdrawing the public contact form closes every intake door expect(open.landed).toHaveLength(1); }); - it('withdrawn in the admin\'s organization: both doors answer 404 FORM_NOT_FOUND and nothing lands', async () => { + it('withdrawn by an admin WITH an active organization: the save is environment-wide, both doors answer 404 FORM_NOT_FOUND and nothing lands', async () => { const orgs = await ql.find('sys_organization', { fields: ['id'], limit: 2, context: SYS }); expect(orgs, 'the showcase boot holds exactly one organization').toHaveLength(1); const on = await stack.apiAs(admin, 'POST', '/auth/organization/set-active', { organizationId: orgs[0].id }); expect(on.status).toBe(200); - expect(await save(false), 'the save is an organization overlay').toContain(`org=${orgs[0].id}`); + // [ADR-0131 D6] Not an organization overlay any more. + expect(await save(false), 'the save is environment-wide').toMatch(/env-wide/); const p = await probe(); expect([p.get, p.getCode, p.submit, p.submitCode]).toEqual([404, 'FORM_NOT_FOUND', 404, 'FORM_NOT_FOUND']); expect(p.landed).toHaveLength(0); }); - it('republished in the same organization (control): both doors accept and the row lands in that organization', async () => { + it('republished by the same admin (control): both doors accept and the row lands in the organization', async () => { const message = await save(true); - const org = /org=(\S+?),/.exec(message)?.[1]; - expect(org, message).toBeTruthy(); + expect(message, 'the save is environment-wide').toMatch(/env-wide/); + const orgs = await ql.find('sys_organization', { fields: ['id'], limit: 2, context: SYS }); + const org = orgs[0].id; const p = await probe(); expect([p.get, p.submit]).toEqual([200, 201]); expect(p.landed).toHaveLength(1); diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index dc3de714377..fe5e633cb2c 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -319,8 +319,24 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t // --------------------------------------------------------------------------- describe('[#13160] §2 the consumer surface, counted from the tree', () => { - it('68 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => { - // [#22430] 66 → 68 sites / 90 → 92 mentions — two NEW consumers, both + it('63 invocation sites, 78 mentions — the thread\'s two control numbers hold', () => { + // [ADR-0131 D6 + #22430, merged] 68 → 63 sites / 92 → 78 mentions: + // #22430's two new BARE sites and ADR-0131 D6's five removed CAUGHT + // sites, landed on parallel branches and composed at the merge. The + // two entries below are each measured against their own base. + // + // [ADR-0131 D6] 66 → 61 sites / 90 → 76 mentions (on its own base) — five consumers + // REMOVED, all from the CAUGHT half (21 → 16; 13 → 12 on the + // invocation line). The `/meta` doors stopped carrying an organization + // into metadata reads, so the five reads that resolved the caller only + // to name its organization no longer resolve it: + // `fetchCurrentMetaDocument` (same-line), the layered read, the + // diagnostics sweep, the references sweep and the `/published` overlay + // read (continuation-line). The bare half (45) is untouched. Nine prose + // mentions went with the org-scope comments that named the seam, so + // mentions fall by 14 against 5 sites. + // + // [#22430] 66 → 68 sites / 90 → 92 mentions (on its own base) — two NEW consumers, both // BARE. The API-description endpoints (`registerOpenApiEndpoints`: the // document and its viewer) resolved no identity at all and served an // anonymous caller; each handler now opens with a bare site and the @@ -466,27 +482,29 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { // naming the seam is the point of the sentence — and the sentence // moving only the mention count is this control working: a site was not // added, and the number that tracks sites did not move. - expect(SITES.length).toBe(68); - expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92); + expect(SITES.length).toBe(63); + expect(SOURCE.split('resolveExecCtx').length - 1).toBe(78); }); - it('the split is 21 locally caught / 47 bare — NOT 13 / 47, which does not add to 68', () => { - // 13 sites spell the catch on the invocation line; 8 more spell it on - // the continuation line. A single-line grep sees 13 and the arithmetic - // silently loses eight sites. [#20237] 15 → 13 and 23 → 21: the list + it('the split is 16 locally caught / 47 bare — NOT 12 / 47, which does not add to 63', () => { + // 12 sites spell the catch on the invocation line; 4 more spell it on + // the continuation line. A single-line grep sees 12 and the arithmetic + // silently loses four sites. [#20237] 15 → 13 and 23 → 21: the list // route's app and dashboard sites moved into the shared list gate (§2). + // [ADR-0131 D6] 13 → 12 and 21 → 16: five organization-only reads + // stopped resolving the caller (§2). // // [commit cc837dbfe] The new site is BARE, and that is a decision the next case // enforces: a locally-caught site sitting behind the shared floor would // be the first of its kind and would break the structural claim below. const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch(')); - expect(sameLine.length).toBe(13); - expect(CAUGHT.length).toBe(21); + expect(sameLine.length).toBe(12); + expect(CAUGHT.length).toBe(16); expect(BARE.length).toBe(47); expect(CAUGHT.length + BARE.length).toBe(SITES.length); }); - it('⭐ every one of the 47 bare sites is guarded on the VERY NEXT LINE, and none of the 21 caught ones is', () => { + it('⭐ every one of the 47 bare sites is guarded on the VERY NEXT LINE, and none of the 16 caught ones is', () => { // This inverts the reason the thread gave for doing the bare sites // first ("no local signal that a fault becomes an anonymous subject"). // The bare sites are bare BECAUSE the shared anonymous floor is the @@ -1052,16 +1070,18 @@ describe('[#13538] §9 a PARTIAL outage is not served as an org-unscoped 200', ( .toEqual({ driven: true, section8: false }); }); - it('CONTROL: healthy, the list door serves 200 and its read NAMES the caller\'s organization', async () => { - // Without this, "no unscoped read" below is satisfied by an instrument - // that never scoped anything in the first place. + it('CONTROL: healthy, the list door serves 200 and its read names NO organization (ADR-0131 D6)', async () => { + // Without this, "no read" below is satisfied by an instrument that + // never reads in the first place. Since the per-organization overlay + // axis retired, the healthy read is environment → code even for a + // caller with an active organization. const { rows, reads } = await sweepSelective(0, ORG_SCOPED_DOC, ORG_SCOPED_TYPE); const list = listRow(rows); expect({ found: list !== undefined, status: list?.status }).toEqual({ found: true, status: 200 }); const listReads = reads.filter((r) => r.route === list!.route); expect(listReads.length).toBeGreaterThan(0); expect(listReads.map((r) => r.request?.organizationId)) - .toEqual(listReads.map(() => ENTITLED.tenantId)); + .toEqual(listReads.map(() => undefined)); }); it('CONTROL: the injector is SELECTIVE — with the second read faulted, the first still fulfils', async () => { @@ -1089,17 +1109,14 @@ describe('[#13538] §9 a PARTIAL outage is not served as an org-unscoped 200', ( .toEqual({ route: list!.route, fabricated200: false }); }); - it('⭐ and it issues NO metadata read without an organization while the tenant is unresolvable', async () => { - // ⭐ THE PROPERTY. The harm is the READ, not the status: a door that - // proceeds with `listCtx === undefined` asks `getMetaItems` for the - // env-wide partition and serves rows from outside the caller's org. - // Asserting on the request the handler BUILT is what survives a - // refactor that changes no spelling. + it('⭐ and it issues NO metadata read while the caller is unresolvable', async () => { + // ⭐ THE PROPERTY. The harm is the READ, not the status: the list still + // resolves its caller (the draft-preview admission reads it), and a + // door that proceeded with `listCtx === undefined` would serve an + // answer to a caller it never identified. Asserting on the requests the + // handler BUILT is what survives a refactor that changes no spelling. const { rows, reads } = await sweepSelective(1, ORG_SCOPED_DOC, ORG_SCOPED_TYPE); const list = listRow(rows); - const unscoped = reads.filter( - (r) => r.route === list!.route && r.request?.organizationId === undefined, - ); - expect(unscoped.map((r) => r.route)).toEqual([]); + expect(reads.filter((r) => r.route === list!.route).map((r) => r.route)).toEqual([]); }); }); diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index 9fd9e7980aa..fddb0d685dc 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -103,16 +103,16 @@ export { refuseRepeatedQueryParams, repeatedQueryParamMessage } from './query-mu // projection every object-schema exit applies (`projectMetaObjectSchema`), the // `GET /meta/book/:name/tree` answer (`createMetaBookTreeAnswer`), the list's // unknown-type refusal (`refuseUnknownMetaListType`) and the organization a -// caller's `/meta` request is scoped to — the VETTED one on its execution -// context (`metaCallerOrganizationId`, and `metaReadOrganizationId` for a read -// of one type). +// caller's `/meta` request carries — the VETTED one on its execution context +// (`metaCallerOrganizationId`). Since ADR-0131 D6 no `/meta` read or write +// names it: the per-organization overlay axis is retired. // // [#20478] …and the layered view's, on both of its spellings: its post-read // chain (`createMetaLayeredAnswer` — the per-caller gate on every layer under // the stored-version doors' policy, the object mask and its cache posture), the // deprecated `?layers=` flag's parse (`wantsMetaItemLayers`) and the headers it // is served under (`metaItemLayersDeprecationHeaders`). The read itself is each -// transport's, scoped by `metaReadOrganizationId`. +// transport's, environment → code. // // [#21087] …and the type-level read admission both transports ask at their // `/meta` entry, before any store read (`metaTypeReadRefusal` over @@ -135,7 +135,6 @@ export { META_TYPE_WRITE_CAPABILITIES, metaCallerOrganizationId, metaItemLayersDeprecationHeaders, - metaReadOrganizationId, metaRequestLocale, metaTypeReadRefusal, metaTypeWriteRefusal, diff --git a/packages/rest/src/meta-alternate-door-read-gates.test.ts b/packages/rest/src/meta-alternate-door-read-gates.test.ts index 347571c2cdd..5ed7192265a 100644 --- a/packages/rest/src/meta-alternate-door-read-gates.test.ts +++ b/packages/rest/src/meta-alternate-door-read-gates.test.ts @@ -840,9 +840,10 @@ describe('[#20156] edges', () => { for (const layer of LAYERS) expect(navIds(layers.body?.[layer]), layer).toEqual(navIds(plainItem(plainApp))); }); - it('[ruling 5856774816] "may write" is the save door\'s WHOLE question, not a capability name: an org-scoped presentation author writes views, not apps, so the app is pruned for them', async () => { - // `manage_org_presentation` admits a save only of an org-overridable - // type, scoped to the session's own organization — `app` is not one. + it('[ruling 5856774816 · ADR-0131 D6] "may write" is the save door\'s WHOLE question, not a capability name: a holder of the retired `manage_org_presentation` writes nothing, so the app is pruned for them', async () => { + // `manage_org_presentation` used to admit an org-scoped save of an + // org-overridable type; it retired with the per-organization overlay + // axis, so its holder is no writer of any type. const presenter: Caller = { ctx: { userId: 'u_presenter', systemPermissions: ['manage_org_presentation'], tenantId: 'org_1' }, holdings: [], @@ -854,14 +855,13 @@ describe('[#20156] edges', () => { }; const { rest, protocol } = setup(presenter); - // The control: the same caller IS a writer, of a type they may write. + // The view's save door refuses them — the door the capability opened... const view = await save(rest, 'view', 'all_leads', clone(LEADS_VIEW)); - expect(view.statusCode).toBe(200); - expect(protocol.saveMetaItem).toHaveBeenCalledTimes(1); - // The app's save door refuses them... + expect(envelope(view)).toEqual({ status: 403, code: 'FORBIDDEN' }); + // ...and so does the app's. const app = await save(rest, 'app', 'crm', clone(CRM_APP)); expect(envelope(app)).toEqual({ status: 403, code: 'FORBIDDEN' }); - expect(protocol.saveMetaItem).toHaveBeenCalledTimes(1); + expect(protocol.saveMetaItem).not.toHaveBeenCalled(); // ...so every stored-version door serves them the plain read's pruned app // — save `/diff`, an authoring door that refuses them outright // ([#20378] ruling 5865708652: they may not read drafts either). diff --git a/packages/rest/src/meta-dashboard-view-i18n-explicit-override.test.ts b/packages/rest/src/meta-dashboard-view-i18n-explicit-override.test.ts index d28e839df98..bdac26105f1 100644 --- a/packages/rest/src/meta-dashboard-view-i18n-explicit-override.test.ts +++ b/packages/rest/src/meta-dashboard-view-i18n-explicit-override.test.ts @@ -13,8 +13,10 @@ * boundary handed the translator a base for objects only * (`packagedObjectBaseOf`). What is pinned here is that plumbing, through the * ROUTES, over the REAL protocol and a REAL registry: the packaged items are - * registered the way the boot registers them and the org overlay rows are - * seeded the way a published overlay stores them, so no write verb is doubled. + * registered the way the boot registers them and the overlay rows are seeded + * the way a published overlay stores them, so no write verb is doubled. Since + * ADR-0131 D6 that is the ENVIRONMENT row (`organization_id` NULL): the `/meta` + * doors carry no organization, though both callers have an active one. * * Measured before the fix (a booted showcase, admin and member of one org): an * overlay on `system_overview` published, `?layers=true` reported it @@ -162,8 +164,9 @@ function createMockServer() { /** * Register the packaged items the way the boot does and seed each overlay as - * the PUBLISHED org row its write stores (`package_id: null`, the org, - * `state: 'active'`). `overlays: []` is the state after a reset. + * the PUBLISHED row its write stores (`package_id: null`, environment-wide + * since ADR-0131 D6, `state: 'active'`). `overlays: []` is the state after a + * reset. */ function makeHost(overlays: OverlayRow[], who: Who) { const registry = new SchemaRegistry({ multiTenant: false }); @@ -178,7 +181,7 @@ function makeHost(overlays: OverlayRow[], who: Who) { type: o.type, name: o.name, package_id: null, - organization_id: ORG, + organization_id: null, state: 'active', metadata: JSON.stringify(o.body), })); @@ -299,7 +302,7 @@ describe('#20730 §1 packagedObjectBaseOf — one per-type packaged-base resolve // §2 — a published dashboard overlay beats the packaged catalog // --------------------------------------------------------------------------- -describe('#20730 §2 dashboard — a published org overlay is what both /meta reads serve', () => { +describe('#20730 §2 dashboard — a published overlay is what both /meta reads serve', () => { it.each(cells())('$read read, $who, $locale: the edited widget serves the edit', async ({ who, locale, read }) => { const host = makeHost([EDITED_DASHBOARD], who); expect(titleOf(await host[read]('dashboard', DASH, locale), 'widget_total_users')).toBe(EDITED_TITLE); @@ -341,7 +344,7 @@ describe('#20730 §2 dashboard — a published org overlay is what both /meta re // §3 — a published view overlay beats the packaged catalog // --------------------------------------------------------------------------- -describe('#20730 §3 view — a published org overlay on a packaged view is what both /meta reads serve', () => { +describe('#20730 §3 view — a published overlay on a packaged view is what both /meta reads serve', () => { it.each(cells())('$read read, $who, $locale: the edited view serves the edit', async ({ who, locale, read }) => { const host = makeHost([EDITED_VIEW], who); expect((await host[read]('view', VIEW, locale))?.label).toBe(EDITED_LABEL); diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index 97c7b96cb09..ef794f2e6c0 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -48,7 +48,7 @@ * ({@link createMetaItemAnswer}), the book tree ({@link createMetaBookTreeAnswer}), * the list's unknown-type refusal ({@link refuseUnknownMetaListType}), the * object mask's cache posture ({@link projectMetaObjectSchema}) and the - * organization a caller's read is scoped to ({@link metaReadOrganizationId}). + * caller's vetted organization ({@link metaCallerOrganizationId}). * [#20478] So does the layered view, on both of its spellings * ({@link createMetaLayeredAnswer}, {@link wantsMetaItemLayers}, * {@link metaItemLayersDeprecationHeaders}). @@ -65,7 +65,6 @@ import { canonicalMetaUrlType, pluralToSingular, unrecognisedMetaTypeRefusal } f import { ObjectSchemaMaskEvaluationError, applyObjectSchemaMask, - organizationIdForMetaRead, relateObjectSchemaMaskPosture, resolveObjectSchemaRuntimeView, type ObjectSchemaMaskPosture, @@ -156,11 +155,9 @@ export interface MetaItemReadGateSources extends MetaReadGateAudienceSources, Me * organization at all (the fail-closed state every other layer reads). The * runtime dispatcher's `/meta` doors used to read the claim straight off the * auth service, so a member removed from an organization kept its metadata - * partition there for the rest of the session: its org-scoped overlays were - * served to them by the item read, the list, `/published` and `?state=draft`, - * `GET /meta/_drafts` listed its pending drafts, and `PUT` wrote into it — - * while `RestServer`, which reads `ctx.tenantId`, answered the same caller the - * env-wide rows. One source, both transports. + * partition there for the rest of the session. One source, both transports. + * Since ADR-0131 D6 no `/meta` read or write carries it; the dispatcher's + * `/packages` doors still do. * * `undefined` for an anonymous caller, a caller with no active organization, * and one whose claim was dropped — which are one fact to every consumer. @@ -170,25 +167,6 @@ export function metaCallerOrganizationId(caller: unknown): string | undefined { return typeof tenantId === 'string' ? tenantId : undefined; } -/** - * [#9454 · #20408] The organization a `/meta` READ of `type` carries: - * `organizationIdForMetaRead` over the FOLDED segment (folded-type commit - * 26f3588fb, whose card no longer resolves: the raw plural would miss the - * registry's override flag) and the caller's vetted - * organization ({@link metaCallerOrganizationId}). An organization reaches the - * read only for a type the registry declares `allowOrgOverride`, so a - * non-overridable type never resurrects a pre-#6190 phantom org row. - * - * `RestServer`'s list and item reads and the runtime dispatcher's ask this, so - * the partition a caller reads cannot differ by transport. - */ -export function metaReadOrganizationId(type: unknown, caller: unknown): string | undefined { - return organizationIdForMetaRead( - canonicalMetaUrlType(typeof type === 'string' ? type : ''), - metaCallerOrganizationId(caller), - ); -} - // ── The verdict ─────────────────────────────────────────────────────────────── /** @@ -2858,11 +2836,9 @@ export type MetaLayeredAnswer = * `meta-list-projection-parity.test.ts` in `@objectstack/runtime` drives both * spellings through both transports. * - * The read stays each transport's, in the caller's VETTED partition - * ({@link metaReadOrganizationId} over the folded type — the partition the plain - * read reads, [#9454] so an author who has just saved an org overlay is not - * shown `overlay: null`) and its `?package=` scope (ADR-0048), exactly as the - * item read's does ({@link createMetaItemAnswer}). + * The read stays each transport's, environment → code (ADR-0131 D6: the + * `/meta` doors name no organization) with its `?package=` scope (ADR-0048), + * exactly as the item read's ({@link createMetaItemAnswer}). * * Not translated and not cached, both deliberately: this is a diagnostic view of * what is STORED at each layer, so locale-collapsing it (or serving it from the diff --git a/packages/rest/src/meta-item-save-capability-gate.test.ts b/packages/rest/src/meta-item-save-capability-gate.test.ts index 47d6d18000e..84e77a0ef3c 100644 --- a/packages/rest/src/meta-item-save-capability-gate.test.ts +++ b/packages/rest/src/meta-item-save-capability-gate.test.ts @@ -293,15 +293,15 @@ describe('#6603 — the exempt authoring caller is unaffected', () => { }); /** - * [#12702] `manage_org_presentation` on this door — the org-scoped - * presentation capability. A subset key beside `manage_metadata`: admitted - * ONLY for a type whose registry entry declares `allowOrgOverride: true` AND a - * session with an active organization (`ctx.tenantId` — the very value the - * door threads as the write's organization). Both directions pinned: the - * tier-A admission with the threaded organization ASSERTED, and the tier-B / - * env-wide / foreign-scope refusals with the protocol never entered. + * [#12702 · ADR-0131 D6] The organization-admin authoring door is CLOSED. The + * per-organization overlay axis is retired: this door threads no organization + * into a metadata write, so `manage_org_presentation` — which admitted only an + * org-scoped write of an org-overridable type — retired with it. Its holder is + * refused like any caller without `manage_metadata`, with the protocol never + * entered; a `manage_metadata` caller's write lands environment-wide whatever + * its active organization. */ -describe('#12702 — PUT /meta/:type/:name: `manage_org_presentation`, org-scoped tier-A admission', () => { +describe('ADR-0131 D6 — PUT /meta/:type/:name: an organization admin\'s metadata write is refused; writes land env-wide', () => { const ORG = 'org_a'; /** A lean boot for driving the door with arbitrary `:type` params. */ @@ -339,67 +339,47 @@ describe('#12702 — PUT /meta/:type/:name: `manage_org_presentation`, org-scope }; } - const HOLDER = { userId: 'u_orgadmin', systemPermissions: ['manage_org_presentation'], tenantId: ORG }; - - it('admits an org-scoped tier-A save, threaded to the caller\'s OWN organization', async () => { - const stack = bootDoor(HOLDER); - const write = await stack.put('view', 'org_grid', { name: 'org_grid', label: 'Org Grid' }); - expect(write.res.statusCode).toBe(200); - expect(stack.saveMetaItem).toHaveBeenCalledTimes(1); - // The threading IS the wall: the only organization an admitted write - // can carry is the caller's own active one. - expect(stack.saveMetaItem.mock.calls[0][0]).toMatchObject({ - type: 'view', name: 'org_grid', organizationId: ORG, - }); - }); - - it('[#10340] the URL-only spelling is folded BEFORE the verdict — `email_templates` is tier-A here too', async () => { - const stack = bootDoor(HOLDER); - const write = await stack.put('email_templates', 'welcome', { name: 'welcome', subject: 'Hi' }); - expect(write.res.statusCode).toBe(200); - expect(stack.saveMetaItem.mock.calls[0][0]).toMatchObject({ - type: 'email_templates', name: 'welcome', organizationId: ORG, - }); - }); + const ORG_ADMIN = { userId: 'u_orgadmin', systemPermissions: ['manage_org_presentation'], tenantId: ORG }; it.each([ + ['view'], + ['email_templates'], + ['dashboard'], ['object'], ['flow'], - ])('refuses the SAME holder a tier-B `%s` write — nothing is written', async (type) => { - const stack = bootDoor(HOLDER); - const write = await stack.put(type, 'account', { label: 'x' }); + ])('refuses a `manage_org_presentation`-only caller with an active organization a `%s` write — nothing is written', async (type) => { + const stack = bootDoor(ORG_ADMIN); + const write = await stack.put(type, 'org_grid', { name: 'org_grid', label: 'x' }); expect(write.res.statusCode).toBe(403); expect(write.body).toMatchObject({ error: { code: 'FORBIDDEN' } }); - // The tier-B sentence is byte-identical to the pre-#12702 one: the - // message varies on the request's tier, never on the caller's own - // grants (#7450). + // The plain sentence, for every type: the message names the sanctioned + // capability and never the caller's own grants (#7450). expect(write.body.error.message).toBe('Saving a metadata item requires the `manage_metadata` capability.'); expect(stack.saveMetaItem).not.toHaveBeenCalled(); }); - it('refuses the SAME holder a tier-A write when the session has NO active organization — env-wide is walled', async () => { - const stack = bootDoor({ userId: 'u_orgadmin', systemPermissions: ['manage_org_presentation'] }); - const write = await stack.put('view', 'org_grid', { name: 'org_grid' }); - expect(write.res.statusCode).toBe(403); - expect(write.body).toMatchObject({ error: { code: 'FORBIDDEN' } }); - expect(String(write.body.error.message)).toContain('active organization'); - expect(stack.saveMetaItem).not.toHaveBeenCalled(); + it('a `manage_metadata` caller WITH an active organization saves a view environment-wide — no organization reaches the protocol', async () => { + const stack = bootDoor({ userId: 'u_author', systemPermissions: ['manage_metadata'], tenantId: ORG }); + const write = await stack.put('view', 'org_grid', { name: 'org_grid', label: 'Org Grid' }); + expect(write.res.statusCode).toBe(200); + expect(stack.saveMetaItem).toHaveBeenCalledTimes(1); + expect(stack.saveMetaItem.mock.calls[0][0]).toMatchObject({ type: 'view', name: 'org_grid' }); + expect('organizationId' in stack.saveMetaItem.mock.calls[0][0]).toBe(false); }); - it('a foreign organization is not expressible: query/body-smuggled organization ids do not move the threading', async () => { - const stack = bootDoor(HOLDER); + it('query/body-smuggled organization ids do not reach the write either', async () => { + const stack = bootDoor({ userId: 'u_author', systemPermissions: ['manage_metadata'], tenantId: ORG }); const write = await stack.put( 'view', 'org_grid', { name: 'org_grid', organization_id: 'org_b', organizationId: 'org_b' }, { organizationId: 'org_b' }, ); expect(write.res.statusCode).toBe(200); - // The save request is built field by field from named `req` values: - // the write still carries the CALLER's organization. - expect(stack.saveMetaItem.mock.calls[0][0]).toMatchObject({ organizationId: ORG }); + // The save request is built field by field from named `req` values. + expect('organizationId' in stack.saveMetaItem.mock.calls[0][0]).toBe(false); }); - it('control: `manage_metadata` with no active organization still saves a view env-wide, as today', async () => { + it('control: `manage_metadata` with no active organization still saves a view env-wide, as before', async () => { const stack = bootDoor({ userId: 'u_author', systemPermissions: ['manage_metadata'] }); const write = await stack.put('view', 'org_grid', { name: 'org_grid' }); expect(write.res.statusCode).toBe(200); diff --git a/packages/rest/src/meta-publish-package-scope.test.ts b/packages/rest/src/meta-publish-package-scope.test.ts index 1ba75e6059f..de4982efa99 100644 --- a/packages/rest/src/meta-publish-package-scope.test.ts +++ b/packages/rest/src/meta-publish-package-scope.test.ts @@ -261,7 +261,7 @@ describe('#10063 POST /meta/:type/:name/publish states the package it is promoti }); describe('the rest of the publish request is untouched', () => { - it('still carries type, name, organization, actor and message', async () => { + it('still carries type, name, actor and message — and, since ADR-0131 D6, no organization', async () => { // A widened accept surface must not move anything already on the // request — this is the preservation half of the pin sweep. const b = boot(AUTHORIZED); @@ -273,7 +273,8 @@ describe('#10063 POST /meta/:type/:name/publish states the package it is promoti const request = requestFrom(b.publishMetaItem); expect(request.type).toBe(TYPE); expect(request.name).toBe('shared_grid'); - expect(request.organizationId).toBe(ORG); + // The caller has an active organization; the publish names none. + expect(request.organizationId).toBeUndefined(); expect(request.actor).toBe('u1'); expect(request.message).toBe('ship it'); expect(request.packageId).toBe(PKG); diff --git a/packages/rest/src/meta-write-door-capability-enumeration.test.ts b/packages/rest/src/meta-write-door-capability-enumeration.test.ts index 5e9612e729e..98fcaa76cca 100644 --- a/packages/rest/src/meta-write-door-capability-enumeration.test.ts +++ b/packages/rest/src/meta-write-door-capability-enumeration.test.ts @@ -537,28 +537,27 @@ describe('#8919 — the two new gates refuse BEFORE the protocol is probed', () }); /** - * [#12702] `manage_org_presentation` across the door set — the org-scoped - * presentation capability, run against EVERY enumerated door rather than one. + * [#12702 · ADR-0131 D6] The organization-admin authoring door is CLOSED on + * every enumerated door, not on one. * * The four ITEM doors (save / reset / publish / rollback) share one verdict - * (`metaWriteCapabilityVerdict`, `@objectstack/metadata-core`): beside - * `manage_metadata` they admit `manage_org_presentation`, ONLY for a type - * whose registry entry declares `allowOrgOverride: true` AND a session with an - * active organization — which is the organization each door threads, so an - * admitted write can only land in the caller's own org partition. - * `_migrate-stored` is the deliberate exclusion: an install-wide rewrite is - * env-wide by definition, so the org condition can never hold there. + * (`metaWriteCapabilityVerdict`, `@objectstack/metadata-core`). It used to + * admit `manage_org_presentation` for an org-scoped write of an org-overridable + * type; the per-organization overlay axis is retired, so no door threads an + * organization any more and the arm retired with the capability. Pinned both + * ways per door: the holder is refused with the protocol never reached, and a + * `manage_metadata` caller with an active organization is admitted with NO + * organization on the request it sends. */ -describe('#12702 — `manage_org_presentation`: org-scoped tier-A admission, per door', () => { +describe('ADR-0131 D6 — `manage_org_presentation` is refused, and no door threads an organization', () => { const ORG = 'org_a'; const ORG_ADMIN = { userId: 'u_orgadmin', systemPermissions: ['manage_org_presentation'], tenantId: ORG }; - const ORG_ADMIN_NO_ORG = { userId: 'u_orgadmin', systemPermissions: ['manage_org_presentation'] }; + const ORG_AUTHOR = { userId: 'u_author', systemPermissions: ['manage_metadata'], tenantId: ORG }; - /** The doors the org capability may open — everything but the install-wide rewrite. */ const ITEM_DOORS = DOORS.filter((d) => d.protocolMethod !== 'migrateStoredMetadata'); const MIGRATE_DOOR = DOORS.find((d) => d.protocolMethod === 'migrateStoredMetadata')!; - /** The same door, addressed at a tier-A type (`view` declares allowOrgOverride). */ + /** The same door, addressed at a formerly org-overridable type (`view`). */ const asView = (door: Door): Door => ({ ...door, params: { ...door.params, type: 'view', name: 'org_grid' }, @@ -568,17 +567,13 @@ describe('#12702 — `manage_org_presentation`: org-scoped tier-A admission, per }); it.each(ITEM_DOORS.map((d) => [d.label, d] as const))( - '%s → an org-active holder is admitted for a tier-A type, threaded to their OWN organization', + '%s → an org-active `manage_org_presentation` holder is refused a `view` write, protocol never reached', async (_label, door) => { const stack = boot(ORG_ADMIN); const out = await stack.knock(asView(door)); - expect(out.status).not.toBe(403); - expect(out.status).not.toBe(401); - expect(stack.calls[door.protocolMethod]).toBe(1); - // The threading IS the wall: the only organization an admitted - // write can carry is the caller's own active one. - const request = (stack.protocol[door.protocolMethod] as any).mock.calls[0][0]; - expect(request).toMatchObject({ organizationId: ORG }); + expect(out.status).toBe(403); + expect(out.body).toMatchObject({ error: { code: 'FORBIDDEN' } }); + expect(stack.calls[door.protocolMethod]).toBe(0); }, ); @@ -594,17 +589,19 @@ describe('#12702 — `manage_org_presentation`: org-scoped tier-A admission, per ); it.each(ITEM_DOORS.map((d) => [d.label, d] as const))( - '%s → the SAME holder with NO active organization is refused a tier-A write — env-wide is walled', + '%s → an org-active `manage_metadata` caller is admitted, and the request names NO organization', async (_label, door) => { - const stack = boot(ORG_ADMIN_NO_ORG); + const stack = boot(ORG_AUTHOR); const out = await stack.knock(asView(door)); - expect(out.status).toBe(403); - expect(out.body).toMatchObject({ error: { code: 'FORBIDDEN' } }); - expect(stack.calls[door.protocolMethod]).toBe(0); + expect(out.status).not.toBe(403); + expect(out.status).not.toBe(401); + expect(stack.calls[door.protocolMethod]).toBe(1); + const request = (stack.protocol[door.protocolMethod] as any).mock.calls[0][0]; + expect(request?.organizationId).toBeUndefined(); }, ); - it(`${MIGRATE_DOOR.label} → stays \`manage_metadata\`-only for an org-active holder (env-wide by definition)`, async () => { + it(`${MIGRATE_DOOR.label} → stays \`manage_metadata\`-only for an org-active holder`, async () => { const stack = boot(ORG_ADMIN); const out = await stack.knock(MIGRATE_DOOR); expect(out.status).toBe(403); diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 9307577aca8..6d8788e1c9a 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -248,9 +248,9 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ responseSchema: 'GetMetaItemResponseSchema', note: 'Answers BARE, so the named schema is the whole body. Filled now that meta-item-layered-route.test.ts parses BOTH branches of this mount (cached and uncached) against it — the uncached branch carries the ADR-0010 protection envelope this schema now declares, every key optional because the cached branch never publishes it' }, { route: 'PUT /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.saveItem', - note: 'Gated on `manage_metadata` (ADR-0066 D1), same mechanism as POST /meta/_migrate-stored — a session alone is no longer enough. The write-side answer to ADR-0106 D1: a masked read PUT back verbatim used to delete the fields the caller could not see. The gate is the shared `metaWriteCapabilityVerdict`: `manage_org_presentation` is also admitted, ONLY for an `allowOrgOverride: true` type written org-scoped to the caller\'s own active organization, so a tenant org admin authors their own org\'s overlays without platform-wide `manage_metadata`' }, + note: 'Gated on `manage_metadata` (ADR-0066 D1), same mechanism as POST /meta/_migrate-stored — a session alone is no longer enough. The write-side answer to ADR-0106 D1: a masked read PUT back verbatim used to delete the fields the caller could not see. The gate is the shared `metaWriteCapabilityVerdict`. The write names no organization and lands environment-wide (ADR-0131 D6); the org-scoped `manage_org_presentation` admission retired with it' }, { route: 'DELETE /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.deleteItem', - note: 'REST-only: the dispatcher /meta branch has no DELETE handling — it falls into the read path. Gated on `manage_metadata` (ADR-0066 D1), same mechanism as the PUT twins — but NOT for the ADR-0106 reason: nothing is masked or round-tripped here, this discards a customization overlay outright, and `?dropStorage=true` takes the object table with it. Same shared verdict as the PUT door: an admitted `manage_org_presentation` reset threads the caller\'s own organization, so the only row it can discard is their own org\'s overlay' }, + note: 'REST-only: the dispatcher /meta branch has no DELETE handling — it falls into the read path. Gated on `manage_metadata` (ADR-0066 D1), same mechanism as the PUT twins — but NOT for the ADR-0106 reason: nothing is masked or round-tripped here, this discards a customization overlay outright, and `?dropStorage=true` takes the object table with it. Same shared verdict as the PUT door; the reset names no organization (ADR-0131 D6), so it discards the environment-wide row' }, // The response schema POSTDATES this row: the row was written when the door // had no declaration, and `HistoryMetaItemResponseSchema` was authored later // by the card that declared the history protocol member. That is why this was @@ -266,10 +266,10 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ responseSchema: 'AuditMetaItemResponseSchema', note: 'REST-only route; payload answered BARE, so the named schema is the whole body. The schema predates this row: it joined the spec when `MetadataProtocol` gained its optional `auditMetaItem` member, an exact field-for-field match of that member\'s declared return; conformance: the audit-door capture suite in spec `api/protocol.test.ts`' }, { route: 'POST /api/v1/meta/:type/:name/publish', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.publishItem', - note: 'per-item ADR-0033 publish; packages.publishDrafts remains the package-scoped flow. Gated by the shared `metaWriteCapabilityVerdict`: `manage_org_presentation` is also admitted for an org-scoped tier-A promotion — the second half of the save→publish loop, promoting only the caller\'s own org partition' }, + note: 'per-item ADR-0033 publish; packages.publishDrafts remains the package-scoped flow. Gated by the shared `metaWriteCapabilityVerdict`, the save door\'s: the second half of the save→publish loop, promoting the environment-wide draft (ADR-0131 D6: no organization)' }, { route: 'POST /api/v1/meta/:type/:name/rollback', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.rollbackItem', responseSchema: 'RollbackMetaItemResponseSchema', - note: 'Gated by the shared `metaWriteCapabilityVerdict`: `manage_org_presentation` is also admitted for an org-scoped tier-A rollback, restoring only a version of the caller\'s own org overlay. REST-only route; payload answered BARE, so the named schema is the whole body — describe-only transcription of `rollbackMetaItem`\'s declared return; conformance: spec `api/protocol.test.ts`' }, + note: 'Gated by the shared `metaWriteCapabilityVerdict`; restores a version of the environment-wide row (ADR-0131 D6: no organization). REST-only route; payload answered BARE, so the named schema is the whole body — describe-only transcription of `rollbackMetaItem`\'s declared return; conformance: spec `api/protocol.test.ts`' }, { route: 'GET /api/v1/meta/:type/:name/diff', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.diffItem', responseSchema: 'DiffMetaItemResponseSchema', note: 'REST-only route; payload answered BARE, so the named schema is the whole body. Describe-only transcription of `diffMetaItem`\'s declared return; conformance: spec `api/protocol.test.ts`' }, diff --git a/packages/rest/src/rest-server-audit-org-scope.test.ts b/packages/rest/src/rest-server-audit-org-scope.test.ts index 4f20dabdb1e..bcb9cb9dbaa 100644 --- a/packages/rest/src/rest-server-audit-org-scope.test.ts +++ b/packages/rest/src/rest-server-audit-org-scope.test.ts @@ -102,13 +102,17 @@ const BUILDER = { systemPermissions: ['manage_metadata'] }; /** The request object the route handed to `auditMetaItem`. */ const requestFrom = (fn: any) => fn.mock.calls[0][0]; -describe('#8747 GET /meta/:type/:name/audit scopes the read to the caller organization', () => { - it('threads the execution context tenant as `organizationId`', async () => { +describe('#8747 GET /meta/:type/:name/audit scopes the read to the environment-wide rows', () => { + it('[ADR-0131 D6] reads the environment-wide rows (`organizationId: null`) even for a caller with an active organization', async () => { + // Every `/meta` write now audits environment-wide, so that is the + // partition this door reads — never every tenant's rows (an absent key). const { auditMetaItem, drive } = boot({ ...BUILDER, userId: 'u1', tenantId: 'org_alpha' }); await drive(); expect(auditMetaItem).toHaveBeenCalledTimes(1); - expect(requestFrom(auditMetaItem).organizationId).toBe('org_alpha'); + const request = requestFrom(auditMetaItem); + expect(request.organizationId).toBe(null); + expect(request).toHaveProperty('organizationId'); }); it('is fail-closed when the caller resolves no organization', async () => { diff --git a/packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts b/packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts index d26a7ce9a73..92c87912f77 100644 --- a/packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts +++ b/packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts @@ -1,10 +1,14 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#16525] The REST cached `/meta` door hands `getMetaItemCached` the EFFECTIVE - * organization, already reduced by the registry read gate — so the validator's - * scope prefix and the representation it validates agree at the only door that - * reaches this verb in production. + * [#16525 · ADR-0131 D6] The REST cached `/meta` door hands `getMetaItemCached` + * NO organization — since the per-organization overlay axis retired, the door + * reads environment → code for every type — so the validator's scope prefix + * and the representation it validates agree at the only door that reaches this + * verb in production. The history below is how the door used to reduce the + * organization by the registry read gate; §1 and §2 now pin that nothing + * reaches the read, and §3 that a supplied organization WOULD move the + * validator (so §1's equality is not vacuous). * * ── Why this file exists ────────────────────────────────────────────────── * @@ -269,7 +273,7 @@ describe('§1 two tenants reading ONE env-wide document', () => { expect(bb.etag).toBe(none.etag); }); - it(`⭐ CONTROL — ${OVERRIDABLE}: the validators DIFFER, because the door forwards the organization`, async () => { + it(`[ADR-0131 D6] ${OVERRIDABLE}: the validators are IDENTICAL too — the door forwards no organization for any type`, async () => { rows.push(row(OVERRIDABLE)); const b = boot(rows); @@ -286,9 +290,11 @@ describe('§1 two tenants reading ONE env-wide document', () => { expect(servedLabel(bb.body)).toBe(`env ${OVERRIDABLE}`); expect(servedLabel(none.body)).toBe(`env ${OVERRIDABLE}`); - expect(a.etag, 'the control did not fire — the ETag ignores the organization entirely').not.toBe(none.etag); - expect(bb.etag).not.toBe(none.etag); - expect(a.etag).not.toBe(bb.etag); + // Non-vacuity: the cached arm ran and issued a validator. §3 is the + // control that the validator DOES fold a supplied organization. + expect(none.etag).toMatch(/^"/); + expect(a.etag, 'an organization reached the cached read — the per-organization axis is retired').toBe(none.etag); + expect(bb.etag).toBe(none.etag); }); }); @@ -296,10 +302,8 @@ describe('§1 two tenants reading ONE env-wide document', () => { // §2 — the organization still reaches the BODY where it legitimately can // ═══════════════════════════════════════════════════════════════════════════ -describe('§2 the reduction is the registry gate, not a dropped organization', () => { - it(`${OVERRIDABLE}: an org-scoped row is still served to its tenant`, async () => { - // ⭐ Without this, §1 is equally consistent with a door that forwards no - // organization at all — which would be #9454 reopened, not a fix. +describe('§2 [ADR-0131 D6] a legacy organization-scoped row is served to nobody', () => { + it(`${OVERRIDABLE}: a legacy org-scoped row is not served, not even to its own organization (environment → code)`, async () => { const b = boot([row(OVERRIDABLE), row(OVERRIDABLE, ORG_A)]); b.as(ORG_A); @@ -307,9 +311,9 @@ describe('§2 the reduction is the registry gate, not a dropped organization', ( b.as(ORG_B); const theirs = await b.get(OVERRIDABLE); - expect(servedLabel(mine.body)).toBe(`${ORG_A} ${OVERRIDABLE}`); + expect(servedLabel(mine.body)).toBe(`env ${OVERRIDABLE}`); expect(servedLabel(theirs.body)).toBe(`env ${OVERRIDABLE}`); - expect(mine.etag).not.toBe(theirs.etag); + expect(mine.etag).toBe(theirs.etag); }); it(`${NON_OVERRIDABLE}: a phantom org row is served to nobody`, async () => { 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 7e80d1af2e0..eafe296f587 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 @@ -1,76 +1,23 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// #13406 — `GET /meta/:type/:name/history` and `GET /meta/:type/:name/diff` -// named no organization, so both read the ENV partition of a per-org table. An -// item whose overlay was authored org-scoped answered `{ events: [] }` and an -// all-empty diff while `sys_metadata_history` held its full log. Direction is -// fail-closed — the caller's OWN org data is under-served; there is no -// cross-org read, and the controls at the bottom of this file are what keep it -// that way. +// [ADR-0131 D6, C5 stage S3] `GET /meta/:type/:name/history` and +// `GET /meta/:type/:name/diff` read the ENVIRONMENT partition of +// `sys_metadata_history`, for every caller. // -// ── Why these two doors and not "the read path" ─────────────────────────── +// History. #13406 found both doors naming no organization while the write +// doors threaded one for the five `allowOrgOverride` types, so an org-scoped +// overlay's log answered `{ events: [] }`; the doors then named the caller's +// organization (gated by `organizationIdForMetaRead`). ADR-0131 D6 retires the +// per-organization overlay axis: no `/meta` write names an organization, so +// every log lives in the environment partition, and these doors read it — for +// the author's organization, another one, or none. A LEGACY organization-scoped +// log (planted straight through the protocol) is served by neither door, not +// even to its own organization, until ADR-0131 C7 promotes it. // -// Every OTHER `/meta` read door already states the scope: the single-item read -// and the listing (#9454), `/layers` (#9454), `/published`, `/_drafts`, and the -// audit twin (#8747). These two were the residue. `protocol.ts` is not at -// fault and is not touched: `request.organizationId ?? null` is the legitimate -// spelling of "env partition", and every correct caller depends on it. -// -// ── ⭐ Why `organizationIdForMetaRead` and NOT the audit twin's expression ── -// -// The audit door passes a RAW `ctx?.tenantId ?? null`, and copying that here -// looks like the obvious repair. It is wrong twice, and both halves are -// asserted below rather than argued: -// -// 1. `auditMetaItem` reads with `$or: [{organization_id: org}, {organization_id: -// null}]` — a UNION, so naming an org there can only add rows. These two -// doors read `sys_metadata_history` with strict equality -// (`SysMetadataRepository.history()` and `diffMetaItem`'s own `find`, both -// `organization_id: orgId`, no `$or`). Under strict equality a raw tenant id -// asks the ORG partition for the history of the types whose rows land -// ENV-WIDE — every `allowOrgOverride: false` type that is still -// runtime-writable, because `organizationIdForMetaWrite` writes those -// env-wide by the #6190 ruling. `object` is the measured specimen, and -// `serves a NON-overridable type's env-wide history to an org session` is -// the assertion that reddens under that ablation. Predicted before running -// it, and it is the whole reason this file exists in this shape. -// 2. `HistoryMetaItemRequestSchema` declares `organizationId: -// z.string().optional()` — optional plain string, NOT nullable, mirroring -// the implementation's `organizationId?: string`. The two doors then fail -// DIFFERENTLY, and the asymmetry is the reason the omit-spread is on both: -// -// • `/history` reddens with **TS2322** — measured: `Type 'string | null' -// is not assignable to type 'string | undefined'`. An ASSIGNABILITY -// failure. ⚠️ NOT TS2353, which is the UNDECLARED-member code: -// `organizationId` IS declared, so the unknown-property code cannot -// apply. (Both this line and the door's own comment said TS2353 when -// they landed, copied from a neighbouring paragraph that is about -// undeclared members and is correct in its own context — comment drift -// by adjacency, corrected and named rather than quietly fixed.) -// -// • `/diff` reddens with **NOTHING**. It reaches `diffMetaItem` through -// `(p as any)`, so the compiler checks nothing about that literal: -// `?? null` type-checks there and is a silent RUNTIME no-op, since -// `null ?? null` is `null`. ⇒ the guard is WEAKEST exactly where the -// argument is most easily assumed to be strongest, and on that door -// the spread is the ONLY thing holding the contract. -// -// ── Why the harness is the REAL protocol, not a spy ─────────────────────── -// -// A spy asserting "the door passed `organizationId`" cannot tell a fix from a -// fix-shaped no-op. The claim is write-then-READ AGREEMENT, so the rows have to -// land in a partition and come back out of it. These drive real REST routes -// against a real `ObjectStackProtocolImplementation` over a stub engine whose -// `sys_metadata_history` table HONOURS the `where` — including -// `organization_id`. That is the load-bearing difference from -// `rest-server-meta-read-org-scope.test.ts`, whose stub returns every history -// row unfiltered: over that engine both doors pass with or without the fix, -// because there is no partition to miss. -// -// ⭐ Every read assertion is preceded by a FIXTURE PROOF that the org-scoped -// history row exists (`historyRowsFor`). "The read is org-scoped" is worthless -// if the fixture never created an org-scoped row, and the card's own repro bar -// was "confirm the pg rows exist before hitting the read door". +// Both doors read the table by STRICT equality on `organization_id` +// (`SysMetadataRepository.history()`, `diffMetaItem`'s own `find`), and this +// harness's stub HONOURS that `where`, so the legacy cases below are the ones +// that redden if a door starts naming an organization again. import { describe, it, expect, beforeEach } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; @@ -87,10 +34,9 @@ const ORG_OVERRIDABLE = ['view', 'dashboard', 'report', 'translation', 'email_te /** * `allowOrgOverride: false` **and** `allowRuntimeCreate: true` — the * combination that makes this the discriminating control rather than - * decoration. Its writes land ENV-WIDE even for a session with an active org - * (`organizationIdForMetaWrite`), so its history lives in the env partition and - * an org-scoped read of it finds nothing. A type that could not be written at - * runtime at all would have no history either way and would prove nothing. + * decoration. Its writes have always landed ENV-WIDE, even for a session with + * an active org, so its history lives in the env partition. A type that could + * not be written at runtime at all would have no history either way. */ const NON_OVERRIDABLE = 'object'; @@ -367,6 +313,9 @@ 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 }), as(tenantId: string | undefined) { session = tenantId === undefined ? { userId: 'u1', systemPermissions: ['manage_metadata'] } @@ -392,64 +341,46 @@ function servedDocument(body: any): any { return body.item ?? body.data ?? body; } -describe('#13406 the /history and /diff read doors state the org partition', () => { +describe('#13406 · ADR-0131 D6 the /history and /diff read doors read the environment partition', () => { let b: ReturnType; beforeEach(() => { b = boot(); }); - describe('⭐ fixture first — the org-scoped rows exist before any door is read', () => { + describe('⭐ fixture first — an org-active author\'s PUT logs environment-wide', () => { it.each(ORG_OVERRIDABLE)( - '%s: a PUT under an active org appends an ORG-SCOPED history row, and none env-wide', + '%s: a PUT under an active org appends an ENV history row, and none org-scoped', async (type) => { const written = await b.put(type, 'authored_at_runtime'); expect(written.status, `PUT /${type} was not accepted`).toBe(200); expect(written.body?.state).toBe('active'); - - const orgRows = b.historyRowsFor(type, 'authored_at_runtime', ORG_A); - const envRows = b.historyRowsFor(type, 'authored_at_runtime', null); - expect( - orgRows.length, - 'nothing landed in the org partition; every read assertion below would ' - + 'then pass or fail for a reason that has nothing to do with org scoping', - ).toBe(1); - // The other half of the premise: the rows are NOT in the env - // partition, which is exactly why an org-blind door missed them. - expect(envRows.length, 'the write also landed env-wide — the partition is not real').toBe(0); + expect(b.historyRowsFor(type, 'authored_at_runtime', null).length, 'nothing logged env-wide').toBe(1); + expect(b.historyRowsFor(type, 'authored_at_runtime', ORG_A).length, 'the write logged org-scoped').toBe(0); }, ); }); - describe('/history serves the org-scoped change log', () => { - it.each(ORG_OVERRIDABLE)('%s: the events the org authored come back', async (type) => { + describe('/history serves the environment change log to every caller', () => { + it.each(ORG_OVERRIDABLE)('%s: the events come back for the author\'s org, another org and none', async (type) => { await b.put(type, 'authored_at_runtime'); await b.put(type, 'authored_at_runtime', MARKER_2); - expect(b.historyRowsFor(type, 'authored_at_runtime', ORG_A).length).toBe(2); - - const read = await b.history(type, 'authored_at_runtime'); - expect(read.thrown, `GET /${type}/history threw: ${read.thrown?.message}`).toBeUndefined(); - expect(read.status).toBe(200); - expect( - read.body?.events?.length, - 'the door answered an empty change log for an item whose org partition holds two events', - ).toBe(2); - expect(read.body.events.map((e: any) => e.version)).toEqual([1, 2]); + expect(b.historyRowsFor(type, 'authored_at_runtime', null).length).toBe(2); + + for (const who of [ORG_A, ORG_B, undefined]) { + b.as(who); + const read = await b.history(type, 'authored_at_runtime'); + expect(read.thrown, `GET /${type}/history threw: ${read.thrown?.message}`).toBeUndefined(); + expect(read.status).toBe(200); + expect(read.body?.events?.map((ev: any) => ev.version), `caller ${who ?? 'none'}`).toEqual([1, 2]); + } }); }); describe('/history honours the caller\'s bound', () => { it('?limit=1 over a two-revision log returns exactly the first event', async () => { - // Added with the `check:objectql-double-limit` repair rather than - // separately from it: the gate's finding was that the stub could - // not OBSERVE `limit`, and the honest close of that is a pin that - // proves the bound now travels the whole door — query string -> - // `historyMetaItem` -> `repo.history()`'s `yielded >= limit` break. - // Fixing the double alone would have satisfied the gate while - // leaving this contract member as untested as it was. await b.put('view', 'bounded'); await b.put('view', 'bounded', MARKER_2); - expect(b.historyRowsFor('view', 'bounded', ORG_A).map((h) => h.version)).toEqual([1, 2]); + expect(b.historyRowsFor('view', 'bounded', null).map((h) => h.version)).toEqual([1, 2]); - // The CONTROL, without which "1 event came back" proves nothing: - // the same read with no bound must return both. + // The CONTROL, without which "1 event came back" proves nothing. const all = await b.history('view', 'bounded'); expect(all.body?.events?.length, 'the unbounded control did not see both revisions').toBe(2); @@ -457,125 +388,68 @@ describe('#13406 the /history and /diff read doors state the org partition', () expect(bounded.thrown, `bounded read threw: ${bounded.thrown?.message}`).toBeUndefined(); expect(bounded.status).toBe(200); expect(bounded.body?.events?.length, 'the caller\'s ?limit= was dropped').toBe(1); - // Oldest-first (the response contract is `seq` order, the opposite - // end of the log from the audit twin), so a bound of 1 keeps - // revision 1 — naming WHICH event guards against a bound that - // truncates from the wrong end. + // Oldest-first, so a bound of 1 keeps revision 1. expect(bounded.body.events[0].version).toBe(1); }); }); - describe('/diff resolves org-scoped versions', () => { - it.each(ORG_OVERRIDABLE)('%s: ?from=1&to=2 compares the two org revisions', async (type) => { + describe('/diff resolves the environment revisions', () => { + it.each(ORG_OVERRIDABLE)('%s: ?from=1&to=2 compares the two revisions, for every caller', async (type) => { await b.put(type, 'two_revisions'); await b.put(type, 'two_revisions', MARKER_2); - expect(b.historyRowsFor(type, 'two_revisions', ORG_A).map((h) => h.version)).toEqual([1, 2]); - - const read = await b.diff(type, 'two_revisions', { from: '1', to: '2' }); - expect(read.thrown, `GET /${type}/diff threw: ${read.thrown?.message}`).toBeUndefined(); - expect(read.status).toBe(200); - expect(read.body?.fromVersion).toBe(1); - expect(read.body?.toVersion).toBe(2); - // The card's shape: bounds echoed but every bucket empty, because - // neither body could be resolved out of the env partition. - expect( - read.body?.changed, - 'the diff resolved no bodies — the card\'s all-empty answer', - ).toContainEqual({ path: 'label', from: MARKER, to: MARKER_2 }); + expect(b.historyRowsFor(type, 'two_revisions', null).map((h) => h.version)).toEqual([1, 2]); + + for (const who of [ORG_A, ORG_B, undefined]) { + b.as(who); + const read = await b.diff(type, 'two_revisions', { from: '1', to: '2' }); + expect(read.thrown, `GET /${type}/diff threw: ${read.thrown?.message}`).toBeUndefined(); + expect(read.status).toBe(200); + expect(read.body?.fromVersion).toBe(1); + expect(read.body?.toVersion).toBe(2); + expect(read.body?.changed, `caller ${who ?? 'none'}`).toContainEqual({ path: 'label', from: MARKER, to: MARKER_2 }); + } }); }); - describe('⛔ controls — the scope is STATED, never widened', () => { - it('serves a NON-overridable type\'s env-wide history to an org session', async () => { - // ⭐ THE ABLATION TARGET, and the reason this door uses - // `organizationIdForMetaRead` rather than a raw `ctx?.tenantId`. - // `object` is `allowOrgOverride: false` + `allowRuntimeCreate: true`, - // so `organizationIdForMetaWrite` puts its history ENV-WIDE even - // though ORG_A is active. A door that named the tenant - // unconditionally would query the org partition and answer - // `{ events: [] }` — reintroducing this very card one type family - // over. PREDICTED DIRECTION: swap the predicate for - // `ctx?.tenantId ?? null` and this test, and only this test, turns - // red. + describe('⛔ controls', () => { + it('serves a NON-overridable type\'s env-wide history to an org session, as before', async () => { const written = await b.put(NON_OVERRIDABLE, 'accounts'); expect(written.status, 'the control never wrote').toBe(200); - expect( - b.historyRowsFor(NON_OVERRIDABLE, 'accounts', null).length, - 'a non-overridable write went org-scoped; the control no longer controls anything', - ).toBe(1); - expect(b.historyRowsFor(NON_OVERRIDABLE, 'accounts', ORG_A).length).toBe(0); + expect(b.historyRowsFor(NON_OVERRIDABLE, 'accounts', null).length).toBe(1); const read = await b.history(NON_OVERRIDABLE, 'accounts'); expect(read.status).toBe(200); - expect( - read.body?.events?.length, - 'the org session lost sight of an env-wide change log it could read before', - ).toBe(1); - }); - - it('still serves env-scoped rows to an env-scoped caller', async () => { - // The other direction of the same harness: nothing about naming the - // org for org callers may disturb the org-less read that worked all - // along. - b.as(undefined); - await b.put('view', 'env_authored'); - expect(b.historyRowsFor('view', 'env_authored', null).length).toBe(1); - - const read = await b.history('view', 'env_authored'); - expect(read.status).toBe(200); - expect(read.body?.events?.length, 'an env-scoped caller lost its own history').toBe(1); + expect(read.body?.events?.length).toBe(1); }); - it('does not serve org A history to org B on the same boot', async () => { - await b.put('dashboard', 'tenant_bound'); - await b.put('dashboard', 'tenant_bound', MARKER_2); - expect(b.historyRowsFor('dashboard', 'tenant_bound', ORG_A).length).toBe(2); + it('⭐ a LEGACY organization-scoped log is served by neither door, not even to its own organization', async () => { + await b.plantLegacyOrgRow('dashboard', 'legacy_logged'); + await b.plantLegacyOrgRow('dashboard', 'legacy_logged', MARKER_2); + expect( + b.historyRowsFor('dashboard', 'legacy_logged', ORG_A).length, + 'the legacy log was not planted; the case proves nothing', + ).toBe(2); - b.as(ORG_B); - const read = await b.history('dashboard', 'tenant_bound'); + const read = await b.history('dashboard', 'legacy_logged'); expect(read.status).toBe(200); - expect(read.body?.events ?? [], 'org B was served org A\'s change log').toEqual([]); + expect(read.body?.events ?? [], 'a legacy org change log was served').toEqual([]); - const diffed = await b.diff('dashboard', 'tenant_bound', { from: '1', to: '2' }); + const diffed = await b.diff('dashboard', 'legacy_logged', { from: '1', to: '2' }); expect(diffed.status).toBe(200); - expect(diffed.body?.changed ?? [], 'org B was served a diff of org A\'s revisions').toEqual([]); - }); - - it('does not serve an org row to a caller that named no org', async () => { - await b.put('view', 'org_a_only'); - expect(b.historyRowsFor('view', 'org_a_only', ORG_A).length).toBe(1); - - b.as(undefined); - const read = await b.history('view', 'org_a_only'); - expect(read.status).toBe(200); - expect( - read.body?.events ?? [], - 'an org-less caller was served an org-scoped change log', - ).toEqual([]); + expect(diffed.body?.changed ?? [], 'a diff of legacy org revisions was served').toEqual([]); }); }); - describe('the card\'s third symptom, RE-MEASURED on today\'s main', () => { - it('single-item dashboard read ALREADY serves the org overlay — premise falsified', async () => { - // #13406 symptom 3 claimed `GET /meta/dashboard/:name` ignores an - // org-scoped overlay. That door was threaded by #9454/#9727 before - // this card was filed; the uncached arm `dashboard` takes carries - // `readOrganizationId` today. Pinned HERE, next to the two doors - // that were genuinely open, so the falsification is auditable - // rather than a claim in a report. (The behaviour itself is owned - // by `rest-server-meta-read-org-scope.test.ts`; this asserts the - // narrow fact the card disputes.) + describe('the single-item read beside them', () => { + it('the dashboard read serves the environment overlay an org-active author saved', async () => { const written = await b.put('dashboard', 'system_overview'); expect(written.status).toBe(200); const row = Array.from(b.rows.values()).find((r) => r.name === 'system_overview'); - expect(row?.organization_id, 'the overlay is not org-scoped; nothing is being measured').toBe(ORG_A); + expect(row?.organization_id ?? null, 'the overlay went org-scoped').toBe(null); const read = await b.get('dashboard', 'system_overview'); expect(read.status).toBe(200); - expect( - servedDocument(read.body)?.label, - 'the single-item dashboard read did NOT serve the org overlay — symptom 3 is live after all', - ).toBe(MARKER); + expect(servedDocument(read.body)?.label).toBe(MARKER); }); }); }); diff --git a/packages/rest/src/rest-server-meta-org-scope-url-spelling.test.ts b/packages/rest/src/rest-server-meta-org-scope-url-spelling.test.ts index 5557f51bfe0..5bc0c056ecd 100644 --- a/packages/rest/src/rest-server-meta-org-scope-url-spelling.test.ts +++ b/packages/rest/src/rest-server-meta-org-scope-url-spelling.test.ts @@ -23,6 +23,14 @@ // boundary consuming the URL spelling contract is the repair // `metadata-url-spelling.ts`'s own header forbids. // +// ── [ADR-0131 D6] What changed ───────────────────────────────────────────── +// +// The per-organization overlay axis is retired: no `/meta` door supplies an +// organization for any type, so the two spellings cannot land in two +// partitions any more — they land in the one environment partition. The pins +// below keep the spelling-twin property (one namespace per item, whatever the +// spelling) and assert it as "no organization", per door and per spelling. +// // ── What these assertions are ABOUT, and why they are argument-level ─────── // // Same reasoning as the #8805 suite next door: the link from @@ -40,7 +48,6 @@ // suite cannot be read as licensing a fold there. import { describe, it, expect, vi } from 'vitest'; -import { organizationIdForMetaWrite } from '@objectstack/metadata-core'; import { META_URL_TO_SINGULAR } from '@objectstack/spec/meta-spelling'; import { RestServer } from './rest-server.js'; @@ -153,7 +160,7 @@ async function readWith(type: string) { return requestFrom(b.getMetaItem); } -describe('#10340 the /meta doors decide org scope on the FOLDED type, not the raw spelling', () => { +describe('#10340 · ADR-0131 D6 — no spelling of any type carries an organization through the /meta doors', () => { describe('the two measured members — spelling twins are ONE namespace again', () => { for (const { plural, singular } of MEMBERS) { it(`PUT /meta/${plural}/:name lands where PUT /meta/${singular}/:name lands`, async () => { @@ -163,46 +170,46 @@ describe('#10340 the /meta doors decide org scope on the FOLDED type, not the ra // shadowed — persisted, receipted as live, served by nothing. const viaPlural = await writeWith(plural); const viaSingular = await writeWith(singular); - expect(viaPlural.organizationId).toBe(ORG); + expect(viaPlural.organizationId).toBeUndefined(); expect(viaPlural.organizationId).toBe(viaSingular.organizationId); }); it(`GET /meta/${plural}/:name resolves the same partition as the singular`, async () => { const viaPlural = await readWith(plural); const viaSingular = await readWith(singular); - expect(viaPlural.organizationId).toBe(ORG); + expect(viaPlural.organizationId).toBeUndefined(); expect(viaPlural.organizationId).toBe(viaSingular.organizationId); }); - it(`scopes every remaining door for /meta/${plural} — list, layers, compound, delete, publish, rollback`, async () => { + it(`names no organization on any remaining door for /meta/${plural} — list, layers, delete, publish, rollback`, async () => { const b = boot(AUTHORIZED); await b.drive('GET', `${META}/:type`, { params: { type: plural } }); - expect(requestFrom(b.getMetaItems).organizationId).toBe(ORG); + expect(requestFrom(b.getMetaItems).organizationId).toBeUndefined(); const b2 = boot(AUTHORIZED); await b2.drive('GET', `${META}/:type/:name/layers`, { params: { type: plural, name: 'greeting' }, }); - expect(requestFrom(b2.getMetaItemLayered).organizationId).toBe(ORG); + expect(requestFrom(b2.getMetaItemLayered).organizationId).toBeUndefined(); const b4 = boot(AUTHORIZED); await b4.drive('DELETE', `${META}/:type/:name`, { params: { type: plural, name: 'greeting' }, }); - expect(requestFrom(b4.deleteMetaItem).organizationId).toBe(ORG); + expect(requestFrom(b4.deleteMetaItem).organizationId).toBeUndefined(); const b5 = boot(AUTHORIZED); await b5.drive('POST', `${META}/:type/:name/publish`, { params: { type: plural, name: 'greeting' }, }); - expect(requestFrom(b5.publishMetaItem).organizationId).toBe(ORG); + expect(requestFrom(b5.publishMetaItem).organizationId).toBeUndefined(); const b6 = boot(AUTHORIZED); await b6.drive('POST', `${META}/:type/:name/rollback`, { params: { type: plural, name: 'greeting' }, body: { toVersion: 1 }, }); - expect(requestFrom(b6.rollbackMetaItem).organizationId).toBe(ORG); + expect(requestFrom(b6.rollbackMetaItem).organizationId).toBeUndefined(); // [commit 7986d973f] The compound-name GET and PUT were driven here too, // as the doors most likely to be left org-BLIND while their @@ -214,7 +221,7 @@ describe('#10340 the /meta doors decide org scope on the FOLDED type, not the ra }); describe('the whole contract, not the two specimens — every spelling in the URL map', () => { - it('decides scope for every URL spelling exactly as for its folded type', async () => { + it('names no organization for any URL spelling of any type', async () => { // The class-closing sweep. For EVERY key of `META_URL_TO_SINGULAR` // the door's decision must equal the predicate's decision on the // FOLDED type — the property the two maps' disagreement broke. A @@ -225,8 +232,8 @@ describe('#10340 the /meta doors decide org scope on the FOLDED type, not the ra const request = await writeWith(spelling); expect( request.organizationId, - `PUT door scope for '${spelling}' disagreed with its fold '${folded}'`, - ).toBe(organizationIdForMetaWrite(folded, ORG)); + `PUT door threaded an organization for '${spelling}' (folds to '${folded}')`, + ).toBeUndefined(); } }); @@ -269,17 +276,13 @@ describe('#10340 the /meta doors decide org scope on the FOLDED type, not the ra expect(requestFrom(b.listDrafts).type).toBe('translations'); }); - it('threads the CALLER org into GET /meta/_drafts — read scope symmetric with the save route (#11087)', async () => { - // A draft saved by a session carrying an active org lands in that - // org's overlay scope (`saveMetaItem`'s `organizationId: - // ctx?.tenantId`). Reading with NO org sees only env-wide rows - // (`getOverlayRepo(null)` → `organization_id IS NULL`), so every - // org-scoped draft was invisible to the pending-changes surfaces — - // the write-org/read-null split behind cloud#1593. The repository's - // own `$or` contract surfaces BOTH scopes once the org is threaded. + it('[ADR-0131 D6] names no organization on GET /meta/_drafts — read scope symmetric with the save route (#11087)', async () => { + // A draft is saved environment-wide (the save door threads no + // organization), so the pending-changes list reads the environment + // partition: write and read scope stay one answer. const b = boot(AUTHORIZED); await b.drive('GET', `${META}/_drafts`, {}); - expect(requestFrom(b.listDrafts).organizationId).toBe(ORG); + expect(requestFrom(b.listDrafts).organizationId).toBeUndefined(); }); }); @@ -291,7 +294,7 @@ describe('#10340 the /meta doors decide org scope on the FOLDED type, not the ra // would hide a drift between them from the protocol's own tests. const request = await writeWith('translations'); expect(request.type).toBe('translations'); - expect(request.organizationId).toBe(ORG); + expect(request.organizationId).toBeUndefined(); }); }); }); 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 99e37c48cb0..fb539c641b6 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 @@ -1,46 +1,28 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// #9454 — a runtime `PUT` of an org-overridable metadata type answered 200 with -// a `state:'active'` receipt, PERSISTED the row with its `organization_id`, and -// then no REST read door served it back. The author's work rendered as lost -// while the write path reported success: declared ≠ enforced, in the direction -// hardest for an author to notice. +// [ADR-0131 D6, C5 stage S3] Every REST `/meta` read door serves what the write +// door persisted — and since the per-organization overlay axis retired, what it +// persists is the ENVIRONMENT row, served to every caller. // -// ── Where the defect was, and where it was NOT ──────────────────────────── +// History. #9454 found a runtime `PUT` of an org-overridable type persisting an +// org-scoped row no read door then served, and made the read doors name the +// caller's organization (gated by `organizationIdForMetaRead`). #13753 / +// #15622 did the same for the diagnostics and references sweeps. ADR-0131 D6 +// retires the axis: the write doors name no organization, so the reads name +// none either, and flip in the SAME change — reads-first would hide the +// organization rows the doors still wrote, writes-first would let legacy +// organization rows shadow new environment saves. // -// NOT in the overlay-resolution layer. `getMetaItem` resolves -// `(orgId ? findOverlay(orgId) : undefined) ?? findOverlay(null)` and -// `getMetaItems` unions both scopes under org-wins precedence — both correct -// and type-agnostic. The REST read doors simply never STATED the scope, so the -// reader looked in the env-wide partition for a row that had landed in an org -// one. The write door is correct as-is: the row is persisted, so its receipt is -// truthful. (Direction (a) — make the read door serve it — settled on-card.) -// -// ── The two-branch trap this file exists to pin ─────────────────────────── -// -// `view` and `dashboard` share ONE mechanism but reach it through two DIFFERENT -// REST branches: `view` takes the cached arm (`getMetaItemCached`), `dashboard` -// bypasses the cache via `isDashboardType` and takes the uncached arm -// (`getMetaItem`). Both omitted the org, so a fix applied to one arm fixes -// exactly ONE type while the receipt keeps claiming success for the other. Both -// arms are driven below, on the same boot, for every org-overridable type. -// -// ── Why the harness is the REAL protocol, not a spy ─────────────────────── -// -// A spy asserting "the door passed `organizationId`" cannot tell a fix from a -// fix-shaped no-op: the claim is write-then-READ AGREEMENT, so the row has to -// actually land in a partition and actually come back out of it. These drive -// real REST routes against a real `ObjectStackProtocolImplementation` over a -// stub engine, so the assertions are round trips on one boot. -// -// ⛔ THE CONTROL THAT MATTERS MOST is `does not serve another org's row`. The -// refused repair for this card was to make the overlay lookup fall back to -// matching ANY org row when the caller names none — `matchesWhere` skips -// `undefined` keys, so that matches an ARBITRARY org's row. It is a cross-tenant -// disclosure, not a fix, and it would pass every other assertion in this file. -// The original reproduction could not have caught it: its confound control was -// "one `sys_organization` row, and it is the session's active org". So a SECOND -// org exists here for no other purpose. +// What is pinned here, over the REAL protocol on one boot: +// • write-then-read agreement on both REST branches (`view` takes the cached +// arm, `dashboard` the uncached one), for an org-active author; +// • the row lands with `organization_id` NULL, and every caller — the +// author's organization, another one, none — is served it; +// • ⭐ a LEGACY organization-scoped row (written straight through the +// protocol, which still accepts one until a later stage refuses it) is NOT +// served by any read door, not even to its own organization: environment → +// code. Until ADR-0131 C7 promotes such rows, a single-posture deployment +// observes this too (stage 0's F10 of the retirement card). import { describe, it, expect, beforeEach } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; @@ -72,6 +54,9 @@ const MARKER = 'AUTHORED_AT_RUNTIME'; /** The second revision's marker — two PUTs, two history events. */ const MARKER_2 = 'AUTHORED_AT_RUNTIME_REV2'; +/** The label a planted LEGACY organization-scoped row carries. */ +const LEGACY_MARKER = 'LEGACY_ORG_ROW'; + /** * A SPEC-VALID body per type, carrying `label` as the marker the reads assert * on. Real bodies, not `{ label }` stubs: the write door runs full spec @@ -350,6 +335,12 @@ function boot() { return { rows, historyRows, + /** + * Write a LEGACY organization-scoped row the way a door did before + * ADR-0131 D6: straight through the protocol, naming the organization. + */ + plantLegacyOrgRow: (type: string, name: string, label = LEGACY_MARKER, organizationId = ORG_A) => + protocol.saveMetaItem({ type, name, item: bodyFor(type, name, label), organizationId }), as(tenantId: string | undefined) { session = tenantId === undefined ? { userId: 'u1', systemPermissions: ['manage_metadata'] } @@ -390,7 +381,35 @@ function listedNames(body: any): string[] { return items.map((i: any) => i?.name).filter(Boolean); } -describe('#9454 every REST /meta read door serves what the write door persisted', () => { +/** Rows in the backing store for one `(type, name, org)` slot. */ +function storedRowsFor( + rows: Map, + type: string, + name: string, + org: string | null, +): T[] { + return Array.from(rows.values()).filter( + (r) => r.type === type && r.name === name && (r.organization_id ?? null) === org, + ); +} + +/** An `object`-typed SOURCE: a lookup field naming `target`. */ +function objectReferencing(name: string, target: string): Record { + return { + // [ADR-0090 D1] `sharingModel` is required at the write door; without + // it this fixture fails on the WRITE and never reaches the read. + name, + label: MARKER, + sharingModel: 'private', + fields: { task_ref: { type: 'lookup', label: 'Task', reference: target } }, + }; +} + +/** The item an operator is about to delete — what `bodyFor('view', …)` binds to. */ +const TARGET_OBJECT = 'task'; + + +describe('#9454 · ADR-0131 D6 every REST /meta read door serves what the write door persisted', () => { let b: ReturnType; beforeEach(() => { b = boot(); }); @@ -399,9 +418,6 @@ describe('#9454 every REST /meta read door serves what the write door persisted' '%s: the 200 state:active receipt is answered by the direct GET', async (type) => { const written = await b.put(type, 'authored_at_runtime'); - // The receipt half — unchanged by this card, asserted so a - // harness that could not write at all cannot pass the read half - // for the wrong reason. expect(written.status, `PUT /${type} was not accepted`).toBe(200); expect(written.body?.state).toBe('active'); @@ -413,7 +429,7 @@ describe('#9454 every REST /meta read door serves what the write door persisted' ); it.each(ORG_OVERRIDABLE)( - '%s: the scoped listing contains it too', + '%s: the listing contains it too', async (type) => { await b.put(type, 'authored_at_runtime'); const listed = await b.list(type); @@ -423,11 +439,6 @@ describe('#9454 every REST /meta read door serves what the write door persisted' ); it('covers BOTH REST branches, not one — the half-fix guard', async () => { - // The assertion is about ROUTE MECHANICS, so it is stated - // separately from the parametrised cases above: `view` and - // `dashboard` agreeing here is what proves the cached arm and the - // `isDashboardType` bypass were BOTH threaded. A fix to one arm - // leaves exactly one of these two red. await b.put(CACHED_ARM, 'both_arms'); await b.put(UNCACHED_ARM, 'both_arms'); @@ -439,566 +450,196 @@ describe('#9454 every REST /meta read door serves what the write door persisted' }); }); - describe('⛔ the scope is STATED, not guessed — cross-tenant controls', () => { - it('does not serve another org row to a caller that named no org', async () => { - // The refused option C, pinned. An org-blind fallback matching ANY - // org row passes every assertion above and fails only here. - await b.put(CACHED_ARM, 'org_a_only'); - b.as(undefined); - - const read = await b.get(CACHED_ARM, 'org_a_only'); - expect( - servedDocument(read.body)?.label, - 'an org-less caller was served an org-scoped row', - ).not.toBe(MARKER); - expect(listedNames((await b.list(CACHED_ARM)).body)).not.toContain('org_a_only'); + describe('⭐ the row is environment-wide, and so is who it is served to', () => { + it.each(ORG_OVERRIDABLE)('%s: an org-active author\'s write persists with organization_id NULL', async (type) => { + const written = await b.put(type, 'partitioned'); + expect(written.status, `PUT answered ${written.status} ${JSON.stringify(written.body)}`).toBe(200); + expect(storedRowsFor(b.rows, type, 'partitioned', null).length, 'nothing landed env-wide').toBe(1); + expect(storedRowsFor(b.rows, type, 'partitioned', ORG_A).length, 'the write went org-scoped').toBe(0); }); - it('does not serve org A row to org B on the same boot', async () => { - // The control the original reproduction structurally could not run: - // it had exactly one organization. - await b.put(UNCACHED_ARM, 'tenant_bound'); + it('another organization is served it on the same boot', async () => { + await b.put(UNCACHED_ARM, 'deployment_wide'); b.as(ORG_B); + expect(servedDocument((await b.get(UNCACHED_ARM, 'deployment_wide')).body)?.label).toBe(MARKER); + expect(listedNames((await b.list(UNCACHED_ARM)).body)).toContain('deployment_wide'); + }); - const read = await b.get(UNCACHED_ARM, 'tenant_bound'); - expect( - servedDocument(read.body)?.label, - 'org B was served org A metadata', - ).not.toBe(MARKER); - expect(listedNames((await b.list(UNCACHED_ARM)).body)).not.toContain('tenant_bound'); + it('a caller that names no organization is served it', async () => { + await b.put(CACHED_ARM, 'deployment_wide'); + b.as(undefined); + expect(servedDocument((await b.get(CACHED_ARM, 'deployment_wide')).body)?.label).toBe(MARKER); + expect(listedNames((await b.list(CACHED_ARM)).body)).toContain('deployment_wide'); }); - it('leaves a NON-overridable type reading env-wide', async () => { - // The registry gate, not decoration. Naming the org for every type - // would resurrect #6190's phantom rows on the READ side — rows boot - // hydration deliberately walks past, so serving them means serving a - // document that vanishes at the next restart. + it('a NON-overridable type reads environment-wide, as before', async () => { await b.put(NON_OVERRIDABLE, 'accounts'); - const row = Array.from(b.rows.values()).find((r) => r.name === 'accounts'); - expect(row?.organization_id ?? null, 'a non-overridable write went org-scoped').toBe(null); - + expect(storedRowsFor(b.rows, NON_OVERRIDABLE, 'accounts', null).length).toBe(1); const read = await b.get(NON_OVERRIDABLE, 'accounts'); expect(read.status).toBe(200); expect(servedDocument(read.body)?.label).toBe(MARKER); }); }); - describe('the row really is org-partitioned — the premise, re-measured', () => { - it('persists with organization_id, which is why an env-wide read missed it', async () => { - // Guards the card's own diagnosis: this is persisted-but-not-served, - // never a silent write no-op. If this turns red the defect has - // changed shape and the rest of this file is asserting the wrong - // thing. - const written = await b.put(CACHED_ARM, 'partitioned'); - const row = Array.from(b.rows.values()).find((r) => r.name === 'partitioned'); + describe('⭐ a LEGACY organization-scoped row is served by no read door (environment → code)', () => { + it.each([CACHED_ARM, UNCACHED_ARM])('%s: shadowed by nothing — its own organization is served the environment row', async (type) => { + // Fixture proof first: the legacy row really is org-scoped. + await b.put(type, 'legacy_item'); + await b.plantLegacyOrgRow(type, 'legacy_item'); + expect(storedRowsFor(b.rows, type, 'legacy_item', ORG_A).length, 'the legacy row was not planted').toBe(1); + expect(storedRowsFor(b.rows, type, 'legacy_item', null).length).toBe(1); + + const read = await b.get(type, 'legacy_item'); + expect(read.status).toBe(200); expect( - row, - 'nothing was persisted at all; PUT answered ' - + `${written.status} ${JSON.stringify(written.body)} thrown=${written.thrown?.message}`, - ).toBeDefined(); - expect(row?.organization_id).toBe(ORG_A); + servedDocument(read.body)?.label, + 'a legacy org row shadowed the environment save — the writes-first hazard', + ).toBe(MARKER); + }); + + it.each([CACHED_ARM, UNCACHED_ARM])('%s: alone, it is not served or listed to its own organization', async (type) => { + await b.plantLegacyOrgRow(type, 'legacy_only'); + expect(storedRowsFor(b.rows, type, 'legacy_only', ORG_A).length, 'the legacy row was not planted').toBe(1); + + const read = await b.get(type, 'legacy_only'); + expect(servedDocument(read.body)?.label, 'a legacy org row was served').not.toBe(LEGACY_MARKER); + expect(listedNames((await b.list(type)).body)).not.toContain('legacy_only'); }); }); }); -// ── #13764 — the instrument's own discriminating power, pinned ──────────── -// -// This file's stub used to DISCARD `opts.where` on both `sys_metadata_history` -// seams: `findOne` answered `null` unconditionally and `find` handed back every -// history row unfiltered. `SysMetadataRepository.history()` and `diffMetaItem` -// filter `organization_id` by STRICT EQUALITY and post-filter nothing, so over -// that stub the org predicate was a NO-OP — an org-scoping assertion for -// `/history` was green whether or not the door forwarded the organization. +// ── the history seams of this harness ───────────────────────────────────── // -// ⭐ THE ASSERTION THAT HOLDS THE STUB RIGHT is `does not serve org A history to -// org B`. The positive case below cannot do that job: un-partition the stub -// again and it stays GREEN, because an unfiltered read still contains the rows -// it looks for. Only the cross-tenant case reddens, because only it asks for an -// answer the unfiltered stub cannot give. It is here for the stub, not for the -// door. -// -// ⛔ These are NOT this file's pins for the two doors' behaviour — those live in -// `rest-server-meta-history-diff-org-scope.test.ts` (#13406), whose partitioned -// stub is the positive control this repair was calibrated against, and which -// owns `?limit=`, `/diff`, and the non-overridable env-wide control. What is -// asserted here is the narrow fact that THIS harness can now tell a forwarded -// org from a dropped one. -describe('#13764 the history seams of this harness honour the org partition', () => { +// [#13764] This file's stub used to DISCARD `opts.where` on both +// `sys_metadata_history` seams, which made any partition assertion a no-op. +// The stub filters now; ⭐ the legacy case below is what reddens if it is ever +// un-partitioned again (an unfiltered read would serve the org log). The door +// pins for `/history` and `/diff` live in +// `rest-server-meta-history-diff-org-scope.test.ts`. +describe('#13764 · ADR-0131 D6 the history seams serve the environment change log', () => { let b: ReturnType; beforeEach(() => { b = boot(); }); - it('serves the org-scoped change log of an item the active org authored', async () => { - // The measurement that names the repair: with the door's org dropped - // this reads the ENV partition and answers zero events. Over the old - // unfiltered stub it answered two in BOTH states. + it('serves an org-active author\'s change log, which is environment-wide, to every caller', async () => { const first = await b.put(CACHED_ARM, 'authored_at_runtime'); expect(first.status, 'the fixture never wrote').toBe(200); await b.put(CACHED_ARM, 'authored_at_runtime', MARKER_2); + expect(b.historyRowsFor(CACHED_ARM, 'authored_at_runtime', null).length).toBe(2); + expect(b.historyRowsFor(CACHED_ARM, 'authored_at_runtime', ORG_A).length).toBe(0); - // Fixture proof first — "the read is org-scoped" is worthless if the - // fixture never created an org-scoped row. - expect( - b.historyRowsFor(CACHED_ARM, 'authored_at_runtime', ORG_A).length, - 'nothing landed in the org partition; the read below would then pass ' - + 'or fail for a reason unrelated to org scoping', - ).toBe(2); - expect( - b.historyRowsFor(CACHED_ARM, 'authored_at_runtime', null).length, - 'the write also landed env-wide — the partition is not real', - ).toBe(0); - - const read = await b.history(CACHED_ARM, 'authored_at_runtime'); - expect(read.thrown, `GET /history threw: ${read.thrown?.message}`).toBeUndefined(); - expect(read.status).toBe(200); - expect( - read.body?.events?.length, - 'the door answered an empty change log for an item whose org partition holds two events', - ).toBe(2); - }); - - it('does not serve org A history to org B on the same boot', async () => { - // ⭐ The one that reddens if the stub is ever un-partitioned again. - await b.put(UNCACHED_ARM, 'tenant_bound'); - await b.put(UNCACHED_ARM, 'tenant_bound', MARKER_2); - expect(b.historyRowsFor(UNCACHED_ARM, 'tenant_bound', ORG_A).length).toBe(2); - - b.as(ORG_B); - const read = await b.history(UNCACHED_ARM, 'tenant_bound'); - expect(read.status).toBe(200); - expect( - read.body?.events ?? [], - 'org B was served org A\'s change log', - ).toEqual([]); + for (const who of [ORG_A, ORG_B, undefined]) { + b.as(who); + const read = await b.history(CACHED_ARM, 'authored_at_runtime'); + expect(read.thrown, `GET /history threw: ${read.thrown?.message}`).toBeUndefined(); + expect(read.status).toBe(200); + expect(read.body?.events?.length, `caller ${who ?? 'none'}`).toBe(2); + } }); - it('does not serve an org-scoped change log to a caller that named no org', async () => { - await b.put(CACHED_ARM, 'org_a_only'); - expect(b.historyRowsFor(CACHED_ARM, 'org_a_only', ORG_A).length).toBe(1); + it('⭐ does not serve a legacy organization-scoped change log, not even to its own organization', async () => { + await b.plantLegacyOrgRow(UNCACHED_ARM, 'legacy_logged'); + expect(b.historyRowsFor(UNCACHED_ARM, 'legacy_logged', ORG_A).length, 'no legacy log was planted').toBe(1); - b.as(undefined); - const read = await b.history(CACHED_ARM, 'org_a_only'); + const read = await b.history(UNCACHED_ARM, 'legacy_logged'); expect(read.status).toBe(200); - expect( - read.body?.events ?? [], - 'an org-less caller was served an org-scoped change log', - ).toEqual([]); + expect(read.body?.events ?? [], 'a legacy org change log was served').toEqual([]); }); }); -// ── [#13753] `GET /meta/diagnostics` ────────────────────────────────────── -// -// The cross-type spec-validation sweep behind the Studio governance directory -// named no organization, so an org's own overlays were absent from it: clean -// tiles rendered over a partition the sweep never read. -// -// ⭐ BOTH ARMS STATE THE ORGANIZATION — and the split below is about WHERE the -// fold happens, not about whether one happens. `getMetaDiagnostics` reads each -// swept type through `getMetaItems({ type: t, organizationId })`. +// ── [#13753 · #15622] `GET /meta/diagnostics` ────────────────────────────── // -// ⚠️ [commit 96326040f, recorded by commit abf9101f1] `getMetaItems` NOW APPLIES THE REGISTRY GATE -// ITSELF, after folding the request type. This header used to say it applied -// none and that the scope was therefore the caller's to decide per type; that -// sentence is FALSE on today's tree. What that dissolved is the obstacle the -// untyped arm was held shut on: -// -// • `?type=` ⇒ `targetTypes` is exactly that one type, so -// `organizationIdForMetaRead` over it IS the request's whole scope. Correct -// by construction; repaired by #13753 and untouched since. -// • no `?type=` ⇒ `targetTypes` is the whole registry, five -// `allowOrgOverride: true` types beside every other declared type. ⭐ -// [#15622] This arm now forwards the caller's organization **RAW** and lets -// the callee's per-`t` gate narrow it: the org for those five, `undefined` -// for every other type, so no pre-#6190 phantom is unioned back in. ⛔ It -// must NOT be pre-folded at the door — there is no single type to fold on, -// and folding on any one of them would suppress the organization for every -// type at once. -// -// ⚠️ THE GAP THIS SECTION USED TO PIN OPEN IS CLOSED, and the pin was REPLACED -// rather than deleted. `an org-scoped item is absent from the whole-registry -// sweep` carried "if this reddens, read the card before making it green"; -// #15622 is that card, and it ruled the arm forwards. Its inverse now stands in -// the same place, beside the narrowness control #15622 named as missing — an -// overridable type's org-authored row PRESENT and a planted phantom on a -// non-overridable type ABSENT, on ONE request. Read #15622 before touching -// either half: alone, neither can tell a per-type gate from an unconditional -// tenant. -// -// ── ⛔ WHAT THIS FILE NO LONGER DISCRIMINATES (commit abf9101f1, MEASURED) ─────────── -// -// This header used to end: "Swap `organizationIdForMetaRead` for a raw -// `ctx?.tenantId` at the call site and that assertion, and only it, turns red." -// MEASURED on the merged tree, that ablation now leaves this file GREEN IN FULL -// (30/30 at that revision; the file has grown since) — `getMetaItems`' own gate -// re-folds the raw tenant id, phantom control included. Same fate as commit a4e4d2d78's -// ablation B, and for the same reason. -// -// ⇒ What this file DOES still discriminate is the organization being DROPPED: -// remove the `organizationId` the `?type=` arm passes and the six repair cases -// above turn red (measured by commit abf9101f1: 6 failed / 24 passed). Read the two apart before -// citing this file as a pin on the door-side predicate — it pins that the arm -// still FOLDS, never that the fold happens at the door. - -/** Rows in the backing store for one `(type, name, org)` slot. */ -function storedRowsFor( - rows: Map, - type: string, - name: string, - org: string | null, -): T[] { - return Array.from(rows.values()).filter( - (r) => r.type === type && r.name === name && (r.organization_id ?? null) === org, - ); -} - -describe('#13753 GET /meta/diagnostics states the org partition on the ?type= arm', () => { +// The cross-type spec-validation sweep behind the Studio governance directory. +// Since ADR-0131 D6 neither arm names an organization: the sweep reads every +// type environment → code — the partition every `/meta` write lands in — for +// every caller, and a legacy organization-scoped row is not swept. +describe('#13753 · ADR-0131 D6 GET /meta/diagnostics sweeps the environment partition', () => { let b: ReturnType; beforeEach(() => { b = boot(); }); - describe('the repair — a ?type= sweep sees what this organization authored', () => { - it.each(ORG_OVERRIDABLE)('%s: the org-scoped item is counted', async (type) => { - const written = await b.put(type, 'authored_at_runtime'); - expect(written.status, `PUT /${type} was not accepted`).toBe(200); - - // ⭐ Fixture proof first. "The sweep is org-scoped" is worthless if - // the fixture never created an org-scoped row — the assertion below - // would then pass or fail for a reason unrelated to org scoping. - expect( - storedRowsFor(b.rows, type, 'authored_at_runtime', ORG_A).length, - 'nothing landed in the org partition', - ).toBe(1); - expect( - storedRowsFor(b.rows, type, 'authored_at_runtime', null).length, - 'the write also landed env-wide — the partition is not real', - ).toBe(0); - - const swept = await b.diagnostics({ type }); - expect(swept.thrown, `GET /diagnostics threw: ${swept.thrown?.message}`).toBeUndefined(); - expect(swept.status).toBe(200); - expect(swept.body?.scannedTypes, 'the ?type= arm swept more than the named type').toBe(1); - expect( - swept.body?.stats?.[type]?.count, - 'the sweep reported a clean tile over a partition it never read — the card', - ).toBe(1); - expect(swept.body?.scannedItems).toBe(1); - }); - - it('a plural URL spelling is folded before the scope decision, not after', async () => { - // [commit 26f3588fb] The predicate is asked with `canonicalMetaUrlType(...)`, - // never the raw segment: `declaresOrgOverride` answers `false` for - // URL-only spellings, so an unfolded `views` would silently drop - // back to env-wide and this case would report a clean tile again. - await b.put(CACHED_ARM, 'authored_at_runtime'); - expect(storedRowsFor(b.rows, CACHED_ARM, 'authored_at_runtime', ORG_A).length).toBe(1); - - const swept = await b.diagnostics({ type: 'views' }); - expect(swept.status).toBe(200); - expect( - swept.body?.stats?.views?.count, - 'the plural spelling was scoped env-wide — the fold happened after the decision', - ).toBe(1); - }); + it.each(ORG_OVERRIDABLE)('%s: an org-active author\'s item is counted by the ?type= arm', async (type) => { + const written = await b.put(type, 'authored_at_runtime'); + expect(written.status, `PUT /${type} was not accepted`).toBe(200); + const swept = await b.diagnostics({ type }); + expect(swept.thrown, `GET /diagnostics threw: ${swept.thrown?.message}`).toBeUndefined(); + expect(swept.status).toBe(200); + expect(swept.body?.scannedTypes, 'the ?type= arm swept more than the named type').toBe(1); + expect(swept.body?.stats?.[type]?.count).toBe(1); }); - describe('⛔ controls — the scope is STATED, never widened', () => { - it('?type=object stays env-wide and does NOT resurrect a phantom org row', async () => { - // ⭐ THE ABLATION TARGET. `object` is `allowOrgOverride: false` + - // `allowRuntimeCreate: true`, so its runtime writes land ENV-WIDE - // even under an active org (`organizationIdForMetaWrite`, #6190) — - // which is why the phantom below has to be planted directly rather - // than written through the door. Rows like it exist in deployments - // that ran before that ruling; boot hydration walks past them, so - // they are dead, and a read door that named the org for every type - // would serve them again. ⚠️ [commit abf9101f1] PREDICTED DIRECTION, - // CORRECTED: replacing the predicate with `ctx?.tenantId` at the - // call site no longer moves this count — `getMetaItems`' own gate - // (commit 96326040f) re-folds it. What still drives it to 2 is a read door - // that reaches the store with the org unfolded, which is why the - // control stays. - const written = await b.put(NON_OVERRIDABLE, 'accounts'); - expect(written.status, 'the control never wrote').toBe(200); - expect( - storedRowsFor(b.rows, NON_OVERRIDABLE, 'accounts', null).length, - 'a non-overridable write went org-scoped; the control no longer controls anything', - ).toBe(1); - - b.rows.set( - keyOf({ type: NON_OVERRIDABLE, name: 'phantom_orders', organization_id: ORG_A, state: 'active' }), - { - id: 'phantom_1', - type: NON_OVERRIDABLE, - name: 'phantom_orders', - organization_id: ORG_A, - package_id: null, - state: 'active', - metadata: JSON.stringify(bodyFor(NON_OVERRIDABLE, 'phantom_orders')), - }, - ); - expect( - storedRowsFor(b.rows, NON_OVERRIDABLE, 'phantom_orders', ORG_A).length, - 'the phantom was not planted; the control proves nothing', - ).toBe(1); - - const swept = await b.diagnostics({ type: NON_OVERRIDABLE }); - expect(swept.status).toBe(200); - expect( - swept.body?.stats?.[NON_OVERRIDABLE]?.count, - 'the sweep read the org partition of a type with no per-org read channel — ' - + 'the phantom rows #6190 stopped minting, resurrected on the read side', - ).toBe(1); - }); - - it('does not sweep org A\'s items for org B on the same boot', async () => { - await b.put(UNCACHED_ARM, 'tenant_bound'); - expect(storedRowsFor(b.rows, UNCACHED_ARM, 'tenant_bound', ORG_A).length).toBe(1); - - b.as(ORG_B); - const swept = await b.diagnostics({ type: UNCACHED_ARM }); - expect(swept.status).toBe(200); - expect( - swept.body?.stats?.[UNCACHED_ARM]?.count, - 'org B was swept over org A\'s items', - ).toBe(0); - }); - - it('does not serve an org-scoped item to a caller that named no org', async () => { - await b.put(CACHED_ARM, 'org_a_only'); - expect(storedRowsFor(b.rows, CACHED_ARM, 'org_a_only', ORG_A).length).toBe(1); - - b.as(undefined); - const swept = await b.diagnostics({ type: CACHED_ARM }); - expect(swept.status).toBe(200); - expect( - swept.body?.stats?.[CACHED_ARM]?.count, - 'an org-less caller was swept over an org-scoped item', - ).toBe(0); - }); - - it('still sweeps env-wide items for an org-scoped caller', async () => { - // The other direction of the same harness: naming the org for org - // callers must not disturb the env-wide read that worked all along. - b.as(undefined); - await b.put(CACHED_ARM, 'env_authored'); - expect(storedRowsFor(b.rows, CACHED_ARM, 'env_authored', null).length).toBe(1); - - b.as(ORG_A); - const swept = await b.diagnostics({ type: CACHED_ARM }); - expect(swept.status).toBe(200); - expect( - swept.body?.stats?.[CACHED_ARM]?.count, - 'an org session lost sight of an env-wide item it could read before', - ).toBe(1); - }); + it('a plural URL spelling sweeps the same item', async () => { + await b.put(CACHED_ARM, 'authored_at_runtime'); + const swept = await b.diagnostics({ type: 'views' }); + expect(swept.status).toBe(200); + expect(swept.body?.stats?.views?.count).toBe(1); }); - describe('#15622 the whole-registry sweep states the org partition too', () => { - it('⭐ THE CARD: an org-authored item on an overridable type IS counted', async () => { - // ⚠️ THIS CASE REPLACES the pin `an org-scoped item is absent from - // the whole-registry sweep`, which asserted the OPPOSITE and - // carried "if this reddens, read the card before making it green". - // #15622 IS that card. It ruled the untyped arm forwards the - // caller's organization RAW, because since commit 96326040f the callee folds - // per swept type inside its own loop — so one org id now expresses - // exactly the per-type scope the old pin said it could not. The - // assertion is INVERTED rather than deleted so the next reader sees - // the flip and its reason, and so the arm cannot drift back to - // env-wide unnoticed. - // - // ⭐ Fixture proof first, for the same reason as the `?type=` cases - // above: "the sweep is org-scoped" says nothing if the fixture never - // created an org-scoped row. - await b.put(CACHED_ARM, 'authored_at_runtime'); - expect( - storedRowsFor(b.rows, CACHED_ARM, 'authored_at_runtime', ORG_A).length, - 'nothing landed in the org partition', - ).toBe(1); - expect( - storedRowsFor(b.rows, CACHED_ARM, 'authored_at_runtime', null).length, - 'the write also landed env-wide — the partition is not real', - ).toBe(0); - - const swept = await b.diagnostics(); - expect(swept.thrown, `GET /diagnostics threw: ${swept.thrown?.message}`).toBeUndefined(); - expect(swept.status).toBe(200); - expect( - swept.body?.scannedTypes, - 'the untyped arm did not sweep the registry; the assertion below would be vacuous', - ).toBeGreaterThan(1); - expect( - swept.body?.stats?.[CACHED_ARM]?.count, - 'the governance summary reported a clean tile over a partition it never read, ' - + 'while its own ?type= drill-down could see the item — the card', - ).toBe(1); - }); - - it('⛔ NARROWNESS CONTROL: a non-overridable type stays env-wide in the SAME sweep', async () => { - // ⭐ THE HALF #15622 NAMED AS MISSING. Without it the change is - // unmeasured: the case above passes just as well for a door that - // hands the callee an UNCONDITIONAL tenant, and that door would - // union a non-overridable type's org-scoped rows — the pre-#6190 - // phantoms `reportUnhydratableOrgScopedRows` warns about, which boot - // hydration walks past — back INTO the governance report as `stats` - // counts. A dashboard whose job is reporting what is wrong would - // report rows that do not survive a restart. This control is what - // proves the CALLEE'S PER-TYPE GATE is doing the work. - // - // `object` is `allowOrgOverride: false` + `allowRuntimeCreate: true`, - // so its runtime writes land ENV-WIDE even under an active org - // (`organizationIdForMetaWrite`, #6190) — which is why the phantom - // has to be planted directly rather than written through the door. - const written = await b.put(NON_OVERRIDABLE, 'accounts'); - expect(written.status, 'the control never wrote').toBe(200); - expect( - storedRowsFor(b.rows, NON_OVERRIDABLE, 'accounts', null).length, - 'a non-overridable write went org-scoped; the control no longer controls anything', - ).toBe(1); - - b.rows.set( - keyOf({ type: NON_OVERRIDABLE, name: 'phantom_orders', organization_id: ORG_A, state: 'active' }), - { - id: 'phantom_sweep_1', - type: NON_OVERRIDABLE, - name: 'phantom_orders', - organization_id: ORG_A, - package_id: null, - state: 'active', - metadata: JSON.stringify(bodyFor(NON_OVERRIDABLE, 'phantom_orders')), - }, - ); - expect( - storedRowsFor(b.rows, NON_OVERRIDABLE, 'phantom_orders', ORG_A).length, - 'the phantom was not planted; the control proves nothing', - ).toBe(1); - - // ⭐ ONE REQUEST, BOTH TYPES — an org-authored `view` beside the two - // `object` rows, so the two opposite scopes are read on ONE sweep. - // That pairing is the fact neither assertion can state alone. - await b.put(CACHED_ARM, 'authored_at_runtime'); - expect(storedRowsFor(b.rows, CACHED_ARM, 'authored_at_runtime', ORG_A).length).toBe(1); - - const swept = await b.diagnostics(); - expect(swept.status).toBe(200); - expect( - swept.body?.stats?.[NON_OVERRIDABLE]?.count, - 'the untyped sweep read the org partition of a type with no per-org read channel — ' - + 'the pre-#6190 phantoms, resurrected inside the governance report. The door passed ' - + 'an unconditional tenant, or the callee stopped gating per type', - ).toBe(1); - expect( - swept.body?.stats?.[CACHED_ARM]?.count, - 'the overridable type lost its org scope on the same request — the gate is not per type', - ).toBe(1); - }); - - it('does not sweep org A\'s items for org B on the same boot', async () => { - await b.put(UNCACHED_ARM, 'tenant_bound'); - expect(storedRowsFor(b.rows, UNCACHED_ARM, 'tenant_bound', ORG_A).length).toBe(1); - - b.as(ORG_B); - const swept = await b.diagnostics(); - expect(swept.status).toBe(200); - expect( - swept.body?.stats?.[UNCACHED_ARM]?.count, - 'org B was swept over org A\'s items — forwarding became a cross-tenant read', - ).toBe(0); - }); - - it('an org-LESS caller reads exactly what it read before', async () => { - // ⛔ #15622 moves NO anonymous / organization-less read. This arm - // resolves an exec ctx it did not resolve before, so the case that - // names no org is the one that could regress silently. - await b.put(CACHED_ARM, 'org_a_only'); - expect(storedRowsFor(b.rows, CACHED_ARM, 'org_a_only', ORG_A).length).toBe(1); + it('every caller — another organization, none — is swept over it, on both arms', async () => { + await b.put(UNCACHED_ARM, 'deployment_wide'); + for (const who of [ORG_B, undefined]) { + b.as(who); + expect((await b.diagnostics({ type: UNCACHED_ARM })).body?.stats?.[UNCACHED_ARM]?.count, `typed, ${who ?? 'none'}`).toBe(1); + expect((await b.diagnostics()).body?.stats?.[UNCACHED_ARM]?.count, `untyped, ${who ?? 'none'}`).toBe(1); + } + }); - b.as(undefined); - const swept = await b.diagnostics(); - expect(swept.status).toBe(200); - expect( - swept.body?.stats?.[CACHED_ARM]?.count, - 'an org-less caller was swept over an org-scoped item', - ).toBe(0); - }); + it('⭐ a legacy organization-scoped item is not swept, not even for its own organization, on either arm', async () => { + await b.plantLegacyOrgRow(CACHED_ARM, 'legacy_only'); + expect(storedRowsFor(b.rows, CACHED_ARM, 'legacy_only', ORG_A).length, 'the legacy row was not planted').toBe(1); + + const typed = await b.diagnostics({ type: CACHED_ARM }); + expect(typed.status).toBe(200); + expect(typed.body?.stats?.[CACHED_ARM]?.count ?? 0).toBe(0); + const untyped = await b.diagnostics(); + expect(untyped.status).toBe(200); + expect(untyped.body?.scannedTypes, 'the untyped arm did not sweep the registry').toBeGreaterThan(1); + expect(untyped.body?.stats?.[CACHED_ARM]?.count ?? 0).toBe(0); + }); - it('still sweeps env-wide items for an org-scoped caller', async () => { - // The other direction: naming the org must not NARROW what an org - // caller could already see. - b.as(undefined); - await b.put(CACHED_ARM, 'env_authored'); - expect(storedRowsFor(b.rows, CACHED_ARM, 'env_authored', null).length).toBe(1); + it('?type=object does not resurrect a pre-#6190 phantom org row either', async () => { + const written = await b.put(NON_OVERRIDABLE, 'accounts'); + expect(written.status, 'the control never wrote').toBe(200); + b.rows.set( + keyOf({ type: NON_OVERRIDABLE, name: 'phantom_orders', organization_id: ORG_A, state: 'active' }), + { + id: 'phantom_1', + type: NON_OVERRIDABLE, + name: 'phantom_orders', + organization_id: ORG_A, + package_id: null, + state: 'active', + metadata: JSON.stringify(bodyFor(NON_OVERRIDABLE, 'phantom_orders')), + }, + ); + expect(storedRowsFor(b.rows, NON_OVERRIDABLE, 'phantom_orders', ORG_A).length).toBe(1); - b.as(ORG_A); - const swept = await b.diagnostics(); - expect(swept.status).toBe(200); - expect( - swept.body?.stats?.[CACHED_ARM]?.count, - 'an org session lost sight of an env-wide item it could read before', - ).toBe(1); - }); + const swept = await b.diagnostics({ type: NON_OVERRIDABLE }); + expect(swept.status).toBe(200); + expect(swept.body?.stats?.[NON_OVERRIDABLE]?.count).toBe(1); + }); - it('the response is the SAME wire shape — no new key, and 200 either way', async () => { - // #15622 forwards an EXISTING value to an EXISTING parameter: no new - // parameter, response field or status code. A repair that added a - // scope discriminator to the envelope would satisfy every assertion - // above and still be a contract change. - await b.put(CACHED_ARM, 'authored_at_runtime'); - const swept = await b.diagnostics(); - expect(swept.status).toBe(200); - expect(Object.keys(swept.body ?? {}).sort()).toEqual( - ['entries', 'scannedItems', 'scannedTypes', 'stats', 'total'], - ); - // The `stats` ROW shape too — the arithmetic is unchanged in shape, - // only in what the sweep can now see. - expect(Object.keys(swept.body?.stats?.[CACHED_ARM] ?? {}).sort()).toEqual( - ['count', 'locked', 'packages'], - ); - expect(typeof swept.body?.total).toBe('number'); - }); + it('the response is the SAME wire shape — no new key, and 200 either way', async () => { + await b.put(CACHED_ARM, 'authored_at_runtime'); + const swept = await b.diagnostics(); + expect(swept.status).toBe(200); + expect(Object.keys(swept.body ?? {}).sort()).toEqual( + ['entries', 'scannedItems', 'scannedTypes', 'stats', 'total'], + ); + expect(Object.keys(swept.body?.stats?.[CACHED_ARM] ?? {}).sort()).toEqual( + ['count', 'locked', 'packages'], + ); + expect(typeof swept.body?.total).toBe('number'); }); }); -// ── [#13753] `GET /meta/:type/:name/references` ─────────────────────────── -// -// `findReferencesToMeta` backs the admin "Used by" panel, whose empty case -// reads — verbatim, objectui `metadata-admin/i18n.ts` — "Nothing in the -// metadata graph points at this item. Safe to delete.", shown to an operator -// about to delete something. The door named no organization, so the sweep read -// the env partition only: an org-scoped `view` pointing at the object being -// deleted was invisible and the panel issued a FALSE CLEARANCE. That is the -// ADR-0110 D3 harm this route's own 501 refusal (#9326) was added to prevent, -// answered by the door after the protocol had refused to answer it. +// ── [#13753 · ADR-0131 D6] `GET /meta/:type/:name/references` ────────────── // -// ⭐ WHY THE DOOR PASSES THE TENANT **RAW** — and why the two cases below are a -// PAIR rather than a case and a decoration. `req.params.type` is the TARGET; -// the organization is spent on the SOURCES (`getMetaItems({ type: -// matcher.fromType, … })` per `matcher`). Pre-gating on the target the way the -// sibling `/meta` doors do would answer a question about the wrong type, and -// on a non-overridable target (`object`, `flow`, `app` — the most common -// delete there is) it would suppress the organization altogether and leave the -// false clearance exactly where it was. Raw is nevertheless not an -// unconditional tenant: since commit 96326040f `getMetaItems` applies -// `organizationIdForMetaRead` to its OWN `request.type`, so the per-SOURCE -// decision is the callee's. -// -// ⇒ The first case pins that an OVERRIDABLE source is now found; the second -// that a NON-OVERRIDABLE source is still read env-wide, phantom row and all. -// One request, two source types, opposite scopes — which is the fact that -// makes "raw" correct and that no assertion on either case alone can state. - -/** An `object`-typed SOURCE: a lookup field naming `target`. */ -function objectReferencing(name: string, target: string): Record { - return { - // [ADR-0090 D1] `sharingModel` is required at the write door; without - // it this fixture fails on the WRITE and never reaches the read. - name, - label: MARKER, - sharingModel: 'private', - fields: { task_ref: { type: 'lookup', label: 'Task', reference: target } }, - }; -} - -/** The item an operator is about to delete — what `bodyFor('view', …)` binds to. */ -const TARGET_OBJECT = 'task'; - -describe('#13753 GET /meta/:type/:name/references states the org partition', () => { +// `findReferencesToMeta` backs the admin "Used by" panel an operator reads +// before a delete. Since ADR-0131 D6 it reads its SOURCES environment → code, +// the world the `/meta` doors serve: an environment `view` that references the +// object is found for every caller, and a legacy organization-scoped source is +// not swept (no door serves it until ADR-0131 C7 promotes it, and that +// ceremony re-judges what it carries). +describe('#13753 · ADR-0131 D6 GET /meta/:type/:name/references sweeps the environment sources', () => { let b: ReturnType; beforeEach(() => { b = boot(); }); @@ -1006,46 +647,33 @@ describe('#13753 GET /meta/:type/:name/references states the org partition', () const rowsOf = (body: any): RefRow[] => (body?.references ?? []) as RefRow[]; const namesOf = (body: any, type: string) => rowsOf(body).filter((r) => r.type === type).map((r) => r.name); - it('⭐ THE CARD: an org-scoped `view` that references the object is FOUND', async () => { - // `view` is `allowOrgOverride: true`, so this PUT lands in the org - // partition — the fixture proof below is what makes the read - // assertion a statement about scope rather than about the store. + it('⭐ an org-active author\'s `view` that references the object is FOUND, for every caller', async () => { const written = await b.put(CACHED_ARM, 'task_list'); expect(written.status, 'the view was never written').toBe(200); - expect( - storedRowsFor(b.rows, CACHED_ARM, 'task_list', ORG_A).length, - 'nothing landed in the org partition', - ).toBe(1); - expect( - storedRowsFor(b.rows, CACHED_ARM, 'task_list', null).length, - 'the write also landed env-wide — the partition is not real', - ).toBe(0); + expect(storedRowsFor(b.rows, CACHED_ARM, 'task_list', null).length).toBe(1); + for (const who of [ORG_A, ORG_B, undefined]) { + b.as(who); + const used = await b.references(NON_OVERRIDABLE, TARGET_OBJECT); + expect(used.thrown, `the door threw: ${used.thrown?.message}`).toBeUndefined(); + expect(used.status).toBe(200); + expect(namesOf(used.body, CACHED_ARM), `caller ${who ?? 'none'}`).toContain('task_list'); + } + }); + + it('a legacy organization-scoped source is not swept', async () => { + await b.plantLegacyOrgRow(CACHED_ARM, 'legacy_task_list'); + expect(storedRowsFor(b.rows, CACHED_ARM, 'legacy_task_list', ORG_A).length, 'the legacy row was not planted').toBe(1); const used = await b.references(NON_OVERRIDABLE, TARGET_OBJECT); - expect(used.thrown, `the door threw: ${used.thrown?.message}`).toBeUndefined(); expect(used.status).toBe(200); - expect( - namesOf(used.body, CACHED_ARM), - 'the sweep read a partition the caller does not live in, and the "Used by" panel ' - + 'rendered "Safe to delete." over an org-scoped view that points straight at this object', - ).toContain('task_list'); + expect(namesOf(used.body, CACHED_ARM)).not.toContain('legacy_task_list'); }); - it('⛔ NARROWNESS CONTROL: a non-overridable SOURCE stays env-wide — no phantom row is resurrected', async () => { - // The other half of the pair. `object` is `allowOrgOverride: false`, so - // its runtime writes land ENV-WIDE even under an active org - // (`organizationIdForMetaWrite`, #6190) — which is why the phantom has - // to be planted directly. Rows like it exist in deployments that ran - // before that ruling; boot hydration walks past them, so they are dead, - // and a door that named the org for EVERY source type would read them - // back into a destructive-action clearance — worse than an omission, - // because a resurrected row reads as evidence. + it('a non-overridable SOURCE stays env-wide — no phantom row is resurrected', async () => { const written = await b.put(NON_OVERRIDABLE, 'env_orders'); expect(written.status, 'the control never wrote').toBe(200); - // Rewrite the stored document so this object actually REFERENCES the - // target; the write door validates, so the shape is a real one. const envRow = storedRowsFor(b.rows, NON_OVERRIDABLE, 'env_orders', null); - expect(envRow.length, 'a non-overridable write went org-scoped; the control controls nothing').toBe(1); + expect(envRow.length).toBe(1); envRow[0].metadata = JSON.stringify(objectReferencing('env_orders', TARGET_OBJECT)); b.rows.set( @@ -1060,115 +688,31 @@ describe('#13753 GET /meta/:type/:name/references states the org partition', () metadata: JSON.stringify(objectReferencing('phantom_orders', TARGET_OBJECT)), }, ); - expect( - storedRowsFor(b.rows, NON_OVERRIDABLE, 'phantom_orders', ORG_A).length, - 'the phantom was not planted; the control proves nothing', - ).toBe(1); - - // ⭐ Same request, both source types — one org-scoped `view` beside the - // two `object` rows, so the two scopes are read on ONE sweep. - await b.put(CACHED_ARM, 'task_list'); const used = await b.references(NON_OVERRIDABLE, TARGET_OBJECT); expect(used.status).toBe(200); - - expect( - namesOf(used.body, NON_OVERRIDABLE), - 'the env-wide `object` source was not swept at all — the exclusion below would be vacuous', - ).toContain('env_orders'); - expect( - namesOf(used.body, NON_OVERRIDABLE), - 'the door named the organization for a type with no per-org read channel — the pre-#6190 ' - + 'phantoms, resurrected on the read side inside a delete clearance', - ).not.toContain('phantom_orders'); - expect( - namesOf(used.body, CACHED_ARM), - 'the overridable source lost its org scope on the same request — the gate is not per type', - ).toContain('task_list'); + expect(namesOf(used.body, NON_OVERRIDABLE), 'the env-wide source was not swept').toContain('env_orders'); + expect(namesOf(used.body, NON_OVERRIDABLE)).not.toContain('phantom_orders'); }); - describe('⛔ controls — the scope is STATED, and nothing else moves', () => { - it('does not serve org A\'s source to org B on the same boot', async () => { - await b.put(CACHED_ARM, 'task_list'); - expect(storedRowsFor(b.rows, CACHED_ARM, 'task_list', ORG_A).length).toBe(1); - - b.as(ORG_B); - const used = await b.references(NON_OVERRIDABLE, TARGET_OBJECT); - expect(used.status).toBe(200); - expect(namesOf(used.body, CACHED_ARM), 'org B was served org A\'s view').not.toContain('task_list'); - }); - - it('an org-LESS caller reads exactly what it read before', async () => { - await b.put(CACHED_ARM, 'task_list'); - expect(storedRowsFor(b.rows, CACHED_ARM, 'task_list', ORG_A).length).toBe(1); - - b.as(undefined); - const used = await b.references(NON_OVERRIDABLE, TARGET_OBJECT); - expect(used.status).toBe(200); - expect( - namesOf(used.body, CACHED_ARM), - 'an anonymous / org-less read moved — this door must not change for a caller that names no org', - ).not.toContain('task_list'); - }); - - it('and still serves ENV-WIDE sources to an org-scoped caller', async () => { - // The other direction: naming the org must not narrow the answer - // an org caller could already see. - b.as(undefined); - await b.put(CACHED_ARM, 'env_task_list'); - expect(storedRowsFor(b.rows, CACHED_ARM, 'env_task_list', null).length).toBe(1); - - b.as(ORG_A); - const used = await b.references(NON_OVERRIDABLE, TARGET_OBJECT); - expect(used.status).toBe(200); - expect( - namesOf(used.body, CACHED_ARM), - 'an org session lost sight of an env-wide reference it could see before', - ).toContain('env_task_list'); - }); - - it('the response is the SAME wire shape — one `references` key, no new field', async () => { - await b.put(CACHED_ARM, 'task_list'); - const used = await b.references(NON_OVERRIDABLE, TARGET_OBJECT); - expect(used.status).toBe(200); - expect(Object.keys(used.body ?? {})).toEqual(['references']); - // The ROW shape too: a repair that added a scope discriminator per - // row would satisfy every assertion above. - expect(rowsOf(used.body).find((r) => r.name === 'task_list')).toEqual({ - type: CACHED_ARM, name: 'task_list', label: MARKER, path: 'object', kind: 'view object', - }); + it('the response is the SAME wire shape — one `references` key, no new field', async () => { + await b.put(CACHED_ARM, 'task_list'); + const used = await b.references(NON_OVERRIDABLE, TARGET_OBJECT); + expect(used.status).toBe(200); + expect(Object.keys(used.body ?? {})).toEqual(['references']); + expect(rowsOf(used.body).find((r) => r.name === 'task_list')).toEqual({ + type: CACHED_ARM, name: 'task_list', label: MARKER, path: 'object', kind: 'view object', }); + }); - it('the #9327 unanswerable-target refusal keeps its code and status', async () => { - // Asserted as `code` + `status` (ADR-0112) rather than as "it - // threw": this route's refusals are the one thing on it an operator - // reads as "the question was never asked", so a scope repair that - // moved either would be moving the destructive-action clearance. - // - // ⚠️ The code is read through BOTH refusal dialects on purpose, - // and the reason CHANGED with #15685 — so the sentence is rewritten - // rather than left standing as a falsified one. - // - // It used to accommodate a real divergence: the missing-method - // branch hand-built the ADR-0112 NESTED `{ error: { code, message } }` - // while the protocol-raised unanswerable-target refusal reached the - // wire as the FLAT `{ error: 'Internal server error', code }`, its - // prescriptive "ask the owning object instead" message scrubbed. - // #15685 closed that: both exits now answer the nested envelope, and - // `body.error.code` reads the same way on each. - // - // The tolerant read STAYS, deliberately. The envelope and the - // message are pinned — positionally, and on both refusals at once — - // by `rest-server-meta-references-refusal-envelope.test.ts`, which - // is where a regression in either belongs. What THIS pin measures is - // that a SCOPE repair moves neither the code nor the status, and - // reading the code wherever it sits is what keeps it measuring that - // and not a second copy of the envelope contract. - const refused = await b.references('field', 'account.owner'); - const body = refused.body as any; - const observed = refused.thrown - ? { status: refused.thrown.status, code: refused.thrown.code } - : { status: refused.status, code: body?.error?.code ?? body?.code }; - expect(observed).toEqual({ status: 501, code: 'NOT_IMPLEMENTED' }); - }); + it('the #9327 unanswerable-target refusal keeps its code and status', async () => { + // Read through both refusal dialects on purpose: the envelope itself is + // pinned by `rest-server-meta-references-refusal-envelope.test.ts`; what + // this pin measures is that a scope change moves neither code nor status. + const refused = await b.references('field', 'account.owner'); + const body = refused.body as any; + const observed = refused.thrown + ? { status: refused.thrown.status, code: refused.thrown.code } + : { status: refused.status, code: body?.error?.code ?? body?.code }; + expect(observed).toEqual({ status: 501, code: 'NOT_IMPLEMENTED' }); }); }); diff --git a/packages/rest/src/rest-server-meta-write-org-scope.test.ts b/packages/rest/src/rest-server-meta-write-org-scope.test.ts index 757b24c7265..03b2a89ff63 100644 --- a/packages/rest/src/rest-server-meta-write-org-scope.test.ts +++ b/packages/rest/src/rest-server-meta-write-org-scope.test.ts @@ -1,52 +1,38 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// #8805 — the REST `/meta` WRITE doors passed no organization, so every audit -// row a REST-authored metadata write produced was stamped env-wide -// (`recordMetadataAudit`: `organization_id: entry.organizationId ?? null`). -// Composed with #8803's scoped READ — own-org rows PLUS env-wide ones, a limb -// that is required rather than optional — that made every REST-authored audit -// row readable by every tenant, carrying its `actor`, `note`, `lock_state` and -// `request_id`. +// [ADR-0131 D6, C5 stage S3] The REST `/meta` WRITE doors carry NO organization. // -// ── What these assertions are ABOUT, and why they are argument-level ─────── +// History. #8805 found these doors passing no organization and made them +// thread the caller's active one for the five types the registry declared +// `allowOrgOverride` (through `organizationIdForMetaWrite`, the dispatcher's +// predicate), so a tenant admin's overlay landed in that tenant's partition and +// its audit row with it. ADR-0131 D6 retires the per-organization overlay +// axis: environment metadata belongs to the whole deployment. The doors now +// thread no organization for any type — every write lands environment-wide +// (`organization_id` NULL) and audits there — and the predicate is deleted. // -// The composition has three links, and two were already pinned before this -// card: `request.organizationId → sys_metadata_audit.organization_id` lives in -// `@objectstack/metadata-protocol`'s own suites, and the DISPATCHER twin's -// end-to-end row is pinned in `@objectstack/runtime`'s -// `meta-write-org-scope.test.ts` (measured for #8805: a `view` written by a -// session with an active org lands `organization_id: 'org_alpha'` on both the -// `sys_metadata` row and the audit row; a `flow` lands `null` on both). The -// missing link — the only one this package owns — is whether the REST door -// SUPPLIES the organization at all. That is an argument, so these are argument -// assertions, exactly as #8747's sibling suite next door reasons about its own. +// ── What these assertions are ABOUT ─────────────────────────────────────── // -// ── The trap this file exists to pin, which is NOT the obvious one ───────── +// The link this package owns is whether the door SUPPLIES an organization, +// so these are argument assertions on the request each door builds. That the +// protocol stores an absent organization as `organization_id` NULL is pinned in +// `@objectstack/metadata-protocol`'s own suites, and the dispatcher twin's +// end-to-end row in `@objectstack/runtime`'s `meta-write-org-scope.test.ts`. // -// Threading `ctx.tenantId` raw would close the disclosure and open an OUTAGE. -// `saveMetaItem`'s `organizationId` is one value feeding two things — the -// `sys_metadata` partition the row lands in AND the audit row — and the -// protocol REFUSES an org-scoped write of a type the registry declares -// `allowOrgOverride: false` (`NOT_OVERRIDABLE`, 403 — the #6190 ruling, which -// deliberately refuses rather than silently coercing the row to env-wide, -// because coercion rewrites the tenancy statement the author made). So a raw -// tenant would turn every `PUT /meta/object/*` from a tenant-admin session into -// a 403. `organizationIdForMetaWrite` is the registry-derived predicate that -// answers this, and it is the dispatcher's OWN — the cases below pin that the -// two doors now answer identically for the same request, which is the property -// the card is really about. +// Reverse verification of the PUT pin (re-threading `ctx.tenantId` into the +// save request) turns the first two cases red; recorded in the stage's PR. import { describe, it, expect, vi } from 'vitest'; -import { organizationIdForMetaWrite } from '@objectstack/metadata-core'; +import * as metadataCore from '@objectstack/metadata-core'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { RestServer } from './rest-server.js'; const META = '/api/v1/meta'; const ORG = 'org_alpha'; -/** `allowOrgOverride: true` — a tenant admin's own overlay really is theirs. */ +/** `allowOrgOverride: true` — until ADR-0131 D6, written into the caller's organization. */ const OVERRIDABLE = 'views'; -/** `allowOrgOverride: false` — an org-scoped write here is refused by the protocol. */ +/** `allowOrgOverride: false` — always written environment-wide. */ const ENV_WIDE = 'object'; function mockServer() { @@ -141,48 +127,37 @@ async function writeWith(type: string, execCtx: any) { return requestFrom(b.saveMetaItem); } -describe('#8805 the REST /meta write doors carry the caller organization', () => { +describe('[ADR-0131 D6] the REST /meta write doors carry no organization', () => { describe('PUT /meta/:type/:name', () => { - it('threads the execution-context tenant for an org-overridable type', async () => { + it('⭐ a caller WITH an active organization writes an org-overridable type environment-wide', async () => { const request = await writeWith(OVERRIDABLE, AUTHORIZED); - expect(request.organizationId).toBe(ORG); + expect(request.organizationId).toBeUndefined(); + expect('organizationId' in request).toBe(false); + }); + + it('⭐ no registered type carries the organization — the twin-parity property, now trivially one answer', async () => { + for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { + const request = await writeWith(entry.type, AUTHORIZED); + expect( + request.organizationId, + `the REST door threaded an organization for type ${entry.type}`, + ).toBeUndefined(); + } }); - it('⛔ does NOT thread it for a type the registry declares non-overridable', async () => { - // THE case that makes the fix safe rather than a trade. The protocol - // answers `NOT_OVERRIDABLE` (403) to an org-scoped write of one of - // these, so a raw `ctx.tenantId` here would turn a working - // `PUT /meta/object/*` into an outage for every tenant-admin session - // — swapping a disclosure for a regression. The write genuinely IS - // env-wide (#6190 option A), so `null` is its truthful audit scope. + it('a non-overridable type stays environment-wide, as before', async () => { const request = await writeWith(ENV_WIDE, AUTHORIZED); expect(request.organizationId).toBeUndefined(); }); - it('is env-wide when the caller resolves no organization', async () => { + it('is environment-wide when the caller resolves no organization, as before', async () => { const request = await writeWith(OVERRIDABLE, AUTHORIZED_NO_ORG); expect(request.organizationId).toBeUndefined(); }); it('judges the plural URL spelling identically to the singular', async () => { - // `/meta/views/x` and `/meta/view/x` are the same item; a predicate - // that scoped only one spelling would partition by URL style. - expect((await writeWith('views', AUTHORIZED)).organizationId).toBe(ORG); - expect((await writeWith('view', AUTHORIZED)).organizationId).toBe(ORG); - }); - - it('never omits the decision — the pre-fix call shape is unreachable', async () => { - // The defect was an ABSENT key, not a wrong value: `orgId = - // request.organizationId ?? null` reads absent and undefined the - // same way downstream, so this asserts the door DECIDED rather than - // forgot. An omitted key is what every REST-authored row had. - for (const type of [OVERRIDABLE, ENV_WIDE]) { - const request = await writeWith(type, AUTHORIZED); - expect( - 'organizationId' in request, - `door omitted organizationId for type ${type}`, - ).toBe(true); - } + expect((await writeWith('views', AUTHORIZED)).organizationId).toBeUndefined(); + expect((await writeWith('view', AUTHORIZED)).organizationId).toBeUndefined(); }); it('leaves the rest of the write request untouched', async () => { @@ -200,20 +175,16 @@ describe('#8805 the REST /meta write doors carry the caller organization', () => expect(request.parentVersion).toBe('sha256:abc'); expect(request.packageId).toBe('pkg_a'); }); + + it('the write-side predicate is gone from `@objectstack/metadata-core`', () => { + expect('organizationIdForMetaWrite' in metadataCore).toBe(false); + // Control: the barrel is the real one. + expect('metaWriteCapabilityVerdict' in metadataCore).toBe(true); + }); }); describe('[#12195] the compound-name PUT twin is retired', () => { - /** - * This drove `PUT /meta/:type/:section/:name` and asserted it carried - * the caller's organization, because #8805 measured that scoping one - * door and not its twin leaves the twin as a bypass — a tenant writing - * through the unscoped spelling reached the env-wide row. - * - * The arity is retired, so the bypass is closed by removal. The pin - * inverts to the absence: a re-mounted compound door arrives org-BLIND - * unless whoever mounts it re-derives #8805. - */ - it('mounts no compound `:section` arity to leave unscoped', () => { + it('mounts no compound `:section` arity', () => { const b = boot(AUTHORIZED); const compound = b.routes() .map((r: any) => String(r.path)) @@ -223,67 +194,48 @@ describe('#8805 the REST /meta write doors carry the caller organization', () => }); describe('DELETE /meta/:type/:name', () => { - it('scopes the reset, so a tenant cannot destroy the env-wide row', async () => { + it('names no organization: the reset reaches the environment-wide row', async () => { const b = boot(AUTHORIZED); await b.drive('DELETE', `${META}/:type/:name`, { params: { type: OVERRIDABLE, name: 'shared_grid' }, }); - expect(requestFrom(b.deleteMetaItem).organizationId).toBe(ORG); - }); - - it('stays env-wide for a non-overridable type', async () => { - const b = boot(AUTHORIZED); - await b.drive('DELETE', `${META}/:type/:name`, { - params: { type: ENV_WIDE, name: 'task' }, - }); expect(requestFrom(b.deleteMetaItem).organizationId).toBeUndefined(); }); }); describe('POST /meta/:type/:name/publish', () => { - it('scopes the publish — without it the save fix would break the draft loop', async () => { - // `promoteDraftForPublish` resolves the draft through - // `getOverlayRepo(orgId)`. Once `PUT ?mode=draft` lands org-scoped, - // an unscoped publish looks in the env-wide partition and answers - // `no_draft`. The two halves are one change, not two. + it('names no organization: the promotion looks in the environment partition the save wrote', async () => { const b = boot(AUTHORIZED); await b.drive('POST', `${META}/:type/:name/publish`, { params: { type: OVERRIDABLE, name: 'shared_grid' }, }); - expect(requestFrom(b.publishMetaItem).organizationId).toBe(ORG); + expect(requestFrom(b.publishMetaItem).organizationId).toBeUndefined(); }); }); describe('POST /meta/:type/:name/rollback', () => { - it('scopes the rollback so it restores into the partition the caller named', async () => { + it('names no organization: it restores a version of the environment-wide row', async () => { const b = boot(AUTHORIZED); await b.drive('POST', `${META}/:type/:name/rollback`, { params: { type: OVERRIDABLE, name: 'shared_grid' }, body: { toVersion: 2 }, }); const request = requestFrom(b.rollbackMetaItem); - expect(request.organizationId).toBe(ORG); + expect(request.organizationId).toBeUndefined(); expect(request.toVersion).toBe(2); }); }); - describe('GET /meta/:type/:name/published — the read that had to move with the write', () => { - it('scopes the published read with the RAW tenant, not the write predicate', async () => { - // This route's comment used to justify omitting the organization by - // symmetry: "this door resolves exactly the publishes this door can - // produce". The write-side fix ends that symmetry, so left unscoped - // this read would 404 about a `view` the same caller published a - // moment earlier through the same transport. The RAW tenant is - // correct here because `getMetaItemLayered` is org-first-then- - // env-wide: fail-open in the safe direction. + describe('GET /meta/:type/:name/published — the read that moves with the write', () => { + it('names no organization for a caller with one: it resolves exactly the publishes the doors produce', async () => { const b = boot(AUTHORIZED); await b.drive('GET', `${META}/:type/:name/published`, { params: { type: OVERRIDABLE, name: 'shared_grid' }, }); - expect(requestFrom(b.getMetaItemLayered).organizationId).toBe(ORG); + expect(requestFrom(b.getMetaItemLayered)).not.toHaveProperty('organizationId'); }); - it('omits it entirely for a caller with no organization', async () => { + it('nor for a caller with no organization', async () => { const b = boot(AUTHORIZED_NO_ORG); await b.drive('GET', `${META}/:type/:name/published`, { params: { type: OVERRIDABLE, name: 'shared_grid' }, @@ -291,21 +243,4 @@ describe('#8805 the REST /meta write doors carry the caller organization', () => expect(requestFrom(b.getMetaItemLayered)).not.toHaveProperty('organizationId'); }); }); - - describe('twin parity — the property the card is actually about', () => { - it('answers what the dispatcher answers, for every registered type', async () => { - // Both doors call the SAME predicate now; this pins that the REST - // door's answer is that predicate's answer rather than a - // coincidence, across the whole registry rather than the two - // specimens above. A registry entry flipping `allowOrgOverride` - // moves both sides of this assertion together (Prime Directive #8). - for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { - const request = await writeWith(entry.type, AUTHORIZED); - expect( - request.organizationId, - `REST door disagreed with the dispatcher for type ${entry.type}`, - ).toBe(organizationIdForMetaWrite(entry.type, ORG)); - } - }); - }); }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 32bb64d053e..618efa6ac28 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -64,18 +64,12 @@ import { resolveObjectSchemaRuntimeView, OBJECT_SCHEMA_MASK_NOT_APPLICABLE, type ObjectSchemaMaskPosture, - // [#8805] The organization a metadata WRITE carries, given the caller's - // active one — the SAME predicate the runtime `/metadata` dispatcher calls - // (`domains/meta.ts`), not a REST-local restatement of it. See the module's - // own header for why the decision belongs to the caller and why it lives in - // `metadata-core`. - organizationIdForMetaRead, - organizationIdForMetaWrite, - // [#12702] The capability half of the same decision, from the same home: - // `manage_metadata` as before, plus `manage_org_presentation` for - // org-overridable types written org-scoped to the caller's own active - // organization. One predicate for every `/meta` item write door on both - // transports — never a REST-local restatement. + // [#12702 · ADR-0131 D6] Which callers a `/meta` item write door admits: + // `manage_metadata` (or `isSystem`). One predicate for every `/meta` item + // write door on both transports — never a REST-local restatement. Since + // the per-organization overlay axis retired, no `/meta` door carries an + // organization into a metadata write or read: every write lands + // environment-wide and every read resolves environment → code. metaWriteCapabilityVerdict, type MetaWriteCapabilityVerdict, // Which form candidates the anonymous form doors serve — the one rule the @@ -3425,7 +3419,7 @@ export class RestServer { if (!withItemWriteVerdict || !caller) return caller; return (withVerdict ??= { ...caller, - mayWriteItem: RestServer.metaSaveVerdict(caller, req.params.type).allowed, + mayWriteItem: RestServer.metaSaveVerdict(caller).allowed, }); }, resolveSecurityService: async () => ( @@ -3453,27 +3447,19 @@ export class RestServer { /** * [#20156] The CURRENT document of `:type/:name`, fetched the way the plain - * read's uncached arm fetches it — same request shape, same org partition - * ({@link organizationIdForMetaRead} over the folded type) — for a door that + * read's uncached arm fetches it — same request shape, environment → code + * (ADR-0131 D6: no organization) — for a door that * serves no document of its own (`/history` and `/audit` serve events, * `/diff` a comparison) and so judges the item the plain read would judge. * `undefined` when nothing is behind the name. */ private async fetchCurrentMetaDocument( - environmentId: string | undefined, req: any, p: RestProtocol, ): Promise { - const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); - const organizationId = organizationIdForMetaRead( - // [folded-type commit 26f3588fb] (the original card no longer - // resolves) FOLDED, not raw — see the PUT door's org-scope comment. - canonicalMetaUrlType(req.params.type), ctx?.tenantId, - ); const currentRequest: GetMetaItemRequest = { type: req.params.type, name: req.params.name, - ...(organizationId ? { organizationId } : {}), }; const envelope = await p.getMetaItem(currentRequest) as Record; return envelope?.item ?? undefined; @@ -3498,7 +3484,7 @@ export class RestServer { const metaType = RestServer.metaTypeSingular(req.params.type); const policy: MetaReadGatePolicy = { arms: 'per-caller', app: 'gate' }; if (!RestServer.gatesPerCaller(metaType)) return undefined; - const current = await this.fetchCurrentMetaDocument(environmentId, req, p); + const current = await this.fetchCurrentMetaDocument(req, p); if (current == null) return undefined; const verdict = await this.metaItemReadGate( environmentId, req, p, metaType, req.params.name, [current], policy, @@ -3610,19 +3596,13 @@ export class RestServer { * * The whole admission is this verdict: the save door refuses on nothing * else about the CALLER before `saveMetaItem` (whose own refusals judge the - * item and the scope, the same for every admitted caller). `rawType` is - * the URL segment, folded here at the boundary ([folded-type commit - * 26f3588fb] — see the PUT door's org-scope comment) so the verdict and - * the door's scope decision read one spelling; the organization is the - * context's `tenantId`, the very value `organizationIdForMetaWrite` - * threads. + * item, the same for every admitted caller). Since ADR-0131 D6 the verdict + * reads the caller alone: no type and no organization enters it. */ - private static metaSaveVerdict(ctx: any, rawType: string): MetaWriteCapabilityVerdict { + private static metaSaveVerdict(ctx: any): MetaWriteCapabilityVerdict { return metaWriteCapabilityVerdict({ isSystem: ctx?.isSystem === true, systemPermissions: ctx?.systemPermissions, - canonicalType: canonicalMetaUrlType(rawType), - activeOrganizationId: ctx?.tenantId, operation: 'save', }); } @@ -3925,8 +3905,8 @@ export class RestServer { * plain read prunes it for everyone else) and the ADR-0106 mask on every * layer with its cache posture — which the runtime dispatcher serves both * spellings through too. ⛔ A step is added there, never here. The read - * stays this transport's, scoped by `metaReadOrganizationId`, the one - * answer the dispatcher's layered read asks. + * stays this transport's, environment → code like the dispatcher's + * layered read (ADR-0131 D6: no organization). * * Not translated and not cached, both deliberately: this is a diagnostic * view of what is STORED at each layer, so locale-collapsing it (or serving @@ -3951,26 +3931,10 @@ export class RestServer { if (refuseRepeatedQueryParams(req, res, ['package'])) return; // [#22188] Read through {@link metaItemPackageBinding}: `all` names no package. const layeredPackageId = metaItemPackageBinding(req.query?.package); - // [#9454] State the ORG scope, exactly as the `/published` overlay read - // already does. Without it the layered view resolved the env-wide row - // only, so an author who had just saved an org overlay opened Studio to - // `overlay: null` and the code layer — the write receipted as live, the - // editor reporting it absent. This is the DIAGNOSTIC view of what is - // stored per layer, so an unstated scope does not merely miss a row: it - // misreports the very thing being diagnosed. - // ⚠️ NOT a new org-resolution seam — `resolveExecCtx` is memoised per - // request (WeakMap keyed by `req`), the same result 40+ handlers here - // already share. Registry-gated via `organizationIdForMetaRead` so a - // non-overridable type keeps reading env-wide (see that predicate for - // why naming the org unconditionally would resurrect #6190's phantoms). - const layeredCtx = await this.resolveExecCtx(environmentId, req) - .catch(rethrowAuthzStoreUnavailable); - // [folded-type commit 26f3588fb] (the original card no longer - // resolves) FOLDED, not raw — see the PUT door's org-scope comment for - // the measurement. [#20478] Asked of `metaReadOrganizationId` (the - // same fold over the vetted `tenantId`), the one answer the runtime - // dispatcher's layered read asks too. - const layeredOrganizationId = metaReadGate.metaReadOrganizationId(req.params.type, layeredCtx); + // [ADR-0131 D6] No organization: the per-organization overlay axis is + // retired, so the layered read is environment → code — the row every + // `/meta` write now lands in. A legacy organization-scoped row is not + // served here; its promotion to the environment layer is ADR-0131 C7's. // [commit 2a29caa53] This door never carried an `as any`, but `p: any` meant its // request literal was never checked either — the same blind spot with // a different spelling. Typing the literal (spec shape + the @@ -3981,7 +3945,6 @@ export class RestServer { name: req.params.name, ...(layeredPackageId ? { packageId: layeredPackageId } : {}), ...(environmentId ? { environmentId } : {}), - ...(layeredOrganizationId ? { organizationId: layeredOrganizationId } : {}), }; const layered = await p.getMetaItemLayered(layeredRequest); // [#20156 · #20478] THE per-caller gate on every layer, then the mask — @@ -5716,161 +5679,16 @@ export class RestServer { const severityParam = (req.query?.severity as string | undefined) ?? 'error'; const severity = severityParam === 'warning' ? 'warning' : 'error'; const diagnosticsType = (req.query?.type as string | undefined) || undefined; - // [#13753, #15622] STATE THE ORG PARTITION — on BOTH - // arms. They differ only in whether the fold happens - // HERE or is left entirely to the callee. - // - // `getMetaDiagnostics` reads each swept type through - // `getMetaItems({ type: t, organizationId })`. - // - // ⚠️ [commit 96326040f] `getMetaItems` NOW APPLIES THE REGISTRY GATE - // ITSELF — `organizationIdForMetaRead(request.type, - // request.organizationId)`, one statement after it folds the - // type through `canonicalizeMetaRequestType`. That is the - // ONE inner gate this call site now sits above; the sibling - // gate in the same file guards `getMetaItem` (the singular - // overlay read, commit d5cbb44f3), which this arm never reaches. - // - // ⛔ Until commit 96326040f this comment said `getMetaItems` applied NO - // registry gate of its own and the scope was therefore - // decided HERE, per type, by the caller. That sentence is - // FALSE on today's tree — do not reintroduce it, and do not - // reason from it. - // - // ⇒ The `?type=` arm is exactly one type - // (`targetTypes = [request.type]`), so the predicate - // over that one type IS the request's whole scope and - // the answer is correct by construction. That is the - // arm Studio's per-type directory drill-down uses, and - // it is the arm #13753 repaired. - // - // ── WHY THE FOLD IS DOUBLED, AND STAYS DOUBLED (commit abf9101f1) ── - // - // The VALUE is redundant, and measured to be. Both sites fold - // the identical string through the identical map — here - // `canonicalMetaUrlType`, inside `getMetaItems` the same - // function reached through `canonicalizeMetaRequestType` → - // `canonicalMetaType` — so `f(t, f(t, o)) === f(t, o)` and the - // inner application is the algebraic no-op. MEASURED: replace - // this predicate with a raw `diagnosticsCtx?.tenantId` and - // `rest-server-meta-read-org-scope.test.ts` stays GREEN IN FULL - // (30/30 at that revision; the file has grown since); - // the inner gate re-folds it, phantom control included. - // - // ⭐ It is KEPT anyway, and the reason is TRUST DOMAIN rather - // than value. `getMetaDiagnostics` is not a member of - // `MetadataProtocol` at all — not required, not optional — - // which is why it is reached through the `(p as any)` cast and - // why the 501 above exists. The inner gate therefore belongs - // to ONE implementation of an UNDECLARED extension, while this - // predicate sits on the REST boundary and holds for every - // `RestProtocol` a host can mount. Delete it and a REST door's - // tenant scope becomes a function of which kernel is mounted — - // and no pin can see that happen, because the harness boots the - // bundled implementation. Defence in depth, on a seam the type - // system does not cover. - // - // ── [#15622] THE UNTYPED SWEEP FORWARDS THE - // ORGANIZATION TOO, and passes it RAW ─────────────── - // - // ⛔ This arm used to be a RECORDED GAP, left env-wide - // on this argument: `targetTypes` is then the whole - // registry — five `allowOrgOverride: true` types and - // every other declared type together — while the - // request carries ONE `organizationId`, and one org id - // could not express a per-type scope from here without - // a fan-out per overridable type plus a REST-side - // re-aggregation of `total`/`stats`/`scannedTypes`. - // - // ⚠️ Commit 96326040f DISSOLVED THAT OBSTACLE (commit abf9101f1 recorded - // it, #15622 acted on it). `getMetaDiagnostics` does - // not spend the organization once: it loops `for (const - // t of targetTypes)` calling `getMetaItems({ type: t, - // organizationId, … })`, and the FIRST thing - // `getMetaItems` does with that organization is - // `organizationIdForMetaRead(request.type, …)` on its - // OWN folded type. So one `organizationId` handed to - // this arm is already narrowed PER TYPE by the callee — - // the org for the five overridable types, `undefined` - // for every other, phantoms of non-overridable types - // dropped. That is precisely the scope the paragraph - // above said one id could not say. No fan-out, no - // REST-side re-aggregation, no second owner of the - // sweep's arithmetic: `stats` / `total` / - // `scannedTypes` are untouched by the gate. - // - // ⭐ RULED that the gap CLOSES rather than being - // re-recorded. A governance summary whose whole job is - // surfacing problems, and which structurally cannot see - // a class of them WHILE ITS OWN drill-down can, issues a - // false all-clear — since #13753 repaired the `?type=` - // arm, this summary undercounts relative to the screen - // you reach by clicking into it. An org-scoped caller - // now sees items THEIR OWN organization authored, on the - // five overridable types only, which for a governance - // report is the correct set. - // - // ⛔ RAW, and deliberately NOT pre-folded with - // `organizationIdForMetaRead(...)` the way the `?type=` - // arm folds above. There is no single type to fold on - // here, and folding on any one of them would suppress - // the organization for EVERY type at once. The per-type - // decision belongs to the callee's loop. Identical in - // shape to the `/references` door below, whose - // narrowness control measured the same callee gate; both - // halves are pinned in - // `rest-server-meta-read-org-scope.test.ts`, where ONE - // request shows an overridable type's org-authored row - // present and a planted pre-#6190 phantom on a - // NON-overridable type absent. - // - // ⚠️ ADR-0131 D6/D7 retires the per-organization - // metadata partition in v18 (#15206, C5), so this - // behaviour has ONE MAJOR to live and reverts to - // environment-wide when the partition goes. An existing - // value handed to an existing parameter: no new - // parameter, response field, status code or contract - // surface. ⛔ Nothing is to be built on it. - // - // ⚠️ NOT a new org-resolution seam: `resolveExecCtx` is - // memoised per request (WeakMap keyed by `req`), the - // same result 40+ handlers here already share. It is now - // resolved for BOTH arms — which is why this reads as a - // statement rather than a ternary: the LOCALLY CAUGHT - // continuation-line spelling is the one the sibling - // doors use and the one `execctx-consumer-census` - // reads, and a third layout would be invisible to it. - // This door does not sit behind the shared anonymous - // floor, so it decides an authz-store outage for itself - // rather than laundering it into an org-unscoped 200 — - // and the untyped arm now shares that, deliberately. - const diagnosticsCtx = await this.resolveExecCtx(environmentId, req) - .catch(rethrowAuthzStoreUnavailable); - const diagnosticsOrganizationId: string | undefined = diagnosticsType - ? organizationIdForMetaRead( - // [commit 26f3588fb] FOLDED, not raw — see the PUT door's - // org-scope comment for the measurement. The - // protocol keeps receiving the caller's own - // spelling (it normalises, and refuses an - // unrecognised one with its own 400); only the - // scope decision reads the canonical singular. - canonicalMetaUrlType(diagnosticsType), diagnosticsCtx?.tenantId, - ) - // [#15622] The whole-registry arm — raw, per above. - : diagnosticsCtx?.tenantId; + // [ADR-0131 D6] No organization: the sweep reads each + // type environment → code, the partition every `/meta` + // write now lands in. A legacy organization-scoped row + // is not swept; its promotion is ADR-0131 C7's. const result = await (p as any).getMetaDiagnostics({ type: diagnosticsType, severity, // [#22188] The list's reading ({@link metaItemPackageBinding}): // each swept type is a list read, so `all` names no package. packageId: metaItemPackageBinding(req.query?.package), - // SPREAD, never `organizationId: x ?? null` — the - // implementation declares `organizationId?: string` - // (optional plain string, not nullable), and a - // `null` would travel into `getMetaItems` as an - // explicit env-partition statement rather than as - // "unstated". - ...(diagnosticsOrganizationId ? { organizationId: diagnosticsOrganizationId } : {}), }); res.json(result); } catch (error: any) { @@ -5946,24 +5764,14 @@ export class RestServer { // [#6877] Both narrow the draft list to one package / // one type; an array reached `listDrafts` untouched. if (refuseRepeatedQueryParams(req, res, ['packageId', 'type'])) return; - // [#11087] Read in the CALLER'S org scope, symmetric with - // the save route (`saveMetaItem`'s `organizationId: - // ctx?.tenantId`, below): a draft saved by a session - // carrying an active org lands in that org's overlay - // scope, and this route used to read with NO org — - // `getOverlayRepo(null)` sees only env-wide - // (`organization_id IS NULL`) rows, so every org-scoped - // draft was invisible to the pending-changes surfaces - // while single reads (which thread the ctx) and the - // publisher (which resolves each draft's own scope) - // saw it fine — the write-org/read-null split behind - // cloud#1593. With the org threaded, the repository's - // own `$or` contract surfaces BOTH the caller's org - // overlay and env-wide drafts. + // [ADR-0131 D6] No organization: a draft is saved + // environment-wide, so the list reads the environment + // partition, symmetric with the save door. A legacy + // organization-scoped draft is not listed; its + // promotion is ADR-0131 C7's. const result = await (p as any).listDrafts({ packageId: (req.query?.packageId as string | undefined) || undefined, type: (req.query?.type as string | undefined) || undefined, - organizationId: ctx?.tenantId ?? undefined, }); res.json(result); } catch (error: any) { @@ -6118,22 +5926,11 @@ export class RestServer { // rule on a READ door, and why it throws instead of // building a body. await this.refuseUnknownMetaListType(p, req.params?.type); - // [#9454] The scoped listing is the second door the - // card measured absent (`?object=` unchanged after a - // runtime PUT). `getMetaItems` unions the env-wide and - // org scopes under org-wins precedence — but only when - // the caller names an org; unnamed, it returns the - // env-wide partition alone and the author's new item is - // simply not in the list. Same memoised `resolveExecCtx` - // and same registry gate as every other read door here. + // [#20338] The caller, for the draft-preview admission + // below. The list names no organization (ADR-0131 D6): + // it reads environment → code. const listCtx = await this.resolveExecCtx(environmentId, req) .catch(rethrowAuthzStoreUnavailable); - // [folded-type commit 26f3588fb] (the original card no - // longer resolves) FOLDED, not raw — see the PUT door's - // org-scope comment for the measurement. [#20408] Asked of - // `metaReadOrganizationId`, the one answer the runtime - // dispatcher's list asks too. - const listOrganizationId = metaReadGate.metaReadOrganizationId(req.params.type, listCtx); // ADR-0033/0037 draft-overlay preview: `?preview=draft` // overlays pending drafts on the active list, exactly as // the runtime dispatcher's /metadata/:type route does — @@ -6158,7 +5955,6 @@ export class RestServer { packageId, ...(previewDrafts ? { previewDrafts: true } : {}), ...(environmentId ? { environmentId } : {}), - ...(listOrganizationId ? { organizationId: listOrganizationId } : {}), }; const items = await p.getMetaItems(listRequest); @@ -6263,64 +6059,13 @@ export class RestServer { }); return; } - // ── [#13753] STATE THE ORG PARTITION — and pass it - // RAW, which is the whole of the decision ─────────── - // - // The admin "Used by" panel renders its empty case as - // "Nothing in the metadata graph points at this item. - // Safe to delete." (objectui `metadata-admin/i18n.ts`), - // shown to an operator about to delete something. With - // no organization stated, the sweep read the env - // partition only: an org-scoped `view` referencing the - // item was invisible and the panel issued a false - // clearance — the ADR-0110 D3 harm this route's own 501 - // refusal (#9326) exists to prevent, delivered by the - // door after the protocol had refused to deliver it. - // - // ⛔ NOT pre-gated with `organizationIdForMetaRead( - // canonicalMetaUrlType(req.params.type), ...)`, the way - // the sibling `/meta` doors gate. Here `req.params.type` - // is the TARGET, and `findReferencesToMeta` spends the - // organization on the SOURCES: it resolves - // `REFERENCE_SITES.byTarget.get(target)`, groups the - // sites by `fromType` and reads each through - // `getMetaItems({ type: matcher.fromType, ... })`. The - // target's own registry flag therefore says nothing - // about the types actually read, and gating on it would - // suppress the organization for exactly the `object` / - // `flow` / `app` deletes this card is about — the card's - // own false clearance, left standing by a change that - // looks like its repair. - // - // ⭐ And RAW is not the unconditional tenant that - // predicate exists to prevent, because since commit 96326040f - // `getMetaItems` applies it ITSELF, to its OWN - // `request.type`, after the fold. The per-SOURCE-type - // decision is already the callee's: an overridable - // source (`view`, `dashboard`, `report`, `translation`, - // `email_template`) honours the organization, every - // other source drops it and stays env-wide, so no - // pre-#6190 phantom row is resurrected into a - // destructive-action clearance. `request.organizationId` - // has exactly ONE use inside `findReferencesToMeta` — - // that `getMetaItems` spread — so passing it raw carries - // no other consequence. Both halves are pinned in - // `rest-server-meta-read-org-scope.test.ts`, the second - // as the narrowness control. - // - // ⚠️ ADR-0131 D6/D7 retires the per-organization - // metadata partition in v18 (#15206, C5), so this is a - // repair inside a mechanism being removed: an existing - // value handed to an existing parameter, no new contract - // surface. ⛔ Nothing is to be built on it. - // - // The same memoised resolution the sibling read doors - // share, in the same locally-caught spelling: this door - // does not sit behind the shared anonymous floor, so it - // decides an authz-store outage for itself rather than - // laundering it into an org-unscoped 200. - const referencesCtx = await this.resolveExecCtx(environmentId, req) - .catch(rethrowAuthzStoreUnavailable); + // [#13753 · ADR-0131 D6] The sweep reads the sources + // environment → code, the world the `/meta` doors serve: + // the per-organization overlay axis is retired, so no + // organization is stated. A legacy organization-scoped + // source row is served by no door until ADR-0131 C7 + // promotes it, and that ceremony re-judges what it + // carries; it is not swept here. // [#15685] The protocol's OWN refusal is re-answered in // the nested envelope the branch above already uses — // see {@link notImplementedRefusalAnswer} for why the @@ -6335,11 +6080,6 @@ export class RestServer { result = await (p as any).findReferencesToMeta({ type: req.params.type, name: req.params.name, - // SPREAD, never `organizationId: x ?? null` — the - // implementation declares `organizationId?: string` - // (optional plain string, not nullable), and it - // forwards on truthiness. - ...(referencesCtx?.tenantId ? { organizationId: referencesCtx.tenantId } : {}), ...(environmentId ? { environmentId } : {}), }); } catch (raised: any) { @@ -6610,8 +6350,7 @@ export class RestServer { // published checksum and drafts are out-of-band. const isAppType = metaType === 'app'; // [#20338] The caller, resolved ABOVE the two draft - // switches because each is admitted per caller; the - // #9454 org resolution below reads the same value. + // switches because each is admitted per caller. // Memoised per request — not a new seam. const readCtx = await this.resolveExecCtx(environmentId, req) .catch(rethrowAuthzStoreUnavailable); @@ -6707,24 +6446,6 @@ export class RestServer { // ADR-0046 §6.7 — the two audience-gated types, excluded // from the cache so {@link metaItemReadGate} judges them. const isAudienceGatedType = metaType === 'book' || metaType === 'doc'; - // [#9454] ONE org resolution for BOTH arms of the fork - // below, computed ABOVE it on purpose. `view` takes the - // cached arm; `dashboard` bypasses it via - // `isDashboardType` and takes the uncached arm. A scope - // threaded into only one arm fixes exactly ONE of the - // five org-overridable types while the receipt keeps - // claiming success for the rest — the half-fix this - // card's pin exists to forbid. Hoisting it makes the - // two arms incapable of disagreeing about scope. - // ⚠️ NOT a new seam: memoised per request, and this - // handler resolves the same context again further down. - // [#20338] `readCtx` is resolved above the draft switches. - // [folded-type commit 26f3588fb] (the original card no - // longer resolves) FOLDED, not raw — see the PUT door's - // org-scope comment for the measurement. [#20408] Asked of - // `metaReadOrganizationId`, the one answer the runtime - // dispatcher's item read asks too. - const readOrganizationId = metaReadGate.metaReadOrganizationId(req.params.type, readCtx); if (metadata.enableCache && p.getMetaItemCached && !isAppType && !isDashboardType && !isDraftRead && !previewDrafts && !packageScoped && !isAudienceGatedType) { // [ADR-0106 D3] When a projection applies, the // protocol is NOT allowed to judge the conditional @@ -6779,13 +6500,6 @@ export class RestServer { ...(cacheLocale ? { locale: cacheLocale } : {}), ...(environmentId ? { environmentId } : {}), // [#9454] The cached door is the `view` arm, and - // it used to hard-code a two-key delegation to - // `getMetaItem` — it could not express an org at - // all, so this threading is paired with a widened - // signature in `metadata-protocol`. The org also - // enters the ETag there, so the validator states - // the scope rather than inheriting it. - ...(readOrganizationId ? { organizationId: readOrganizationId } : {}), }; const result = await p.getMetaItemCached(cachedRequest); @@ -6940,11 +6654,6 @@ export class RestServer { // never `req.query` re-read here. ...(isDraftRead ? { state: 'draft' as const } : {}), ...(previewDrafts ? { previewDrafts: true } : {}), - // [#9454] The uncached arm — `dashboard`'s route - // (`isDashboardType`), and every read the cache - // exclusions divert here. Same hoisted scope as - // the cached arm above, by construction. - ...(readOrganizationId ? { organizationId: readOrganizationId } : {}), }; const envelope = await p.getMetaItem(itemRequest) as Record; @@ -7121,15 +6830,13 @@ export class RestServer { // item is authoring; `isSystem` bypasses, matching every // other capability gate on the platform. // - // [#12702] The gate is the shared `metaWriteCapabilityVerdict` - // (`@objectstack/metadata-core`, beside the org-scope - // predicate this door already runs): beside `manage_metadata` - // it admits `manage_org_presentation`, ONLY for a type whose - // registry entry declares `allowOrgOverride: true` AND a - // session with an active organization — `ctx.tenantId`, the - // very value `organizationIdForMetaWrite` threads below, so - // an admitted write can only land org-scoped in the caller's - // own partition: never env-wide, never another org's. + // [#12702 · ADR-0131 D6] The gate is the shared + // `metaWriteCapabilityVerdict` (`@objectstack/metadata-core`): + // `manage_metadata` or `isSystem`. The org-scoped + // `manage_org_presentation` arm retired with the + // per-organization overlay axis — this door threads no + // organization, so that arm would have admitted its holders + // to environment-wide authoring. // // [#20156] Asked through {@link metaSaveVerdict}, the ONE // spelling the stored-version read doors' author exemption @@ -7139,7 +6846,7 @@ export class RestServer { // everything that was stored. const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); { - const verdict = RestServer.metaSaveVerdict(ctx, req.params.type); + const verdict = RestServer.metaSaveVerdict(ctx); if (!verdict.allowed) { res.status(403).json({ error: { @@ -7237,72 +6944,16 @@ export class RestServer { // ({@link metaItemPackageBinding}, the item read's too). const packageId = metaItemPackageBinding(req.query?.package); - // [#8805] THE WRITE-SIDE ORGANIZATION. Until this landed the - // door passed none, so `recordMetadataAudit` stamped - // `organization_id: null` on EVERY row a REST-authored - // metadata write produced (`entry.organizationId ?? null`). - // Composed with #8803's scoped read — own-org rows PLUS - // env-wide ones, a limb that is required, not optional — - // that made every REST-authored audit row readable by every - // tenant, carrying its `actor`, `note`, `lock_state` and - // `request_id`. The read half could not close it: the rows - // were genuinely unscoped, so no filter could separate them. - // - // Two measurements decided the SHAPE, and neither is - // obvious from the defect: - // - // 1. The organization must NOT be threaded unconditionally. - // `saveMetaItem`'s `organizationId` is one value feeding - // two things — the `sys_metadata` partition the row - // lands in AND the audit row — and the protocol REFUSES - // an org-scoped write of a type the registry declares - // `allowOrgOverride: false` (`NOT_OVERRIDABLE`, 403; - // `orgScopedWriteRefusal`, the #6190 ruling). Passing - // `ctx.tenantId` raw would turn every `PUT /meta/object/*` - // from a tenant-admin session into a 403 — trading a - // disclosure for an outage. `organizationIdForMetaWrite` - // is the registry-derived predicate that answers this, - // and it is the DISPATCHER's own: this door now behaves - // identically to its `/metadata` twin for the same - // request, which is the whole point. - // 2. So a non-overridable type still audits env-wide — and - // that is correct rather than residue. Its WRITE is - // env-wide (#6190 option A: the runtime stops minting - // rows boot never reads), so an env-wide audit row is - // the truthful scope for it, symmetric with what the - // `/published` route's comment argues further down this - // file. `null` stays reserved for writes that really are - // environment-wide. - // - // ⚠️ NOT a new org-resolution seam — the thing the - // `/published` comment forbids. `ctx` is the SAME - // `resolveExecCtx` result the capability gate above already - // resolved (memoised per request, called in 40+ handlers - // here); no `resolveActiveOrganizationId` is minted, exactly - // as #8803 did for the audit READ on the sibling route. - // `computeExecCtx` assembles `tenantId` from the shared - // `resolveAuthzContext` — an API key's principal tenant, - // else the session's `activeOrganizationId`, which is the - // very field the dispatcher twin reads. - // - // [commit 26f3588fb] The type is FOLDED before the scope decision, - // never the raw URL spelling. Storage folds `:type` - // through `META_URL_TO_SINGULAR` — the COMPLETE map — - // while `declaresOrgOverride` tolerates only the - // manifest-collection spellings (incomplete by design; - // see its header). Measured on `origin/main` for the two - // registry-derived spellings, `translations` and - // `email_templates`: the raw segment read and wrote - // ENV-WIDE where the singular twin was org-scoped — one - // item, two partitions, addressed by spelling (#4432 / - // #7894's defect one layer down). Folding HERE keeps the - // scope decision and the storage fold answering one - // question, which is what `metadata-url-spelling.ts` - // mandates: folding happens at the boundary and only - // there; the layers below read the canonical singular. - const organizationId = organizationIdForMetaWrite( - canonicalMetaUrlType(req.params.type), ctx?.tenantId, - ); + // [ADR-0131 D6] THE WRITE NAMES NO ORGANIZATION. The + // per-organization overlay axis is retired: every write this + // door admits lands environment-wide (`organization_id` + // NULL), its audit row included, whatever the caller's + // active organization. Until this stage the door threaded + // the active organization for the five types the registry + // declared `allowOrgOverride`; a single-posture Studio save + // of those types therefore sits under the Default + // Organization, and is not served again until ADR-0131 C7 + // promotes it to the environment layer. // [#12004] The `as any` cast this call carried came off // when `SaveMetaItemRequestSchema` caught up with the // members this door sends. `saveMetaItem` is a REQUIRED @@ -7322,7 +6973,6 @@ export class RestServer { type: req.params.type, name: req.params.name, item, - organizationId, // [commit d806081dd] This door answers with an ADR-0112 error // envelope that carries the refusal's `issues[]` // structurally beside the message (`sendError` threads a @@ -7385,22 +7035,14 @@ export class RestServer { // `deleteMetaItem` has run would still be the bug. // `isSystem` bypasses, as everywhere else. // - // [#12702] Same shared verdict as the PUT door. On THIS - // verb the org condition is also what bounds the blast - // radius: an admitted org-presentation reset threads the - // caller's own organization, and `orgId` selects the - // overlay repository — so the only row such a caller can - // discard is their own org's overlay, never the env-wide - // one (see the [#8805] comment below). `?dropStorage=true` - // is `object`-only, and `object` is not org-overridable, - // so the org capability can never reach it. + // [#12702 · ADR-0131 D6] Same shared verdict as the PUT + // door. The reset names no organization either, so it + // discards the environment-wide row. const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); { const verdict = metaWriteCapabilityVerdict({ isSystem: ctx?.isSystem === true, systemPermissions: ctx?.systemPermissions, - canonicalType: canonicalMetaUrlType(req.params.type), - activeOrganizationId: ctx?.tenantId, operation: 'reset', }); if (!verdict.allowed) { @@ -7461,23 +7103,8 @@ export class RestServer { // orphan table. Destructive — opt-in, defaults off. const dropStorage = req.query?.dropStorage === 'true' || req.query?.dropStorage === '1'; - // [#8805] Same write-side organization as the `PUT` twins — - // and on this verb it is not only the audit row. `orgId` - // selects the overlay repository, so it decides WHICH row a - // reset destroys. Once a `view` authored here lands - // org-scoped, a delete that passed no organization would - // reach past the caller's own overlay and reset the ENV-WIDE - // row instead — one tenant's "reset to default" blanking the - // item for every other tenant and the control plane, which - // is the failure `restoreArtifactRegistryView` records having - // already been paid for once. The two halves have to move - // together. `ctx` is the capability gate's own - // `resolveExecCtx` result, resolved above. - const organizationId = organizationIdForMetaWrite( - // [commit 26f3588fb] FOLDED, not raw — see the PUT door's - // org-scope comment for the measurement. - canonicalMetaUrlType(req.params.type), ctx?.tenantId, - ); + // [ADR-0131 D6] No organization, as on the `PUT` twin: the + // reset reaches the environment-wide row. // [#11679] The `(p as any)` cast this call carried came off // when `DeleteMetaItemRequestSchema` caught up with the // eight members this door sends. Unlike the publish door's @@ -7497,7 +7124,6 @@ export class RestServer { const deleteRequest: TransportScopedMetaRequest = { type: req.params.type, name: req.params.name, - organizationId, ...(environmentId ? { environmentId } : {}), ...(parentVersion !== undefined ? { parentVersion } : {}), ...(actor ? { actor } : {}), @@ -7553,8 +7179,7 @@ export class RestServer { // door and `/diff` only: `/layers` and `?layers=true` read // the active row and keep the pruned plain-read answer. // - // `historyCtx` is this door's one caller resolution; the org - // partition below reads the same value. The refusal is + // `historyCtx` is this door's one caller resolution. The refusal is // {@link refuseNonAuthoringCaller}, shared with `/diff` and // `/audit` (#20441) so the three cannot drift apart. const historyCtx = await this.resolveExecCtx(environmentId, req) @@ -7611,45 +7236,14 @@ export class RestServer { return; } } - // [#13406] STATE THE ORG PARTITION. `sys_metadata_history` - // is a per-org log — `SysMetadataRepository.history()` - // filters `organization_id = this.organizationId` by strict - // equality (no `$or`), and `event_seq` is documented as a - // "Per-organization monotonic event log cursor". So a door - // that names no organization does not read "everything": it - // reads the ENV partition (`organizationId ?? null` in - // `historyMetaItem`), and an item whose overlay was authored - // org-scoped answered `{ events: [] }` while its log was - // full. The write door that produced those rows has stated - // the org since #8805; only the read door had not. + // [#13406 · ADR-0131 D6] The ENV partition, stated by + // omission: `sys_metadata_history` is filtered by strict + // equality on `organization_id`, and since the + // per-organization overlay axis retired every `/meta` write + // logs there (`organizationId ?? null` in + // `historyMetaItem`). A legacy organization-scoped log is + // not served; its promotion is ADR-0131 C7's. // - // ⭐ `organizationIdForMetaRead`, NOT the audit twin's raw - // `ctx?.tenantId ?? null`, and the difference is measured - // rather than stylistic. `auditMetaItem` reads with - // `$or: [{organization_id: org}, {organization_id: null}]`, - // so naming an org there can only ADD rows. This door's - // repository does strict equality, so a raw tenant id would - // ask the org partition for the history of a type whose - // rows land ENV-WIDE — every `allowOrgOverride: false` type - // that is still runtime-writable (`object`, `hook`, `page`, - // `app`, `dataset`), because `organizationIdForMetaWrite` - // deliberately writes those env-wide (#6190). That would - // turn a working read into `{ events: [] }` for them: the - // card's own defect, newly minted one type family over. - // Gating the read on the same registry predicate the WRITE - // uses is what makes the two sides incapable of drifting — - // the reasoning `organizationIdForMetaRead` was written for. - // - // ⚠️ NOT a new org-resolution seam: `historyCtx` is the - // caller resolved at the head of this door (#20378), and - // `resolveExecCtx` is memoised per request (WeakMap keyed by - // `req`), the same result the audit twin and 40+ handlers - // here already share. - const historyOrganizationId = organizationIdForMetaRead( - // [commit 26f3588fb] FOLDED, not raw — see the PUT door's - // org-scope comment for the measurement. - canonicalMetaUrlType(req.params.type), historyCtx?.tenantId, - ); // Typed through `TransportScopedMetaRequest` like the // reset door above, NOT as a plain `HistoryMetaItemRequest` // like the audit door below: this door still spreads the @@ -7660,39 +7254,10 @@ export class RestServer { // member on. Every OTHER key is compiled against the spec // contract — an undeclared member here is now TS2353 // instead of a payload member no contract has ever seen. - // - // ⛔ [#13406] `organizationId` is SPREAD, never written as - // `organizationId: x ?? null`. `HistoryMetaItemRequestSchema` - // declares it `z.string().optional()` — optional plain - // string, NOT nullable, mirroring the implementation's - // `organizationId?: string` — and the spec's own describe - // text names the asymmetry against the audit twin, which - // declares `string | null`. Copying the audit door's - // expression here is a **TS2322** compile error, measured: - // `error TS2322: Type 'string | null' is not assignable to - // type 'string | undefined'`. It is also a no-op at runtime - // (`null ?? null` is `null`). - // - // ⚠️ TS2322, NOT the TS2353 the paragraph directly above - // names, and the difference is the whole point: TS2353 is - // the UNDECLARED-member code, and `organizationId` IS - // declared — so this is an assignability failure, not an - // unknown-property one. This comment said TS2353 when it - // landed, copied from its neighbour nine lines up, which is - // correct in ITS context and wrong here. Comment drift by - // adjacency; named so the next reader standing in the same - // spot does not repeat it. - // - // ⚠️ And the guard is WEAKER one door over, not stronger: - // the `/diff` twin reaches `diffMetaItem` through - // `(p as any)`, so `?? null` there reddens with NOTHING and - // is a silent runtime no-op. Do not generalise "the - // compiler catches this" from here to that door. const historyRequest: TransportScopedMetaRequest = { type: req.params.type, name: req.params.name, ...(environmentId ? { environmentId } : {}), - ...(historyOrganizationId ? { organizationId: historyOrganizationId } : {}), // Both already finite or absent — the declared parses above // refuse anything else, so no `Number.isFinite` drop is left here. ...(sinceSeq !== undefined ? { sinceSeq } : {}), @@ -7747,10 +7312,9 @@ export class RestServer { // the protocol is resolved (no 501-vs-200 probe), before the // query is parsed, and before any item or event is read. // Whoever it admits reads exactly what they read before, - // the per-caller refusal and the org scope below included. + // the per-caller refusal below included. // - // `auditCtx` is this door's one caller resolution; the org - // scope below reads the same value. + // `auditCtx` is this door's one caller resolution. const auditCtx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); if (refuseNonAuthoringCaller(auditCtx, res, 'Reading a metadata item\'s audit trail')) return; const p = await this.resolveProtocol(environmentId, req); @@ -7829,41 +7393,15 @@ export class RestServer { return; } } - // [#8747] SCOPE THE READ. Without an organization this - // route returned every tenant's audit rows for a - // `(type, name)` — measured, not inferred — and it carried - // no capability gate then (unlike its `PUT` twin, which - // gates on `manage_metadata`), so the cohort was any - // authenticated principal of any tenant, on the published - // SDK surface. [#20441] It carries the authoring-door gate - // now, and the scope still matters: that gate admits a - // builder of ONE organization, never a reader of another's - // trail, so the tenant separation stays this scope's job. - // - // The organization comes from `resolveExecCtx`, which this - // file already calls in 40+ handlers including the `PUT` - // twin — `computeExecCtx` assembles `tenantId` from the - // shared `resolveAuthzContext` (an API key's principal - // tenant, else the session's `activeOrganizationId`). + // [#8747 · ADR-0131 D6] SCOPE THE READ: the + // environment-wide rows only (`organization_id: null`), + // never every tenant's. Since the per-organization overlay + // axis retired every `/meta` write audits there; a legacy + // organization-scoped audit row is not served. // - // ⚠️ This deliberately does NOT mint the seam the - // `/published` route's comment forbids further down this - // file: no `resolveActiveOrganizationId`, no new org - // plumbing in `packages/rest`. It reads a field the - // execution context already carries. `?? null` keeps the - // fail-closed direction — an unresolved organization reads - // env-wide rows, never everyone's. - // - // `environmentId` is GONE from this payload, and that is a - // deletion of dead weight rather than a behaviour change: - // `auditMetaItem`'s request type never declared it and its - // body never read it. Environment scoping is unaffected - // because it comes from WHICH protocol `resolveProtocol` - // hands back — the same reasoning the `/published` route - // states below — not from the request payload. It is still - // read on the two lines that need it. - // - // `auditCtx` is the caller resolved at the head of this door + // `environmentId` is not in this payload: `auditMetaItem`'s + // request type never declared it. Environment scoping comes + // from WHICH protocol `resolveProtocol` hands back. // (#20441), not a second resolution. // // The `(p as any)` casts this door carried came off when @@ -7880,7 +7418,7 @@ export class RestServer { const auditRequest: AuditMetaItemRequest = { type: req.params.type, name: req.params.name, - organizationId: auditCtx?.tenantId ?? null, + organizationId: null, // Already finite or absent — the declared parse above // refuses anything else. ...(limit !== undefined ? { limit } : {}), @@ -7934,21 +7472,14 @@ export class RestServer { // before the refusal. `isSystem` bypasses, matching every other // capability gate on the platform. // - // [#12702] Same shared verdict as the save door, because - // promotion is the second half of the save→publish loop: a - // caller admitted to author an org-scoped draft must be able - // to promote it, and the SAME conditions bound what a - // promotion can reach — `promoteDraftForPublish` resolves - // the draft through `getOverlayRepo(orgId)`, so an admitted - // org-presentation publish promotes only the caller's own - // org partition. + // [#12702 · ADR-0131 D6] Same shared verdict as the save + // door, because promotion is the second half of the + // save→publish loop. const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); { const verdict = metaWriteCapabilityVerdict({ isSystem: ctx?.isSystem === true, systemPermissions: ctx?.systemPermissions, - canonicalType: canonicalMetaUrlType(req.params.type), - activeOrganizationId: ctx?.tenantId, operation: 'publish', }); if (!verdict.allowed) { @@ -8020,29 +7551,9 @@ export class RestServer { if (refuseRepeatedQueryParams(req, res, ['package'])) return; const packageId = metaItemPackageBinding(req.query?.package); - // [#8805] The publish half of the same organization, and it - // is REQUIRED for the `PUT` fix to be usable rather than a - // separate improvement: `promoteDraftForPublish` resolves the - // draft through `getOverlayRepo(orgId)`, so once a draft - // authored through `PUT ?mode=draft` lands org-scoped, a - // publish carrying no organization looks in the env-wide - // partition, finds nothing, and answers `no_draft` — the - // Studio designer's save→publish loop, broken. Scoping the - // save without scoping the publish is not a smaller change, - // it is a broken one. - // - // [commit b5378550e] The context is now the one the capability gate above - // already resolved, so the caller a publish is SCOPED to can - // never drift from the caller it was AUTHORIZED against — the - // same single-resolution shape the `PUT` door carries. - // `resolveExecCtx` is memoised per request and called in 40+ - // handlers in this file (see the `/published` comment's seam - // warning, which stands). - const organizationId = organizationIdForMetaWrite( - // [commit 26f3588fb] FOLDED, not raw — see the PUT door's - // org-scope comment for the measurement. - canonicalMetaUrlType(req.params.type), ctx?.tenantId, - ); + // [ADR-0131 D6] No organization, as on the `PUT` twin: a + // draft is saved environment-wide, so the promotion looks in + // the environment partition. // [#11145] The `(p as any)` cast this call carried came off // when `MetadataProtocol` declared `publishMetaItem` (commit cccbe51bf, // maintainer ruling 2026-08-22, option B). What the cast was @@ -8077,7 +7588,6 @@ export class RestServer { const publishRequest: TransportScopedMetaRequest = { type: req.params.type, name: req.params.name, - organizationId, ...(environmentId ? { environmentId } : {}), ...(actor ? { actor } : {}), ...(message ? { message } : {}), @@ -8125,20 +7635,13 @@ export class RestServer { // leaks no kernel capability and nothing is restored before the // refusal. `isSystem` bypasses, as everywhere else. // - // [#12702] Same shared verdict as the sibling doors. The - // org condition bounds this verb too: `rollbackMetaItem` - // resolves the row AND its history through the organization - // (see the [#8805] comment below), so an admitted - // org-presentation rollback restores only a version of the - // caller's own org overlay — the env-wide row and its - // history stay out of reach. + // [#12702 · ADR-0131 D6] Same shared verdict as the + // sibling doors. const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); { const verdict = metaWriteCapabilityVerdict({ isSystem: ctx?.isSystem === true, systemPermissions: ctx?.systemPermissions, - canonicalType: canonicalMetaUrlType(req.params.type), - activeOrganizationId: ctx?.tenantId, operation: 'rollback', }); if (!verdict.allowed) { @@ -8179,25 +7682,12 @@ export class RestServer { // resolveMetaWriteActor). `X-Actor` is not consulted. const actor = await this.resolveMetaWriteActor(environmentId, req); const message = typeof body.message === 'string' ? body.message : undefined; - // [#8805] The rollback half. Same argument as publish, one - // step sharper: `rollbackMetaItem` resolves the row AND its - // history through the organization, so an unscoped rollback - // of an org-scoped item restores the env-wide body over the - // env-wide row — a write to a partition the caller never - // named, audited as `null`. See the `PUT` door above. - // - // [commit b5378550e] `ctx` is the one the capability gate above resolved, - // so scope and authorization read the same identity. - const organizationId = organizationIdForMetaWrite( - // [commit 26f3588fb] FOLDED, not raw — see the PUT door's - // org-scope comment for the measurement. - canonicalMetaUrlType(req.params.type), ctx?.tenantId, - ); + // [ADR-0131 D6] No organization, as on the `PUT` twin: the + // rollback restores a version of the environment-wide row. const result = await (p as any).rollbackMetaItem({ type: req.params.type, name: req.params.name, toVersion, - organizationId, ...(environmentId ? { environmentId } : {}), ...(actor ? { actor } : {}), ...(message ? { message } : {}), @@ -8248,8 +7738,7 @@ export class RestServer { // door and `/history` only: `/layers` and `?layers=true` // read the active row and keep the pruned plain-read answer. // - // `diffCtx` is this door's one caller resolution; the org - // partition below reads the same value. The refusal is + // `diffCtx` is this door's one caller resolution. The refusal is // {@link refuseNonAuthoringCaller}, shared with `/history` // and `/audit` (#20441) so the three cannot drift apart. const diffCtx = await this.resolveExecCtx(environmentId, req) @@ -8306,52 +7795,23 @@ export class RestServer { } const diffGated = RestServer.gatesPerCaller(diffMetaType); const diffCurrent = diffGated - ? await this.fetchCurrentMetaDocument(environmentId, req, p) + ? await this.fetchCurrentMetaDocument(req, p) : undefined; if (diffGated && diffCurrent == null) { sendMetaItemAbsent(res); return; } - // [#13406] STATE THE ORG PARTITION — the history twin's - // omission, on the door that reads the SAME table. See the - // history door above for why the predicate is - // `organizationIdForMetaRead` and not the audit twin's raw - // `ctx?.tenantId ?? null`; both arguments carry over - // unchanged, because `diffMetaItem` reads - // `sys_metadata_history` with the identical strict-equality - // `where` (`organization_id: orgId`, no `$or`) and derives - // `orgId` from the identical `request.organizationId ?? null`. - // - // Version identity is the second reason the partition is - // strict rather than unioned here, and it is sharper on this - // door than on `/history`: `version` is a PER-(org,type,name) - // lineage counter, so an org revision 1 and an env revision 1 - // both exist. `?from=1&to=2` unioned across partitions would - // have two candidate bodies per bound and would answer a diff - // between revisions of two different lineages — a well-formed - // 200 that is simply not the comparison anyone asked for. - // - // ⚠️ This door reaches `diffMetaItem` through `(p as any)`, - // so — unlike the history twin — the compiler checks NOTHING - // about this literal; measured, not assumed. The omit-spread - // is therefore load-bearing by RUNTIME contract alone: the - // implementation declares `organizationId?: string` and does - // `request.organizationId ?? null`, so an `?? null` copied - // from the audit door would type-check here and still be a - // silent no-op — the exact fix-shaped-non-fix this card is. - // - // `diffCtx` is the caller resolved at the head of this door - // (#20378), not a second resolution. - const diffOrganizationId = organizationIdForMetaRead( - // [commit 26f3588fb] FOLDED, not raw — see the PUT door's - // org-scope comment for the measurement. - canonicalMetaUrlType(req.params.type), diffCtx?.tenantId, - ); + // [#13406 · ADR-0131 D6] The ENV partition, stated by + // omission, as on the history twin: `diffMetaItem` reads + // `sys_metadata_history` by strict equality on + // `organization_id` (`request.organizationId ?? null`), and + // `version` is a per-(org, type, name) lineage counter, so + // the environment lineage is the one every `/meta` write + // now extends. const result = await (p as any).diffMetaItem({ type: req.params.type, name: req.params.name, ...(environmentId ? { environmentId } : {}), - ...(diffOrganizationId ? { organizationId: diffOrganizationId } : {}), ...(fromVersion !== undefined ? { fromVersion } : {}), ...(toVersion !== undefined ? { toVersion } : {}), }); @@ -8637,41 +8097,12 @@ export class RestServer { // layer into its own answer, so this route could no longer // tell the two stores apart. // - // [#8805] SCOPED, and this reverses what this comment - // used to say. It read: "NO `organizationId`, and that - // is the ONE deliberate divergence from the dispatcher - // twin" — justified because omitting it read the - // env-wide row, "symmetric with what an org-less - // `publishPackageDrafts` writes, so this door resolves - // exactly the publishes this door can produce." - // - // That symmetry was the whole argument, and #8805's - // write-side fix is what ends it: `POST /meta/:type/ - // :name/publish` now carries the caller's organization - // for `allowOrgOverride: true` types, so this door can - // now produce an ORG-SCOPED publish. Left unscoped, this - // read would answer 404 about a `view` the very same - // caller published a moment earlier through the very - // same transport — the #8278 defect this route exists to - // close, reopened one partition over. A statement that - // was true of the old write path is not evidence about - // the new one. - // - // ⚠️ Still NOT the forbidden seam. `packages/rest` mints - // no `resolveActiveOrganizationId` — the warning - // `package-routes.ts` echoes at its `deletePackage` call - // ("the dispatcher twin owns that seam") is about - // inventing org RESOLUTION here, and this reads - // `tenantId` off the execution context `resolveExecCtx` - // already resolves, exactly as #8803 did for the audit - // read. [commit e1d4f9e3f] The CALLEE gates: `getMetaItemLayered` - // resolves `organizationIdForMetaRead` AFTER its canonical - // fold, so the tenant goes over RAW. ⛔ Pre-gating HERE, on - // the unfolded `:type`, would be the defect commit 26f3588fb fixed. ⛔ And - // the old "fail-open in the safe direction" reading is the - // argument the predicate refutes: an org named on a type - // the registry does not declare overridable resurrects the - // phantoms #6190 stopped minting. + // [ADR-0131 D6] NO `organizationId`: the per-organization + // overlay axis is retired, so every publish this + // transport produces lands environment-wide, and this + // door resolves exactly those. A legacy + // organization-scoped row is not served; its promotion is + // ADR-0131 C7's. // // Environment scoping still holds: it comes from WHICH // protocol `resolveProtocol` hands back, not from the @@ -8737,14 +8168,9 @@ export class RestServer { let publishedOverlay: unknown; if (typeof publishedProtocol?.getMetaItemLayered === 'function') { try { - const publishedCtx = await this.resolveExecCtx(environmentId, req) - .catch(rethrowAuthzStoreUnavailable); const layered = await publishedProtocol.getMetaItemLayered({ type, name, - ...(publishedCtx?.tenantId - ? { organizationId: publishedCtx.tenantId } - : {}), }); if (layered?.overlay !== undefined && layered?.overlay !== null) { // [#21002, #21986, ADR-0126 §2, ADR-0062 D4] diff --git a/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts b/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts index 29901154093..151869d7aab 100644 --- a/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts +++ b/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts @@ -1,9 +1,12 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#20408] The dispatcher's `/meta` doors scope a caller's metadata to the - * organization `RestServer` scopes it to — the VETTED active organization on - * the caller's execution context, never the raw session claim. + * [#20408 · ADR-0131 D6] The dispatcher's `/meta` doors answer a caller what + * `RestServer` answers it. Since the per-organization overlay axis retired, + * NEITHER transport names an organization on a metadata read: a CURRENT member + * of an organization that holds a LEGACY org-scoped overlay is served the + * environment row on both (environment → code), which the controls below pin; + * the history of the vetted-organization defect is kept as it was measured. * * ## The defect * @@ -273,15 +276,20 @@ afterEach(() => { warnSpy.mockRestore(); }); // ── Controls: the rig separates the organizations, and the resolver drops the claim ── -describe('[#20408] controls: the rig can tell the organizations apart', () => { - it('a CURRENT member reads its own organization\'s overlay on both transports', async () => { +describe('[#20408 · ADR-0131 D6] controls: no transport reads a legacy organization overlay', () => { + it('⭐ a CURRENT member is served the environment row on both transports — its organization\'s legacy overlay is not', async () => { for (const boot of [bootDispatcher, bootRest]) { - const { call } = boot(); + const { call, protocol } = boot(); const item = await call('GET', 'member', '/meta/view/lead_all'); - expect({ status: item.status, label: item.data?.item?.label }).toEqual({ status: 200, label: 'Alpha pipeline' }); - expect(labelOf((await call('GET', 'member', '/meta/view')).data, 'lead_all')).toBe('Alpha pipeline'); + expect({ status: item.status, label: item.data?.item?.label }).toEqual({ status: 200, label: 'All leads' }); + expect(labelOf((await call('GET', 'member', '/meta/view')).data, 'lead_all')).toBe('All leads'); const published = await call('GET', 'member', '/meta/view/lead_all/published'); - expect({ status: published.status, label: published.data?.label }).toEqual({ status: 200, label: 'Alpha pipeline' }); + expect({ status: published.status, label: published.data?.label }).toEqual({ status: 200, label: 'All leads' }); + // The organization each read asked for: none, though the member has one. + for (const fn of ['getMetaItem', 'getMetaItems', 'getMetaItemLayered'] as const) { + const asked = (protocol as any)[fn]?.mock?.calls?.map(([req]: any[]) => req?.organizationId) ?? []; + expect(asked.every((o: unknown) => o === undefined), `${boot.name} ${fn} named an organization`).toBe(true); + } } }); @@ -292,12 +300,12 @@ describe('[#20408] controls: the rig can tell the organizations apart', () => { } }); - it('the ex-member, switched to an organization they ARE in, reads that organization on both transports', async () => { + it('the ex-member, switched to an organization they ARE in, is served the environment row too', async () => { for (const boot of [bootDispatcher, bootRest]) { const sessions = makeSessions(); sessions.sid_exmember.activeOrganizationId = 'org_beta'; const { call } = boot(sessions); - expect((await call('GET', 'exmember', '/meta/view/lead_all')).data?.item?.label).toBe('Beta pipeline'); + expect((await call('GET', 'exmember', '/meta/view/lead_all')).data?.item?.label).toBe('All leads'); } }); }); @@ -375,10 +383,11 @@ describe('[#20478] the layered view scopes a caller to the organization RestServ const layers = (a: Answer) => ({ status: a.status, overlay: a.data?.overlay?.label, effective: a.data?.effective?.label }); for (const [spelling, path, query] of SPELLINGS) { - it(`${spelling}: a CURRENT member reads its own organization's overlay on both transports (control)`, async () => { + it(`${spelling}: [ADR-0131 D6] a CURRENT member reads the environment overlay on both transports, never its organization's legacy one`, async () => { for (const boot of [bootDispatcher, bootRest]) { - const { call } = boot(); - expect(layers(await call('GET', 'member', path, query))).toEqual({ status: 200, overlay: 'Alpha pipeline', effective: 'Alpha pipeline' }); + const { call, protocol } = boot(); + expect(layers(await call('GET', 'member', path, query))).toEqual({ status: 200, overlay: 'All leads', effective: 'All leads' }); + expect(protocol.getMetaItemLayered.mock.calls.map(([req]: any[]) => req.organizationId)).toEqual([undefined]); } }); diff --git a/packages/runtime/src/domains/meta-save-capability-gate.test.ts b/packages/runtime/src/domains/meta-save-capability-gate.test.ts index d39e45c6cd0..0c158d55e44 100644 --- a/packages/runtime/src/domains/meta-save-capability-gate.test.ts +++ b/packages/runtime/src/domains/meta-save-capability-gate.test.ts @@ -225,11 +225,10 @@ describe('#7019 — dispatcher PUT /meta/:type/:name: the capability gate', () = expect(saveItem).not.toHaveBeenCalled(); }); - it('holding `manage_org_presentation` alone is not enough for an OBJECT save — tier-B stays walled', async () => { - // The org-scoped presentation capability (#12702) reaches ONLY types - // whose registry entry declares `allowOrgOverride: true`; `object` - // does not, so this caller is exactly as refused as a capability-less - // one — full matrix in the #12702 describe below. + it('holding the retired `manage_org_presentation` alone is not enough for an OBJECT save', async () => { + // [ADR-0131 D6] The capability retired with the per-organization + // overlay axis, so this caller is exactly as refused as a + // capability-less one — full matrix in the ADR-0131 D6 describe below. const stack = boot(); const res = await stack.dispatcher.handleMetadata( @@ -265,15 +264,15 @@ describe('#7019 — dispatcher PUT /meta/:type/:name: the capability gate', () = }); /** - * [#12702] `manage_org_presentation` — the org-scoped presentation capability - * on THIS transport. A subset key beside `manage_metadata`: admitted ONLY for - * a type whose registry entry declares `allowOrgOverride: true` AND a session - * with an active organization, and the admitted write is threaded to exactly - * that organization — the same single resolution feeding authorization and - * scope. Both directions pinned: the tier-A admission (with the threaded - * organization asserted, not assumed) and the tier-B / env-wide refusals. + * [#12702 · ADR-0131 D6] The organization-admin authoring door is CLOSED on + * THIS transport too. The per-organization overlay axis is retired: the + * dispatcher threads no organization into a metadata write, so + * `manage_org_presentation` — which admitted only an org-scoped write of an + * org-overridable type — retired with it. Its holder is refused like any + * caller without `manage_metadata`; a `manage_metadata` caller's write carries + * no organization whatever its active one. */ -describe('#12702 — dispatcher PUT: `manage_org_presentation`, org-scoped tier-A admission', () => { +describe('ADR-0131 D6 — dispatcher PUT: an organization admin\'s metadata write is refused; writes carry no organization', () => { /** * The `boot()` double above, plus an auth service whose session carries an * active organization — and [#20408] `caller()`, the execution context the @@ -300,82 +299,43 @@ describe('#12702 — dispatcher PUT: `manage_org_presentation`, org-scoped tier- const HOLDER = { userId: 'u_orgadmin', systemPermissions: ['manage_org_presentation'] }; - it('admits an org-scoped tier-A save, threaded to the caller\'s OWN organization', async () => { - const stack = bootOrg('org_a'); - - const res = await stack.dispatcher.handleMetadata( - '/view/org_grid', stack.caller(HOLDER), 'PUT', { name: 'org_grid', label: 'Org Grid' }, - ); - - expect(res.response?.status).toBe(200); - expect(stack.saveMetaItem).toHaveBeenCalledTimes(1); - // The threading IS the wall: the only organization an admitted write - // can carry is the caller's own active one. - expect(stack.saveMetaItem.mock.calls[0][0]).toMatchObject({ - type: 'view', name: 'org_grid', organizationId: 'org_a', - }); - }); - - it('[#10340] the URL-only spelling is folded BEFORE the verdict — `email_templates` is tier-A here too', async () => { - const stack = bootOrg('org_a'); - - const res = await stack.dispatcher.handleMetadata( - '/email_templates/welcome', stack.caller(HOLDER), 'PUT', { name: 'welcome', subject: 'Hi' }, - ); - - expect(res.response?.status).toBe(200); - // The request type stays the RAW segment (the protocol folds it - // itself); only the verdict and the scope argument read the fold. - expect(stack.saveMetaItem.mock.calls[0][0]).toMatchObject({ - type: 'email_templates', name: 'welcome', organizationId: 'org_a', - }); - }); - it.each([ + ['view', '/view/org_grid'], + ['email_templates', '/email_templates/welcome'], ['object', '/object/account'], ['flow', '/flow/order_followup'], - ])('refuses the SAME holder a tier-B `%s` write — org active or not, nothing is written', async (_t, path) => { + ])('refuses an org-active `manage_org_presentation` holder a `%s` write — nothing is written', async (_t, path) => { const stack = bootOrg('org_a'); const res = await stack.dispatcher.handleMetadata( - path, stack.caller(HOLDER), 'PUT', { label: 'x' }, + path, stack.caller(HOLDER), 'PUT', { name: 'org_grid', label: 'x' }, ); expect(res.response?.status).toBe(403); expect(res.response?.body?.error?.code).toBe('PERMISSION_DENIED'); + expect(String(res.response?.body?.error?.message ?? '')) + .toBe('Saving a metadata item requires the `manage_metadata` capability.'); expect(stack.saveMetaItem).not.toHaveBeenCalled(); }); - it('refuses the SAME holder a tier-A write when the session has NO active organization — env-wide is walled', async () => { - const stack = bootOrg(undefined); - - const res = await stack.dispatcher.handleMetadata( - '/view/org_grid', stack.caller(HOLDER), 'PUT', { name: 'org_grid', label: 'Org Grid' }, - ); - - expect(res.response?.status).toBe(403); - expect(res.response?.body?.error?.code).toBe('PERMISSION_DENIED'); - // The message names the sanctioned path and the scope fact — never the - // caller's own grants (#7450). - expect(String(res.response?.body?.error?.message ?? '')).toContain('active organization'); - expect(stack.saveMetaItem).not.toHaveBeenCalled(); - }); - - it('a foreign organization is not expressible: a body-smuggled organization_id does not move the threading', async () => { + it('a `manage_metadata` caller WITH an active organization saves a view with no organization on the request', async () => { const stack = bootOrg('org_a'); const res = await stack.dispatcher.handleMetadata( - '/view/org_grid', stack.caller(HOLDER), 'PUT', + '/view/org_grid', + stack.caller({ userId: 'u_author', systemPermissions: ['manage_metadata'] }), + 'PUT', { name: 'org_grid', label: 'Org Grid', organization_id: 'org_b', organizationId: 'org_b' }, ); expect(res.response?.status).toBe(200); - // `item` is data, never a channel (the request is built field by - // field): the write still carries the CALLER's organization. - expect(stack.saveMetaItem.mock.calls[0][0]).toMatchObject({ organizationId: 'org_a' }); + expect(stack.saveMetaItem).toHaveBeenCalledTimes(1); + expect(stack.saveMetaItem.mock.calls[0][0]).toMatchObject({ type: 'view', name: 'org_grid' }); + // Neither the session's organization nor a body-smuggled one reaches it. + expect('organizationId' in stack.saveMetaItem.mock.calls[0][0]).toBe(false); }); - it('control: `manage_metadata` with no active organization still saves a view env-wide, as today', async () => { + it('control: `manage_metadata` with no active organization still saves a view env-wide, as before', async () => { const stack = bootOrg(undefined); const res = await stack.dispatcher.handleMetadata( diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 81a614e5097..9fe80a938ca 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -12,7 +12,6 @@ import { shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, } from '@objectstack/core'; // [commit 67ceb9aef] `canonicalMetaUrlType` is the FOLD this transport was missing. -// See the two call sites below for what each one was deciding raw. import { canonicalMetaUrlType, pluralToSingular } from '@objectstack/spec/shared'; import { CoreServiceName } from '@objectstack/spec/system'; // [ADR-0106 / #3682] Metadata-plane FLS — the SAME projection the REST `/meta` @@ -27,13 +26,10 @@ import { relateObjectSchemaMaskPosture, resolveObjectSchemaMaskPosture, type ObjectSchemaMaskPosture, - // [#8805] Moved to `metadata-core` so the REST `/meta` write doors decide - // this the same way rather than through a second copy. Behaviour unchanged. - organizationIdForMetaWrite, - // [#12702] The capability half of the same decision, from the same home - // and for the same no-second-copy reason: `manage_metadata` as before, - // plus `manage_org_presentation` for org-overridable types written - // org-scoped to the caller's own active organization. + // [#12702 · ADR-0131 D6] Which callers a `/meta` item write admits: + // `manage_metadata` (or `isSystem`), the one predicate the REST doors run + // too. No `/meta` branch here carries an organization into a metadata + // write or read: the per-organization overlay axis is retired. metaWriteCapabilityVerdict, } from '@objectstack/metadata-core'; // [#15238] The DECLARED protocol contracts this domain's request literals are @@ -71,10 +67,8 @@ import { createMetaLayeredAnswer, createMetaListAnswer, isPublicAudienceRead, - metaCallerOrganizationId, metaItemLayersDeprecationHeaders, metaItemPackageBinding, - metaReadOrganizationId, metaRequestLocale, metaSaveRequestOptions, metaTypeReadRefusal, @@ -673,10 +667,7 @@ async function answerMetaItem( } /** [#20320] This transport's save-door admission of `:type/:name` — see `saveVerdict` in {@link handleMetadataRequest}. */ -type MetaSaveVerdict = ( - canonicalType: string, - activeOrganizationId: string | undefined, -) => ReturnType; +type MetaSaveVerdict = () => ReturnType; /** * [#20320] `GET /meta/:type/:name?state=draft` for a caller who may read @@ -700,12 +691,8 @@ type MetaSaveVerdict = ( * Only an admitted caller arrives: the switch is declared with * {@link mayReadPendingDrafts} at the item read's entry. * - * [#20408] Scoped to the caller's VETTED organization — the read to - * {@link metaReadOrganizationId}'s partition, the author exemption to the save - * door's verdict over {@link metaCallerOrganizationId} — exactly as - * `RestServer`'s plain read scopes both. It read the session's claim as stored, - * so a member removed from an organization read that organization's pending - * drafts here. + * [ADR-0131 D6] Environment → code, exactly as `RestServer`'s plain read: the + * per-organization overlay axis is retired, so no organization is named. */ async function readPendingDraft( deps: DomainHandlerDeps, @@ -722,18 +709,17 @@ async function readPendingDraft( if (!protocol || typeof protocol.getMetaItem !== 'function') { return { handled: true, response: deps.error('Not found', 404) }; } - const caller = context.executionContext as MetaReadGateCaller | undefined; let envelope: any; try { envelope = await protocol.getMetaItem({ - type: singularType, name, packageId, organizationId: metaReadOrganizationId(type, caller), state: 'draft', previewDrafts, + type: singularType, name, packageId, state: 'draft', previewDrafts, }); } catch (e: any) { return { handled: true, response: deps.errorFromThrown(e, 404) }; } if (envelope?.item == null) return { handled: true, response: deps.error('Not found', 404) }; - const mayWriteItem = saveVerdict(canonicalMetaUrlType(type), metaCallerOrganizationId(caller)).allowed; + const mayWriteItem = saveVerdict().allowed; return answerMetaItem( deps, context, protocol, { metaType: singularType, name, policy: STORED_VERSION_DOOR_POLICY, maskPosture: item.maskPosture }, @@ -776,9 +762,8 @@ type MetaLayeredProtocol = MetaDomainProtocol & Required> = {}, ): Promise { const { type, name, packageId, maskPosture } = request; - const caller = context.executionContext as MetaReadGateCaller | undefined; - const organizationId = metaReadOrganizationId(type, caller); - const mayWriteItem = saveVerdict(canonicalMetaUrlType(type), metaCallerOrganizationId(caller)).allowed; + const mayWriteItem = saveVerdict().allowed; let answer: MetaLayeredAnswer; try { const layered = await protocol.getMetaItemLayered({ type, name, ...(packageId ? { packageId } : {}), - ...(organizationId ? { organizationId } : {}), }); answer = await createMetaLayeredAnswer( metaItemReadGateSources(deps, context, protocol, mayWriteItem), @@ -889,17 +871,14 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // whole, or a save drops entries silently」), which // `MetaReadGateCaller.mayWriteItem` says must be the transport's own // save-door answer. A second spelling at a read could drift from the door - // it stands for. `canonicalType` is the folded segment and - // `activeOrganizationId` the one resolution each caller already made, so - // authorization and scope read one value. [#20478] Declared here, above - // every branch, so the `/layers` branch asks the same one. - const saveVerdict: MetaSaveVerdict = (canonicalType, activeOrganizationId) => { + // it stands for. Since ADR-0131 D6 it reads the caller alone. [#20478] + // Declared here, above every branch, so the `/layers` branch asks the same + // one. + const saveVerdict: MetaSaveVerdict = () => { const ec: any = _context.executionContext; return metaWriteCapabilityVerdict({ isSystem: ec?.isSystem === true, systemPermissions: ec?.systemPermissions, - canonicalType, - activeOrganizationId, operation: 'save', }); }; @@ -1177,16 +1156,11 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin let publishedOverlay: unknown; if (protocol && typeof protocol.getMetaItemLayered === 'function') { try { - // [#20408] The caller's VETTED organization, as `RestServer`'s - // `/published` reads it (`ctx.tenantId`, raw — the protocol's - // layered read gates it by type itself). The session's claim as - // stored served a removed member the overlay of the organization - // they had left. - const organizationId = metaCallerOrganizationId(_context.executionContext as MetaReadGateCaller | undefined); + // [ADR-0131 D6] No organization, as `RestServer`'s `/published`: + // every publish lands environment-wide. const layered = await protocol.getMetaItemLayered({ type, name, - ...(organizationId ? { organizationId } : {}), }); if (layered?.overlay !== undefined && layered?.overlay !== null) { // [#21002, #21986, ADR-0126 §2, ADR-0062 D4] As @@ -1309,35 +1283,15 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // engine self-invocation (`isSystem`) bypasses, matching // `actionPermissionError` and the migrate-stored gate below. // - // [#12702] The gate is the shared `metaWriteCapabilityVerdict` - // (`@objectstack/metadata-core` — the same home as the org-scope - // predicate below, for the same no-second-copy reason): beside - // `manage_metadata` it admits `manage_org_presentation`, ONLY for - // a type whose registry entry declares `allowOrgOverride: true` - // AND a session with an active organization — which is exactly the - // organization `organizationIdForMetaWrite` threads below, so an - // admitted write can only land org-scoped in the caller's own - // partition, never env-wide and never another org's. The active - // organization is resolved HERE, once, and reused by the write - // threading below — authorization and scope read one value (the - // single-resolution shape the REST doors carry, commit b5378550e). Resolving - // it is a session read, not a protocol probe: the 403-vs-501 - // discipline above is untouched. - // [commit 67ceb9aef] Folded at the boundary, once — the verdict and the - // scope decision below must read the same spelling. - const canonicalType = canonicalMetaUrlType(type); - // [#20408] The caller's VETTED organization — the `tenantId` - // `resolveAuthzContext` left on the execution context, the value - // `RestServer`'s `PUT` door reads — ⛔ never the session's claim as - // stored: under a walled posture a claim naming an organization the - // caller has LEFT is dropped there, and this door read it anyway, so - // a removed member's write landed in that organization's partition. - // No read at all now, so the 403-vs-501 discipline above is - // untouched. - const activeOrganizationId = metaCallerOrganizationId(_context.executionContext as MetaReadGateCaller | undefined); + // [#12702 · ADR-0131 D6] The gate is the shared + // `metaWriteCapabilityVerdict` (`@objectstack/metadata-core`): + // `manage_metadata` or `isSystem`. The org-scoped + // `manage_org_presentation` arm retired with the per-organization + // overlay axis — this branch threads no organization, so that arm + // would have admitted its holders to environment-wide authoring. // [#20320] Spelled once (`saveVerdict` above), because the // `?state=draft` read's author exemption asks this same question. - const verdict = saveVerdict(canonicalType, activeOrganizationId); + const verdict = saveVerdict(); if (!verdict.allowed) { // `deps.error(msg, 403)` derives the code from the status — // `PERMISSION_DENIED`, this transport's pinned spelling. @@ -1383,54 +1337,10 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin if (protocol && typeof protocol.saveMetaItem === 'function') { try { - // [#7018 / the #6190 ruling, Option A] The session's active - // organization rides this write ONLY for types the registry - // declares `allowOrgOverride: true`. For every other type it - // is dropped and the write lands env-wide — byte-identical to - // what a no-active-org session already produces today. - // - // Threading it unconditionally is how the runtime minted rows - // boot never reads: `SysMetadataRepository.put` stamps - // `organization_id` for EVERY type, while `loadMetaFromDb` - // hydrates `organization_id IS NULL` only. See - // `@objectstack/metadata-core`'s `meta-write-org-scope.ts` - // for why the predicate is the static registry flag and not - // `isOverlayAllowed` — and, since #8805, why it lives there: - // the REST `/meta` write doors run the same one. - // - // [#12702] `activeOrganizationId` is the ONE resolution the - // capability gate above already made — scope and - // authorization read the same value by construction. - // - // [commit 67ceb9aef] The segment is FOLDED before the scope decision - // — the correction commit 26f3588fb landed for the REST `/meta` - // doors, arriving on the second transport. This branch read - // the RAW `parts[0]`, while `protocol.saveMetaItem` below - // folds the same string through `canonicalizeMetaRequestType` - // for storage. Two maps that must agree did not: storage - // folds through `META_URL_TO_SINGULAR` (every spelling), - // while `declaresOrgOverride` tolerates only the MANIFEST - // collection spellings. For the two URL-only spellings of - // `allowOrgOverride: true` types — `translations` and - // `email_templates` — an org-active caller's write therefore - // landed ENV-WIDE where the singular twin landed org-scoped: - // one item, two partitions, addressed by spelling. - // - // ⛔ NOT repaired by widening `declaresOrgOverride`'s set — - // a predicate below the boundary consuming the URL spelling - // contract is what `metadata-url-spelling.ts`'s own header - // forbids ("folding happens at the boundary and only - // there"), and `meta-write-org-scope.ts`'s - // `ORG_OVERRIDABLE_TYPES` header pins that limit. - // - // Only the scope ARGUMENT is folded. The request `type` - // stays the raw segment, exactly as the REST doors leave - // it: the protocol boundary folds it itself, and two - // pre-folds would hide a drift between them from the - // protocol's own tests. - const organizationId = organizationIdForMetaWrite( - canonicalType, activeOrganizationId, - ); + // [ADR-0131 D6] THE WRITE NAMES NO ORGANIZATION, as on + // `RestServer`'s `PUT` twin: every admitted write lands + // environment-wide (`organization_id` NULL), whatever the + // caller's active organization. // [commit d806081dd] Server-stated face: this branch answers through // `deps.errorFromThrown`, which carries the refusal's // `issues[]` in `details` (see the `details.issues` pin in @@ -1462,7 +1372,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // cannot smuggle a `force` (or a `writeFace`) through it. // Pinned both ways in `meta-save-destructive-remedy.test.ts`. const result = await protocol.saveMetaItem({ - type, name, item, organizationId, + type, name, item, writeFace: 'meta-dispatch', ...(packageId ? { packageId } : {}), // [#22141] `parentVersion` and `mode`, each present @@ -1601,11 +1511,8 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin && query.preview.toLowerCase() === 'draft' && mayReadPendingDrafts(_context.executionContext); - // [#20408] The caller's VETTED organization (`metaReadOrganizationId` - // gates it by the folded type): the partition `RestServer`'s plain read - // reads. The session's claim as stored served a member removed from an - // organization that organization's overlays here. - const caller = _context.executionContext as MetaReadGateCaller | undefined; + // [ADR-0131 D6] The item read names no organization: environment → + // code, the partition `RestServer`'s plain read reads. const singularType = pluralToSingular(type); // [ADR-0106 D2/D3 · #20408] ONE posture for this caller × this item, @@ -1691,9 +1598,8 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin const protocolFirst = scoped || previewDrafts; // The protocol read `RestServer`'s plain read makes: the same - // `?package=` scope (ADR-0048), the same admitted switch and the - // same org partition — `organizationIdForMetaRead` never names an - // organization for `object`, so no phantom org row resurrects. + // `?package=` scope (ADR-0048), the same admitted switch and no + // organization (ADR-0131 D6). const readFromProtocol = async (): Promise => { // [#15238] `protocol &&` spelled out: the `any` cast this // branch used to resolve through let two sibling guards drift @@ -1703,7 +1609,6 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin const data = await protocol.getMetaItem({ type: 'object', name, - organizationId: metaReadOrganizationId(type, caller), ...(packageId ? { packageId } : {}), ...(previewDrafts ? { previewDrafts: true } : {}), }); @@ -1748,7 +1653,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // `previewDrafts` is the ADMITTED switch declared above // this block's branches (#20338). const data = await protocol.getMetaItem({ - type: singularType, name, packageId, organizationId: metaReadOrganizationId(type, caller), previewDrafts, + type: singularType, name, packageId, previewDrafts, }); // [#18401] The SAME hit test the `object` branch above runs, // asked here for the same reason. `getMetaItem` answers a @@ -1842,15 +1747,11 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin const protocol = await resolveProtocol(deps, _context); if (protocol && typeof protocol.listDrafts === 'function') { try { - // [#20408] The caller's VETTED organization, raw — what - // `RestServer`'s `_drafts` hands down (`ctx.tenantId`). The - // session's claim as stored listed a removed member the pending - // drafts of the organization they had left. - const organizationId = metaCallerOrganizationId(ec); + // [ADR-0131 D6] No organization, as `RestServer`'s `_drafts`: + // every draft is saved environment-wide. const data = await protocol.listDrafts({ packageId: query?.packageId || undefined, type: query?.type || undefined, - organizationId, }); return { handled: true, response: deps.success(data) }; } catch (e: any) { @@ -1884,11 +1785,9 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // publishing metadata, which is exactly what a rewrite is; engine // self-invocation (`isSystem`) bypasses, matching `actionPermissionError`. // - // [#12702] Deliberately NOT `metaWriteCapabilityVerdict`: an - // install-wide stored-metadata rewrite is env-wide by definition, so - // `manage_org_presentation`'s "org-scoped to the caller's own active - // organization" condition can never hold here. `manage_metadata`-only, - // unchanged — do not copy the item doors' acceptance in. + // [#12702] Its own `manage_metadata` gate rather than + // `metaWriteCapabilityVerdict`: an install-wide stored-metadata rewrite + // is not an item door, and its refusal sentence is its own. const ec: any = _context.executionContext; if (!ec?.isSystem && !new Set(ec?.systemPermissions ?? []).has('manage_metadata')) { return { @@ -1986,12 +1885,9 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin let listed: any; if (protocol && typeof protocol.getMetaItems === 'function') { try { - // [#20408] The caller's VETTED organization, gated by the folded - // type — the partition `RestServer`'s list reads. - const organizationId = metaReadOrganizationId( - typeOrName, _context.executionContext as MetaReadGateCaller | undefined, - ); - const data = await protocol.getMetaItems({ type: typeOrName, packageId, organizationId, previewDrafts }); + // [ADR-0131 D6] No organization: environment → code, the + // partition `RestServer`'s list reads. + const data = await protocol.getMetaItems({ type: typeOrName, packageId, previewDrafts }); // Return any valid response from protocol (including empty items arrays) if (data && (data.items !== undefined || Array.isArray(data))) listed = data; } catch (e: any) { diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index d422204c00b..a42fab09b1e 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -68,9 +68,6 @@ import type { MetadataProtocol, PackageProtocol } from '@objectstack/spec/api'; // private restatement of "when may a caught sentence be quoted" is where the // two copies start answering differently. import { clientFacingFailureText, seedRequestValidationError } from '@objectstack/metadata-protocol'; -// [#8805] Moved to `metadata-core` so the REST `/meta` write doors decide this -// the same way rather than through a second copy. Behaviour unchanged. -import { organizationIdForMetaWrite } from '@objectstack/metadata-core'; // [#15591] The DECLARED removal of our OWN read-time annotations, imported // from the list that defines them (`METADATA_READ_DECORATIONS`) rather than // re-spelled here. `applyPublishedSeeds` below re-parses a SERVED document, and @@ -1609,8 +1606,8 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // one. // // [#7018 / the #6190 ruling, Option A] `app` declares - // `allowOrgOverride: false`, so this flip does NOT carry the - // session's active organization — it lands env-wide, on the + // `allowOrgOverride: false`, so this flip carries no + // organization — it lands env-wide, on the // very row boot hydrates and the App Switcher reads. An // org-scoped flip was a phantom: the app looked published for // the life of the process and went back to `_unpublished: @@ -1635,7 +1632,6 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // write scope now answer one question through one registry // flag; ⛔ never "restore" the organization to this read. const flipped: string[] = []; - const flipOrganizationId = organizationIdForMetaWrite('app', organizationId); try { if ( typeof protocol.getMetaItems === 'function' && @@ -1662,7 +1658,6 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // app carries is copied through untouched. item: { ...app, _unpublished: false }, packageId: id, - ...(flipOrganizationId ? { organizationId: flipOrganizationId } : {}), ...(body?.actor ? { actor: body.actor } : {}), }); flipped.push(app.name); diff --git a/packages/runtime/src/meta-write-org-scope.test.ts b/packages/runtime/src/meta-write-org-scope.test.ts index 9caeccd504c..d4a6832ec95 100644 --- a/packages/runtime/src/meta-write-org-scope.test.ts +++ b/packages/runtime/src/meta-write-org-scope.test.ts @@ -1,6 +1,14 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** + * [ADR-0131 D6, C5 stage S3] The dispatcher threads NO organization into a + * metadata write: every type, an `allowOrgOverride: true` one included, lands + * environment-wide (`organization_id` NULL) whatever the session's active + * organization. The per-organization overlay axis is retired; the CONTROL cases + * that used to pin `view` landing org-scoped are the stage's pins now, and they + * read the stored ROW through the real stack. + * + * History, kept below for the reverse-verification record it carries: * #7018 — the runtime threads the session's organization into a metadata WRITE * only for types the registry declares `allowOrgOverride: true`. * @@ -68,8 +76,9 @@ import { // REST `/meta` write doors share it. This suite still drives the DISPATCHER // through the real stack — that is why it stays in this package. declaresOrgOverride, - organizationIdForMetaWrite, assertEngineFindOnePredicate, + assertEngineFindOnePredicate, } from '@objectstack/metadata-core'; +import * as metadataCore from '@objectstack/metadata-core'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; // [commit 67ceb9aef] The URL spelling contract itself — the map storage folds through, @@ -365,8 +374,8 @@ describe('#7018 — the registry decides whether a metadata write carries the se // channel either, so it is env-wide too. (`webhook` took this slot // from `theme` at commit 35ad101bc — the retired kind left the contract.) expect(declaresOrgOverride('webhook')).toBe(false); - // No active org in, no org out — for every type. - expect(organizationIdForMetaWrite('view', undefined)).toBeUndefined(); + // [ADR-0131 D6] The write-side predicate is gone: no door asks it. + expect('organizationIdForMetaWrite' in metadataCore).toBe(false); }); // ── PUT /meta/:type/:name — the dispatcher's metadata write ─────────── @@ -419,24 +428,26 @@ describe('#7018 — the registry decides whether a metadata write carries the se expect(a.body.data).toMatchObject({ success: true, state: 'active' }); }); - it('CONTROL — an `allowOrgOverride: true` type keeps its org scoping exactly as before', async () => { + it('⭐ [ADR-0131 D6] an `allowOrgOverride: true` type lands env-wide too, and the read serves it', async () => { const { engine, dispatcher } = makeStack(ACTIVE_ORG); const res = responseOf(await dispatcher.handleMetadata(`/view/${VIEW.name}`, ctx(ACTIVE_ORG), 'PUT', VIEW)); expect(res.status).toBe(200); - // ADR-0005's per-org overlay is the point of the flag and must survive - // this change untouched — `getMetaItem`/`getMetaItems` load it on demand. - expect(metaRow(engine, 'view', VIEW.name)!.organization_id).toBe(ACTIVE_ORG); + // The per-organization overlay axis is retired: the Studio save of a + // view belongs to the whole deployment. + expect(metaRow(engine, 'view', VIEW.name)!.organization_id).toBeNull(); + const read = responseOf(await dispatcher.handleMetadata(`/view/${VIEW.name}`, ctx(ACTIVE_ORG), 'GET')); + expect(read.status).toBe(200); }); - it('CONTROL — the plural URL spelling of an overridable type is scoped the same way', async () => { + it('[ADR-0131 D6] the plural URL spelling of an overridable type lands env-wide the same way', async () => { const { engine, dispatcher } = makeStack(ACTIVE_ORG); const res = responseOf(await dispatcher.handleMetadata(`/views/${VIEW.name}`, ctx(ACTIVE_ORG), 'PUT', VIEW)); expect(res.status).toBe(200); - expect(metaRow(engine, 'view', VIEW.name)!.organization_id).toBe(ACTIVE_ORG); + expect(metaRow(engine, 'view', VIEW.name)!.organization_id).toBeNull(); }); // ── POST /packages/:id/publish-drafts — the ADR-0045 visibility flip ── @@ -567,7 +578,7 @@ describe('#7018 — the registry decides whether a metadata write carries the se * pass the red cases and fail there, re-minting the #6190 phantom rows. * Measured result recorded in the PR body. */ -describe('#10503 the dispatcher /metadata transport decides org scope on the FOLDED type', () => { +describe('#10503 · ADR-0131 D6 no spelling carries an organization through the dispatcher /metadata transport', () => { beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -606,7 +617,7 @@ describe('#10503 the dispatcher /metadata transport decides org scope on the FOL ] as const; for (const { plural, singular, item } of MEMBERS) { - it(`PUT /metadata/${plural}/:name lands ORG-SCOPED, where its ${singular} twin lands`, async () => { + it(`PUT /metadata/${plural}/:name lands env-wide, where its ${singular} twin lands`, async () => { // THE assertion, and it is the stored row rather than the status: // before the fold this write persisted with `organization_id // NULL` while the author's own org-scoped read looked elsewhere — @@ -618,7 +629,7 @@ describe('#10503 the dispatcher /metadata transport decides org scope on the FOL expect(resPlural.status).toBe(200); const pluralRow = metaRow(viaPlural.engine, singular, item.name); expect(pluralRow).toBeDefined(); - expect(pluralRow!.organization_id).toBe(ACTIVE_ORG); + expect(pluralRow!.organization_id).toBeNull(); // The live control, in the same file and the same direction: the // singular twin's scoping is what the plural must equal, and it @@ -629,7 +640,7 @@ describe('#10503 the dispatcher /metadata transport decides org scope on the FOL ); expect(resSingular.status).toBe(200); const singularRow = metaRow(viaSingular.engine, singular, item.name); - expect(singularRow!.organization_id).toBe(ACTIVE_ORG); + expect(singularRow!.organization_id).toBeNull(); expect(pluralRow!.organization_id).toBe(singularRow!.organization_id); }); @@ -651,7 +662,7 @@ describe('#10503 the dispatcher /metadata transport decides org scope on the FOL (r: any) => r.type === singular && r.name === item.name && r.state === 'active', ); expect(rows).toHaveLength(1); - expect(rows[0].organization_id).toBe(ACTIVE_ORG); + expect(rows[0].organization_id).toBeNull(); }); it(`CONTROL — with NO active org, /${plural} still lands env-wide`, async () => { @@ -690,7 +701,7 @@ describe('#10503 the dispatcher /metadata transport decides org scope on the FOL // ── the class, not the two specimens ────────────────────────────────── - it('decides scope for EVERY spelling in the URL contract exactly as for its folded type', async () => { + it('names no organization for EVERY spelling in the URL contract', async () => { // The class-closing sweep. For every key of `META_URL_TO_SINGULAR` the // transport's decision must equal the predicate's decision on the // FOLDED type — the property the two maps' disagreement broke. A future @@ -712,8 +723,8 @@ describe('#10503 the dispatcher /metadata transport decides org scope on the FOL expect( saveMetaItem.mock.calls[0][0].organizationId, - `PUT /metadata/${spelling} scope disagreed with its fold '${folded}'`, - ).toBe(organizationIdForMetaWrite(folded, ACTIVE_ORG)); + `PUT /metadata/${spelling} (folds to '${folded}') threaded an organization`, + ).toBeUndefined(); } }); @@ -731,7 +742,7 @@ describe('#10503 the dispatcher /metadata transport decides org scope on the FOL const request = saveMetaItem.mock.calls[0][0]; expect(request.type).toBe('translations'); - expect(request.organizationId).toBe(ACTIVE_ORG); + expect(request.organizationId).toBeUndefined(); }); // ── the smaller second site: GET /metadata/:type/:name/published ────── diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index b20f94609eb..ec7f4ced260 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -494,7 +494,7 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ { route: 'GET /meta/:type', domain: '/meta', disposition: 'sdk', client: 'meta.getItems' }, { route: 'GET /meta/:type/:name', domain: '/meta', disposition: 'sdk', client: 'meta.getItem' }, { route: 'PUT /meta/:type/:name', domain: '/meta', disposition: 'sdk', client: 'meta.saveItem', - note: 'Gated on `manage_metadata` (ADR-0066 D1) — the dispatcher transport of the REST save door. The gate is the shared `metaWriteCapabilityVerdict` (`@objectstack/metadata-core`): `manage_org_presentation` is also admitted, ONLY for an `allowOrgOverride: true` type written org-scoped to the caller\'s own active organization, so a tenant org admin authors their own org\'s overlays without platform-wide `manage_metadata`; refusals answer 403 `PERMISSION_DENIED`, this transport\'s pinned spelling' }, + note: 'Gated on `manage_metadata` (ADR-0066 D1) — the dispatcher transport of the REST save door. The gate is the shared `metaWriteCapabilityVerdict` (`@objectstack/metadata-core`); the write names no organization and lands environment-wide (ADR-0131 D6), so the org-scoped `manage_org_presentation` admission retired with it; refusals answer 403 `PERMISSION_DENIED`, this transport\'s pinned spelling' }, { route: 'GET /meta/:type/:name/published', domain: '/meta', disposition: 'sdk', client: 'meta.getPublished', responseSchema: 'GetPublishedMetaItemResponseSchema', note: 'Enveloped on THIS surface — the named schema is the `data`, and it is DELIBERATELY OPAQUE (`z.unknown()`): the route answers an arbitrary metadata item body, never a union frozen against the type registry. The REST twin (`rest-route-ledger.ts`) answers the same payload BARE' }, diff --git a/packages/services/service-datasource/src/plugin.ts b/packages/services/service-datasource/src/plugin.ts index e1a8fb94762..35b5c9faea5 100644 --- a/packages/services/service-datasource/src/plugin.ts +++ b/packages/services/service-datasource/src/plugin.ts @@ -176,8 +176,8 @@ export class ExternalDatasourceServicePlugin implements Plugin { * beside the save — the save already writes the registry through. * * The request is the one that door sends for an `object`, field for - * field: no `organizationId`, because `object` is not org-overridable and - * that door's `organizationIdForMetaWrite` resolves none for it; no + * field: no `organizationId`, because that door carries none into any + * metadata write (ADR-0131 D6); no * `packageId`, `mode` or `force`, because the import route takes no * `?package`, `?mode` or `?force`. * diff --git a/packages/spec/src/migrations/entries/semantic/18.manage-org-presentation-retired.ts b/packages/spec/src/migrations/entries/semantic/18.manage-org-presentation-retired.ts new file mode 100644 index 00000000000..b4f547b1a7d --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.manage-org-presentation-retired.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0131 D6 (C5, stage S3) — a platform-capability RETIREMENT, not a spec-key +// retirement: `systemPermissions` is a list of plain strings, so no authorable +// key moves, nothing lands in RETIRED_KEYS_BY_MAJOR and no D2 conversion exists +// to pair with. Measured: a permission set naming the capability still parses +// and loads (the schema accepts any string, and an undeclared name is +// back-derived as a capability the stack declares); only the grant goes inert. +export const entry: SemanticMigration = { + id: 'manage-org-presentation-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the manage_org_presentation platform capability (its PLATFORM_CAPABILITIES entry in ' + + '@objectstack/spec security, the ORG_PRESENTATION_AUTHORING_CAPABILITY constant exported ' + + 'by @objectstack/metadata-core) and the arm of metaWriteCapabilityVerdict that admitted its ' + + 'holders to org-scoped writes of the five org-overridable types through the /meta item doors', + replacement: + 'grant `manage_metadata` to whoever must author views, dashboards, reports, translations or ' + + 'email templates through Studio or `PUT /api/v1/meta//`; such a write now lands ' + + 'environment-wide (`organization_id` NULL) and is served to every organization of the ' + + 'deployment. There is no organization-bounded authoring capability: delete ' + + '`manage_org_presentation` from every permission set\'s `systemPermissions`, and delete any ' + + 'import of `ORG_PRESENTATION_AUTHORING_CAPABILITY`. `metaWriteCapabilityVerdict` takes ' + + '`{ isSystem, systemPermissions, operation }`: drop the `canonicalType` and ' + + '`activeOrganizationId` members from the call', + reason: + 'ADR-0131 D6 retires the per-organization overlay axis, and the /meta doors stop carrying an ' + + 'organization into a metadata write (the companion entry meta-doors-organization-scope-retired). ' + + 'The capability admitted an organization admin to exactly the writes those doors threaded ' + + 'into the admin\'s own organization; with no organization threaded, keeping it would have ' + + 'admitted its holders to environment-wide authoring, which is the reach of manage_metadata ' + + 'and a wider one than the capability ever granted. It was granted by no shipped permission ' + + 'set, so a deployment that never granted it by hand observes nothing.', + acceptanceCriteria: + 'PLATFORM_CAPABILITY_NAMES no longer holds manage_org_presentation, so the authoring lint ' + + 'resolves the name only where a stack itself declares or grants it. A caller holding ' + + 'manage_org_presentation and not manage_metadata is answered 403 on PUT, DELETE, publish ' + + 'and rollback of /api/v1/meta// (FORBIDDEN on the REST doors, PERMISSION_DENIED ' + + 'on the dispatcher), whatever its active organization. A persisted permission set naming the ' + + 'capability still loads and its other grants still apply. The sys_capability row the ' + + 'platform seeded for it earlier is not pruned (the seeder upserts only); it names a ' + + 'capability nothing consults, and an operator may delete it in Setup.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.meta-doors-organization-scope-retired.ts b/packages/spec/src/migrations/entries/semantic/18.meta-doors-organization-scope-retired.ts new file mode 100644 index 00000000000..5fbef6f589a --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.meta-doors-organization-scope-retired.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0131 D6 (C5, stage S3) — a runtime-door narrowing with no authorable key: +// the /meta doors of both transports stop carrying the caller's organization +// into a metadata write or read. Registered because legacy organization-scoped +// rows stop being served, which an operator must be told (stage 0's F10 of the +// retirement card: a single-posture deployment observes it too). +export const entry: SemanticMigration = { + id: 'meta-doors-organization-scope-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the organization the /meta doors of @objectstack/rest and of the runtime dispatcher thread ' + + 'into a metadata write (PUT, DELETE, publish, rollback) or read (item, list, layered, ' + + 'published, drafts, history, audit, diff, diagnostics, references) of the five ' + + 'org-overridable types; the organizationIdForMetaWrite export of @objectstack/metadata-core; ' + + 'the metaReadOrganizationId export of @objectstack/rest; and the Default Organization read ' + + 'of the email-template boot sweep in @objectstack/plugin-email', + replacement: + 'nothing to write: every `/meta` write now lands environment-wide (`organization_id` NULL) ' + + 'and every `/meta` read resolves environment → code, for every caller and every tenancy ' + + 'posture. Delete any import of `organizationIdForMetaWrite` (a write carries no ' + + 'organization) or `metaReadOrganizationId` (a read carries none either). A caller that ' + + 'needs the vetted organization of a request for another purpose still reads ' + + '`metaCallerOrganizationId`', + reason: + 'ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by ' + + 'Studio, by the cloud build agent or by a template install belongs to the whole deployment. ' + + 'The doors threaded the active organization for view, dashboard, report, translation and ' + + 'email_template, so under the single posture, where the Default Organization is active, ' + + 'every Studio save of those types was stored under that organization. The read and the ' + + 'write flip together: reads-first would hide the organization rows the doors still wrote, ' + + 'writes-first would let those rows shadow new environment saves.', + acceptanceCriteria: + 'A manage_metadata caller with an active organization saves a view through PUT ' + + '/api/v1/meta/view/ on either transport: the stored row carries organization_id ' + + 'NULL and GET serves it. An organization-scoped row stored before this release, including ' + + 'a single-posture Studio save filed under the Default Organization, is no longer served by ' + + 'any /meta read (the environment row or the code definition is), nor projected by the ' + + 'email-template boot sweep; it stays in sys_metadata untouched until the promotion ' + + 'ceremony (ADR-0131 C7) carries it to the environment layer. Re-save such an item in ' + + 'Studio to make the edit live on the /meta doors now. Public forms are the exception: ' + + 'until that ceremony the anonymous form doors read a form view in the Default Organization ' + + 'and prefer its overlay for the form\'s body, while a withdrawal in either layer closes the ' + + 'form, fail-closed. So a legacy organization overlay of a public form keeps serving its ' + + 'body there: a Studio re-save of that body (an environment row) does not change the body ' + + 'the public form serves, and a Studio withdrawal (an environment row) still closes it.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 868d157c83e..ed7eda14f48 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -15674,6 +15674,48 @@ const step18: MigrationStep = { + 'naming the suffixed key and its def; the parsed defaults are 5000 / 1000 / 30000 / 1000 as ' + 'before; and each published describe names milliseconds.', }, + // ADR-0131 D6 (C5, stage S3) — a platform-capability RETIREMENT, not a spec-key + // retirement: `systemPermissions` is a list of plain strings, so no authorable + // key moves, nothing lands in RETIRED_KEYS_BY_MAJOR and no D2 conversion exists + // to pair with. Measured: a permission set naming the capability still parses + // and loads (the schema accepts any string, and an undeclared name is + // back-derived as a capability the stack declares); only the grant goes inert. + { + id: 'manage-org-presentation-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the manage_org_presentation platform capability (its PLATFORM_CAPABILITIES entry in ' + + '@objectstack/spec security, the ORG_PRESENTATION_AUTHORING_CAPABILITY constant exported ' + + 'by @objectstack/metadata-core) and the arm of metaWriteCapabilityVerdict that admitted its ' + + 'holders to org-scoped writes of the five org-overridable types through the /meta item doors', + replacement: + 'grant `manage_metadata` to whoever must author views, dashboards, reports, translations or ' + + 'email templates through Studio or `PUT /api/v1/meta//`; such a write now lands ' + + 'environment-wide (`organization_id` NULL) and is served to every organization of the ' + + 'deployment. There is no organization-bounded authoring capability: delete ' + + '`manage_org_presentation` from every permission set\'s `systemPermissions`, and delete any ' + + 'import of `ORG_PRESENTATION_AUTHORING_CAPABILITY`. `metaWriteCapabilityVerdict` takes ' + + '`{ isSystem, systemPermissions, operation }`: drop the `canonicalType` and ' + + '`activeOrganizationId` members from the call', + reason: + 'ADR-0131 D6 retires the per-organization overlay axis, and the /meta doors stop carrying an ' + + 'organization into a metadata write (the companion entry meta-doors-organization-scope-retired). ' + + 'The capability admitted an organization admin to exactly the writes those doors threaded ' + + 'into the admin\'s own organization; with no organization threaded, keeping it would have ' + + 'admitted its holders to environment-wide authoring, which is the reach of manage_metadata ' + + 'and a wider one than the capability ever granted. It was granted by no shipped permission ' + + 'set, so a deployment that never granted it by hand observes nothing.', + acceptanceCriteria: + 'PLATFORM_CAPABILITY_NAMES no longer holds manage_org_presentation, so the authoring lint ' + + 'resolves the name only where a stack itself declares or grants it. A caller holding ' + + 'manage_org_presentation and not manage_metadata is answered 403 on PUT, DELETE, publish ' + + 'and rollback of /api/v1/meta// (FORBIDDEN on the REST doors, PERMISSION_DENIED ' + + 'on the dispatcher), whatever its active organization. A persisted permission set naming the ' + + 'capability still loads and its other grants still apply. The sys_capability row the ' + + 'platform seeded for it earlier is not pruned (the seeder upserts only); it names a ' + + 'capability nothing consults, and an operator may delete it in Setup.', + }, // A rename is the one thing this entry deliberately does NOT prescribe // mechanically. An id is an IDENTITY: it is what the registry addresses the // package by (`manifest_id`), what an installed row is keyed on, and what a @@ -15891,6 +15933,52 @@ const step18: MigrationStep = { '`persistence.key`; `initialData` record values containing literal `${…}` keep parsing ' + 'byte-identically.', }, + // ADR-0131 D6 (C5, stage S3) — a runtime-door narrowing with no authorable key: + // the /meta doors of both transports stop carrying the caller's organization + // into a metadata write or read. Registered because legacy organization-scoped + // rows stop being served, which an operator must be told (stage 0's F10 of the + // retirement card: a single-posture deployment observes it too). + { + id: 'meta-doors-organization-scope-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the organization the /meta doors of @objectstack/rest and of the runtime dispatcher thread ' + + 'into a metadata write (PUT, DELETE, publish, rollback) or read (item, list, layered, ' + + 'published, drafts, history, audit, diff, diagnostics, references) of the five ' + + 'org-overridable types; the organizationIdForMetaWrite export of @objectstack/metadata-core; ' + + 'the metaReadOrganizationId export of @objectstack/rest; and the Default Organization read ' + + 'of the email-template boot sweep in @objectstack/plugin-email', + replacement: + 'nothing to write: every `/meta` write now lands environment-wide (`organization_id` NULL) ' + + 'and every `/meta` read resolves environment → code, for every caller and every tenancy ' + + 'posture. Delete any import of `organizationIdForMetaWrite` (a write carries no ' + + 'organization) or `metaReadOrganizationId` (a read carries none either). A caller that ' + + 'needs the vetted organization of a request for another purpose still reads ' + + '`metaCallerOrganizationId`', + reason: + 'ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by ' + + 'Studio, by the cloud build agent or by a template install belongs to the whole deployment. ' + + 'The doors threaded the active organization for view, dashboard, report, translation and ' + + 'email_template, so under the single posture, where the Default Organization is active, ' + + 'every Studio save of those types was stored under that organization. The read and the ' + + 'write flip together: reads-first would hide the organization rows the doors still wrote, ' + + 'writes-first would let those rows shadow new environment saves.', + acceptanceCriteria: + 'A manage_metadata caller with an active organization saves a view through PUT ' + + '/api/v1/meta/view/ on either transport: the stored row carries organization_id ' + + 'NULL and GET serves it. An organization-scoped row stored before this release, including ' + + 'a single-posture Studio save filed under the Default Organization, is no longer served by ' + + 'any /meta read (the environment row or the code definition is), nor projected by the ' + + 'email-template boot sweep; it stays in sys_metadata untouched until the promotion ' + + 'ceremony (ADR-0131 C7) carries it to the environment layer. Re-save such an item in ' + + 'Studio to make the edit live on the /meta doors now. Public forms are the exception: ' + + 'until that ceremony the anonymous form doors read a form view in the Default Organization ' + + 'and prefer its overlay for the form\'s body, while a withdrawal in either layer closes the ' + + 'form, fail-closed. So a legacy organization overlay of a public form keeps serving its ' + + 'body there: a Studio re-save of that body (an environment row) does not change the body ' + + 'the public form serves, and a Studio withdrawal (an environment row) still closes it.', + }, { id: 'metadata-changed-event-payload-retired', surface: diff --git a/packages/spec/src/security/capabilities.test.ts b/packages/spec/src/security/capabilities.test.ts index 43f39b26d17..84912b8a6c5 100644 --- a/packages/spec/src/security/capabilities.test.ts +++ b/packages/spec/src/security/capabilities.test.ts @@ -54,3 +54,12 @@ describe('defineCapability (ADR-0066 D1 package declaration)', () => { } }); }); + +describe('ADR-0131 D6 — `manage_org_presentation` retired', () => { + it('is no longer a curated platform capability', () => { + expect(PLATFORM_CAPABILITY_NAMES.has('manage_org_presentation')).toBe(false); + expect(PLATFORM_CAPABILITIES.some((c) => c.name === 'manage_org_presentation')).toBe(false); + // Control: its platform-wide sibling stays. + expect(PLATFORM_CAPABILITY_NAMES.has('manage_metadata')).toBe(true); + }); +}); diff --git a/packages/spec/src/security/capabilities.ts b/packages/spec/src/security/capabilities.ts index eaa5690ce94..0fb596a18e5 100644 --- a/packages/spec/src/security/capabilities.ts +++ b/packages/spec/src/security/capabilities.ts @@ -41,25 +41,10 @@ export const PLATFORM_CAPABILITIES: readonly PlatformCapability[] = [ { name: 'manage_users', label: 'Manage Users', description: 'Create, edit, and deactivate users across the platform.', scope: 'platform' }, { name: 'manage_org_users', label: 'Manage Organization Users', description: 'Manage members within the caller’s organization.', scope: 'org' }, { name: 'manage_metadata', label: 'Manage Metadata', description: 'Author and publish object/view/flow and other metadata.', scope: 'platform' }, - // [#12702] The org-scoped SUBSET key beside `manage_metadata` — presentation - // authoring authority bounded to the caller's own organization. It admits - // `/meta` item writes ONLY when the target type's registry entry declares - // `allowOrgOverride: true` (ADR-0005 tier A: view / dashboard / report / - // translation / email_template today — the REGISTRY is the authority, this - // comment merely names today's members) AND the write is org-scoped to the - // session's active organization. Never a tier-B reach, never an env-wide - // (`organization_id NULL`) write, never another organization's partition — - // enforced by `metaWriteCapabilityVerdict` (`@objectstack/metadata-core`), - // the one predicate every `/meta` write door runs. It exists so a - // walled-posture (single-DB SaaS) operator can let a tenant org admin - // customize presentation overlays without handing them platform-wide - // `manage_metadata`, whose reach includes env-wide tier-B authoring - // (maintainer direction 2026-08-27, quoted in #12701). Deliberately granted - // by NO shipped permission set: the operator grants it per deployment, so - // existing postures — `single` included — are byte-unchanged by its - // existence. ⛔ Never add it to `PLATFORM_ADMIN_ONLY_CAPABILITIES` - // (`plugin-security`): it is precisely NOT a platform-admin marker. - { name: 'manage_org_presentation', label: 'Manage Organization Presentation', description: 'Author per-organization presentation overlays — the metadata types whose registry entry declares allowOrgOverride — scoped to the caller’s own organization.', scope: 'org' }, + // [ADR-0131 D6] `manage_org_presentation` (#12702) retired with the + // per-organization overlay axis: no `/meta` door threads an organization into + // a metadata write, so the org-scoped authoring it admitted no longer exists + // (ADR-0087 entry `manage-org-presentation-retired`). { name: 'manage_platform_settings', label: 'Manage Platform Settings', description: 'Configure global platform settings (mail, storage, AI, licensing, …) and platform-only Setup pages.', scope: 'platform' }, { name: 'setup.access', label: 'Setup Access', description: 'Enter the Setup app shell.', scope: 'platform' }, // [Finding-1] The write counterpart to `setup.access`: saving changes to