From 6145f439b87cb47776f2e3ebd41f09402fb286f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 06:49:09 +0000 Subject: [PATCH 1/4] fix(metadata-protocol): refuse org-scoped writes of non-org-overridable types (#6190) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01W6bLax4KMrSfnE1ydFU8Dw --- .changeset/org-scoped-write-refused.md | 49 ++ .../protocol-publish-drafts-org-scope.test.ts | 26 +- ...ocol.adr0005-org-override-rollback.test.ts | 55 +- .../protocol.capability-write-door.test.ts | 11 +- .../src/protocol.code-only-types.test.ts | 13 +- .../protocol.flow-org-override-closed.test.ts | 42 +- .../protocol.org-scoped-write-refused.test.ts | 500 ++++++++++++++++++ ...rotocol.platform-schedule-org-gate.test.ts | 33 +- .../src/protocol.save-receipt-wording.test.ts | 13 +- .../src/protocol.stored-migration.test.ts | 33 +- packages/metadata-protocol/src/protocol.ts | 143 +++++ 11 files changed, 887 insertions(+), 31 deletions(-) create mode 100644 .changeset/org-scoped-write-refused.md create mode 100644 packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts diff --git a/.changeset/org-scoped-write-refused.md b/.changeset/org-scoped-write-refused.md new file mode 100644 index 0000000000..3d9667a1b3 --- /dev/null +++ b/.changeset/org-scoped-write-refused.md @@ -0,0 +1,49 @@ +--- +"@objectstack/metadata-protocol": minor +--- + +fix(metadata-protocol): refuse an org-scoped write of a type that has no per-org channel (#6190) + +`allowOrgOverride` and `allowRuntimeCreate` are orthogonal tiers, and the +runtime-create tier never consulted the ORG dimension: +`SysMetadataRepository.put` stamps `organization_id` on the row whatever the +type is. So a Studio-authored item of an `allowOrgOverride: false` type +persisted a per-org row the platform can never read back — `loadMetaFromDb` +loads env-wide rows only. The write path was strictly more permissive than the +read path, and the row was lost at the next restart with no log line. + +Measured consequences, both silent before this change: + +- **`flow`** binds its triggers for the life of the process that wrote it, then + stops firing after the next restart. +- **`object`** is worse and fails CLOSED: absent from the registry after boot + while its physical table still holds the data, so every record in it answers + 404 `OBJECT_NOT_FOUND`. + +`saveMetaItem` (draft and publish modes) and the draft→active promotion +(`publishMetaItem`, `publishPackageDrafts`) now refuse such a write with 403 +`NOT_OVERRIDABLE` before anything is persisted, naming the organization, the +flag that produced the verdict, the consequence, and the two legitimate +alternatives (save it env-wide, or ship the per-org variant as its own +deployment — ADR-0005: "Per-org variants are a deployment, not an overlay"). + +**Which types change behaviour.** The predicate is derived from +`DEFAULT_METADATA_TYPE_REGISTRY`, never a hand-written list: 19 of its 27 +entries declare `allowOrgOverride: false` with `allowRuntimeCreate: true` — +`object`, `field`, `hook`, `seed`, `mapping`, `page`, `app`, `action`, +`dataset`, `flow`, `datasource`, `external_catalog`, `api`, `doc`, `book`, +`permission`, `position`, `tool`, `skill`. Unaffected: `view`, `dashboard`, +`report`, `translation`, `email_template` (they have a per-org channel and +their org rows are read back on demand), plus plugin types with no static +registry entry, which keep today's behaviour. Env-wide writes of every type are +unchanged. + +`OS_METADATA_WRITABLE` deliberately does **not** unlock the org dimension: it +unlocks the write, not the read, so honouring it here would re-open the phantom +in exactly the deployments most likely to have one. + +**No data migration is included.** Per the maintainer ruling, rows written +before this gate are residue handled non-destructively — made audible by the +cold-boot warning and disposed of operationally. They are not rewritten or +deleted, and `migrateStoredMetadata` now reports them instead of rewriting +them, which makes that pass a second residue detector. diff --git a/packages/metadata-protocol/src/protocol-publish-drafts-org-scope.test.ts b/packages/metadata-protocol/src/protocol-publish-drafts-org-scope.test.ts index 7d9eb840f8..4bae6b052e 100644 --- a/packages/metadata-protocol/src/protocol-publish-drafts-org-scope.test.ts +++ b/packages/metadata-protocol/src/protocol-publish-drafts-org-scope.test.ts @@ -189,6 +189,15 @@ const objectBody = (name: string) => ({ }, }); +/** [#6190] The org-scoped specimen — `view` is `allowOrgOverride: true`, so it + * is a type that legitimately carries per-org rows. See the third case. */ +const viewBody = (name: string) => ({ + name, + label: 'Project Tasks', + object: 'proj_task', + columns: [{ field: 'title', label: 'Title' }], +}); + describe('publishPackageDrafts — env-wide draft under a non-null active org (#3115)', () => { it('saves the object draft env-wide (organization_id = NULL) when no org is threaded', async () => { const { engine, rows } = makeStubEngine(); @@ -249,10 +258,21 @@ describe('publishPackageDrafts — env-wide draft under a non-null active org (# const protocol = new ObjectStackProtocolImplementation(engine); // A per-org overlay draft (organization_id = org_alpha). + // + // [#6190, 2026-08-09] Re-spelled from `object` to `view`. The org-scope + // resolution this case guards (#3115 — promote the draft in the scope + // `listDrafts` surfaced it from) is unchanged and is what is measured + // here; what changed is which TYPES may carry an org-scoped row at all. + // `object` is `allowOrgOverride: false`, so since the #6190 ruling its + // org-scoped draft cannot be written in the first place — a fixture + // that kept spelling it would have been pinning a write the platform + // refuses, i.e. nothing. `view` is `allowOrgOverride: true`: it HAS a + // per-org channel, its org rows ARE read back, and it therefore + // exercises the #3115 seam exactly as `object` used to. await protocol.saveMetaItem({ - type: 'object', - name: 'proj_task', - item: objectBody('proj_task'), + type: 'view', + name: 'proj_task_grid', + item: viewBody('proj_task_grid'), organizationId: 'org_alpha', packageId: 'app.projects', mode: 'draft', diff --git a/packages/metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts b/packages/metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts index b2fb70b44a..82ce72627e 100644 --- a/packages/metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts +++ b/packages/metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts @@ -48,8 +48,16 @@ * • CREATING A BRAND-NEW ITEM (`allowRuntimeCreate`) — still open for all * nine, deliberately: no code-shipped artifact is being shadowed. The * Studio "save a new view of the world" flows keep working. - * • The env-var escape hatch (`OS_METADATA_WRITABLE`) — untouched; an - * operator can still opt a type back in at runtime, per ADR-0005. + * [#6190, 2026-08-09] Narrowed by one dimension since: still open, but + * ENV-WIDE only. An org-scoped brand-new item of these nine is refused, + * because `loadMetaFromDb` can never read such a row back — the write + * succeeded and the item vanished at the next restart. See the paired + * cases below and `protocol.org-scoped-write-refused.test.ts`. + * • The env-var escape hatch (`OS_METADATA_WRITABLE`) — untouched for the + * overlay dimension; an operator can still opt a type back in at runtime, + * per ADR-0005. [#6190] It does NOT unlock the ORG dimension: the hatch + * unlocks the write, not the read, so honouring it there would re-open the + * phantom in the deployments most likely to have one. * * Known write-side consumers, verified while landing this: * • `OVERLAY_ALLOWED_TYPES` (protocol.ts) and the repository's @@ -264,22 +272,61 @@ describe('#6483 — the nine ADR-0005 divergences: allowOrgOverride rolled back // ── the half that stays open, deliberately ──────────────────────────── - it.each(ROLLED_BACK)('a BRAND-NEW org %s still saves — allowRuntimeCreate is a different tier', async (type) => { + it.each(ROLLED_BACK)('a BRAND-NEW ENV-WIDE %s still saves — allowRuntimeCreate is a different tier', async (type) => { // Covers the two production write paths verified above: ADR-0045's // publish visibility flip (`app` rows materialized into sys_metadata) // and ADR-0094's write-through for runtime-created permission sets. + // + // [#6190, 2026-08-09] This case used to pass `organizationId: + // 'org_alpha'`. The 2026-08-08 ruling on #6190 refuses an org-scoped + // write of any type the registry declares non-org-overridable — these + // nine among them — so the case now pins the half that survives, and + // the sibling below pins the half that closed. Re-measured against the + // two production paths named above rather than assumed: + // + // • ADR-0094's permission write-through + // (`plugin-security/src/permission-set-projection.ts`) passes NO + // `organizationId` on any of its four `saveMetaItem` call sites — + // it is env-scoped by design ("its update/delete translate into + // env-scope overlay operations"). Unaffected, and this case is its + // accurate pin. + // • ADR-0045's visibility flip (`runtime/src/domains/packages.ts`) + // DOES thread the session's active org into + // `saveMetaItem({ type: 'app' })`. That path is the open question + // #6190's report escalates: it is refused now, and the row it used + // to write was itself a phantom (the unhide landed in an org row + // boot never reads, so the app went back to hidden on restart). + // Not papered over here — pinning a green org-scoped `app` write + // would be pinning that phantom. const { protocol } = makeProtocol([], 'env_prod'); const result = await protocol.saveMetaItem({ type, name: 'probe_item', item: BODIES[type], - organizationId: 'org_alpha', }); expect(result.success).toBe(true); }); + it.each(ROLLED_BACK)('[#6190] a BRAND-NEW ORG-SCOPED %s is refused — no per-org channel exists', async (type) => { + // The scope half of the same declaration. `allowRuntimeCreate` grants + // authoring; it never granted the row an org partition the loader + // cannot read back. All nine rolled-back types inherit this the day + // their flag rolled back, because the predicate is the registry. + const { protocol, rows } = makeProtocol([], 'env_prod'); + + await expect( + protocol.saveMetaItem({ + type, + name: 'probe_item', + item: BODIES[type], + organizationId: 'org_alpha', + }), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(rows.size).toBe(0); + }); + // ── the control that makes the red half mean something ──────────────── it('view — still allowOrgOverride:true — is still accepted over a packaged artifact', async () => { diff --git a/packages/metadata-protocol/src/protocol.capability-write-door.test.ts b/packages/metadata-protocol/src/protocol.capability-write-door.test.ts index a7e2e8b07b..0f53e2c22a 100644 --- a/packages/metadata-protocol/src/protocol.capability-write-door.test.ts +++ b/packages/metadata-protocol/src/protocol.capability-write-door.test.ts @@ -277,12 +277,20 @@ describe('#5961 — capability: the runtime write door is closed, and validated ObjectStackProtocolImplementation.resetEnvWritableCache(); const { protocol, rows } = makeProtocol([], 'env_prod'); + // [#6190] The `organizationId` this case used to pass was incidental — + // the door under test is the SCHEMA one, not the org one. Since the + // #6190 ruling an org-scoped write of a non-org-overridable type is + // refused BEFORE the schema is consulted (and `OS_METADATA_WRITABLE` + // deliberately does not unlock the org dimension), so keeping the org + // here would have turned this into a test of that other refusal and + // left the 422 unmeasured. Env-wide keeps it pointed at the schema. + // The hatch-plus-org interaction is pinned in + // `protocol.org-scoped-write-refused.test.ts` (case R7). await expect( protocol.saveMetaItem({ type: 'capability', name: 'billing.refund', item: NOT_A_CAPABILITY, - organizationId: 'org_alpha', }), ).rejects.toMatchObject({ code: 'INVALID_METADATA', status: 422 }); expect(rows.size).toBe(0); @@ -300,7 +308,6 @@ describe('#5961 — capability: the runtime write door is closed, and validated type: 'capability', name: 'billing.refund', item: CAPABILITY, - organizationId: 'org_alpha', }); expect(result.success).toBe(true); diff --git a/packages/metadata-protocol/src/protocol.code-only-types.test.ts b/packages/metadata-protocol/src/protocol.code-only-types.test.ts index 38e735b5b3..4715df6634 100644 --- a/packages/metadata-protocol/src/protocol.code-only-types.test.ts +++ b/packages/metadata-protocol/src/protocol.code-only-types.test.ts @@ -314,13 +314,19 @@ describe('code-only metadata types are refused on every kernel (#5086)', () => { // means only `allowRuntimeCreate` is required. This is the case the // #5086 gate must NOT catch — it is the difference between "code-only" // and "packaged items are locked". + // [#6190] The per-kernel `organizationId` was incidental scenery — + // what this case measures is that the #5086 code-only gate does + // NOT catch a type that merely lacks `allowOrgOverride`. Since the + // #6190 ruling an org-scoped write of such a type is refused by a + // DIFFERENT gate, so passing one here would have measured that + // refusal instead of this one. The two-kernel matrix, which is the + // point, is untouched. for (const { environmentId } of KERNELS) { const { protocol, rows } = makeProtocol(environmentId); const result = await protocol.saveMetaItem({ type: 'hook', name: 'rc3_probe_hook', item: { name: 'rc3_probe_hook', object: 'task', events: ['beforeUpdate'] }, - ...(environmentId ? { organizationId: 'org_alpha' } : {}), }); expect(result.success).toBe(true); expect(metaRows(rows).length).toBe(1); @@ -436,11 +442,14 @@ describe('code-only metadata types are refused on every kernel (#5086)', () => { it(`answers a ${type} save with a repository receipt on a ${label}`, async () => { const { protocol, writes } = makeProtocol(environmentId); + // [#6190] Env-wide on both kernels — see the note on the + // `hook` case above. The receipt SHAPE (seq / state / + // history row) is what this matrix measures, and it does + // not vary with the row's org scope. const result = await protocol.saveMetaItem({ type, name: 'rc3_receipt_view', item, - ...(environmentId ? { organizationId: 'org_alpha' } : {}), }); expect(result.success).toBe(true); diff --git a/packages/metadata-protocol/src/protocol.flow-org-override-closed.test.ts b/packages/metadata-protocol/src/protocol.flow-org-override-closed.test.ts index 345896c504..f5dd92dc49 100644 --- a/packages/metadata-protocol/src/protocol.flow-org-override-closed.test.ts +++ b/packages/metadata-protocol/src/protocol.flow-org-override-closed.test.ts @@ -37,6 +37,22 @@ * automation is being shadowed, and that write is what the ADR means by * "a deployment". Nothing in #6283 touches it. * + * [#6190, 2026-08-09] One clause of that second bullet has since been + * narrowed, and this file is where it was written down, so it is corrected + * here rather than left to contradict the code. The runtime-create tier stays + * open — a brand-new flow is still authorable — but only ENV-WIDE. An + * org-scoped brand-new flow is now refused too (`orgScopedWriteRefusal`), + * because the row it used to write is one `loadMetaFromDb` can never read + * back: it bound its triggers until the next restart and then stopped firing, + * silently. That is the defect #6190 was filed about, and the maintainer ruled + * on 2026-08-08 that the write is refused rather than coerced or logged. The + * case below that used to pin "a BRAND-NEW ORG flow still saves" now pins the + * two halves separately: env-wide saves, org-scoped refuses. Its own closing + * sentence predicted this ("If a later issue decides tenants may not author + * flows at all, that is a change to `allowRuntimeCreate` and it lands here, + * loudly") — the later issue decided something narrower than it guessed: not + * whether tenants may author flows, but what SCOPE such a write may claim. + * * --------------------------------------------------------------------------- * Reverse verification, direction predicted BEFORE running * --------------------------------------------------------------------------- @@ -235,23 +251,39 @@ describe('#6283 — flow: allowOrgOverride rolled back to false', () => { // ── the half that stays open, deliberately ──────────────────────────── - it('a BRAND-NEW org flow still saves — allowRuntimeCreate is a different tier', async () => { + it('a BRAND-NEW ENV-WIDE flow still saves — allowRuntimeCreate is a different tier', async () => { // Not a leak in the rollback: no artifact is being shadowed, so this - // is ADR-0005's "a deployment", authored through the runtime API. If a - // later issue decides tenants may not author flows at all, that is a - // change to `allowRuntimeCreate` and it lands here, loudly. + // is ADR-0005's "a deployment", authored through the runtime API. + // [#6190] Env-wide is the half that survives — see the sibling case. const { protocol } = makeProtocol([], 'env_prod'); const result = await protocol.saveMetaItem({ type: 'flow', name: 'escalate_overdue', item: FLOW, - organizationId: 'org_alpha', }); expect(result.success).toBe(true); }); + it('[#6190] …but the ORG-SCOPED brand-new flow is refused — the row would be unreadable after boot', async () => { + // The other half of the tier, closed by the 2026-08-08 ruling on + // #6190. `allowRuntimeCreate` says a tenant may AUTHOR a flow; it never + // said the row may claim an org partition the loader cannot read. This + // is the write that fired all day and stopped after the restart. + const { protocol, rows } = makeProtocol([], 'env_prod'); + + await expect( + protocol.saveMetaItem({ + type: 'flow', + name: 'escalate_overdue', + item: FLOW, + organizationId: 'org_alpha', + }), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(rows.size).toBe(0); + }); + // ── the control that makes the red half mean something ──────────────── it('view — still allowOrgOverride:true — is still accepted over a packaged artifact', async () => { diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts new file mode 100644 index 0000000000..2ac1f9f790 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -0,0 +1,500 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #6190 — an org-scoped write of a type the registry declares NOT per-org + * overridable is REFUSED at write time, on both minting paths. + * + * ## The defect this closes + * + * `allowOrgOverride` and `allowRuntimeCreate` are orthogonal tiers. #6283 / + * PR #6478 closed the OVERLAY tier for `flow`; the runtime-create tier stayed + * open by design and never consulted the ORG dimension at all — + * `SysMetadataRepository.put` stamps `organization_id: this.organizationId` + * whatever the type is. So a Studio-authored item of an + * `allowOrgOverride: false` type persisted a per-org row that the platform can + * never read back: `loadMetaFromDb` filters `organization_id: null`, and the + * env-wide consumers never ask for the org partition. The write path was + * strictly more permissive than the read path — ADR-0049's false-compliance + * shape, and the reason #6190 was filed. + * + * Two measured specimens, in ascending severity: + * + * • `flow` — binds its triggers for the life of the process that wrote it, + * then silently stops firing after the next restart. + * • `object` — fails CLOSED. The row is absent from the registry after boot + * while its physical table still holds the data, so `assertObjectRegistered` + * answers 404 `OBJECT_NOT_FOUND` for every record in it. That gate's own + * TSDoc justified failing closed with "`object` is `allowOrgOverride: false` + * … so no per-org overlay can legitimately exist outside the process-wide + * registry" — true of the overlay tier, false of the runtime-create tier + * until this refusal landed. `object` is kept as a named specimen below so + * that premise cannot silently go stale again. + * + * Maintainer ruling 2026-08-08 (option A of three): reject the write. Option B + * — silently coercing the row to env-wide — was rejected because it rewrites + * the tenancy statement the author made; option D — the cold-boot log alone, + * shipped in PR #6600 — leaves declared ≠ enforced. Ruling 2 = A: rows written + * BEFORE this gate are residue handled non-destructively (audible via + * `reportUnhydratableOrgScopedRows`, disposed of operationally); this PR ships + * NO data migration, which is why the promotion half below matters — residue + * must not be promotable into a fresh phantom. + * + * ## Reverse verification, direction predicted BEFORE running + * + * Ordinary red with a deliberately green half. Predicted: removing the two + * `orgScopedWriteRefusal` call sites turns all 10 enforcement cases red and + * leaves the 4 controls + the declaration pin green. Measured: 10 red / 5 + * green, and the enforcement cases failed in the shape that names the bug — + * `promise resolved "{ success: true, …}" instead of rejecting`, i.e. the + * accepted-then-unreadable write, reproduced on demand. The green half is not + * slack: a "fix" that closed this by making the whole type unwritable would + * pass the red half and fail G2/G3, and a harness that could not save anything + * would pass the red half for the wrong reason — which is what G1/G2 exclude. + * + * Harness: the real write path over a stub engine — the gate runs inside + * `saveMetaItem` / `promoteDraftForPublish`, so a harness that mocks either + * cannot see it. + */ +import { afterEach, describe, expect, it } from 'vitest'; +// [#5619] The producer's OWN write-verb dispatch decisions (#4550 delete / +// #5480 update). Imported from `@objectstack/metadata-core`, never from +// `@objectstack/objectql`: objectql DEPENDS ON this package, so that import +// would close a dependency cycle turbo rejects outright. +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; + checksum?: string; + version?: number; +} + +interface HistoryRow { + id: string; + type: string; + name: string; + version: number; + organization_id: string | null; + metadata: string | null; + checksum: string | null; + operation_type: string; + recorded_at: string; +} + +/** ADR-0048 overlay key — (type, name, org, state, package_id). */ +const keyOf = (w: Record) => + `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; + +/** Top-level eq + `$or` + explicit-NULL, the subset these paths emit. */ +function matchesWhere(r: Row, where: Record): boolean { + for (const [k, v] of Object.entries(where)) { + if (k === '$or') { + const clauses = v as Array>; + if (!clauses.some((c) => matchesWhere(r, c))) return false; + continue; + } + if (v === undefined) continue; + if ((r as unknown as Record)[k] !== v) return false; + } + return true; +} + +function makeStubEngine(artifacts: Array<{ type: string; name: string }> = []) { + const rows = new Map(); + const historyRows: HistoryRow[] = []; + let nextId = 0; + const artifactKeys = new Set(artifacts.map((a) => `${a.type}|${a.name}`)); + + const findRow = (w: Record): { key: string; row: Row } | null => { + if (w.id !== undefined) { + for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r }; + return null; + } + if (w.package_id !== undefined) { + const k = keyOf(w); + const r = rows.get(k); + if (r) return { key: k, row: r }; + } + for (const [k, r] of rows) if (matchesWhere(r, w)) return { key: k, row: r }; + return null; + }; + + const engine: any = { + async findOne(table: string, opts: { where: Record }) { + if (table === 'sys_metadata_history') { + return historyRows.find((h) => { + const w = opts.where; + if (w.type !== undefined && h.type !== w.type) return false; + if (w.name !== undefined && h.name !== w.name) return false; + if (w.version !== undefined && h.version !== w.version) return false; + if (w.organization_id !== undefined && h.organization_id !== w.organization_id) return false; + return true; + }) ?? null; + } + return findRow(opts.where)?.row ?? null; + }, + async find(table: string, opts?: { where?: Record }) { + if (table === 'sys_metadata_history') return historyRows; + return Array.from(rows.values()).filter((r) => matchesWhere(r, opts?.where ?? {})); + }, + async insert(table: string, data: Record) { + if (table === 'sys_metadata_audit') return { id: 'audit_skip' }; + if (table === 'sys_metadata_history') { + nextId += 1; + const h = { ...(data as unknown as HistoryRow), id: `h_${nextId}` }; + historyRows.push(h); + return { id: h.id }; + } + if (table !== 'sys_metadata') return { id: 'side_effect_skip' }; + nextId += 1; + const row = { ...(data as unknown as Row), id: `r_${nextId}` }; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update(_t: string, data: Record, opts: { where: Record }) { + assertEngineUpdateDispatch(data, opts); + const found = findRow(opts.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...(data as unknown as Row) }; + rows.delete(found.key); + rows.set(keyOf(merged), merged); + return { id: found.row.id }; + }, + async delete(_t: string, opts: { where: Record }) { + assertEngineDeleteDispatch(opts); + const found = findRow(opts.where); + if (!found) return { deleted: 0 }; + rows.delete(found.key); + return { deleted: 1 }; + }, + async transaction(cb: (ctx: unknown, info: { owned: boolean }) => Promise): Promise { + return cb(undefined, { owned: true }); + }, + async syncObjectSchema() { return true; }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + listItems: () => [], + getItem: () => undefined, + getObject: () => undefined, + getPackage: () => undefined, + // `isArtifactBacked` prefers this lookup — a hit means the name is + // shipped by a code package (`_packageId` provenance). + getArtifactItem: (type: string, name: string) => + artifactKeys.has(`${type}|${name}`) ? { name, _packageId: 'showcase' } : undefined, + }, + }; + return { engine, rows }; +} + +function makeProtocol(artifacts?: Array<{ type: string; name: string }>, environmentId?: string) { + const { engine, rows } = makeStubEngine(artifacts); + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), environmentId) as any; + return { protocol, rows }; +} + +/** Seed the residue this PR deliberately does NOT migrate: an org-scoped + * draft row written the way `saveMetaItem` used to write it — through the + * repository, which is where `organization_id` is stamped. Bypasses only the + * new protocol gate, so the row is byte-identical to a legacy one. */ +async function seedLegacyOrgDraft( + protocol: any, + args: { type: string; name: string; body: unknown; organizationId: string; packageId?: string | null }, +): Promise { + await protocol.ensureOverlayIndex(); + const repo = protocol.getOverlayRepo(args.organizationId); + await repo.put( + { type: args.type, name: args.name, org: args.organizationId }, + args.body, + { + parentVersion: null, + actor: null, + source: 'test.legacy-residue', + intent: 'runtime-only', + state: 'draft', + packageId: args.packageId ?? null, + }, + ); +} + +const OBJECT = { + name: 'org_widget', + label: 'Org Widget', + fields: { title: { type: 'text', label: 'Title' } }, +}; + +/** A schema-VALID flow body — a minimal one 422s before the gate is reached. */ +const FLOW = { + name: 'org_sweep', + label: 'Org sweep', + 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' }], +}; + +/** The control specimen: `allowOrgOverride: true`, so it HAS a per-org channel. */ +const VIEW = { + name: 'org_grid', + label: 'Org grid', + object: 'task', + columns: [{ field: 'title', label: 'Title' }], +}; + +const orgRows = (rows: Map) => + Array.from(rows.values()).map((r) => ({ type: r.type, name: r.name, org: r.organization_id, state: r.state })); + +describe('#6190 — org-scoped writes of non-org-overridable types are refused', () => { + afterEach(() => { + delete process.env.OS_METADATA_WRITABLE; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + }); + + // ── path 1: saveMetaItem ────────────────────────────────────────────── + + it('R1/R2 — object: the org-scoped save is refused with the envelope, and NOTHING is persisted', async () => { + // The specimen whose post-restart consequence fails CLOSED: the row's + // organization_id makes cold boot skip it, the object is absent from + // the registry, and every record in its still-populated table 404s. + const { protocol, rows } = makeProtocol([], 'env_prod'); + + await expect( + protocol.saveMetaItem({ type: 'object', name: 'org_widget', item: OBJECT, organizationId: 'org_a' }), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + + // "Refused", not "refused after writing" — the phantom row IS the + // defect, so its absence is part of the claim. + expect(orgRows(rows)).toEqual([]); + }); + + it('R3 — the refusal does not depend on deployment topology (no environmentId either)', async () => { + // ADR-0005's "single kernels keep their existing behaviour" carve-out is + // keyed on `environmentId`. A refusal that only bit in one topology + // would leave the flagship showcase — a host config boots with NO + // environmentId (#5086) — still writing phantoms. + const { protocol, rows } = makeProtocol(); + + await expect( + protocol.saveMetaItem({ type: 'object', name: 'org_widget', item: OBJECT, organizationId: 'org_a' }), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(orgRows(rows)).toEqual([]); + }); + + it('R4 — flow: the original #6190 specimen, brand-new and org-scoped, is refused', async () => { + // No artifact is shadowed here, so this is the `allowRuntimeCreate` + // tier — the tier PR #6478 deliberately left open and the tier the + // tenant scenario in the issue actually uses (authoring a NEW flow in + // Studio, not overlaying a packaged one). + const { protocol, rows } = makeProtocol([], 'env_prod'); + + await expect( + protocol.saveMetaItem({ type: 'flow', name: 'org_sweep', item: FLOW, organizationId: 'org_a' }), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(orgRows(rows)).toEqual([]); + }); + + it('R5 — the draft door is gated identically (#4463 D1)', async () => { + // Gating the direct-active save and letting drafts through would make + // the refusal bypassable by anyone who saves `?mode=draft` and then + // POSTs `/publish` — which is exactly what Studio's designer does on + // every edit. + const { protocol, rows } = makeProtocol([], 'env_prod'); + + await expect( + protocol.saveMetaItem({ + type: 'object', name: 'org_widget', item: OBJECT, organizationId: 'org_a', mode: 'draft', + }), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(orgRows(rows)).toEqual([]); + }); + + it('R6 — the plural REST spelling is refused too', async () => { + // `PUT /api/v1/meta/objects/:name` reaches the same gate; a refusal + // keyed on one spelling is a refusal with a documented bypass. + const { protocol, rows } = makeProtocol([], 'env_prod'); + + await expect( + protocol.saveMetaItem({ type: 'objects', name: 'org_widget', item: OBJECT, organizationId: 'org_a' }), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(orgRows(rows)).toEqual([]); + }); + + it('R7 — OS_METADATA_WRITABLE does NOT unlock org scoping (the gate is env-blind)', async () => { + // The escape hatch unlocks the WRITE; it does not teach + // `loadMetaFromDb` to read the row back. Honouring it here would + // re-open the phantom in exactly the deployments most likely to hit it + // — the same reasoning the cold-boot audit (PR #6600) used to keep its + // own type set env-blind. Env-wide writes of `object` stay unlocked by + // it; only the ORG dimension is closed. + process.env.OS_METADATA_WRITABLE = 'object'; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + const { protocol, rows } = makeProtocol([], 'env_prod'); + + await expect( + protocol.saveMetaItem({ type: 'object', name: 'org_widget', item: OBJECT, organizationId: 'org_a' }), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(orgRows(rows)).toEqual([]); + }); + + it('R8 — the refusal names the scope, the flag and the remedy (#5240: one condition, one wording)', async () => { + // An AI author cannot self-correct from a vague 403. The wording is + // contract here: it must name the ORG (so the author knows which + // dimension was refused, not just "forbidden"), the flag that produced + // the verdict, and the two legitimate alternatives. + const { protocol } = makeProtocol([], 'env_prod'); + + const err = await protocol + .saveMetaItem({ type: 'object', name: 'org_widget', item: OBJECT, organizationId: 'org_a' }) + .catch((e: any) => e); + + expect(err.message).toContain( + "[not_overridable] Metadata item 'object/org_widget' cannot be written org-scoped (organization 'org_a').", + ); + expect(err.message).toContain('allowOrgOverride=false'); + expect(err.message).toContain('Save it env-wide instead'); + expect(err.organizationId).toBe('org_a'); + }); + + // ── path 2: draft → active promotion ────────────────────────────────── + + it('R9 — a LEGACY org-scoped draft cannot be promoted into a fresh active phantom', async () => { + // This PR ships no data migration (ruling 2 = A), so residue exists by + // design. It must not be promotable: promoting it would mint a NEW + // active org-scoped row — the very thing path 1 now refuses. + const { protocol, rows } = makeProtocol([], 'env_prod'); + await seedLegacyOrgDraft(protocol, { + type: 'object', name: 'org_widget', body: OBJECT, organizationId: 'org_a', + }); + expect(orgRows(rows)).toEqual([ + { type: 'object', name: 'org_widget', org: 'org_a', state: 'draft' }, + ]); + + await expect( + protocol.publishMetaItem({ type: 'object', name: 'org_widget', organizationId: 'org_a' }), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + + // The draft is still there (refusing is not disposal — ruling 2 = A), + // and no ACTIVE row was minted. + expect(orgRows(rows).filter((r) => r.state === 'active')).toEqual([]); + }); + + it('R10 — publishPackageDrafts refuses the batch rather than promoting the residue', async () => { + // Studio's "publish whole app". The batch is atomic by ADR-0067 D2, so + // one refused item fails the whole publish loudly instead of half-landing. + const { protocol, rows } = makeProtocol([], 'env_prod'); + await seedLegacyOrgDraft(protocol, { + type: 'object', name: 'org_widget', body: OBJECT, organizationId: 'org_a', packageId: 'app.demo', + }); + + const res = await protocol.publishPackageDrafts({ packageId: 'app.demo', organizationId: 'org_a' }); + + expect(res.success).toBe(false); + expect(res.publishedCount).toBe(0); + expect(res.failed).toHaveLength(1); + expect(res.failed[0]).toMatchObject({ type: 'object', name: 'org_widget', code: 'NOT_OVERRIDABLE' }); + expect(orgRows(rows).filter((r) => r.state === 'active')).toEqual([]); + }); + + // ── the controls: what must NOT change ──────────────────────────────── + + it('G1 — view IS allowOrgOverride:true, so its org-scoped write still succeeds', async () => { + // Without this control the refusals above would also pass on a harness + // that could not save anything at all. `view` is the type ADR-0005 + // whitelists, and its per-org rows ARE read back (on demand, by + // `getMetaItem`/`getMetaItems`) — which is the whole distinction. + const { protocol, rows } = makeProtocol([], 'env_prod'); + + const result = await protocol.saveMetaItem({ + type: 'view', name: 'org_grid', item: VIEW, organizationId: 'org_a', + }); + + expect(result.success).toBe(true); + expect(orgRows(rows)).toEqual([ + { type: 'view', name: 'org_grid', org: 'org_a', state: 'active' }, + ]); + }); + + it('G2 — an ENV-WIDE write of the same object still succeeds', async () => { + // The refusal is about the org dimension only. A tenant-authored object + // remains authorable; it just lands where boot can read it back. + const { protocol, rows } = makeProtocol([], 'env_prod'); + + const result = await protocol.saveMetaItem({ type: 'object', name: 'org_widget', item: OBJECT }); + + expect(result.success).toBe(true); + expect(orgRows(rows)).toEqual([ + { type: 'object', name: 'org_widget', org: null, state: 'active' }, + ]); + }); + + it('G3 — an ENV-WIDE brand-new flow still saves (the allowRuntimeCreate tier is intact)', async () => { + // #6283 left this tier open and this change does not close it. What + // changed is the SCOPE such a write may claim, not whether tenants may + // author automations. + const { protocol, rows } = makeProtocol([], 'env_prod'); + + const result = await protocol.saveMetaItem({ type: 'flow', name: 'org_sweep', item: FLOW }); + + expect(result.success).toBe(true); + expect(orgRows(rows)).toEqual([ + { type: 'flow', name: 'org_sweep', org: null, state: 'active' }, + ]); + }); + + it('G4 — an env-wide draft still publishes under a session carrying an active org (#3115)', async () => { + // The highest-traffic path in Studio, and the one most at risk from a + // gate keyed on the wrong org: `publishPackageDrafts` promotes each + // draft in the draft's OWN scope, so a session's active org must not + // make an env-wide draft look org-scoped. + const { protocol, rows } = makeProtocol([], 'env_prod'); + await protocol.saveMetaItem({ + type: 'object', name: 'org_widget', item: OBJECT, packageId: 'app.demo', mode: 'draft', + }); + + const res = await protocol.publishPackageDrafts({ packageId: 'app.demo', organizationId: 'org_a' }); + + expect(res.failed).toEqual([]); + expect(res).toMatchObject({ success: true, publishedCount: 1, failedCount: 0 }); + expect(orgRows(rows).filter((r) => r.state === 'active')).toEqual([ + { type: 'object', name: 'org_widget', org: null, state: 'active' }, + ]); + }); + + // ── the declaration behind the enforcement ──────────────────────────── + + it('G5 — the refused set is DERIVED from the registry, not a parallel list', async () => { + // Prime Directive #8. If anyone re-adds a type to a hand-written list + // instead, the enforcement cases above go red rather than this one — + // which is why they, not this, are the acceptance criterion. Recorded + // as a measurement so the blast radius of the ruling is auditable: + // 19 of 27 registry entries change behaviour here. + const affected = DEFAULT_METADATA_TYPE_REGISTRY + .filter((e) => !e.allowOrgOverride && e.allowRuntimeCreate) + .map((e) => e.type); + const orgOverridable = DEFAULT_METADATA_TYPE_REGISTRY + .filter((e) => e.allowOrgOverride) + .map((e) => e.type); + + expect(orgOverridable).toEqual(['view', 'dashboard', 'report', 'translation', 'email_template']); + // The types the maintainer ruling names explicitly, all present. + for (const t of ['object', 'field', 'hook', 'seed', 'mapping', 'api', 'flow']) { + expect(affected, `${t} must be refused org-scoped`).toContain(t); + } + expect(affected).toHaveLength(19); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.platform-schedule-org-gate.test.ts b/packages/metadata-protocol/src/protocol.platform-schedule-org-gate.test.ts index 7a85694ade..2314fceb0f 100644 --- a/packages/metadata-protocol/src/protocol.platform-schedule-org-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.platform-schedule-org-gate.test.ts @@ -480,17 +480,30 @@ describe('#6285 refusal through saveMetaItem / publishMetaItem', () => { expect(err.status).toBe(422); }); - it('allows an ORG-SCOPED write of the same flow — the row already names its organization', async () => { - // The limb negation at the caller face, and worth driving end-to-end - // rather than only on the pure function: #6283 flipped flow's - // `allowOrgOverride` to `false`, so the ADR-0005 gate now 403s an org - // overlay of a PACKAGED flow. A brand-new runtime-created flow is a - // different door (`allowRuntimeCreate: true` survived that flip), and - // this pins which of the two an org-scoped write of a new flow takes. + it('[#6190] an ORG-SCOPED write of the same flow is now refused BEFORE this rule runs', async () => { + // Replaced wholesale rather than re-spelled, because what changed is + // this case's REACHABILITY, and a fixture that keeps asserting a + // verdict nothing can produce is green for an empty reason. + // + // It used to pin the limb NEGATION at the caller face: an org-scoped + // write escaped #6285's 422 because "the row already names its + // organization". The 2026-08-08 ruling on #6190 refuses an org-scoped + // write of any non-org-overridable type — `flow` among them — at a gate + // that runs EARLIER than `assertRuntimeAuthoringRules`. So the negation + // is no longer reachable through `saveMetaItem`/`publishMetaItem` for + // `flow`: the write never gets far enough to be judged by this rule. + // + // Recorded here, not acted on: whether #6285's org-present limb should + // survive at all now that nothing can reach it through these two doors + // is a question for that rule's own owner (#6710 is in flight in that + // region), and this PR deliberately does not touch it. What this case + // pins is the honest current fact — which refusal an org-scoped + // platform-schedule flow actually gets. const { protocol, rows } = makeProtocol(); - const result = await save(protocol, scheduledSweep(), { organizationId: 'org_a' }); - expect(result.success).toBe(true); - expect(flowRows(rows).map((r) => r.organization_id)).toEqual(['org_a']); + const err = await save(protocol, scheduledSweep(), { organizationId: 'org_a' }).catch((e: any) => e); + expect(err.code).toBe('NOT_OVERRIDABLE'); + expect(err.status).toBe(403); + expect(flowRows(rows)).toEqual([]); }); it('allows a DRAFT of the identical body, and refuses the publish that promotes it', async () => { diff --git a/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts b/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts index a90e1d8a26..86f9fc0e12 100644 --- a/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts +++ b/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts @@ -199,16 +199,25 @@ describe('#5265 — a save receipt names what was actually written', () => { } it('an org-scoped runtime-only save names the org, not an overlay', async () => { + // [#6190, 2026-08-09] Re-spelled from `hook` to `view`. The claim is + // about the RECEIPT — "(org=…)" rather than the overlay phrasing — and + // the receipt does not vary by type. What changed is which types can + // reach this receipt at all: since the #6190 ruling an org-scoped write + // requires a type that declares `allowOrgOverride`, and the + // overlay-less-yet-overridable population is empty by ruling (see the + // population pin above). `view` is runtime-only here for the reason the + // case below states — no artifact was shipped at this name — so this + // still measures a RUNTIME-ONLY org-scoped save, not an overlay. const { protocol } = makeProtocol(); const result = await protocol.saveMetaItem({ - type: 'hook', name: 'rc5_acct', item: OVERLAYLESS_PROBES.hook, + type: 'view', name: 'rc5_probe_view', item: VIEW, organizationId: 'org_alpha', }); expect(result.message).not.toContain('customization overlay'); expect(result.message).toBe( - `Saved hook 'rc5_acct' (org=org_alpha, state=active) [seq=${result.seq}]`, + `Saved view 'rc5_probe_view' (org=org_alpha, state=active) [seq=${result.seq}]`, ); }); diff --git a/packages/metadata-protocol/src/protocol.stored-migration.test.ts b/packages/metadata-protocol/src/protocol.stored-migration.test.ts index c4fc9cde89..4ef122a143 100644 --- a/packages/metadata-protocol/src/protocol.stored-migration.test.ts +++ b/packages/metadata-protocol/src/protocol.stored-migration.test.ts @@ -264,7 +264,27 @@ describe('migrateStoredMetadata — apply (#4327)', () => { expect(metaRows(tables)[0]!.state).toBe('draft'); }); - it('walks every org, not just the env-wide bucket', async () => { + it('walks every org, not just the env-wide bucket — and REPORTS the org-scoped residue it cannot rewrite', async () => { + // The walk is what this case is named for, and the walk is unchanged: + // both buckets are scanned. What changed is the org row's OUTCOME. + // + // [#6190, 2026-08-09] `action` is `allowOrgOverride: false`, so since + // that ruling an org-scoped row of it cannot be written — and this pass + // rewrites through `saveMetaItem`, so it is refused like any other + // write. That is the correct outcome, not a gap to route around: + // + // • Ruling 2 = A made existing org-scoped rows of such types + // NON-DESTRUCTIVE residue — audible, disposed of operationally, + // never rewritten by a migration. A canonicalization pass that + // quietly rewrote them would be doing exactly the migration the + // ruling declined to authorise, one row at a time. + // • And the refusal is not silent: the row surfaces in the report + // with the reason, which makes this pass a SECOND residue detector + // alongside the cold-boot warn (PR #6600). + // + // Deliberately NOT re-spelled to an org-overridable type: that would + // have kept the assertion green while deleting the only coverage of + // what the pass does with residue. const { engine, tables } = makeStubEngine([ legacyObjectRow, { ...legacyActionRow, organization_id: 'org_a' }, @@ -274,9 +294,16 @@ describe('migrateStoredMetadata — apply (#4327)', () => { const report = await protocol.migrateStoredMetadata({ apply: true }); expect(report.scanned).toBe(2); - expect(report.rewritten).toBe(2); + expect(report.rewritten).toBe(1); + expect(report.failed).toBe(1); + + const orgReport = report.rows.find((r: any) => r.type === 'action')!; + expect(orgReport.outcome).toBe('failed'); + expect(orgReport.reason).toContain('cannot be written org-scoped'); + + // Non-destructive: the stored bytes are exactly as they were. const orgRow = metaRows(tables).find((r) => r.organization_id === 'org_a')!; - expect(JSON.parse(orgRow.metadata).target).toBe('convertHandler'); + expect(JSON.parse(orgRow.metadata).execute).toBe('convertHandler'); }); it('leaves archived rows alone — they are a record of what was, not served metadata', async () => { diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 9e262efc0a..f222317a1f 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -4605,6 +4605,26 @@ export class ObjectStackProtocolImplementation implements * both boot hydration (`loadMetaFromDb`) and runtime authoring * (`applyObjectRegistryMutation`) register the schema before its table * is reachable. + * + * [#6190] That justification rested on a premise the WRITE path did not + * enforce until this note was written. `allowOrgOverride: false` closed + * the overlay tier only; `object` is also `allowRuntimeCreate: true`, and + * that tier stamped `organization_id` on the row like any other — so a + * Studio-authored `object` COULD legitimately exist as a per-org row, + * invisible to boot hydration, and this gate's fail-closed answer meant + * 404 for every record in a table that still held the data. The premise + * is now true by enforcement: {@link orgScopedWriteRefusal} refuses an + * org-scoped write of any type the registry declares non-org-overridable, + * on both minting paths, so the only org-scoped `object` rows that can + * exist are residue written before that gate (#6190's ruling 2 = A: + * handled non-destructively — made audible by + * {@link reportUnhydratableOrgScopedRows} and disposed of operationally, + * NOT rewritten by a migration). Fail-closed stays the right answer for + * those: the registry entry is genuinely absent, and serving the table + * would serve one org's rows to every org. Pinned by + * `protocol.org-scoped-write-refused.test.ts`, which keeps `object` as + * its named specimen precisely so this paragraph cannot go stale + * silently again. * - **No registry on the engine at all → skip.** There is no source of * truth to consult, so the check cannot answer; failing closed would * break every registry-less host (edge/Lite embeddings, engine doubles) @@ -7412,6 +7432,96 @@ export class ObjectStackProtocolImplementation implements return err; } + /** + * [#6190] The org-scope half of the same family: a write that would stamp + * `sys_metadata.organization_id` on a type the registry declares has NO + * per-org channel. Returns the refusal, or `null` when the write is fine. + * + * ## Why a write-time refusal and not a read-time repair + * + * `allowOrgOverride` and `allowRuntimeCreate` are orthogonal tiers (see + * {@link isRuntimeCreateAllowed}), and the runtime-create tier never + * consulted the ORG dimension: `SysMetadataRepository.put` stamps + * `organization_id: this.organizationId` whatever the type is, so a + * Studio-authored item of an `allowOrgOverride: false` type persisted a + * per-org row that the platform can never read back. Cold boot + * (`loadMetaFromDb`, `organization_id: null`) walks past it and the + * env-wide consumers never ask for it — the write path was strictly more + * permissive than the read path, which is the false-compliance shape + * ADR-0049 forbids. Measured consequences, both silent before this gate: + * + * - `flow` — the row binds its triggers for the life of the process that + * wrote it and stops firing after the next restart, with no log line + * (#6190's original report; the cold-boot warn that made the residue + * audible shipped separately, see + * {@link reportUnhydratableOrgScopedRows}). + * - `object` — worse, and fails CLOSED: the object is absent from the + * registry after boot while its physical table still holds the data, so + * {@link assertObjectRegistered} answers 404 `OBJECT_NOT_FOUND` for + * every record in it. + * + * Maintainer ruling 2026-08-08 on #6190 (option A of three): refuse the + * write. Option B — silently coercing the row to env-wide — was rejected + * because it rewrites the tenancy statement the author made; option D — + * the log alone — leaves declared ≠ enforced. + * + * ## Shape decisions + * + * - **Registry-derived, never a hand-written type list** (Prime Directive + * #8): the predicate is {@link OVERLAY_ALLOWED_TYPES}, the same derived + * set the overlay gate uses. A type that gains `allowOrgOverride: true` + * tomorrow is admitted here the same day, with nothing to keep in sync. + * - **Env-blind, deliberately.** Unlike {@link isOverlayAllowed} this does + * NOT consult `OS_METADATA_WRITABLE`. That hatch unlocks the WRITE; it + * does not teach `loadMetaFromDb` to read the row back, so honouring it + * here would re-open the phantom in exactly the deployments most likely + * to hit it. Same reasoning, and the same direction, as the cold-boot + * audit's own env-blind type set. + * - **Statically-declared types only.** A type with no entry in + * `DEFAULT_METADATA_TYPE_REGISTRY` is plugin-registered at runtime, and + * both existing gates ({@link isRuntimeCreateAllowed} here, + * `assertAllowed` in the repository) treat that family as permissive by + * construction — `getMetaTypes()` synthesises `allowRuntimeCreate: true` + * for it. Refusing those here would extend a ruling measured over the + * registry to a surface nobody measured, so they keep today's behaviour. + * Their org rows are skipped by cold boot too; that gap is stated in the + * PR rather than silently widened here. + * - **`NOT_OVERRIDABLE`, not a new code.** The condition IS "this type has + * no per-org override channel", the sentence `NOT_OVERRIDABLE` already + * carries, and the code vocabulary is a closed set owned by + * `packages/spec`'s ledger (ADR-0112 D3) — a cross-package edit this + * card is not authorised to make. The message carries the distinction. + * + * Pinned by `protocol.org-scoped-write-refused.test.ts`. + */ + private static orgScopedWriteRefusal( + type: string, + name: string, + organizationId: string | null | undefined, + ): Error | null { + if (!organizationId) return null; + const singular = PLURAL_TO_SINGULAR[type] ?? type; + if (this.OVERLAY_ALLOWED_TYPES.has(singular) || this.OVERLAY_ALLOWED_TYPES.has(type)) return null; + if (!this.STATIC_REGISTRY_TYPES.has(singular) && !this.STATIC_REGISTRY_TYPES.has(type)) return null; + const err: any = new Error( + `[not_overridable] Metadata item '${type}/${name}' cannot be written org-scoped ` + + `(organization '${organizationId}'). ` + + `The metadata-type registry declares allowOrgOverride=false for '${singular}', so the platform has ` + + `no per-org channel for it: boot hydration loads env-wide rows only, so this row would be absent ` + + `from the registry after the next restart — a '${singular}' that answered today would stop ` + + `(an 'object' answers 404 OBJECT_NOT_FOUND for every record in its still-populated table, a 'flow' ` + + `silently stops firing). Save it env-wide instead (retry with no active organization), or ship the ` + + `per-org variant as its own deployment (ADR-0005: "Per-org variants are a deployment, not an ` + + `overlay"). OS_METADATA_WRITABLE does not unlock org scoping — it unlocks the write, not the read. ` + + `See docs/adr/0005-metadata-customization-overlay.md and #6190.` + ); + err.code = 'NOT_OVERRIDABLE'; + err.status = 403; + err.organizationId = organizationId; + err.docs = 'docs/adr/0005-metadata-customization-overlay.md'; + return err; + } + /** * Does an artifact (npm-package-loaded) item exist at `(type, name)`? * @@ -8352,6 +8462,24 @@ export class ObjectStackProtocolImplementation implements : ObjectStackProtocolImplementation.codeOnlyCreateError(request.type); } + // [#6190] …and the ORG dimension of the same declaration, on the tier + // that never consulted it. Placed HERE — before the topology carve-out + // below, before the destructive diff, before the schema parse — for the + // two reasons #5086 put its own refusal first: the verdict depends on + // nothing but the type and the requested scope, and "refused, not + // refused after writing" is the property the issue was filed about, so + // the gate must precede every path that could persist a row. Draft + // saves are gated identically (the branch is below): a draft is the + // first half of the SECOND minting path this closes, and #4463 D1 + // recorded what happens when only one of the two doors gates. + // See {@link orgScopedWriteRefusal} for the ruling and the shape. + { + const orgRefusal = ObjectStackProtocolImplementation.orgScopedWriteRefusal( + request.type, request.name, request.organizationId, + ); + if (orgRefusal) throw orgRefusal; + } + if (this.environmentId !== undefined) { const artifactBacked = this.isArtifactBacked(request.type, request.name); if (artifactBacked && !overlayAllowed) { @@ -9293,6 +9421,21 @@ export class ObjectStackProtocolImplementation implements err.status = 403; throw err; } + // [#6190] The draft→active promotion is the OTHER way an org-scoped row + // of a non-org-overridable type reaches `active` — `publishMetaItem` + // and, behind Studio's "publish whole app", `publishPackageDrafts`. + // `saveMetaItem`'s gate now refuses to MINT such a draft, so what this + // door closes is the promotion of residue that predates the refusal: + // a legacy org-scoped draft row must not be promotable into a fresh + // active phantom. Exactly the #4463 D1 posture — gating one door and + // not the other makes the refusal bypassable by anyone who saves + // `?mode=draft` and then POSTs `/publish`. + { + const orgRefusal = ObjectStackProtocolImplementation.orgScopedWriteRefusal( + request.type, request.name, request.organizationId, + ); + if (orgRefusal) throw orgRefusal; + } // ADR-0010 L3 — lock blocks publish too (publishing is a write). const _publishLockErr = await this.assertLockAllowsWrite({ type: request.type, From 1f8033af78f8acadb065382d996b19251d0fcc28 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 06:52:04 +0000 Subject: [PATCH 2/4] test(metadata-protocol): record the reverse-verification count that missed (#6190) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01W6bLax4KMrSfnE1ydFU8Dw --- .../protocol.org-scoped-write-refused.test.ts | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index 2ac1f9f790..921169ba6d 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -42,14 +42,27 @@ * ## Reverse verification, direction predicted BEFORE running * * Ordinary red with a deliberately green half. Predicted: removing the two - * `orgScopedWriteRefusal` call sites turns all 10 enforcement cases red and - * leaves the 4 controls + the declaration pin green. Measured: 10 red / 5 - * green, and the enforcement cases failed in the shape that names the bug — - * `promise resolved "{ success: true, …}" instead of rejecting`, i.e. the - * accepted-then-unreadable write, reproduced on demand. The green half is not - * slack: a "fix" that closed this by making the whole type unwritable would - * pass the red half and fail G2/G3, and a harness that could not save anything - * would pass the red half for the wrong reason — which is what G1/G2 exclude. + * `orgScopedWriteRefusal` call sites turns every enforcement case in this file + * red and leaves the 4 controls + the declaration pin green. + * + * Measured: **9 red / 5 green** here (and 21 red across the package, the other + * 12 being the fixtures elsewhere that had pinned the reversed behaviour). The + * enforcement cases failed in the shape that names the bug — + * `AssertionError: promise resolved "{ success: true, …(4) }" instead of + * rejecting` — i.e. the accepted-then-unreadable write, reproduced on demand. + * + * One prediction missed, recorded rather than tidied away: the written + * prediction said "10 enforcement cases", counting R1 and R2 as two. They are + * one `it()` — the envelope and the "nothing persisted" assertion belong to a + * single case, because "refused AFTER writing" would satisfy either one alone + * and neither is the claim on its own. So the predicted count was 10 and the + * real one is 9; the direction and the membership were right, the arithmetic + * was not. + * + * The green half is not slack: a "fix" that closed this by making the whole + * type unwritable would pass the red half and fail G2/G3, and a harness that + * could not save anything would pass the red half for the wrong reason — which + * is what G1/G2 exclude. * * Harness: the real write path over a stub engine — the gate runs inside * `saveMetaItem` / `promoteDraftForPublish`, so a harness that mocks either From 692f617bffcbe65a026cf081778fbc858976be30 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 07:40:05 +0000 Subject: [PATCH 3/4] fix(metadata-protocol,objectql): align the org-scope refusal with the operator hatch, re-judge objectql fixtures (#6190) The refusal now uses the same `isOverlayAllowed` predicate as the sibling NOT_OVERRIDABLE it was asked to mirror, so OS_METADATA_WRITABLE stays ONE door. The cold-boot diagnostic stays deliberately wider than the refusal. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01W6bLax4KMrSfnE1ydFU8Dw --- .../protocol.org-scoped-write-refused.test.ts | 41 +++++++++++++++---- packages/metadata-protocol/src/protocol.ts | 36 +++++++++++----- .../objectql/src/overlay-precedence.test.ts | 9 +++- packages/objectql/src/protocol-meta.test.ts | 40 ++++++++++++------ ...protocol-org-overlay-registry-gate.test.ts | 30 ++++++++++---- .../src/protocol-save-meta-repo-path.test.ts | 5 +-- 6 files changed, 118 insertions(+), 43 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index 921169ba6d..f084de550f 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -347,17 +347,44 @@ describe('#6190 — org-scoped writes of non-org-overridable types are refused', expect(orgRows(rows)).toEqual([]); }); - it('R7 — OS_METADATA_WRITABLE does NOT unlock org scoping (the gate is env-blind)', async () => { - // The escape hatch unlocks the WRITE; it does not teach - // `loadMetaFromDb` to read the row back. Honouring it here would - // re-open the phantom in exactly the deployments most likely to hit it - // — the same reasoning the cold-boot audit (PR #6600) used to keep its - // own type set env-blind. Env-wide writes of `object` stay unlocked by - // it; only the ORG dimension is closed. + it('R7 — OS_METADATA_WRITABLE unlocks org scoping too: the operator hatch stays ONE door', async () => { + // Not a leak, and the case is here because the alternative was + // seriously considered and rejected. The predicate is + // `isOverlayAllowed`, the SAME one the sibling `NOT_OVERRIDABLE` + // refusal uses — the ruling named this refusal that sibling, and this + // file already promises "unlocking a type there unlocks it here too". + // A second, differently-keyed notion of "overridable" inside one method + // is the drift, not the safety. + // + // What keeps that honest is that the DIAGNOSTIC is deliberately wider + // than the refusal: `reportUnhydratableOrgScopedRows` ignores the hatch + // (PR #6600) and reports the row at every boot, because no hatch can + // teach `loadMetaFromDb` to read it back. So an operator who opens the + // door still gets told what it cost them. Warning is free and should be + // maximal; refusing removes a capability, and the declaration — with + // its documented override — is what decides that. process.env.OS_METADATA_WRITABLE = 'object'; ObjectStackProtocolImplementation.resetEnvWritableCache(); const { protocol, rows } = makeProtocol([], 'env_prod'); + const result = await protocol.saveMetaItem({ + type: 'object', name: 'org_widget', item: OBJECT, organizationId: 'org_a', + }); + + expect(result.success).toBe(true); + expect(orgRows(rows)).toEqual([ + { type: 'object', name: 'org_widget', org: 'org_a', state: 'active' }, + ]); + }); + + it('R7b — …and with the hatch CLOSED the same write is refused', async () => { + // The pair that makes R7 evidence rather than a hole: the hatch is what + // opens it, and nothing else does. Without this, R7 would be + // indistinguishable from a gate that never fired for `object` at all. + delete process.env.OS_METADATA_WRITABLE; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + const { protocol, rows } = makeProtocol([], 'env_prod'); + await expect( protocol.saveMetaItem({ type: 'object', name: 'org_widget', item: OBJECT, organizationId: 'org_a' }), ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index f222317a1f..bb345be67b 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -7468,15 +7468,27 @@ export class ObjectStackProtocolImplementation implements * ## Shape decisions * * - **Registry-derived, never a hand-written type list** (Prime Directive - * #8): the predicate is {@link OVERLAY_ALLOWED_TYPES}, the same derived - * set the overlay gate uses. A type that gains `allowOrgOverride: true` - * tomorrow is admitted here the same day, with nothing to keep in sync. - * - **Env-blind, deliberately.** Unlike {@link isOverlayAllowed} this does - * NOT consult `OS_METADATA_WRITABLE`. That hatch unlocks the WRITE; it - * does not teach `loadMetaFromDb` to read the row back, so honouring it - * here would re-open the phantom in exactly the deployments most likely - * to hit it. Same reasoning, and the same direction, as the cold-boot - * audit's own env-blind type set. + * #8): the predicate is {@link isOverlayAllowed} — the same one the + * sibling refusal below it uses, over the same derived + * {@link OVERLAY_ALLOWED_TYPES} set. A type that gains + * `allowOrgOverride: true` tomorrow is admitted here the same day, with + * nothing to keep in sync. + * - **The operator hatch stays ONE door.** Because the predicate is + * `isOverlayAllowed`, `OS_METADATA_WRITABLE` unlocks org scoping exactly + * as it unlocks the overlay — which is what this file already promises a + * few lines down ("unlocking a type there unlocks it here too") and what + * the ruling asked for by naming this the *sibling* of the + * `NOT_OVERRIDABLE` refusal. Two differently-keyed notions of + * "overridable" inside one method would be the drift, not the safety. + * + * The DIAGNOSTIC is deliberately wider than the refusal: + * {@link reportUnhydratableOrgScopedRows} ignores the hatch and reports + * an org-scoped row of any non-org-overridable type, because the hatch + * unlocks the write and cannot teach `loadMetaFromDb` to read the row + * back. So an operator who deliberately opens the door still gets told, + * at every boot, that what they wrote did not survive it. Warning is + * free and should be maximal; refusing removes a capability, and the + * declaration — including its documented override — decides that. * - **Statically-declared types only.** A type with no entry in * `DEFAULT_METADATA_TYPE_REGISTRY` is plugin-registered at runtime, and * both existing gates ({@link isRuntimeCreateAllowed} here, @@ -7501,7 +7513,7 @@ export class ObjectStackProtocolImplementation implements ): Error | null { if (!organizationId) return null; const singular = PLURAL_TO_SINGULAR[type] ?? type; - if (this.OVERLAY_ALLOWED_TYPES.has(singular) || this.OVERLAY_ALLOWED_TYPES.has(type)) return null; + if (this.isOverlayAllowed(type)) return null; if (!this.STATIC_REGISTRY_TYPES.has(singular) && !this.STATIC_REGISTRY_TYPES.has(type)) return null; const err: any = new Error( `[not_overridable] Metadata item '${type}/${name}' cannot be written org-scoped ` @@ -7512,7 +7524,9 @@ export class ObjectStackProtocolImplementation implements + `(an 'object' answers 404 OBJECT_NOT_FOUND for every record in its still-populated table, a 'flow' ` + `silently stops firing). Save it env-wide instead (retry with no active organization), or ship the ` + `per-org variant as its own deployment (ADR-0005: "Per-org variants are a deployment, not an ` - + `overlay"). OS_METADATA_WRITABLE does not unlock org scoping — it unlocks the write, not the read. ` + + `overlay"). An operator may set OS_METADATA_WRITABLE=${singular} to grant a runtime escape hatch, ` + + `but note the row still will not survive a restart — the hatch unlocks the write, not the read, ` + + `and boot logs every such row it walks past. ` + `See docs/adr/0005-metadata-customization-overlay.md and #6190.` ); err.code = 'NOT_OVERRIDABLE'; diff --git a/packages/objectql/src/overlay-precedence.test.ts b/packages/objectql/src/overlay-precedence.test.ts index 04cb437835..2c6494307b 100644 --- a/packages/objectql/src/overlay-precedence.test.ts +++ b/packages/objectql/src/overlay-precedence.test.ts @@ -259,11 +259,18 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => { for (const { type, item } of runtimeCreatable) { it(`accepts brand-new ${type}`, async () => { + // [#6190] These writes used to pass `organizationId: 'org_alpha'`. + // The org was scenery: what this loop measures is the two-tier + // verdict — brand-new items of `allowRuntimeCreate` types are + // NOT caught by the overlay whitelist. Since the 2026-08-08 + // ruling an org-scoped write of these very types is refused by a + // different gate, so keeping the org here would have measured + // that refusal instead of this one. Pinned in metadata-protocol's + // `protocol.org-scoped-write-refused.test.ts`. const result = await protocol.saveMetaItem({ type, name: item.name, item, - organizationId: 'org_alpha', }); expect(result.success).toBe(true); }); diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index d16e76bc83..a0e3610f47 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -20,6 +20,15 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { description: 'A test application', }; + /** [#6190] An org-overridable specimen — `view` declares `allowOrgOverride`, + * so it is the type that may legitimately carry an org-scoped row. */ + const sampleView = { + name: 'test_grid', + type: 'grid', + label: 'Test Grid', + columns: ['id', 'title'], + }; + beforeEach(() => { // Each test owns a fresh registry instance — the protocol reads it // via `engine.registry`, mirroring the real ObjectQL contract. @@ -52,17 +61,23 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { describe('per-organization overlay isolation', () => { it('saveMetaItem persists organization_id when provided', async () => { + // [#6190] Re-spelled from `app` to `view`. The claim — an org-scoped + // save stamps `organization_id` on the row — is unchanged, but since + // the 2026-08-08 ruling only types that DECLARE a per-org channel may + // carry one, and `app` rolled back to `allowOrgOverride: false` in + // #6483. `view` is the whitelisted specimen, so this now measures the + // stamping on a row the platform can actually read back. mockEngine.findOne.mockResolvedValue(null); await protocol.saveMetaItem({ - type: 'app', - name: 'test_app', - item: sampleApp, + type: 'view', + name: 'test_grid', + item: sampleView, organizationId: 'org_alpha', }); expect(mockEngine.findOne).toHaveBeenCalledWith('sys_metadata', { // ADR-0048 — a package-less save scopes the upsert lookup to the // GLOBAL row (package_id IS NULL), not any package's row. - where: { type: 'app', name: 'test_app', organization_id: 'org_alpha', state: 'active', package_id: null }, + where: { type: 'view', name: 'test_grid', organization_id: 'org_alpha', state: 'active', package_id: null }, }); expect(mockEngine.insert).toHaveBeenCalledWith('sys_metadata', expect.objectContaining({ organization_id: 'org_alpha', @@ -276,13 +291,16 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { // installed packages keep independent customizations. mockEngine.findOne.mockResolvedValue(null); + // [#6190] `app` -> `view` for the same reason as the org-persistence + // case above: the package dimension this pins is untouched, but the + // ORG dimension now requires a type that declares a per-org channel. await protocol.saveMetaItem({ - type: 'app', name: 'test_app', item: sampleApp, + type: 'view', name: 'test_grid', item: sampleView, organizationId: 'org_alpha', packageId: 'com.acme.beta', }); expect(mockEngine.findOne).toHaveBeenCalledWith('sys_metadata', { - where: { type: 'app', name: 'test_app', organization_id: 'org_alpha', state: 'active', package_id: 'com.acme.beta' }, + where: { type: 'view', name: 'test_grid', organization_id: 'org_alpha', state: 'active', package_id: 'com.acme.beta' }, }); expect(mockEngine.insert).toHaveBeenCalledWith('sys_metadata', expect.objectContaining({ package_id: 'com.acme.beta', @@ -1372,7 +1390,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { type: 'hook', name: 'my_user_hook', item: { name: 'my_user_hook', object: 'case', events: ['beforeUpdate'] }, - organizationId: 'org_alpha', }); expect(result.success).toBe(true); @@ -1395,7 +1412,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { type: 'hook', name: 'my_user_hook', item: { name: 'my_user_hook', object: 'case', events: ['beforeInsert', 'beforeUpdate'] }, - organizationId: 'org_alpha', }); expect(result.success).toBe(true); @@ -1411,13 +1427,11 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { type: 'trigger', name: 'my_trigger', item: { name: 'my_trigger', object: 'case', event: 'beforeInsert' }, - organizationId: 'org_alpha', }); const seedResult = await scoped.saveMetaItem({ type: 'seed', name: 'my_seed', item: { object: 'case', records: [] }, - organizationId: 'org_alpha', }); expect(triggerResult.success).toBe(true); @@ -1657,7 +1671,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { target: 'alpha_task', objectParams: { object: 'alpha_task', operation: 'find' }, }, - organizationId: 'org_alpha', }); expect(result.success).toBe(true); @@ -1670,11 +1683,13 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { // save with `success: true`: no `type`, no `target`, so it could // never be executed by anything. It is now named and refused. await expect( + // [#6190] Env-wide: `api` is `allowOrgOverride: false`, so an + // org-scoped write is refused BEFORE the schema is consulted and + // this case would have measured that refusal instead of the 422. scoped.saveMetaItem({ type: 'api', name: 'my_api', item: { name: 'my_api', path: '/x', method: 'GET' }, - organizationId: 'org_alpha', }), ).rejects.toMatchObject({ code: 'INVALID_METADATA', @@ -1805,7 +1820,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { label: 'Quote', fields: { name: { type: 'text' }, amount: { type: 'number' } }, } as any, - organizationId: 'org_alpha', }); // The relaxed save path must succeed — proving the sentinel // is not treated as a real artifact origin. diff --git a/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts b/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts index 5b1633c54c..9a617b0166 100644 --- a/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts +++ b/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts @@ -198,14 +198,28 @@ describe('#6602 — WRITE seam: applyRegistryWriteThrough refuses org-scoped row expect(namesIn(registry.listItems('view'))).not.toContain('org_grid'); }); - it('an ORG-scoped flow write does not reach it either (runtime-create tier)', async () => { - const saved = await protocol.saveMetaItem({ - type: 'flow', - name: 'org_sweep', - item: flowBody('org_sweep', 'Org A sweep'), - organizationId: ORG_A, - }); - expect(saved.success).toBe(true); + it('[#6190] an ORG-scoped flow write is now refused outright — it never reaches this seam', async () => { + // Replaced wholesale rather than re-spelled. This case used to assert + // `saved.success === true` and then that the row stayed out of the + // shared registry: the runtime-create tier could mint an org-scoped + // `flow`, and #6602's job was to stop that row LEAKING into the + // process-wide registry. Since the 2026-08-08 ruling on #6190 the write + // itself is refused, one layer earlier, so the leak is closed by + // construction and the old assertion could only ever pass for an empty + // reason. + // + // The write-through gate keeps its own coverage: the `view` cases above + // exercise it with a type that CAN legitimately carry an org-scoped row, + // which is the only way to reach this seam with an org row at all. + await expect( + protocol.saveMetaItem({ + type: 'flow', + name: 'org_sweep', + item: flowBody('org_sweep', 'Org A sweep'), + organizationId: ORG_A, + }), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(registry.getItem('flow', 'org_sweep')).toBeUndefined(); expect(namesIn(registry.listItems('flow'))).not.toContain('org_sweep'); }); diff --git a/packages/objectql/src/protocol-save-meta-repo-path.test.ts b/packages/objectql/src/protocol-save-meta-repo-path.test.ts index 46182cc3f0..2008a5e917 100644 --- a/packages/objectql/src/protocol-save-meta-repo-path.test.ts +++ b/packages/objectql/src/protocol-save-meta-repo-path.test.ts @@ -307,7 +307,6 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { protocol.saveMetaItem({ type: 'object', name: 'maint_asset', - organizationId: 'org_alpha', packageId: 'app.objectstack.hotcrm', // Studio had a code package selected mode: 'draft', item: { name: 'maint_asset', label: 'Asset', fields: { name: { type: 'text', label: 'Name' } } }, @@ -330,7 +329,6 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { protocol.saveMetaItem({ type: 'object', name: 'maint_asset', - organizationId: 'org_alpha', packageId: 'platform.core', mode: 'draft', item: { name: 'maint_asset', label: 'Asset', fields: { name: { type: 'text', label: 'Name' } } }, @@ -347,10 +345,11 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { // No manifests map / empty → isLoadedPackage('com.acme.beta') is false. const inserts = spyInserts(engine); const protocol = new ObjectStackProtocolImplementation(engine); + // [#6190] Env-wide — the org was scenery; this case is about the + // PACKAGE binding surviving a runtime-only create. await protocol.saveMetaItem({ type: 'object', name: 'maint_ticket', - organizationId: 'org_alpha', packageId: 'com.acme.beta', mode: 'draft', item: { name: 'maint_ticket', label: 'Ticket', fields: { name: { type: 'text', label: 'Name' } } }, From 6f2247d0b30f57da7738213d03a9954d8c180c68 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 14:11:06 +0000 Subject: [PATCH 4/4] =?UTF-8?q?test(metadata-protocol):=20re-measure=20the?= =?UTF-8?q?=20G5=20census=20after=20#5488=20=E2=80=94=20api=20left=20the?= =?UTF-8?q?=20org-gate=20set=20for=20the=20code-only=20tier=20(18=20of=202?= =?UTF-8?q?7)=20(#6190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The census is derived, and the merged registry answers 18: #5488 withdrew api's runtime-create door on main while this branch was held, so api is now refused as NOT_CREATABLE before the #6190 gate is consulted — a strictly stronger refusal, pinned as such instead of silently dropped. Changeset's enumerated list re-measured to match. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LGRN2cSRfggfX9B2L83bQc --- .changeset/org-scoped-write-refused.md | 16 +++++++++------- .../protocol.org-scoped-write-refused.test.ts | 14 +++++++++++--- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/.changeset/org-scoped-write-refused.md b/.changeset/org-scoped-write-refused.md index 3d9667a1b3..201dbd6247 100644 --- a/.changeset/org-scoped-write-refused.md +++ b/.changeset/org-scoped-write-refused.md @@ -28,15 +28,17 @@ alternatives (save it env-wide, or ship the per-org variant as its own deployment — ADR-0005: "Per-org variants are a deployment, not an overlay"). **Which types change behaviour.** The predicate is derived from -`DEFAULT_METADATA_TYPE_REGISTRY`, never a hand-written list: 19 of its 27 +`DEFAULT_METADATA_TYPE_REGISTRY`, never a hand-written list: 18 of its 27 entries declare `allowOrgOverride: false` with `allowRuntimeCreate: true` — `object`, `field`, `hook`, `seed`, `mapping`, `page`, `app`, `action`, -`dataset`, `flow`, `datasource`, `external_catalog`, `api`, `doc`, `book`, -`permission`, `position`, `tool`, `skill`. Unaffected: `view`, `dashboard`, -`report`, `translation`, `email_template` (they have a per-org channel and -their org rows are read back on demand), plus plugin types with no static -registry entry, which keep today's behaviour. Env-wide writes of every type are -unchanged. +`dataset`, `flow`, `datasource`, `external_catalog`, `doc`, `book`, +`permission`, `position`, `tool`, `skill`. (`api` was the 19th when the ruling +was made; #5488 has since withdrawn its runtime-create door entirely, so it is +refused as code-only before this gate is consulted.) Unaffected: `view`, +`dashboard`, `report`, `translation`, `email_template` (they have a per-org +channel and their org rows are read back on demand), plus plugin types with no +static registry entry, which keep today's behaviour. Env-wide writes of every +type are unchanged. `OS_METADATA_WRITABLE` deliberately does **not** unlock the org dimension: it unlocks the write, not the read, so honouring it here would re-open the phantom diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index f084de550f..9141f58dae 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -522,7 +522,10 @@ describe('#6190 — org-scoped writes of non-org-overridable types are refused', // instead, the enforcement cases above go red rather than this one — // which is why they, not this, are the acceptance criterion. Recorded // as a measurement so the blast radius of the ruling is auditable: - // 19 of 27 registry entries change behaviour here. + // 18 of 27 registry entries change behaviour here. (It was 19 when the + // ruling was made; #5488 has since withdrawn `api`'s runtime-create + // door entirely, so `api` now sits in the CODE-ONLY tier — refused + // env-wide and org-scoped alike, before this gate is consulted.) const affected = DEFAULT_METADATA_TYPE_REGISTRY .filter((e) => !e.allowOrgOverride && e.allowRuntimeCreate) .map((e) => e.type); @@ -532,9 +535,14 @@ describe('#6190 — org-scoped writes of non-org-overridable types are refused', expect(orgOverridable).toEqual(['view', 'dashboard', 'report', 'translation', 'email_template']); // The types the maintainer ruling names explicitly, all present. - for (const t of ['object', 'field', 'hook', 'seed', 'mapping', 'api', 'flow']) { + for (const t of ['object', 'field', 'hook', 'seed', 'mapping', 'flow']) { expect(affected, `${t} must be refused org-scoped`).toContain(t); } - expect(affected).toHaveLength(19); + // `api` was also named by the ruling; it left this set for the + // stronger tier, not for a per-org channel — pin the direction. + expect( + DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'api'), + ).toMatchObject({ allowOrgOverride: false, allowRuntimeCreate: false }); + expect(affected).toHaveLength(18); }); });