From ad11fe5a84776b243d1b64db914203b8f9bfb2f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 10:35:32 +0000 Subject: [PATCH 1/5] fix(runtime): thread the session org into saveMetaItem only for allowOrgOverride types (#7018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #6190 ruling's runtime half (Option A). Both dispatcher write sites used to thread `resolveActiveOrganizationId` into `protocol.saveMetaItem` unconditionally, and `SysMetadataRepository.put` stamps `organization_id` for every type — so a session with an active organization minted org-scoped rows for types cold boot never reads (`loadMetaFromDb` hydrates `organization_id IS NULL` only). Those rows are phantom writes: a `flow` fires until the next restart and then silently stops; an `object` 404s every record. - `domains/meta.ts` PUT: the active org rides the write only when the target type declares `allowOrgOverride: true`; otherwise the write lands env-wide — the same row, and the same receipt, a no-active-org session produces today. - `domains/packages.ts` ADR-0045 §3 visibility flip: `app` is non-overridable, so the flip writes env-wide, on the row boot hydrates. The org-scoped flip was itself a phantom that reverted on restart. The predicate is derived from `DEFAULT_METADATA_TYPE_REGISTRY` (PD #8, no parallel allowlist) and deliberately ignores `OS_METADATA_WRITABLE` — the same call `reportUnhydratableOrgScopedRows` already made on the read side. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2 --- .changeset/runtime-meta-write-org-scope.md | 46 ++ packages/runtime/src/domains/meta.ts | 16 +- packages/runtime/src/domains/packages.ts | 15 +- .../runtime/src/meta-write-org-scope.test.ts | 403 ++++++++++++++++++ packages/runtime/src/meta-write-org-scope.ts | 102 +++++ 5 files changed, 580 insertions(+), 2 deletions(-) create mode 100644 .changeset/runtime-meta-write-org-scope.md create mode 100644 packages/runtime/src/meta-write-org-scope.test.ts create mode 100644 packages/runtime/src/meta-write-org-scope.ts diff --git a/.changeset/runtime-meta-write-org-scope.md b/.changeset/runtime-meta-write-org-scope.md new file mode 100644 index 0000000000..95e3c72903 --- /dev/null +++ b/.changeset/runtime-meta-write-org-scope.md @@ -0,0 +1,46 @@ +--- +"@objectstack/runtime": minor +--- + +fix(runtime): a metadata write carries the session's organization only for types that declare `allowOrgOverride` (#7018) + +The dispatcher threaded the caller's active organization into +`protocol.saveMetaItem` **unconditionally**, and `SysMetadataRepository.put` +stamps `organization_id` for every type. So any session with an active +organization minted an org-scoped `sys_metadata` row even for types the registry +declares NOT per-org overridable — and cold boot (`loadMetaFromDb`) hydrates +`organization_id IS NULL` only. + +Those rows were **phantom writes**: correct for the life of the process, silently +absent after the next restart. The measured specimens are the ones #6190 filed — +a `flow` authored in Studio binds its triggers, fires all day, and stops firing +after a restart with nothing said; an `object` written the same way 404s every +record. For `allowOrgOverride: true` types (`view`, `dashboard`, `report`, +`translation`, `email_template`) the same skip is the ADR-0005 design, because +those overlays are loaded on demand by `getMetaItem`/`getMetaItems`. + +Both runtime write sites now consult the type's registry declaration: + +- `PUT /api/v1/meta/:type/:name` — the active organization rides the write only + when the target type declares `allowOrgOverride: true`. Otherwise it is + dropped and the write lands env-wide, producing exactly the row (and exactly + the receipt) a session with no active organization already produces today. +- `POST /api/v1/packages/:id/publish-drafts` — the ADR-0045 §3 visibility flip + writes `app` (`allowOrgOverride: false`), so it now lands env-wide, on the row + cold boot hydrates and the App Switcher reads. The org-scoped flip was itself a + phantom: the app looked published until the next restart and then went back to + `_unpublished: true`, because the env-wide row it left untouched is the only + one boot loads. + +The predicate is derived from `DEFAULT_METADATA_TYPE_REGISTRY`, so a registry +entry flipping `allowOrgOverride` moves the runtime with it — there is no second +list to keep in sync. It deliberately does **not** consult the +`OS_METADATA_WRITABLE` escape hatch: that hatch unlocks the *write*, and an +env-unlocked type's org rows are hydrated no more than any other's, which is the +same call `reportUnhydratableOrgScopedRows` already made on the read side. + +No authoring change and no new refusal: writes that succeeded still succeed, with +the same response body. What changes is which partition the row lands in for +types that never had a per-org read channel. + +Part of the #6190 maintainer ruling (Option A, runtime half). diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 3c9a9a5de5..b468e8a0dd 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -24,6 +24,7 @@ import { resolveObjectSchemaMaskPosture, type ObjectSchemaMaskPosture, } from '@objectstack/metadata-core'; +import { organizationIdForMetaWrite } from '../meta-write-org-scope.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; @@ -267,7 +268,20 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin if (protocol && typeof protocol.saveMetaItem === 'function') { try { - const organizationId = await deps.resolveActiveOrganizationId(_context); + // [#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 + // `../meta-write-org-scope.js` for why the predicate is the + // static registry flag and not `isOverlayAllowed`. + const activeOrganizationId = await deps.resolveActiveOrganizationId(_context); + const organizationId = organizationIdForMetaWrite(type, activeOrganizationId); const result = await protocol.saveMetaItem({ type, name, item: body, organizationId, ...(packageId ? { packageId } : {}) }); return { handled: true, response: deps.success(result) }; } catch (e: any) { diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 6cdc0ad0d7..e1a2789bf1 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -11,6 +11,7 @@ import { CoreServiceName } from '@objectstack/spec/system'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; +import { organizationIdForMetaWrite } from '../meta-write-org-scope.js'; import { setPackageDisabled } from '../package-state-store.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; @@ -227,7 +228,19 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // that cannot verify or update that consumer — would be a // silent break of the exact kind #4829 is about. The rename // rides the objectui follow-up card, together. + // + // [#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 + // 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: + // true` on the next restart, because the env-wide row it left + // untouched is the only one cold boot loads. The READ above is + // left org-aware on purpose — a layered read is a superset, + // never a loss. const flipped: string[] = []; + const flipOrganizationId = organizationIdForMetaWrite('app', organizationId); try { if ( typeof (protocol as any).getMetaItems === 'function' && @@ -254,7 +267,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // app carries is copied through untouched. item: { ...app, _unpublished: false }, packageId: id, - ...(organizationId ? { organizationId } : {}), + ...(flipOrganizationId ? { organizationId: flipOrganizationId } : {}), ...(body?.actor ? { actor: body.actor } : {}), }); flipped.push(app.name); diff --git a/packages/runtime/src/meta-write-org-scope.test.ts b/packages/runtime/src/meta-write-org-scope.test.ts new file mode 100644 index 0000000000..4fbb2ec107 --- /dev/null +++ b/packages/runtime/src/meta-write-org-scope.test.ts @@ -0,0 +1,403 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #7018 — the runtime threads the session's organization into a metadata WRITE + * only for types the registry declares `allowOrgOverride: true`. + * + * This is the runtime half of the #6190 ruling (2026-08-09, Option A). Before + * it, both dispatcher write sites threaded `resolveActiveOrganizationId` + * unconditionally, and `SysMetadataRepository.put` stamps `organization_id` + * for EVERY type — so any session with an active organization minted an + * org-scoped `sys_metadata` row even for types that have no per-org read + * channel at all. Cold boot (`loadMetaFromDb`) hydrates + * `organization_id IS NULL` only, so those rows are **phantom writes**: live + * for the life of the process, silently absent after the next restart. + * + * ── Why these tests exist even though the runtime suite was already green ── + * + * It was green because nothing in it ever populated + * `session.activeOrganizationId`: with no active org the two branches are + * indistinguishable. Every case below therefore drives a session that HAS one, + * through the real `HttpDispatcher.resolveActiveOrganizationId` (a real + * auth-service `getSession` shape), the real `handleMetadataRequest` / + * `handlePackagesRequest`, the real `ObjectStackProtocolImplementation`, and + * the real `SysMetadataRepository` — and then reads the stored ROW. Anything + * that stubs `saveMetaItem` cannot see this defect, because the defect is + * which partition the row lands in. + * + * ── Reverse verification, direction predicted BEFORE running ─────────────── + * + * Ordinary red, with a deliberately green control. Restoring the unconditional + * threading (drop `organizationIdForMetaWrite` at both call sites and pass the + * active org straight through) must turn the env-wide pins RED — the `flow` + * row, the `object` row and the ADR-0045 `app` flip all come back + * `organization_id: 'org_alpha'`, and the post-flip visibility read goes back + * to reporting the app as unpublished. The `view` case must stay GREEN, and it + * is the reason it is here: a "fix" that simply stopped threading the org + * anywhere would pass every red case and fail there, silently retiring + * ADR-0005 per-org overlays. Predicted 5 red / 3 green. + * + * Measured (recorded in the PR body): 5 red / 3 green, and the reds fail in the + * shape that names the defect — + * + * AssertionError: expected 'org_alpha' to be null + * + * — the phantom row of #6190, reproduced on demand. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; +import { HttpDispatcher } from './http-dispatcher.js'; +import { declaresOrgOverride, organizationIdForMetaWrite } from './meta-write-org-scope.js'; + +const ACTIVE_ORG = 'org_alpha'; + +// --------------------------------------------------------------------------- +// Harness — a `sys_metadata`-shaped store the real repository writes into. +// --------------------------------------------------------------------------- + +interface Row { + id: string; + [k: string]: unknown; +} + +/** Match one row against a `where` clause, honouring the operators these paths lower. */ +function matches(row: Row, where: Record | undefined): boolean { + if (!where) return true; + for (const [key, cond] of Object.entries(where)) { + if (cond === undefined) continue; + if (key === '$or') { + const branches = cond as Array>; + if (!branches.some((b) => matches(row, b))) return false; + continue; + } + const value = row[key]; + if (cond !== null && typeof cond === 'object') { + const op = cond as Record; + if ('$null' in op) { + const isNull = value === null || value === undefined; + if (isNull !== (op.$null === true)) return false; + continue; + } + if ('$in' in op) { + if (!(op.$in as unknown[]).includes(value)) return false; + continue; + } + // Any other operator clause is not exercised by these paths. + continue; + } + if (cond === null) { + if (value !== null && value !== undefined) return false; + continue; + } + if (value !== cond) return false; + } + return true; +} + +function makeEngine() { + const tables = new Map(); + let nextId = 0; + const tableOf = (name: string) => { + let t = tables.get(name); + if (!t) { t = []; tables.set(name, t); } + return t; + }; + const registryItems = new Map>(); + const engine: any = { + registry: { + listItems: (type: string) => Array.from(registryItems.get(type)?.values() ?? []), + getItem: (type: string, name: string) => registryItems.get(type)?.get(name), + // Nothing here is code-shipped: every specimen below is a + // runtime-authored item, which is the tier the tenant scenario in + // #6190 actually uses (`allowRuntimeCreate: true`). + getArtifactItem: () => undefined, + getObject: () => undefined, + getPackage: () => undefined, + registerItem: (type: string, name: string, item: unknown) => { + let byName = registryItems.get(type); + if (!byName) { byName = new Map(); registryItems.set(type, byName); } + byName.set(name, item); + }, + registerObject: () => {}, + }, + async find(table: string, opts?: { where?: Record }) { + return tableOf(table).filter((r) => matches(r, opts?.where)); + }, + async findOne(table: string, opts?: { where?: Record }) { + return tableOf(table).find((r) => matches(r, opts?.where)) ?? null; + }, + async insert(table: string, data: Record) { + nextId += 1; + const row: Row = { id: (data.id as string) ?? `r_${nextId}`, ...data }; + tableOf(table).push(row); + return row; + }, + async update(table: string, data: Record, opts?: { where?: Record }) { + const target = tableOf(table).find((r) => matches(r, opts?.where)); + if (target) Object.assign(target, data); + return target ?? null; + }, + async delete(table: string, opts?: { where?: Record }) { + const rows = tableOf(table); + const keep = rows.filter((r) => !matches(r, opts?.where)); + const deleted = rows.length - keep.length; + tables.set(table, keep); + return { deleted }; + }, + async count(table: string, opts?: { where?: Record }) { + return tableOf(table).filter((r) => matches(r, opts?.where)).length; + }, + async aggregate() { return []; }, + async execute() { return undefined; }, + metaRows: () => tableOf('sys_metadata'), + }; + return engine; +} + +/** + * A dispatcher whose auth service answers a session with an ACTIVE + * ORGANIZATION — the population the whole defect keys off, and the one the + * pre-existing runtime dispatcher tests never produced. + */ +function makeDispatcher(protocol: unknown, engine: any, activeOrganizationId: string | undefined) { + const services: Record = { + protocol, + objectql: { registry: engine.registry }, + auth: { + api: { + getSession: async () => ( + activeOrganizationId ? { session: { activeOrganizationId } } : { session: {} } + ), + }, + }, + }; + const kernel = { + getServiceAsync: async (name: string) => services[name] ?? null, + getService: (name: string) => services[name] ?? null, + context: { getService: (name: string) => services[name] ?? null }, + } as any; + return new HttpDispatcher(kernel); +} + +/** An authenticated request context — the anonymous-deny gate (#3963) is unconditional. */ +const ctx = (): any => ({ + request: { headers: {} }, + environmentId: 'env_1', + executionContext: { userId: 'usr_1', systemPermissions: [] }, +}); + +function makeStack(activeOrganizationId: string | undefined) { + const engine = makeEngine(); + // `environmentId` set: an environment kernel, the topology ADR-0005's + // overlay gate actually runs on. + const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_1'); + return { engine, protocol, dispatcher: makeDispatcher(protocol, engine, activeOrganizationId) }; +} + +const metaRow = (engine: any, type: string, name: string) => + engine.metaRows().find((r: any) => r.type === type && r.name === name && r.state === 'active'); + +// --------------------------------------------------------------------------- +// Specimens — schema-VALID bodies. A minimal one 422s before the scoping +// decision is ever reached, which would pass these tests for the wrong reason. +// --------------------------------------------------------------------------- + +/** `allowOrgOverride: false`, `allowRuntimeCreate: true` — the #6190 specimen. */ +const FLOW = { + name: 'escalate_overdue', + label: 'Escalate overdue tasks', + type: 'record_change', + status: 'active', + nodes: [ + { + id: 'start', + type: 'start', + label: 'Start', + config: { objectName: 'task', triggerType: 'record-after-update' }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], +}; + +/** `allowOrgOverride: true` — the control. Its org scoping must NOT change. */ +const VIEW = { + name: 'overdue_grid', + label: 'Overdue', + object: 'task', + columns: [{ field: 'name', label: 'Name' }], +}; + +/** `allowOrgOverride: false` — the ADR-0045 publish-visibility specimen. */ +const APP = { name: 'crm', label: 'CRM' }; + +describe('#7018 — the registry decides whether a metadata write carries the session org', () => { + let warn: ReturnType; + let error: ReturnType; + + beforeEach(() => { + // The protocol logs degradation lines on these paths; they are not the + // subject and must not drown the run. `error` is spied rather than + // silenced-and-forgotten — the ADR-0045 flip reports its own failure + // there (#4754), and the last case reads it back. + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + error = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + // ── the predicate itself ────────────────────────────────────────────── + + it('is derived from the registry, not a parallel allowlist (PD #8)', () => { + // Deliberately recomputed from `DEFAULT_METADATA_TYPE_REGISTRY` rather + // than spelled out: a hand-written list here would agree with a + // hand-written list there and pin nothing. Flipping any registry entry + // moves both sides of this assertion together. + for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { + expect(declaresOrgOverride(entry.type)).toBe(entry.allowOrgOverride); + } + // Plural URL spellings are judged identically (`/meta/views/...`). + expect(declaresOrgOverride('views')).toBe(declaresOrgOverride('view')); + expect(declaresOrgOverride('flows')).toBe(declaresOrgOverride('flow')); + // A runtime-registered type with no registry entry has no per-org read + // channel either, so it is env-wide too. + expect(declaresOrgOverride('theme')).toBe(false); + // No active org in, no org out — for every type. + expect(organizationIdForMetaWrite('view', undefined)).toBeUndefined(); + }); + + // ── PUT /meta/:type/:name — the dispatcher's metadata write ─────────── + + it('a NON-overridable type lands env-wide even though the session has an active org', async () => { + const { engine, dispatcher } = makeStack(ACTIVE_ORG); + + const res = await dispatcher.handleMetadata(`/flow/${FLOW.name}`, ctx(), 'PUT', FLOW); + + expect(res.response.status).toBe(200); + const row = metaRow(engine, 'flow', FLOW.name); + expect(row).toBeDefined(); + // THE assertion. Before #7018 this was `'org_alpha'` — a row + // `loadMetaFromDb` walks past and the `kernel:ready` flow binder + // (`getMetaItems({type:'flow'})`, no org) never sees, so the automation + // fires until the next restart and then silently stops. + expect(row!.organization_id).toBeNull(); + }); + + it('the same is true for `object`, whose org-scoped rows 404 every record after a restart', async () => { + const { engine, dispatcher } = makeStack(ACTIVE_ORG); + const OBJECT = { + name: 'ticket', + label: 'Ticket', + fields: { subject: { type: 'text', label: 'Subject' } }, + }; + + const res = await dispatcher.handleMetadata(`/object/${OBJECT.name}`, ctx(), 'PUT', OBJECT); + + expect(res.response.status).toBe(200); + expect(metaRow(engine, 'object', OBJECT.name)!.organization_id).toBeNull(); + }); + + it('the receipt an org session gets back is the one a no-org session gets', async () => { + // "Otherwise the write lands env-wide — the same row a no-active-org + // session already produces today" (the #6190 ruling). Same row AND same + // receipt: the caller cannot tell the two sessions apart, which is what + // makes this a scoping fix rather than a new refusal. The row assertion + // above is the load-bearing one; this pins that nothing else moved. + const withOrg = makeStack(ACTIVE_ORG); + const withoutOrg = makeStack(undefined); + + const a = await withOrg.dispatcher.handleMetadata(`/flow/${FLOW.name}`, ctx(), 'PUT', FLOW); + const b = await withoutOrg.dispatcher.handleMetadata(`/flow/${FLOW.name}`, ctx(), 'PUT', FLOW); + + expect(a.response.status).toBe(200); + expect(a.response.body.data).toEqual(b.response.body.data); + expect(a.response.body.data).toMatchObject({ success: true, state: 'active' }); + }); + + it('CONTROL — an `allowOrgOverride: true` type keeps its org scoping exactly as before', async () => { + const { engine, dispatcher } = makeStack(ACTIVE_ORG); + + const res = await dispatcher.handleMetadata(`/view/${VIEW.name}`, ctx(), 'PUT', VIEW); + + expect(res.response.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); + }); + + it('CONTROL — the plural URL spelling of an overridable type is scoped the same way', async () => { + const { engine, dispatcher } = makeStack(ACTIVE_ORG); + + const res = await dispatcher.handleMetadata(`/views/${VIEW.name}`, ctx(), 'PUT', VIEW); + + expect(res.response.status).toBe(200); + expect(metaRow(engine, 'view', VIEW.name)!.organization_id).toBe(ACTIVE_ORG); + }); + + // ── POST /packages/:id/publish-drafts — the ADR-0045 visibility flip ── + + it('the ADR-0045 publish flip writes env-wide, and the app is visible afterwards', async () => { + const { engine, protocol, dispatcher } = makeStack(ACTIVE_ORG); + + // The starting state a materialized (additive) build leaves behind: the + // app persisted env-wide, gated `_unpublished: true`, awaiting the flip. + await protocol.saveMetaItem({ + type: 'app', + name: APP.name, + item: { ...APP, _unpublished: true }, + packageId: 'crm_pkg', + }); + expect(metaRow(engine, 'app', APP.name)!.organization_id).toBeNull(); + + // `publishPackageDrafts` is stubbed to "nothing to promote" — the + // materialized regime has no drafts left, which is exactly the branch + // ADR-0045 §3's flip exists to serve. Everything the flip itself + // touches (`getMetaItems`, `saveMetaItem`) stays REAL. + protocol.publishPackageDrafts = async () => ({ + success: true, publishedCount: 0, failedCount: 0, published: [], failed: [], + }); + + const res = await dispatcher.handlePackages('/crm_pkg/publish-drafts', 'POST', {}, {}, ctx()); + + expect(res.response.status).toBe(200); + expect(res.response.body.data.unhiddenApps).toEqual([APP.name]); + expect(res.response.body.data.unhideError).toBeUndefined(); + + // One row, still env-wide — not a second, org-scoped row shadowing it. + const appRows = engine.metaRows().filter((r: any) => r.type === 'app' && r.state === 'active'); + expect(appRows).toHaveLength(1); + expect(appRows[0].organization_id).toBeNull(); + + // And the flip is real where it counts: the env-wide read — the one + // cold boot and the App Switcher do — now sees a published app. An + // org-scoped flip left THIS read reporting `_unpublished: true`, which + // is why the old flip reverted on restart. + const listed = await protocol.getMetaItems({ type: 'app' }); + const served: any = (listed.items as any[]).find((i: any) => i?.name === APP.name); + expect(served).toBeDefined(); + expect(served._unpublished).toBe(false); + }); + + it('the flip reports NO degradation — it is a clean write, not a warn-and-continue', async () => { + // Every case above mutes `console.warn`, so a regression that degraded + // into "flip failed, carry on" would otherwise read as a clean pass. + // The route answers 200 either way (#4754), so the log line is the only + // place that failure is visible. + const { protocol, dispatcher } = makeStack(ACTIVE_ORG); + await protocol.saveMetaItem({ + type: 'app', name: APP.name, item: { ...APP, _unpublished: true }, packageId: 'crm_pkg', + }); + protocol.publishPackageDrafts = async () => ({ + success: true, publishedCount: 0, failedCount: 0, published: [], failed: [], + }); + error.mockClear(); + + const res = await dispatcher.handlePackages('/crm_pkg/publish-drafts', 'POST', {}, {}, ctx()); + + expect(res.response.body.data.unhiddenApps).toEqual([APP.name]); + const flipComplaints = error.mock.calls + .map((c) => String(c[0])) + .filter((line) => line.includes('visibility flip')); + expect(flipComplaints).toEqual([]); + }); +}); diff --git a/packages/runtime/src/meta-write-org-scope.ts b/packages/runtime/src/meta-write-org-scope.ts new file mode 100644 index 0000000000..04ca977abc --- /dev/null +++ b/packages/runtime/src/meta-write-org-scope.ts @@ -0,0 +1,102 @@ +// 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. + * + * ⛔ 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. + */ + +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; +import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; + +/** + * Metadata types whose registry entry declares `allowOrgOverride: true`, + * augmented with each one's plural spelling so a REST-conventional URL + * (`/api/v1/meta/views/...`) is judged identically to the singular form — + * the same normalization the protocol's own allow-list does. + */ +const ORG_OVERRIDABLE_TYPES: ReadonlySet = (() => { + const out = new Set(); + for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { + if (!entry.allowOrgOverride) continue; + out.add(entry.type); + const plural = SINGULAR_TO_PLURAL[entry.type]; + if (plural) out.add(plural); + } + return out; +})(); + +/** + * Does the registry declare this metadata type per-org overridable? + * + * Accepts either spelling of the type (`view` / `views`). A type with no + * registry entry at all — runtime-registered plugin types — answers `false`: + * boot hydration has no per-org channel for them either, so an org-scoped row + * would be the same phantom. + */ +export function declaresOrgOverride(type: string): boolean { + const singular = PLURAL_TO_SINGULAR[type] ?? type; + return ORG_OVERRIDABLE_TYPES.has(singular) || ORG_OVERRIDABLE_TYPES.has(type); +} + +/** + * 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; +} From 1a01e8d44ca2e5708de8eaadc0d3e81c1b77c1dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 12:40:17 +0000 Subject: [PATCH 2/5] test(runtime): pin the new engine double to the producer's write-verb dispatch (#7018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:engine-double-contract` flagged the fake engine in `meta-write-org-scope.test.ts`: its `update()`/`delete()` accepted call shapes the real ObjectQL engine refuses. Both verbs now open with `assertEngineUpdateDispatch` / `assertEngineDeleteDispatch` from `@objectstack/metadata-core` (never `@objectstack/objectql` — that reverse edge is a cycle turbo refuses), and route by-id dispatches through the bound id. Also records the MEASURED reverse-verification direction in the file header (4 red / 4 green, not the 5/3 first predicted) and adds the two registry methods `getMetaItems` calls on the app-listing path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2 --- .../runtime/src/meta-write-org-scope.test.ts | 59 ++++++++++++++----- 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/packages/runtime/src/meta-write-org-scope.test.ts b/packages/runtime/src/meta-write-org-scope.test.ts index 4fbb2ec107..0abd84f682 100644 --- a/packages/runtime/src/meta-write-org-scope.test.ts +++ b/packages/runtime/src/meta-write-org-scope.test.ts @@ -27,25 +27,40 @@ * * ── Reverse verification, direction predicted BEFORE running ─────────────── * - * Ordinary red, with a deliberately green control. Restoring the unconditional - * threading (drop `organizationIdForMetaWrite` at both call sites and pass the - * active org straight through) must turn the env-wide pins RED — the `flow` - * row, the `object` row and the ADR-0045 `app` flip all come back - * `organization_id: 'org_alpha'`, and the post-flip visibility read goes back - * to reporting the app as unpublished. The `view` case must stay GREEN, and it - * is the reason it is here: a "fix" that simply stopped threading the org - * anywhere would pass every red case and fail there, silently retiring - * ADR-0005 per-org overlays. Predicted 5 red / 3 green. + * Ordinary red, with deliberately green controls. Taking the fix back out + * (`git checkout origin/main -- src/domains/meta.ts src/domains/packages.ts`, + * restoring the unconditional threading) must turn the env-wide pins RED and + * leave the `view` controls GREEN — the latter is the reason they are here: a + * "fix" that simply stopped threading the org anywhere would pass every red + * case and fail there, silently retiring ADR-0005 per-org overlays. * - * Measured (recorded in the PR body): 5 red / 3 green, and the reds fail in the - * shape that names the defect — + * Predicted 4 red / 4 green; measured 4 red / 4 green, against the real stack: * - * AssertionError: expected 'org_alpha' to be null + * with the fix without it (origin/main) + * ------------------ --------------------------------------------------- + * flow org = null org = "org_alpha" → RED + * object org = null org = "org_alpha" → RED + * receipt identical "(org=org_alpha, …)" vs "(env-wide, …)" → RED + * app 1 row, null TWO rows: env-wide `_unpublished:true` PLUS + * org-scoped `_unpublished:false`, and the + * env-wide list still answers `_unpublished: + * true` — the flip that reverts on restart → RED + * view org = "org_alpha" unchanged → GREEN + * views org = "org_alpha" unchanged → GREEN + * predicate (registry-derived) unchanged → GREEN + * flip logs no failure unchanged → GREEN * - * — the phantom row of #6190, reproduced on demand. + * The last green is NOT slack, and it is why the count is 4/4 rather than the + * 5/3 this file first predicted: that case asserts an ABSENCE of a degradation + * line, and the unfixed code satisfies it too — its flip succeeds, it just + * succeeds into the wrong partition. It guards the opposite regression (a + * future change that degrades the flip into warn-and-continue, which this route + * answers 200 through), so it is kept and its greenness stated rather than + * dressed up as a red. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { HttpDispatcher } from './http-dispatcher.js'; @@ -115,6 +130,8 @@ function makeEngine() { getArtifactItem: () => undefined, getObject: () => undefined, getPackage: () => undefined, + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, registerItem: (type: string, name: string, item: unknown) => { let byName = registryItems.get(type); if (!byName) { byName = new Map(); registryItems.set(type, byName); } @@ -134,14 +151,26 @@ function makeEngine() { tableOf(table).push(row); return row; }, + // [#5619] Both write verbs open with the PRODUCER's own dispatch + // predicate, so this double cannot accept a call the real ObjectQL + // engine would refuse (`check:engine-double-contract`). Imported from + // `@objectstack/metadata-core`, never `@objectstack/objectql` — that + // reverse edge is a cycle turbo refuses. async update(table: string, data: Record, opts?: { where?: Record }) { - const target = tableOf(table).find((r) => matches(r, opts?.where)); + const dispatch = assertEngineUpdateDispatch(data as any, opts as any); + const rows = tableOf(table); + const target = dispatch.kind === 'by-id' + ? rows.find((r) => r.id === dispatch.id) + : rows.find((r) => matches(r, opts?.where)); if (target) Object.assign(target, data); return target ?? null; }, async delete(table: string, opts?: { where?: Record }) { + const dispatch = assertEngineDeleteDispatch(opts as any); const rows = tableOf(table); - const keep = rows.filter((r) => !matches(r, opts?.where)); + const keep = dispatch.kind === 'by-id' + ? rows.filter((r) => r.id !== dispatch.id) + : rows.filter((r) => !matches(r, opts?.where)); const deleted = rows.length - keep.length; tables.set(table, keep); return { deleted }; From 7b9c8f25fc84676717b4062ef1aa3e7d41fdb347 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 12:47:19 +0000 Subject: [PATCH 3/5] test(runtime): authorize the org-scope harness for the #7043 gate; changeset to patch (#7018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased-in-place adjustments for the r2 takeover branch, on top of the prior session's ad11fe5a + 1a01e8d4: - ctx() now grants manage_metadata: the dispatcher's /meta PUT gate (#7019, landed on main after the prior branch forked) 403s an unauthorized caller before the org-scoping decision these tests pin is ever reached. - registry stub carries isPackageDisabled + applyNavContributions — the two methods getMetaItems grew on main (disabled-package filter, ADR-0029 D7 nav merge); without them the ADR-0045 flip cases fail on a TypeError inside the flip's try, not on the partition assertion. - reverse verification re-measured on the merged #7043 base: same 4 red / 4 green, same failure shapes (header updated in place). - changeset level minor -> patch: behavioural fix, no new API surface. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LGRN2cSRfggfX9B2L83bQc --- .changeset/runtime-meta-write-org-scope.md | 2 +- .../runtime/src/meta-write-org-scope.test.ts | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.changeset/runtime-meta-write-org-scope.md b/.changeset/runtime-meta-write-org-scope.md index 95e3c72903..b36c3c27ff 100644 --- a/.changeset/runtime-meta-write-org-scope.md +++ b/.changeset/runtime-meta-write-org-scope.md @@ -1,5 +1,5 @@ --- -"@objectstack/runtime": minor +"@objectstack/runtime": patch --- fix(runtime): a metadata write carries the session's organization only for types that declare `allowOrgOverride` (#7018) diff --git a/packages/runtime/src/meta-write-org-scope.test.ts b/packages/runtime/src/meta-write-org-scope.test.ts index 0abd84f682..410d6ce006 100644 --- a/packages/runtime/src/meta-write-org-scope.test.ts +++ b/packages/runtime/src/meta-write-org-scope.test.ts @@ -34,7 +34,8 @@ * "fix" that simply stopped threading the org anywhere would pass every red * case and fail there, silently retiring ADR-0005 per-org overlays. * - * Predicted 4 red / 4 green; measured 4 red / 4 green, against the real stack: + * Predicted 4 red / 4 green; measured 4 red / 4 green, against the real stack + * (re-measured 2026-08-09 on the merged #7043 base — same 4/4, same shapes): * * with the fix without it (origin/main) * ------------------ --------------------------------------------------- @@ -130,6 +131,10 @@ function makeEngine() { getArtifactItem: () => undefined, getObject: () => undefined, getPackage: () => undefined, + // `getMetaItems` filters every listed item through the disabled- + // package gate and, for apps, merges nav contributions (ADR-0029 + // D7) — the same stubs every metadata-protocol harness carries. + // No package is disabled and nothing contributes nav here. isPackageDisabled: () => false, applyNavContributions: (app: unknown) => app, registerItem: (type: string, name: string, item: unknown) => { @@ -210,11 +215,18 @@ function makeDispatcher(protocol: unknown, engine: any, activeOrganizationId: st return new HttpDispatcher(kernel); } -/** An authenticated request context — the anonymous-deny gate (#3963) is unconditional. */ +/** + * An authenticated request context — the anonymous-deny gate (#3963) is + * unconditional, and since #7019 the dispatcher's `/meta` PUT also requires + * the `manage_metadata` capability (ADR-0066 D1). These tests are about which + * PARTITION an authorized write lands in, so the caller is authorized: without + * the capability every PUT 403s before the scoping decision is ever reached, + * and each case would pass for the wrong reason. + */ const ctx = (): any => ({ request: { headers: {} }, environmentId: 'env_1', - executionContext: { userId: 'usr_1', systemPermissions: [] }, + executionContext: { userId: 'usr_1', systemPermissions: ['manage_metadata'] }, }); function makeStack(activeOrganizationId: string | undefined) { From 63722c9871f57d09d506ab3c644b6421674bb9f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 12:51:49 +0000 Subject: [PATCH 4/5] test(runtime): grant the ADR-0066 D1 authoring capability in the org-scope fixture (#7018) `main` moved under this branch: #6603 / PR #7027 put a `manage_metadata` gate in front of `PUT /meta/:type/:name`, ahead of everything this file measures. The fixture's session carried `systemPermissions: []`, so on the PR's merge ref the door answered 403 and the scoping decision was never reached. The capability is now granted explicitly rather than inherited from whatever another suite registered, so the file is order- and shard-independent. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2 --- packages/runtime/src/meta-write-org-scope.test.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/meta-write-org-scope.test.ts b/packages/runtime/src/meta-write-org-scope.test.ts index 0abd84f682..305e313c3a 100644 --- a/packages/runtime/src/meta-write-org-scope.test.ts +++ b/packages/runtime/src/meta-write-org-scope.test.ts @@ -210,11 +210,22 @@ function makeDispatcher(protocol: unknown, engine: any, activeOrganizationId: st return new HttpDispatcher(kernel); } -/** An authenticated request context — the anonymous-deny gate (#3963) is unconditional. */ +/** + * An authenticated, metadata-authoring request context. + * + * Two gates run before any of the code under test here, and BOTH are granted + * explicitly so this file is order- and shard-independent — it must never + * depend on a capability another suite happens to have registered: + * + * - the anonymous-deny gate (#3963), unconditional ⇒ a real `userId`; + * - the ADR-0066 D1 authoring capability on `PUT /meta/:type/:name` + * (#6603 / PR #7027) ⇒ `manage_metadata`, or the door answers 403 and + * never reaches the scoping decision this file is about. + */ const ctx = (): any => ({ request: { headers: {} }, environmentId: 'env_1', - executionContext: { userId: 'usr_1', systemPermissions: [] }, + executionContext: { userId: 'usr_1', systemPermissions: ['manage_metadata'] }, }); function makeStack(activeOrganizationId: string | undefined) { From a308aaaa5da96ad980404a85a641c2b03e475314 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 13:08:15 +0000 Subject: [PATCH 5/5] test(runtime): the org-scope harness adds zero errors to the TEST_DEBT ledger (#7018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:type-check-debt` went red on the merged base: `@objectstack/runtime`'s TEST_DEBT is a shrink-only ratchet (#5278) recording 227 raw errors, and the new file pushed the measured count to 240. The package's own `tsc --noEmit` never saw them — `tsconfig.json` excludes `*.test.ts`, which is the hidden layer that ledger exists to measure. Fixed rather than ledgered, since none of the 15 were irreducible: - `HttpDispatcherResult.response` is optional (a declining route answers `{ handled: false }`), so every `res.response.status` was a TS18048. One `responseOf()` helper says once, loudly, that these routes must answer, and hands back a narrowed response — instead of a narrowing dance at each of the 13 call sites. - the muting `console.warn` spy was bound to an unread variable (TS6133); only the `console.error` spy is read back, so only that one is bound now. - the `error.mock.calls` reducers carried implicit `any` parameters (TS7006). Measured with the ledger's own method — a sibling tsconfig that drops the test exclusion — over this file: 15 errors before, 0 after. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2 --- .../runtime/src/meta-write-org-scope.test.ts | 59 +++++++++++-------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/packages/runtime/src/meta-write-org-scope.test.ts b/packages/runtime/src/meta-write-org-scope.test.ts index 7b8e6fa1e1..961b19af9c 100644 --- a/packages/runtime/src/meta-write-org-scope.test.ts +++ b/packages/runtime/src/meta-write-org-scope.test.ts @@ -65,6 +65,7 @@ import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objects import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { HttpDispatcher } from './http-dispatcher.js'; +import type { HttpDispatcherResult } from './http-dispatcher.js'; import { declaresOrgOverride, organizationIdForMetaWrite } from './meta-write-org-scope.js'; const ACTIVE_ORG = 'org_alpha'; @@ -279,8 +280,20 @@ const VIEW = { /** `allowOrgOverride: false` — the ADR-0045 publish-visibility specimen. */ const APP = { name: 'crm', label: 'CRM' }; +/** + * The dispatcher's `response` is optional on `HttpDispatcherResult` — a route + * that declines answers `{ handled: false }`. Every case here drives a route + * that MUST answer, so an absent response is a failure of the harness rather + * than a value to narrow around at each call site: this says so once, loudly, + * and hands back a response the assertions can read. + */ +function responseOf(result: HttpDispatcherResult): NonNullable { + const response = result.response; + if (!response) throw new Error('the dispatcher handled the route but returned no response'); + return response; +} + describe('#7018 — the registry decides whether a metadata write carries the session org', () => { - let warn: ReturnType; let error: ReturnType; beforeEach(() => { @@ -288,7 +301,7 @@ describe('#7018 — the registry decides whether a metadata write carries the se // subject and must not drown the run. `error` is spied rather than // silenced-and-forgotten — the ADR-0045 flip reports its own failure // there (#4754), and the last case reads it back. - warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); error = vi.spyOn(console, 'error').mockImplementation(() => {}); }); @@ -317,9 +330,9 @@ describe('#7018 — the registry decides whether a metadata write carries the se it('a NON-overridable type lands env-wide even though the session has an active org', async () => { const { engine, dispatcher } = makeStack(ACTIVE_ORG); - const res = await dispatcher.handleMetadata(`/flow/${FLOW.name}`, ctx(), 'PUT', FLOW); + const res = responseOf(await dispatcher.handleMetadata(`/flow/${FLOW.name}`, ctx(), 'PUT', FLOW)); - expect(res.response.status).toBe(200); + expect(res.status).toBe(200); const row = metaRow(engine, 'flow', FLOW.name); expect(row).toBeDefined(); // THE assertion. Before #7018 this was `'org_alpha'` — a row @@ -337,9 +350,9 @@ describe('#7018 — the registry decides whether a metadata write carries the se fields: { subject: { type: 'text', label: 'Subject' } }, }; - const res = await dispatcher.handleMetadata(`/object/${OBJECT.name}`, ctx(), 'PUT', OBJECT); + const res = responseOf(await dispatcher.handleMetadata(`/object/${OBJECT.name}`, ctx(), 'PUT', OBJECT)); - expect(res.response.status).toBe(200); + expect(res.status).toBe(200); expect(metaRow(engine, 'object', OBJECT.name)!.organization_id).toBeNull(); }); @@ -352,20 +365,20 @@ describe('#7018 — the registry decides whether a metadata write carries the se const withOrg = makeStack(ACTIVE_ORG); const withoutOrg = makeStack(undefined); - const a = await withOrg.dispatcher.handleMetadata(`/flow/${FLOW.name}`, ctx(), 'PUT', FLOW); - const b = await withoutOrg.dispatcher.handleMetadata(`/flow/${FLOW.name}`, ctx(), 'PUT', FLOW); + const a = responseOf(await withOrg.dispatcher.handleMetadata(`/flow/${FLOW.name}`, ctx(), 'PUT', FLOW)); + const b = responseOf(await withoutOrg.dispatcher.handleMetadata(`/flow/${FLOW.name}`, ctx(), 'PUT', FLOW)); - expect(a.response.status).toBe(200); - expect(a.response.body.data).toEqual(b.response.body.data); - expect(a.response.body.data).toMatchObject({ success: true, state: 'active' }); + expect(a.status).toBe(200); + expect(a.body.data).toEqual(b.body.data); + expect(a.body.data).toMatchObject({ success: true, state: 'active' }); }); it('CONTROL — an `allowOrgOverride: true` type keeps its org scoping exactly as before', async () => { const { engine, dispatcher } = makeStack(ACTIVE_ORG); - const res = await dispatcher.handleMetadata(`/view/${VIEW.name}`, ctx(), 'PUT', VIEW); + const res = responseOf(await dispatcher.handleMetadata(`/view/${VIEW.name}`, ctx(), 'PUT', VIEW)); - expect(res.response.status).toBe(200); + 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); @@ -374,9 +387,9 @@ describe('#7018 — the registry decides whether a metadata write carries the se it('CONTROL — the plural URL spelling of an overridable type is scoped the same way', async () => { const { engine, dispatcher } = makeStack(ACTIVE_ORG); - const res = await dispatcher.handleMetadata(`/views/${VIEW.name}`, ctx(), 'PUT', VIEW); + const res = responseOf(await dispatcher.handleMetadata(`/views/${VIEW.name}`, ctx(), 'PUT', VIEW)); - expect(res.response.status).toBe(200); + expect(res.status).toBe(200); expect(metaRow(engine, 'view', VIEW.name)!.organization_id).toBe(ACTIVE_ORG); }); @@ -403,11 +416,11 @@ describe('#7018 — the registry decides whether a metadata write carries the se success: true, publishedCount: 0, failedCount: 0, published: [], failed: [], }); - const res = await dispatcher.handlePackages('/crm_pkg/publish-drafts', 'POST', {}, {}, ctx()); + const res = responseOf(await dispatcher.handlePackages('/crm_pkg/publish-drafts', 'POST', {}, {}, ctx())); - expect(res.response.status).toBe(200); - expect(res.response.body.data.unhiddenApps).toEqual([APP.name]); - expect(res.response.body.data.unhideError).toBeUndefined(); + expect(res.status).toBe(200); + expect(res.body.data.unhiddenApps).toEqual([APP.name]); + expect(res.body.data.unhideError).toBeUndefined(); // One row, still env-wide — not a second, org-scoped row shadowing it. const appRows = engine.metaRows().filter((r: any) => r.type === 'app' && r.state === 'active'); @@ -438,12 +451,12 @@ describe('#7018 — the registry decides whether a metadata write carries the se }); error.mockClear(); - const res = await dispatcher.handlePackages('/crm_pkg/publish-drafts', 'POST', {}, {}, ctx()); + const res = responseOf(await dispatcher.handlePackages('/crm_pkg/publish-drafts', 'POST', {}, {}, ctx())); - expect(res.response.body.data.unhiddenApps).toEqual([APP.name]); - const flipComplaints = error.mock.calls + expect(res.body.data.unhiddenApps).toEqual([APP.name]); + const flipComplaints = (error.mock.calls as unknown[][]) .map((c) => String(c[0])) - .filter((line) => line.includes('visibility flip')); + .filter((line: string) => line.includes('visibility flip')); expect(flipComplaints).toEqual([]); }); });