From 180f79361145a438eaaec00bc71bbcb7ec90b2c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 01:20:55 +0000 Subject: [PATCH] =?UTF-8?q?fix(spec):=20flow=20=E7=9A=84=20allowOrgOverrid?= =?UTF-8?q?e=20=E5=9B=9E=E6=BB=9A=E4=B8=BA=20false=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?ADR-0005=20=E5=8E=9F=E5=88=A4,=E5=86=99=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=93=8D=E4=BA=AE=E6=8B=92=E7=BB=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DEFAULT_METADATA_TYPE_REGISTRY` 里 flow 那一行的 `allowOrgOverride: true` 出自 commit ba252da0b(反转记录见 #6191),没有 ADR 支撑,并且与 ADR-0005 白名单表 (`docs/adr/0005-metadata-customization-overlay.md:57`,automation ❌ —— "Per-org variants are a deployment, not an overlay")直接冲突。#6155 Q1=B 维持 ADR 原判,本 PR 落地契约半边。 同一行的 `supportsOverlay: false` 早就说明加载器不会合并 per-org 的 flow overlay, 所以那句 `true` 授予的是"写得进、读不回"的权限 —— #6190 实测到的正是这个幻影: 写入成功、冷启动后绑定消失。 declared = enforced(验收判据)已核验通过,不是推断:新增 `protocol.flow-org-override-closed.test.ts` 驱动真实写路径,证明对**代码包交付的** flow 做 org 覆盖返回 403 NOT_OVERRIDABLE,且不依赖部署拓扑(`environmentId` 未设置的单内核同样拒绝,走 `SysMetadataRepository.assertAllowed`)。标志位有写侧 消费者,因此正文第 2 项的"缺口另立单"分支不触发。 未收紧的一侧同样钉死:`allowRuntimeCreate` 仍为 true,租户新建**全新** flow 依旧 成功 —— 它没有影子化任何代码包自动化,正是 ADR-0005 所说的 "a deployment"。 fixture 分诊(替换而非改写):save/delete 两个 receipt-wording 测试各有一例把 flow 当作 "supportsOverlay:false 却 allowOrgOverride:true" 的标本。回滚后该组合在 flow 上不复存在,案例会因为"什么都没产生"而空绿,故整例替换为 `action` —— 同一族群的幸存成员 —— 并把这个配对从 registry 读出来钉住。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- .changeset/flow-org-override-rolled-back.md | 23 ++ .../protocol.delete-receipt-wording.test.ts | 40 ++- .../protocol.flow-org-override-closed.test.ts | 272 ++++++++++++++++++ .../src/protocol.save-receipt-wording.test.ts | 28 +- .../objectql/src/overlay-precedence.test.ts | 18 +- .../spec/src/kernel/metadata-plugin.zod.ts | 27 +- 6 files changed, 389 insertions(+), 19 deletions(-) create mode 100644 .changeset/flow-org-override-rolled-back.md create mode 100644 packages/metadata-protocol/src/protocol.flow-org-override-closed.test.ts diff --git a/.changeset/flow-org-override-rolled-back.md b/.changeset/flow-org-override-rolled-back.md new file mode 100644 index 0000000000..7bd09c670c --- /dev/null +++ b/.changeset/flow-org-override-rolled-back.md @@ -0,0 +1,23 @@ +--- +'@objectstack/spec': patch +--- + +flow 不再允许按组织覆盖:`allowOrgOverride` 回滚为 `false` + +`DEFAULT_METADATA_TYPE_REGISTRY` 里 `flow` 那一行的 `allowOrgOverride: true` 来自 +commit ba252da0b,没有 ADR 支撑,并且与 ADR-0005 的白名单表(automation ❌ —— +「Carry execution side-effects (events, jobs, audit). Per-org variants are a +deployment, not an overlay.」)直接冲突;同一行的 `supportsOverlay: false` 也早已 +说明加载器根本不会合并 per-org 的 flow overlay。两者相加的结果是:写入被批准、 +落库成功,而下一次冷启动读路径把该行滤掉,绑定消失。 + +现在这类写入在发生的那一刻就被响亮拒绝:对**已由代码包交付的** flow 做 org 覆盖 +返回 `403 not_overridable`(与 `object` 同一条路径,且不依赖部署拓扑 —— +`environmentId` 未设置的单内核同样拒绝)。 + +未收紧、也不打算收紧的一侧:`allowRuntimeCreate` 仍为 `true`,租户依旧可以通过 +运行时 API 新建一条**全新的** flow —— 它没有影子化任何代码包交付的自动化,正是 +ADR-0005 所说的「a deployment」。 + +影响面:此前唯一能走通这条写路径的是「写入成功但重启即失绑」的幻觉状态,收掉的 +是幻觉而不是能力。 diff --git a/packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts b/packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts index 1a5222abaf..b9cd69936c 100644 --- a/packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts +++ b/packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts @@ -163,6 +163,13 @@ describe('#5927 — a delete receipt names what actually happened', () => { expect(entry('object')).toMatchObject({ allowRuntimeCreate: true, allowOrgOverride: false }); expect(entry('view')).toMatchObject({ allowRuntimeCreate: true, allowOrgOverride: true }); expect(entry('job')).toMatchObject({ allowRuntimeCreate: false, allowOrgOverride: false }); + // #6283 — the overlay-less-yet-overridable specimen the last case in + // this file needs. It was `flow` until that issue rolled flow's + // `allowOrgOverride` back to `false` (ADR-0005:57); `action` is the + // surviving member of that pairing. If it ever leaves too, the case + // below must be re-read rather than repaired — there would be no type + // left that can be both overlay-less and per-org overridden. + expect(entry('action')).toMatchObject({ supportsOverlay: false, allowOrgOverride: true }); }); // ── repository path — sentence 2 (a row was deleted) ────────────────── @@ -211,22 +218,35 @@ describe('#5927 — a delete receipt names what actually happened', () => { ); }); - it('an overlay of a packaged FLOW — supportsOverlay:false, and still a real reset', async () => { - // The mirror case, and the sharpest one. `flow` declares + it('an overlay of a packaged ACTION — supportsOverlay:false, and still a real reset', async () => { + // The mirror case, and the sharpest one. `action` declares // `supportsOverlay: false` yet is `allowOrgOverride: true`, so a - // packaged flow really can be overridden at runtime — and then lifting - // that overlay really does restore the packaged default. A receipt - // decided by `supportsOverlay` would get this exactly backwards; one - // decided by artifact backing gets it right. + // packaged action really can be overridden at runtime — and then + // lifting that overlay really does restore the packaged default. A + // receipt decided by `supportsOverlay` would get this exactly + // backwards; one decided by artifact backing gets it right. + // + // The specimen was `flow` until #6283 rolled that type's + // `allowOrgOverride` back to `false` (ADR-0005:57 — automation carries + // execution side-effects, so a per-org variant is a deployment, not an + // overlay). This is a REPLACEMENT, not a re-spelling: after the + // rollback a packaged flow cannot be overridden at all, so the case + // would have gone green for the empty reason — `assertAllowed` refuses + // the write before any receipt is built, and there is no overlay left + // to lift. `action` is the surviving member of the same population + // (`supportsOverlay: false` + `allowOrgOverride: true`), so the + // distinction this case exists to prove is still exercised. The + // premise pin below reads that pairing from the registry rather than + // restating it, so a future flip of `action` lands here loudly. const { protocol } = tenantProtocol({ - rows: [overlayRow('flow', 'rc9_escalate')], - artifacts: [{ type: 'flow', name: 'rc9_escalate' }], + rows: [overlayRow('action', 'rc9_escalate')], + artifacts: [{ type: 'action', name: 'rc9_escalate' }], }); - const result = await protocol.deleteMetaItem({ type: 'flow', name: 'rc9_escalate' }); + const result = await protocol.deleteMetaItem({ type: 'action', name: 'rc9_escalate' }); expect(result.message).toBe( - `Customization overlay deleted — flow/rc9_escalate reset to artifact default. [seq=${result.seq}]`, + `Customization overlay deleted — action/rc9_escalate reset to artifact default. [seq=${result.seq}]`, ); }); 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 new file mode 100644 index 0000000000..345896c504 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.flow-org-override-closed.test.ts @@ -0,0 +1,272 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #6283 — `flow` is not per-org overridable, and the WRITE PATH says so. + * + * The registry row for `flow` carried `allowOrgOverride: true` from commit + * ba252da0b (recorded, unreviewed, in #6191) while + * `docs/adr/0005-metadata-customization-overlay.md:57` said — and had always + * said — the opposite: + * + * | automation | `flow`, `workflow`, `approval` | ❌ | Carry execution + * | side-effects (events, jobs, audit). Per-org variants are a deployment, + * | not an overlay. | + * + * #6155 Q1=B upheld the ADR, so the flag rolled back to `false`. That is a + * one-word diff in `packages/spec`; this file is the other half of it, and the + * half the issue named as the acceptance criterion: **declared = enforced**. + * A flag flipped in a registry nobody reads on the write path would have + * changed a document, not a behaviour — #6190 measured this family's failure + * mode exactly ("the write path is more permissive than the read path"), where + * an org-scoped flow overlay was accepted, persisted, and then lost its + * binding on the next cold start. The point of the rollback is that the write + * now fails LOUDLY at the moment it is attempted instead of succeeding into a + * phantom. + * + * --------------------------------------------------------------------------- + * What the flag does and does NOT close — measured, not assumed + * --------------------------------------------------------------------------- + * `allowOrgOverride` governs ONE of the two tiers ADR-0005's extension + * defines, and this file pins both sides so the boundary is not mistaken for + * an oversight later: + * + * • OVERRIDING A PACKAGED FLOW (`intent: 'override-artifact'`) — closed by + * this change. 403 `NOT_OVERRIDABLE`. This is the overlay ADR-0005 refuses. + * • CREATING A BRAND-NEW FLOW (`intent: 'runtime-only'`) — still open, and + * deliberately so: `allowRuntimeCreate` stays `true`, no code-shipped + * automation is being shadowed, and that write is what the ADR means by + * "a deployment". Nothing in #6283 touches it. + * + * --------------------------------------------------------------------------- + * Reverse verification, direction predicted BEFORE running + * --------------------------------------------------------------------------- + * Ordinary red, with a deliberately green half — predicted, then measured. + * Restoring `allowOrgOverride: true` on the `flow` registry row turns the two + * declaration pins AND the two refusal cases red, and leaves the + * runtime-create case and the `view` control green, because neither reads that + * flag. Predicted 4 red / 2 green; measured 4 red / 2 green, and the refusal + * case failed in the shape that names the bug rather than merely a different + * error: + * + * AssertionError: promise resolved "{ success: true, …(4) }" instead of + * rejecting + * + * — the accepted-then-unbindable write #6190 measured, reproduced on demand. + * The green half is not slack: a "fix" that closed `flow` by making the whole + * type unwritable would pass the red half and fail here, and a harness that + * could not produce a successful save at all would pass the red half for the + * wrong reason — which is what the `view` control exists to exclude. + * + * Harness: the real write path over a stub engine, the same shape as + * `protocol.save-receipt-wording.test.ts` — the gate runs INSIDE + * `saveMetaItem` / `SysMetadataRepository.put`, so a harness that mocks either + * cannot see it. + */ +import { 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; + state: string; + metadata: string; +} + +function makeStubEngine(artifacts: Array<{ type: string; name: string }> = []) { + const rows = new Map(); + let nextId = 0; + const artifactKeys = new Set(artifacts.map((a) => `${a.type}|${a.name}`)); + const keyOf = (w: Record) => + `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}`; + const engine: any = { + async findOne(_t: string, opts: { where: Record }) { + for (const row of rows.values()) { + if (opts.where.type !== undefined && row.type !== opts.where.type) continue; + if (opts.where.name !== undefined && row.name !== opts.where.name) continue; + if (opts.where.state !== undefined && row.state !== opts.where.state) continue; + return row; + } + return null; + }, + async find() { return []; }, + async insert(_t: string, data: Record) { + if (_t !== 'sys_metadata') return { id: 'side_effect_skip' }; + nextId += 1; + const row = { id: `r_${nextId}`, ...(data as any) } as Row; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update(_t: string, data: Record, opts?: Record) { + assertEngineUpdateDispatch(data, opts); + return { id: null }; + }, + async delete(_t: string, opts?: Record) { + assertEngineDeleteDispatch(opts); + return { deleted: 0 }; + }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + listItems: () => [], + getItem: () => undefined, + // `isArtifactBacked` prefers this lookup — a hit here 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 }; +} + +/** A schema-VALID flow body — a minimal one 422s before the gate is reached. */ +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' }], +}; + +/** The control specimen: still `allowOrgOverride: true`, untouched by #6283. */ +const VIEW = { + name: 'overdue_grid', + label: 'Overdue', + object: 'task', + columns: [{ field: 'name', label: 'Name' }], +}; + +const entry = (type: string) => DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === type); + +describe('#6283 — flow: allowOrgOverride rolled back to false', () => { + // ── the declaration ─────────────────────────────────────────────────── + + it('the registry declares flow non-overridable, and the row no longer contradicts itself', () => { + // Both halves of the ADR-0005 verdict in one assertion. The pairing is + // the point: `supportsOverlay: false` says the LOADER cannot merge a + // per-org flow overlay, so `allowOrgOverride: true` had been granting + // permission for a write nothing could ever read back — the phantom + // #6190 measured. `allowRuntimeCreate` stays true on purpose; see the + // two-tier case below. + expect(entry('flow')).toMatchObject({ + supportsOverlay: false, + allowOrgOverride: false, + allowRuntimeCreate: true, + }); + }); + + it('no parallel allowlist — the derived overlay set drops flow with it', () => { + // Prime Directive #8: `OVERLAY_ALLOWED_TYPES` is DERIVED from this + // registry, in both `protocol.ts` and `sys-metadata-repository.ts`. + // If anyone re-adds flow to a hand-written list instead, the refusal + // cases below go red rather than this one — which is why they, not + // this, are the acceptance criterion. + const derived = new Set( + DEFAULT_METADATA_TYPE_REGISTRY.filter((e) => e.allowOrgOverride).map((e) => e.type), + ); + expect(derived.has('flow')).toBe(false); + }); + + // ── the enforcement (the acceptance criterion) ──────────────────────── + + it('an ORG-scoped overlay of a packaged flow is refused loudly, not accepted', async () => { + // The exact write #6190 watched succeed and then lose its binding. + // With the flag off it never reaches the store at all. + const { protocol, rows } = makeProtocol([{ type: 'flow', name: 'escalate_overdue' }], 'env_prod'); + + await expect( + protocol.saveMetaItem({ + type: 'flow', + name: 'escalate_overdue', + item: FLOW, + organizationId: 'org_alpha', + }), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + + // Refused, not "refused after writing" — the phantom row is the thing + // #6190 was about, so its absence is part of the claim. + expect(rows.size).toBe(0); + }); + + it('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` at the PROTOCOL layer, but + // `SysMetadataRepository.assertAllowed` is not — it refuses an + // `override-artifact` write on any kernel. Pinned because a rollback + // 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([{ type: 'flow', name: 'escalate_overdue' }]); + + await expect( + protocol.saveMetaItem({ type: 'flow', name: 'escalate_overdue', item: FLOW }), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(rows.size).toBe(0); + }); + + // ── the half that stays open, deliberately ──────────────────────────── + + it('a BRAND-NEW org 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. + 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); + }); + + // ── the control that makes the red half mean something ──────────────── + + it('view — still allowOrgOverride:true — is still accepted over a packaged artifact', async () => { + // Without this, the two refusals above would also pass on a harness + // that could not save ANYTHING. `view` is the type ADR-0005 whitelists + // and #6283 does not touch. + const { protocol } = makeProtocol([{ type: 'view', name: 'overdue_grid' }], 'env_prod'); + + const result = await protocol.saveMetaItem({ + type: 'view', + name: 'overdue_grid', + item: VIEW, + organizationId: 'org_alpha', + }); + + expect(result.success).toBe(true); + }); +}); 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 1ed1ace511..639ae4a22e 100644 --- a/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts +++ b/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts @@ -163,6 +163,14 @@ describe('#5265 — a save receipt names what was actually written', () => { for (const type of Object.keys(OVERLAYLESS_PROBES)) { expect(OVERLAYLESS_RUNTIME_WRITABLE, `${type} left the overlay-less set`).toContain(type); } + // #6283 — the override-artifact case at the bottom of this file needs a + // type that is BOTH overlay-less and per-org overridable. `flow` played + // that part until its `allowOrgOverride` was rolled back to `false` + // (ADR-0005:57); `action` is the surviving member. Pinned from the + // registry so a further flip re-opens the case instead of silently + // turning it into an untested `NOT_OVERRIDABLE`. + const action = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'action'); + expect(action).toMatchObject({ supportsOverlay: false, allowOrgOverride: true }); }); // ── runtime-only: nothing was overlaid, so nothing may claim it was ── @@ -268,10 +276,10 @@ describe('#5265 — a save receipt names what was actually written', () => { ); }); - it('an overlay of a packaged FLOW — supportsOverlay:false, and still an override', async () => { + it('an overlay of a packaged ACTION — supportsOverlay:false, and still an override', async () => { // The mirror of the first block, and the sharpest case in this file. - // `flow` sits in the overlay-less population above (`supportsOverlay: - // false`) yet is `allowOrgOverride: true`, so a packaged flow really + // `action` sits in the overlay-less population above (`supportsOverlay: + // false`) yet is `allowOrgOverride: true`, so a packaged action really // can be overridden at runtime — and then the overlay sentence is the // true one. A receipt decided by `supportsOverlay` would get this // exactly backwards; one decided by artifact backing gets it right. @@ -280,14 +288,22 @@ describe('#5265 — a save receipt names what was actually written', () => { // `SysMetadataRepository.assertAllowed` refuses an `override-artifact` // write with `[NOT_OVERRIDABLE]` before any receipt is built. Measured, // not assumed — this case was written against `object` first.) - const { protocol } = makeProtocol([{ type: 'flow', name: 'rc5_acct' }]); + // + // The specimen was `flow` until #6283 rolled flow's `allowOrgOverride` + // back to `false` (ADR-0005:57 — automation carries execution + // side-effects, so a per-org variant is a deployment, not an overlay). + // A REPLACEMENT rather than a re-spelling: with the flag off, `flow` + // joined `object` in the sentence above and can no longer reach a + // receipt at all. `action` is the surviving member of the pairing this + // case needs, and the premise pin below reads it from the registry. + const { protocol } = makeProtocol([{ type: 'action', name: 'rc5_acct' }]); const result = await protocol.saveMetaItem({ - type: 'flow', name: 'rc5_acct', item: OVERLAYLESS_PROBES.flow, + type: 'action', name: 'rc5_acct', item: OVERLAYLESS_PROBES.action, }); expect(result.message).toBe( - `Saved customization overlay (env-wide, state=active) — type=flow, name=rc5_acct [seq=${result.seq}]`, + `Saved customization overlay (env-wide, state=active) — type=action, name=rc5_acct [seq=${result.seq}]`, ); }); diff --git a/packages/objectql/src/overlay-precedence.test.ts b/packages/objectql/src/overlay-precedence.test.ts index 180e9ac4eb..31e72f756b 100644 --- a/packages/objectql/src/overlay-precedence.test.ts +++ b/packages/objectql/src/overlay-precedence.test.ts @@ -18,7 +18,12 @@ * and profile all flipped to `allowOrgOverride: true` in commit * ba252da0b (feat: add project mode, metadata forms, and org * overlays). The invariant now pins the execution/wiring-layer - * types that MUST stay false. + * types that MUST stay false. Several of that commit's flips have + * since been rolled back for want of an ADR behind them — + * object/field (2026-05-29), agent (ADR-0063 §2) and, with #6283, + * `flow`: ADR-0005:57 lists automation as ❌ and always did, so the + * registry had been contradicting the document it is the + * machine-readable form of. * * 2. **Canonical hash stability** — every overlay row will carry a * content hash once PR-10b lands. The hash must be insensitive @@ -320,7 +325,16 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => { // (packaged-object lock; tenants create new ones via runtime-create). expect(allowedFromRegistry.has('object')).toBe(false); expect(allowedFromRegistry.has('field')).toBe(false); - expect(allowedFromRegistry.has('flow')).toBe(true); + // #6283 — `flow` rolled BACK to allowOrgOverride:false. The `true` + // this line used to assert came from commit ba252da0b (see the file + // header) and never had an ADR behind it: ADR-0005's amendment + // table (`docs/adr/0005-metadata-customization-overlay.md:57`) has + // always listed automation as ❌ ("Per-org variants are a + // deployment, not an overlay"), because flows carry execution + // side-effects. #6155 Q1=B upheld the ADR. Doubles as a + // reintroduction guard now: re-opening flow requires amending + // ADR-0005, not editing this line. + expect(allowedFromRegistry.has('flow')).toBe(false); // ADR-0020: `workflow` retired as a metadata type. expect(allowedFromRegistry.has('workflow')).toBe(false); // ADR-0063 §2: tenant custom agents withdrawn — `agent` is now diff --git a/packages/spec/src/kernel/metadata-plugin.zod.ts b/packages/spec/src/kernel/metadata-plugin.zod.ts index 6115c673e4..3e0e69d541 100644 --- a/packages/spec/src/kernel/metadata-plugin.zod.ts +++ b/packages/spec/src/kernel/metadata-plugin.zod.ts @@ -652,7 +652,32 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntryParsed[] = // nodes inside a `flow`, so they load and version with their enclosing flow. // ADR-0020: there is no `workflow` metadata type — record state machines are // a `state_machine` validation rule on the object, not a standalone artifact. - { type: 'flow', label: 'Flow', filePatterns: ['**/*.flow.ts', '**/*.flow.yml', '**/*.flow.json'], supportsOverlay: false, allowOrgOverride: true, allowRuntimeCreate: true, supportsVersioning: true, executionPinned: true, loadOrder: 80, domain: 'automation' }, + // + // `allowOrgOverride: false` — ROLLED BACK from `true` (#6283, settling the + // contract half of #6155 Q1=B; the unreviewed flip itself is recorded in + // #6191, commit ba252da0b). ADR-0005's amendment table + // (`docs/adr/0005-metadata-customization-overlay.md:57`) has never said + // anything else about this row: + // + // | automation | `flow`, `workflow`, `approval` | ❌ | Carry execution + // | side-effects (events, jobs, audit). Per-org variants are a deployment, + // | not an overlay. | + // + // That ADR is what `OVERLAY_ALLOWED_TYPES` derives from (Prime Directive #8), + // so `true` here was the registry contradicting the document it is supposed + // to be the machine-readable form of — and contradicting its OWN row, which + // declares `supportsOverlay: false`: the loader cannot merge a per-org flow + // overlay, so the write permission granted a write nothing could ever read + // back. #6190 measured exactly that phantom: an org-scoped flow overlay wrote + // successfully and lost its binding on the next cold start. Rolling the flag + // back turns that silent phantom into a loud `403 not_overridable` at the + // moment of the write. + // + // NOT closed by this flag: `allowRuntimeCreate` stays `true`, so a tenant may + // still author a BRAND-NEW flow through the runtime API (the two-tier model — + // that write overlays no code-shipped automation and is what ADR-0005 means by + // "a deployment"). What is closed is overlaying a PACKAGED flow per org. + { type: 'flow', label: 'Flow', filePatterns: ['**/*.flow.ts', '**/*.flow.yml', '**/*.flow.json'], supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: true, supportsVersioning: true, executionPinned: true, loadOrder: 80, domain: 'automation' }, // `job`: A JOB IS A CODE ARTIFACT, and the flags now say so (#4509). // // `JobSchema.handler` is the name of a function in the compiled bundle's