From c6382ee248bf4e8bfea0c4d7333497ae69f6492e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:19:17 +0000 Subject: [PATCH 01/10] wip(S3): doors carry no organization; manage_org_presentation retires Claude-Session: https://claude.ai/code/session_01T33XAHecf6UARBkb45mecB Co-authored-by: Claude --- packages/metadata-core/src/index.ts | 28 +- .../src/meta-write-capability.ts | 148 +--- .../metadata-core/src/meta-write-org-scope.ts | 123 +-- .../src/bootstrap-declared-email-templates.ts | 53 +- .../plugins/plugin-email/src/email-plugin.ts | 10 +- packages/rest/src/index.ts | 9 +- packages/rest/src/meta-item-read-gate.ts | 38 +- packages/rest/src/rest-server.ts | 781 +++--------------- packages/runtime/src/domains/meta.ts | 190 +---- packages/runtime/src/domains/packages.ts | 9 +- .../18.manage-org-presentation-retired.ts | 46 ++ ...8.meta-doors-organization-scope-retired.ts | 46 ++ .../spec/src/security/capabilities.test.ts | 9 + packages/spec/src/security/capabilities.ts | 23 +- 14 files changed, 367 insertions(+), 1146 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.manage-org-presentation-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.meta-doors-organization-scope-retired.ts 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.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.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.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/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-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/rest-server.ts b/packages/rest/src/rest-server.ts index 89fae0b5e5f..bfcd8cc7a00 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,8 +3447,8 @@ 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. @@ -3464,16 +3458,9 @@ export class RestServer { 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; @@ -3610,19 +3597,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 +3906,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 +3932,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 +3946,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 — @@ -5689,161 +5653,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) { @@ -5919,24 +5738,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) { @@ -6091,22 +5900,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 — @@ -6131,7 +5929,6 @@ export class RestServer { packageId, ...(previewDrafts ? { previewDrafts: true } : {}), ...(environmentId ? { environmentId } : {}), - ...(listOrganizationId ? { organizationId: listOrganizationId } : {}), }; const items = await p.getMetaItems(listRequest); @@ -6236,64 +6033,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 @@ -6308,11 +6054,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) { @@ -6583,8 +6324,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); @@ -6680,24 +6420,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 @@ -6752,13 +6474,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); @@ -6913,11 +6628,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; @@ -7094,15 +6804,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 @@ -7112,7 +6820,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: { @@ -7210,72 +6918,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 @@ -7295,7 +6947,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 @@ -7358,22 +7009,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) { @@ -7434,23 +7077,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 @@ -7470,7 +7098,6 @@ export class RestServer { const deleteRequest: TransportScopedMetaRequest = { type: req.params.type, name: req.params.name, - organizationId, ...(environmentId ? { environmentId } : {}), ...(parentVersion !== undefined ? { parentVersion } : {}), ...(actor ? { actor } : {}), @@ -7526,8 +7153,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) @@ -7584,45 +7210,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 @@ -7633,39 +7228,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 } : {}), @@ -7720,10 +7286,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); @@ -7802,41 +7367,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 @@ -7853,7 +7392,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 } : {}), @@ -7907,21 +7446,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) { @@ -7993,29 +7525,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 @@ -8050,7 +7562,6 @@ export class RestServer { const publishRequest: TransportScopedMetaRequest = { type: req.params.type, name: req.params.name, - organizationId, ...(environmentId ? { environmentId } : {}), ...(actor ? { actor } : {}), ...(message ? { message } : {}), @@ -8098,20 +7609,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) { @@ -8152,25 +7656,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 } : {}), @@ -8221,8 +7712,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) @@ -8285,46 +7775,17 @@ export class RestServer { 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 } : {}), }); @@ -8610,41 +8071,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 @@ -8710,14 +8142,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.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/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..bb86a3b9cf3 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.meta-doors-organization-scope-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 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 now. The anonymous public-form doors still read the Default ' + + 'Organization\'s form withdrawals, fail-closed, until that ceremony.', +}; 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 From ea327666e3065be5748e6d3c9be08855301e38b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:27:45 +0000 Subject: [PATCH 02/10] wip(S3): route-ledger notes, ADR-0087 registry regenerated Claude-Session: https://claude.ai/code/session_01T33XAHecf6UARBkb45mecB Co-authored-by: Claude --- packages/rest/src/rest-route-ledger.ts | 8 +- packages/runtime/src/route-ledger.ts | 2 +- .../services/service-datasource/src/plugin.ts | 4 +- packages/spec/src/migrations/registry.ts | 84 +++++++++++++++++++ 4 files changed, 91 insertions(+), 7 deletions(-) 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/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/registry.ts b/packages/spec/src/migrations/registry.ts index 0153882b1c4..23dcf3090f7 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -15621,6 +15621,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 @@ -15838,6 +15880,48 @@ 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 now. The anonymous public-form doors still read the Default ' + + 'Organization\'s form withdrawals, fail-closed, until that ceremony.', + }, { id: 'metadata-changed-event-payload-retired', surface: From e21a1fb1bbc480f4cb96f47b8f963dd3e4e39770 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:46:05 +0000 Subject: [PATCH 03/10] test(S3): flip REST capability and scope pins to the env-only doors Claude-Session: https://claude.ai/code/session_01T33XAHecf6UARBkb45mecB Co-authored-by: Claude --- .../src/meta-write-capability.test.ts | 212 +++++------------- .../src/meta-write-org-scope.test.ts | 16 +- ...bootstrap-declared-email-templates.test.ts | 42 ++-- .../rest/src/execctx-consumer-census.test.ts | 60 +++-- .../meta-alternate-door-read-gates.test.ts | 16 +- ...hboard-view-i18n-explicit-override.test.ts | 17 +- .../meta-item-save-capability-gate.test.ts | 78 +++---- .../src/meta-publish-package-scope.test.ts | 5 +- ...-write-door-capability-enumeration.test.ts | 51 ++--- .../src/rest-server-audit-org-scope.test.ts | 10 +- ...server-meta-cached-etag-door-scope.test.ts | 32 +-- packages/rest/src/rest-server.ts | 5 +- 12 files changed, 215 insertions(+), 329 deletions(-) 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-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/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/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index f16a67a06d3..8e2a9a6768a 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -319,7 +319,18 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t // --------------------------------------------------------------------------- describe('[#13160] §2 the consumer surface, counted from the tree', () => { - it('66 invocation sites, 90 mentions — the thread\'s two control numbers hold', () => { + it('61 invocation sites, 76 mentions — the thread\'s two control numbers hold', () => { + // [ADR-0131 D6] 66 → 61 sites / 90 → 76 mentions — 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. + // // [#20237] 68 → 66 sites / 92 → 90 mentions — a MOVE, not a lost // consumer. `GET /meta/:type`'s app nav filter and dashboard widget // gate each resolved the context inline (two same-line CAUGHT sites); @@ -457,27 +468,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(66); - expect(SOURCE.split('resolveExecCtx').length - 1).toBe(90); + expect(SITES.length).toBe(61); + expect(SOURCE.split('resolveExecCtx').length - 1).toBe(76); }); - it('the split is 21 locally caught / 45 bare — NOT 13 / 45, which does not add to 66', () => { - // 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 / 45 bare — NOT 12 / 45, which does not add to 61', () => { + // 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(45); expect(CAUGHT.length + BARE.length).toBe(SITES.length); }); - it('⭐ every one of the 45 bare sites is guarded on the VERY NEXT LINE, and none of the 21 caught ones is', () => { + it('⭐ every one of the 45 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 @@ -1043,16 +1056,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 () => { @@ -1080,17 +1095,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/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-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-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.ts b/packages/rest/src/rest-server.ts index bfcd8cc7a00..f506ba04e5e 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3454,7 +3454,6 @@ export class RestServer { * `undefined` when nothing is behind the name. */ private async fetchCurrentMetaDocument( - environmentId: string | undefined, req: any, p: RestProtocol, ): Promise { @@ -3485,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, @@ -7769,7 +7768,7 @@ 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); From dab790eb299f8f70a00c0c41fb03032a8f4570d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:50:47 +0000 Subject: [PATCH 04/10] test(S3): REST org-scope pins flip to environment-only reads and writes Claude-Session: https://claude.ai/code/session_01T33XAHecf6UARBkb45mecB Co-authored-by: Claude --- ...server-meta-history-diff-org-scope.test.ts | 272 ++--- ...server-meta-org-scope-url-spelling.test.ts | 49 +- .../rest-server-meta-read-org-scope.test.ts | 932 +++++------------- .../rest-server-meta-write-org-scope.test.ts | 173 +--- 4 files changed, 391 insertions(+), 1035 deletions(-) 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)); - } - }); - }); }); From 475f181d2a19488488030bd8e7c57947502dd63c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 08:06:00 +0000 Subject: [PATCH 05/10] test(S3): dispatcher pins flip to environment-only doors Claude-Session: https://claude.ai/code/session_01T33XAHecf6UARBkb45mecB Co-authored-by: Claude --- .../meta-read-org-scope-parity.test.ts | 37 +++++--- .../domains/meta-save-capability-gate.test.ts | 94 ++++++------------- .../runtime/src/meta-write-org-scope.test.ts | 47 ++++++---- 3 files changed, 79 insertions(+), 99 deletions(-) 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/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 ────── From 63f49223aee98278b9556fa8adc3a6d276f6d292 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 08:19:46 +0000 Subject: [PATCH 06/10] test(S3): dogfood form-withdrawal and email-template pins follow env-only saves Claude-Session: https://claude.ai/code/session_01T33XAHecf6UARBkb45mecB Co-authored-by: Claude --- ...late-overlay-survives-boot.dogfood.test.ts | 18 +++-- ...lic-form-withdrawal-walled.dogfood.test.ts | 56 ++++++++------- ...lic-form-withdrawal-layers.dogfood.test.ts | 72 ++++++++++--------- ...ase-public-form-withdrawal.dogfood.test.ts | 17 +++-- 4 files changed, 93 insertions(+), 70 deletions(-) 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); From 9a2d8803528561cb4f92dcdbf21608bd721db7b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 08:20:54 +0000 Subject: [PATCH 07/10] docs(S3): changeset and docs for the environment-only /meta doors Claude-Session: https://claude.ai/code/session_01T33XAHecf6UARBkb45mecB Co-authored-by: Claude --- .../15206-meta-doors-environment-only.md | 38 +++++++++++++++++++ content/docs/concepts/metadata-lifecycle.mdx | 4 +- .../kernel/contracts/metadata-service.mdx | 21 ++++++---- content/docs/protocol/objectui/concept.mdx | 2 +- content/docs/ui/create-vs-edit-form.mdx | 2 +- 5 files changed, 56 insertions(+), 11 deletions(-) create mode 100644 .changeset/15206-meta-doors-environment-only.md diff --git a/.changeset/15206-meta-doors-environment-only.md b/.changeset/15206-meta-doors-environment-only.md new file mode 100644 index 00000000000..e3c08b778c5 --- /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: such an edit reads as reverted to the environment or code definition. Re-save the item in Studio to make the edit live now. The anonymous public-form doors still read the Default Organization's form withdrawals, fail-closed, until that ceremony. + +**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..f3bcf452000 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 until the promotion ceremony (ADR-0131 C7) carries it to the environment layer; re-save the item in Studio to make the edit live now. 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..3e129b9a9a2 100644 --- a/content/docs/kernel/contracts/metadata-service.mdx +++ b/content/docs/kernel/contracts/metadata-service.mdx @@ -401,14 +401,19 @@ 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 until the promotion +ceremony (ADR-0131 C7) carries it to the environment layer. 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. From 691f52d585e7462a79d6ee324d77e7fd23d2e608 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 10:29:11 +0000 Subject: [PATCH 08/10] docs(S3): scope the legacy-row sentences to the /meta doors and name the public-form exception Claude-Session: https://claude.ai/code/session_01T33XAHecf6UARBkb45mecB Co-authored-by: Claude --- .changeset/15206-meta-doors-environment-only.md | 2 +- content/docs/concepts/metadata-lifecycle.mdx | 2 +- content/docs/kernel/contracts/metadata-service.mdx | 9 +++++++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.changeset/15206-meta-doors-environment-only.md b/.changeset/15206-meta-doors-environment-only.md index e3c08b778c5..198d5fd6110 100644 --- a/.changeset/15206-meta-doors-environment-only.md +++ b/.changeset/15206-meta-doors-environment-only.md @@ -33,6 +33,6 @@ ADR-0131 D6 retires the per-organization overlay axis: environment metadata writ - 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: such an edit reads as reverted to the environment or code definition. Re-save the item in Studio to make the edit live now. The anonymous public-form doors still read the Default Organization's form withdrawals, fail-closed, until that ceremony. +**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 one exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay — body and withdrawal alike, fail-closed — so a legacy organization overlay of a public form keeps being served there, and a Studio re-save (an environment row) does not change what that public form serves. **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 f3bcf452000..a8dc6a041dc 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -106,7 +106,7 @@ later ships. See [ADR-0070](https://github.com/objectstack-ai/objectstack/blob/m 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 until the promotion ceremony (ADR-0131 C7) carries it to the environment layer; re-save the item in Studio to make the edit live now. The table still records which types take an overlay of a shipped item. +> **ADR-0131 D6 — the per-organization overlay axis is retired.** The `/meta` doors (REST and the runtime dispatcher) carry no organization into a metadata write or read: a Studio or `PUT /api/v1/meta/...` write of the five ✅ types below lands **environment-wide** (`organization_id` NULL) and is served to every organization, and an organization admin holding only the retired `manage_org_presentation` capability is refused (`403`). 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 one exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay — body and withdrawal alike — so a legacy organization overlay of a public form keeps being served there, and a Studio re-save (an environment row) does not change what that public form serves. The table still records which types take an overlay of a shipped item. | Type | `allowOrgOverride` | Rationale | | :--- | :---: | :--- | diff --git a/content/docs/kernel/contracts/metadata-service.mdx b/content/docs/kernel/contracts/metadata-service.mdx index 3e129b9a9a2..4d546711772 100644 --- a/content/docs/kernel/contracts/metadata-service.mdx +++ b/content/docs/kernel/contracts/metadata-service.mdx @@ -410,8 +410,13 @@ View customization rides ADR-0005's metadata overlay (opt-in per type via 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 until the promotion -ceremony (ADR-0131 C7) carries it to the environment layer. The per-user, +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 one exception: until that ceremony the anonymous form +doors read a form `view` in the Default Organization and prefer its overlay — +body and withdrawal alike — so a legacy organization overlay of a public form +keeps being served there, and a Studio re-save (an environment row) does not +change what that public form serves. 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. From 82bd7e85a261a556638e4e14de612ebb16dadfc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 11:13:31 +0000 Subject: [PATCH 09/10] docs(S3): scope the migration entry's re-save sentence to the /meta doors and name the public-form exception Claude-Session: https://claude.ai/code/session_01T33XAHecf6UARBkb45mecB Co-authored-by: Claude --- .../semantic/18.meta-doors-organization-scope-retired.ts | 7 +++++-- packages/spec/src/migrations/registry.ts | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) 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 index bb86a3b9cf3..8208250ab9f 100644 --- 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 @@ -41,6 +41,9 @@ export const entry: SemanticMigration = { + '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 now. The anonymous public-form doors still read the Default ' - + 'Organization\'s form withdrawals, fail-closed, until that ceremony.', + + 'Studio to make the edit live on the /meta doors now. Public forms are the one exception: ' + + 'until that ceremony the anonymous form doors read a form view in the Default Organization ' + + 'and prefer its overlay, body and withdrawal alike, fail-closed, so a legacy organization ' + + 'overlay of a public form keeps being served there, and a Studio re-save (an environment ' + + 'row) does not change what that public form serves.', }; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 9ac9e298e85..5f8cbbe7115 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -15972,8 +15972,11 @@ const step18: MigrationStep = { + '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 now. The anonymous public-form doors still read the Default ' - + 'Organization\'s form withdrawals, fail-closed, until that ceremony.', + + 'Studio to make the edit live on the /meta doors now. Public forms are the one exception: ' + + 'until that ceremony the anonymous form doors read a form view in the Default Organization ' + + 'and prefer its overlay, body and withdrawal alike, fail-closed, so a legacy organization ' + + 'overlay of a public form keeps being served there, and a Studio re-save (an environment ' + + 'row) does not change what that public form serves.', }, { id: 'metadata-changed-event-payload-retired', From fa9f7b6483a755ab62b3d227c9c2c6e70c944b1e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 11:57:30 +0000 Subject: [PATCH 10/10] docs(S3): the public-form exception prefers the organization overlay for the body only; a withdrawal in either layer closes the form Claude-Session: https://claude.ai/code/session_01T33XAHecf6UARBkb45mecB Co-authored-by: Claude --- .changeset/15206-meta-doors-environment-only.md | 2 +- content/docs/concepts/metadata-lifecycle.mdx | 2 +- content/docs/kernel/contracts/metadata-service.mdx | 12 +++++++----- .../18.meta-doors-organization-scope-retired.ts | 9 +++++---- packages/spec/src/migrations/registry.ts | 9 +++++---- 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/.changeset/15206-meta-doors-environment-only.md b/.changeset/15206-meta-doors-environment-only.md index 198d5fd6110..2f69ab79e38 100644 --- a/.changeset/15206-meta-doors-environment-only.md +++ b/.changeset/15206-meta-doors-environment-only.md @@ -33,6 +33,6 @@ ADR-0131 D6 retires the per-organization overlay axis: environment metadata writ - 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 one exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay — body and withdrawal alike, fail-closed — so a legacy organization overlay of a public form keeps being served there, and a Studio re-save (an environment row) does not change what that public form serves. +**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 a8dc6a041dc..cdac13d1c9b 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -106,7 +106,7 @@ later ships. See [ADR-0070](https://github.com/objectstack-ai/objectstack/blob/m 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 one exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay — body and withdrawal alike — so a legacy organization overlay of a public form keeps being served there, and a Studio re-save (an environment row) does not change what that public form serves. The table still records which types take an overlay of a shipped item. +> **ADR-0131 D6 — the per-organization overlay axis is retired.** The `/meta` doors (REST and the runtime dispatcher) carry no organization into a metadata write or read: a Studio or `PUT /api/v1/meta/...` write of the five ✅ types below lands **environment-wide** (`organization_id` NULL) and is served to every organization, and an organization admin holding only the retired `manage_org_presentation` capability is refused (`403`). 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 | | :--- | :---: | :--- | diff --git a/content/docs/kernel/contracts/metadata-service.mdx b/content/docs/kernel/contracts/metadata-service.mdx index 4d546711772..9505a0325b0 100644 --- a/content/docs/kernel/contracts/metadata-service.mdx +++ b/content/docs/kernel/contracts/metadata-service.mdx @@ -412,11 +412,13 @@ overlay axis is retired: the doors carry no organization, so the overlay is 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 one exception: until that ceremony the anonymous form -doors read a form `view` in the Default Organization and prefer its overlay — -body and withdrawal alike — so a legacy organization overlay of a public form -keeps being served there, and a Studio re-save (an environment row) does not -change what that public form serves. The per-user, +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. 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 index 8208250ab9f..5fbef6f589a 100644 --- 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 @@ -41,9 +41,10 @@ export const entry: SemanticMigration = { + '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 one exception: ' + + '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, body and withdrawal alike, fail-closed, so a legacy organization ' - + 'overlay of a public form keeps being served there, and a Studio re-save (an environment ' - + 'row) does not change what that public form serves.', + + '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 5f8cbbe7115..ed7eda14f48 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -15972,11 +15972,12 @@ const step18: MigrationStep = { + '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 one exception: ' + + '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, body and withdrawal alike, fail-closed, so a legacy organization ' - + 'overlay of a public form keeps being served there, and a Studio re-save (an environment ' - + 'row) does not change what that public form serves.', + + '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',