diff --git a/.changeset/runtime-meta-write-org-scope.md b/.changeset/runtime-meta-write-org-scope.md new file mode 100644 index 0000000000..b36c3c27ff --- /dev/null +++ b/.changeset/runtime-meta-write-org-scope.md @@ -0,0 +1,46 @@ +--- +"@objectstack/runtime": patch +--- + +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 9927eba50a..94ce23727b 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'; @@ -301,7 +302,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..961b19af9c --- /dev/null +++ b/packages/runtime/src/meta-write-org-scope.test.ts @@ -0,0 +1,462 @@ +// 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 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. + * + * 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) + * ------------------ --------------------------------------------------- + * 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 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'; +import type { HttpDispatcherResult } 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, + // `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) => { + 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; + }, + // [#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 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 = 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 }; + }, + 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, 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. + * + * Both gates are satisfied HERE, explicitly, rather than inherited from + * whatever another suite happens to have registered — that is what makes this + * file order- and shard-independent, which is how the 403 reached CI at all: + * the branch was cut before #7027 merged, so the gate did not exist locally. + */ +const ctx = (): any => ({ + request: { headers: {} }, + environmentId: 'env_1', + executionContext: { userId: 'usr_1', systemPermissions: ['manage_metadata'] }, +}); + +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' }; + +/** + * 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 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. + 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 = responseOf(await dispatcher.handleMetadata(`/flow/${FLOW.name}`, ctx(), 'PUT', FLOW)); + + 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 + // `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 = responseOf(await dispatcher.handleMetadata(`/object/${OBJECT.name}`, ctx(), 'PUT', OBJECT)); + + expect(res.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 = 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.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 = responseOf(await dispatcher.handleMetadata(`/view/${VIEW.name}`, ctx(), '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); + }); + + it('CONTROL — the plural URL spelling of an overridable type is scoped the same way', async () => { + const { engine, dispatcher } = makeStack(ACTIVE_ORG); + + const res = responseOf(await dispatcher.handleMetadata(`/views/${VIEW.name}`, ctx(), 'PUT', VIEW)); + + expect(res.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 = responseOf(await dispatcher.handlePackages('/crm_pkg/publish-drafts', 'POST', {}, {}, ctx())); + + 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'); + 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 = responseOf(await dispatcher.handlePackages('/crm_pkg/publish-drafts', 'POST', {}, {}, ctx())); + + expect(res.body.data.unhiddenApps).toEqual([APP.name]); + const flipComplaints = (error.mock.calls as unknown[][]) + .map((c) => String(c[0])) + .filter((line: string) => 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; +}