diff --git a/.changeset/20679-automation-door-package-lock.md b/.changeset/20679-automation-door-package-lock.md new file mode 100644 index 00000000000..0f7fe590958 --- /dev/null +++ b/.changeset/20679-automation-door-package-lock.md @@ -0,0 +1,21 @@ +--- +'@objectstack/runtime': patch +'@objectstack/metadata-protocol': minor +--- + +fix(runtime): the `/automation` write doors refuse a packaged flow, as the metadata door does (#20679) + +Clause-②: yes (widening) + +A flow that a code package ships has a locked base (ADR-0126 §2): changing or removing it in place is refused. `PUT /api/v1/meta/flow/:name` already refused it. The two `/automation` definition doors did not: an administrator holding `manage_metadata` could rewrite a packaged flow in the live engine with `PUT /api/v1/automation/:name` or with `POST /api/v1/automation` under its name (a create onto an existing name overwrites it), or remove it with `DELETE /api/v1/automation/:name`. + +All three now answer a packaged flow with the same code and status the metadata door gives (`403` `NOT_OVERRIDABLE`), and with the same sentence wherever the metadata protocol's own package door answers. The refusal comes before the engine is called, so nothing is registered or removed. On `DELETE`, it also comes before the engine's own `DELETE_RESTRICTED` / `409` for a packaged subflow that packaged callers still reach. + +What is not refused: + +- A flow that no code package ships, including a flow created with `POST /api/v1/automation` or authored through the metadata door. It is updated and removed as before. +- `POST /api/v1/automation/:name/clone`, which copies a packaged flow under a new name. This is the supported way to customize one (ADR-0126 §7.1). +- `POST /api/v1/automation/:name/toggle`, the switch that turns a packaged flow on or off (ADR-0126 §7.2). +- A deployment that sets `OS_METADATA_WRITABLE=flow`. It opens both doors, as the refusal message says. + +**The widening.** `@objectstack/metadata-protocol` gains one public method, `ObjectStackProtocolImplementation.packagedBaseRefusal({ type, name, operation })`. It returns the refusal the metadata door would give for writing (`'save'`) or removing (`'delete'`) an existing item because a code package ships it, or `null` when that door would not refuse on this ground. `saveMetaItem` and `deleteMetaItem` call the same code, so the two doors cannot disagree. Their own refusals are unchanged. diff --git a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts new file mode 100644 index 00000000000..48f156f8686 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20679, ADR-0126 §2] `packagedBaseRefusal` — the `/meta` door's + * locked-base verdict, as a value, for a second door onto the same artifact. + * + * It is not a new rule. It hands out the SAME verdict `saveMetaItem` and + * `deleteMetaItem` reach — the package doors both now call, lifted out of them + * unchanged, `refusePackagedBaseOverride` / `refusePackagedBaseRemoval` — so this file + * pins two things and only two: + * + * 1. it answers exactly what those two methods throw for the same item (the + * ONE-emitter claim: same code, status and sentence); + * 2. its matrix is the metadata door's matrix, including the answers that are + * deliberately `null` — a name no package ships, a Regime O overlay type, + * the #6960 delete carve-out, and the operator hatch. + * + * The registry double serves only `getArtifactItem`, which is all the verdict + * reads; what it returns is what the real `SchemaRegistry` returns for an + * artifact a code package registered (`_packageId` stamped, package + * provenance). `@objectstack/objectql` cannot be imported here: it depends on + * this package. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; + +const PACKAGE_ID = 'com.example.pkg'; + +const shipped = (name: string, extra: Record = {}) => + ({ name, label: name, _packageId: PACKAGE_ID, _provenance: 'package', ...extra }); + +/** Packaged artifacts of three regimes: behavioral (`flow`), overlay (`view`), rolled-back overlay (`page`). */ +const ARTIFACTS = new Map>([ + ['flow', new Map([['pkg_flow', shipped('pkg_flow', { type: 'autolaunched', nodes: [], edges: [] })]])], + ['view', new Map([['pkg_view', shipped('pkg_view')]])], + ['page', new Map([['pkg_page', shipped('pkg_page')]])], +]); + +function protocolOn(environmentId: string | undefined): ObjectStackProtocolImplementation { + const registry = { getArtifactItem: (type: string, name: string) => ARTIFACTS.get(type)?.get(name) }; + return new ObjectStackProtocolImplementation({ registry } as never, () => new Map(), environmentId); +} + +const shape = (e: any) => (e ? { code: e.code, status: e.status } : null); + +afterEach(() => { + delete process.env.OS_METADATA_WRITABLE; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); +}); + +describe('packagedBaseRefusal — the /meta door\'s locked-base verdict, handed to a second door', () => { + for (const environmentId of [undefined, 'env_1']) { + it(`refuses a packaged flow's save AND removal on ${environmentId ? 'an environment' : 'a host-config'} kernel`, () => { + const p = protocolOn(environmentId); + expect(shape(p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }))) + .toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(shape(p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' }))) + .toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + }); + } + + it('ONE emitter: the value equals what saveMetaItem / deleteMetaItem throw for the same item', async () => { + const p = protocolOn('env_1'); + const thrownBy = (run: Promise) => run.then(() => undefined, (e: any) => e); + + const save = await thrownBy(p.saveMetaItem({ type: 'flow', name: 'pkg_flow', item: { name: 'pkg_flow', label: 'x' } })); + const saveVerdict: any = p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }); + expect({ ...shape(save), message: save?.message }).toEqual({ ...shape(saveVerdict), message: saveVerdict?.message }); + + const del = await thrownBy(p.deleteMetaItem({ type: 'flow', name: 'pkg_flow' })); + const delVerdict: any = p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' }); + expect({ ...shape(del), message: del?.message }).toEqual({ ...shape(delVerdict), message: delVerdict?.message }); + }); + + it('folds the type at the producer — a plural spelling cannot address around the lock', () => { + const p = protocolOn(undefined); + expect(shape(p.packagedBaseRefusal({ type: 'flows', name: 'pkg_flow', operation: 'save' }))) + .toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + }); + + it('a name no code package ships is not locked — creation is not this verdict\'s question', () => { + const p = protocolOn(undefined); + expect(p.packagedBaseRefusal({ type: 'flow', name: 'customer_flow', operation: 'save' })).toBeNull(); + expect(p.packagedBaseRefusal({ type: 'flow', name: 'customer_flow', operation: 'delete' })).toBeNull(); + }); + + it('a Regime O overlay type (allowOrgOverride) is never refused on this ground', () => { + const p = protocolOn(undefined); + expect(p.packagedBaseRefusal({ type: 'view', name: 'pkg_view', operation: 'save' })).toBeNull(); + expect(p.packagedBaseRefusal({ type: 'view', name: 'pkg_view', operation: 'delete' })).toBeNull(); + }); + + it('mirrors the #6960 carve-out: a rolled-back overlay type refuses the write but not the removal', () => { + const p = protocolOn(undefined); + expect(shape(p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' }))) + .toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'delete' })).toBeNull(); + }); + + it('a lookup that FAILS is re-raised, never handed out as a verdict', () => { + // The lifted helpers throw, as the inline code did; only the refusal is + // turned into a value. A registry that cannot answer must not become a + // well-formed "refused" (or "allowed") — it stays the fault it is. + const registry = { getArtifactItem: () => { throw new Error('registry unreadable'); } }; + const p = new ObjectStackProtocolImplementation({ registry } as never, () => new Map(), undefined); + expect(() => p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' })) + .toThrow('registry unreadable'); + expect(() => p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' })) + .toThrow('registry unreadable'); + }); + + it('reads the operator hatch through the same predicate the metadata door reads', () => { + process.env.OS_METADATA_WRITABLE = 'flow'; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + const p = protocolOn(undefined); + expect(p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' })).toBeNull(); + expect(p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' })).toBeNull(); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts b/packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts index 3a9cc0a57b4..01ac3de7e2f 100644 --- a/packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts +++ b/packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts @@ -210,7 +210,7 @@ async function expectSpellingRefusal(run: () => Promise) { } describe('#9157 — the population, re-derived from the code rather than from the card', () => { - it('every `/meta` request-boundary verb with a required `type` calls the fold — all thirteen', () => { + it('every `/meta` request-boundary verb with a required `type` calls the fold — all fourteen', () => { // ⭐ The card hand-listed "nine fold, three do not". Hand-listed sets of // this shape have shipped short before, so the set is DERIVED here and // the derivation is the pin: a tenth verb arriving unfolded turns this @@ -280,6 +280,9 @@ describe('#9157 — the population, re-derived from the code rather than from th // in-process caller hands it a type spelling too. 'getMetaItemsForExecution', 'historyMetaItem', + // [#20679] The locked-base verdict a second write door asks — it + // takes the type a caller names, so it folds at the producer too. + 'packagedBaseRefusal', 'publishMetaItem', 'rollbackMetaItem', 'saveMetaItem', diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 77cb8eeb221..73d008481f2 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -14191,6 +14191,227 @@ export class ObjectStackProtocolImplementation implements return Object.prototype.hasOwnProperty.call(fields, name.slice(sep + 1)); } + /** + * [#20679, ADR-0126 §2] The `/meta` door's LOCKED-BASE verdict on an item + * that already exists, as a VALUE — for a second door that writes or + * removes the same artifact without passing through {@link saveMetaItem} / + * {@link deleteMetaItem}. + * + * The one caller today is `@objectstack/runtime`'s `/automation` domain: + * `PUT /automation/:name` re-registers a flow definition in the live + * automation engine and `DELETE /automation/:name` unregisters one, and + * neither is a `sys_metadata` write — so neither ever reached this class's + * refusals, nor `SysMetadataRepository`'s. A packaged flow the `/meta` door + * refuses to change could be changed, or removed, in place through that + * door on the `manage_metadata` authoring gate alone: the ADR-0126 §2 + * promise ("the packaged base is locked — in-place edit refused loudly at + * the write door") kept by one door onto the artifact and not the other. + * + * ## Asked HERE, never re-derived by the caller + * + * Both answers are the ones this class already gives: {@link + * refusePackagedBaseOverride} is `saveMetaItem`'s package door and {@link + * refusePackagedBaseRemoval} is `deleteMetaItem`'s, each lifted out of that + * method UNCHANGED and called by it exactly where the inline code stood. + * They THROW, as that code did; this method is the one place a throw is + * turned into a value, and it re-raises anything that is not the refusal. + * "Is this artifact-backed?" ({@link isArtifactBacked}: the registry's + * artifact-only lookup, which answers from the entries the artifact loader + * registered under a package id — never from the body a caller sends) and + * "does the type have an overlay channel?" ({@link isOverlayAllowed}, + * `allowOrgOverride` plus the `OS_METADATA_WRITABLE` hatch) keep exactly + * one spelling. The refusal — code, status and sentence — is registered to + * this package in the ADR-0112 ledger, so the caller relays it verbatim + * and stamps no code of its own. + * + * ## Topology-INDEPENDENT, deliberately + * + * `saveMetaItem` asks its package door behind `environmentId !== undefined` + * because on a host-config kernel the SAME predicate is enforced one layer + * down, by `SysMetadataRepository.assertAllowed` at the write itself — so + * the `/meta` door refuses a packaged item's in-place write on every + * topology. A caller whose write never reaches the repository has no such + * second layer, so it is answered here on every topology. The removal side + * agrees: the `/meta` door never removes a packaged base on any topology + * (on a host-config kernel with no overlay row its delete is a no-op that + * leaves the artifact standing, and with one the repository's delete gate + * refuses). + * + * ## What it deliberately does NOT answer + * + * - a name no code package ships (not artifact-backed ⇒ `null`) — + * creation is the `allowRuntimeCreate` tier's question, never this + * lock's; + * - the ADR-0010 per-item `_lock` (L3), which `saveMetaItem` enforces + * separately and records to `sys_metadata_audit`: with the hatch shut, + * an artifact-backed item of a type without `allowOrgOverride` is + * refused here before its `_lock` could matter; + * - a named base (`?package=`): the second door carries none, so the + * package-less refusal (`NOT_OVERRIDABLE`) is the whole answer there. + * + * @returns the refusal to relay, or `null` when the `/meta` door would not + * refuse this write or removal on the locked-base ground. + */ + packagedBaseRefusal(request: { type: string; name: string; operation: 'save' | 'delete' }): Error | null { + // [#9009] Folded HERE, at the producer of the verdict, so a caller that + // arrives with a plural spelling cannot address around the lock. + const folded = canonicalizeMetaRequestType(request); + try { + if (folded.operation === 'delete') this.refusePackagedBaseRemoval(folded); + else this.refusePackagedBaseOverride({ type: folded.type, name: folded.name }); + } catch (err) { + if (ObjectStackProtocolImplementation.isPackagedBaseRefusal(err)) return err; + throw err; + } + return null; + } + + /** + * The two refusals {@link refusePackagedBaseOverride} and {@link + * refusePackagedBaseRemoval} can raise — `NOT_OVERRIDABLE`, and + * `ITEM_LOCKED` from `readOnlyBaseOverrideError` on a named read-only base + * — both `403`. Anything else a lookup might throw is not a verdict and is + * re-raised by {@link packagedBaseRefusal}, never handed out as one. + */ + private static isPackagedBaseRefusal(err: unknown): err is Error { + if (!(err instanceof Error)) return false; + const { code, status } = err as Error & { code?: unknown; status?: unknown }; + return status === 403 && (code === 'NOT_OVERRIDABLE' || code === 'ITEM_LOCKED'); + } + + /** + * [#8184] THE PACKAGE DOOR — `saveMetaItem`'s refusal of a write onto an + * item a code package ships, on a type with no per-org overlay channel. + * Throws the refusal; returns when the write is not refused on that ground. + * + * [#20679] Lifted out of `saveMetaItem` UNCHANGED — the `if` below, its + * record and both emitters are that method's lines, byte for byte apart + * from indentation — so {@link packagedBaseRefusal} can hand a second write + * door the same verdict. `saveMetaItem` calls it at the same position + * (behind `environmentId !== undefined`, below the code-only and org-scope + * refusals, above the ADR-0010 `_lock` check). The one added line computes + * `overlayAllowed` the way `saveMetaItem` computes it at its top. In the + * record, "the block comment above" and "this method" mean `saveMetaItem`. + */ + private refusePackagedBaseOverride( + request: { type: string; name: string; packageId?: string | null }, + ): void { + const overlayAllowed = ObjectStackProtocolImplementation.isOverlayAllowed(request.type); + const artifactBacked = this.isArtifactBacked(request.type, request.name); + if (artifactBacked && !overlayAllowed) { + // [#8184] THE PACKAGE DOOR — the SECOND refusal point for one + // condition, and the reason this card exists. + // + // `SysMetadataRepository.assertAllowed` reads the base the + // caller NAMED and answers `ITEM_LOCKED` (`lockSource: + // 'package'`) when it is read-only (#7682, then #8146's + // hatch ruling). That door is topology-INDEPENDENT — and it + // was unreachable here, because this branch throws first on + // every kernel with an `environmentId`. So one request + // answered `ITEM_LOCKED` on a host-config / CLI-assembled + // kernel and the undiscriminated `NOT_OVERRIDABLE` on a + // project/cloud per-env one: the refusal VOCABULARY keyed off + // a row-scoping key, which is the #5086 / #6710 finding + // (see the block comment above) arriving on the error codes. + // A client that learns to handle `ITEM_LOCKED` on one + // deployment never saw it on the other. + // + // ⚠️ MIRRORED, NOT RE-INVENTED. Same predicate + // ({@link isWritablePackage}, the ADR-0070 rule in one + // place), same emitter — `readOnlyBaseOverrideError` is + // called, not copied — so the code, the status, the + // `lockSource`, the `packageId` and the sentence cannot drift + // between the two doors. Two independently-authored refusals + // for one condition is how `NOT_OVERRIDABLE`-everywhere + // started. + // + // THE LIMB ORDERING IS THE RULE, and it is the same ordering + // the repository states: BELOW every registry limb, ABOVE the + // hatch limb. + // • Below the registry limb — this whole branch is guarded + // by `!overlayAllowed`, so an `allowOrgOverride` type + // never reaches the door. That is ADR-0005: an org + // overlay of a code-shipped item ALWAYS names the + // read-only package it customizes, and a door one limb + // higher would close the overlay model outright. Pinned. + // • Above the hatch limb — `isOverlayAllowed` folds + // `OS_METADATA_WRITABLE` in, so an OPEN hatch takes the + // write past this branch entirely, down to the repository + // door, which applies the same rule with `hatchOpen: + // true` and its own remedy. The hatch therefore still + // never unlocks package writability on this topology + // either (#8146 NARROW), and both directions of that + // remedy selection are pinned in + // `sys-metadata-repository.package-writability.test.ts`. + // That is also why `hatchOpen` is passed as a literal + // `false` here rather than recomputed: reaching this line + // PROVES the hatch is closed, and a recomputed value + // would be dead code dressed as a decision. + // + // ⛔ NARROW, exactly as the repository is: only a write that + // NAMES a read-only base is re-coded. A package-less write + // keeps `NOT_OVERRIDABLE` verbatim. Refusing a hatch write + // that names NO read-only base (BROAD) retires the hatch's + // only documented use and needs a maintainer decision plus a + // docs/ADR change — never arrived at from here. + // + // `runtime-only` needs no limb here: this branch is guarded by + // `artifactBacked`, so the intent is always + // `override-artifact`. The create side of the door is the + // ADR-0070 D1 gate further down this method, which is already + // topology-independent and already answers + // `WRITABLE_PACKAGE_REQUIRED` / 422 on every kernel. + const namedBase = typeof request.packageId === 'string' && request.packageId.length > 0; + if (namedBase && !this.isWritablePackage(request.packageId)) { + throw SysMetadataRepository.readOnlyBaseOverrideError( + request.type, request.packageId as string, false, + ); + } + const err = new Error( + `Metadata item '${request.type}/${request.name}' is provided by a code package ` + + `and the type has not opted into per-org overlay writes (allowOrgOverride=false). ` + + `Edit the source artifact and redeploy, or set OS_METADATA_WRITABLE to grant a runtime escape hatch. ` + + `See docs/adr/0005-metadata-customization-overlay.md.` + ); + (err as any).code = 'NOT_OVERRIDABLE'; + (err as any).status = 403; + throw err; + } + } + + /** + * `deleteMetaItem`'s refusal of a removal of an item a code package ships, + * on a type with no per-org overlay channel — EXCEPT a type whose loader + * merges an overlay at read time, where removing the row is repair (the + * #6960 ruling; the call site in {@link deleteMetaItem} carries the record + * and the tier boundary, which is `supportsOverlay`, never + * `allowOrgOverride`). Throws the refusal; returns when the removal is not + * refused on that ground. + * + * [#20679] Lifted out of `deleteMetaItem` UNCHANGED, for the reason {@link + * refusePackagedBaseOverride} was lifted out of `saveMetaItem`: from + * `legacyOverlayRemoval` to the closing brace these are that method's + * lines, byte for byte apart from indentation. The two added lines compute + * `overlayAllowed` and `artifactBacked` the way `deleteMetaItem` computes + * them, where both still stand for its `NOT_CREATABLE` check. + */ + private refusePackagedBaseRemoval(request: { type: string; name: string }): void { + const overlayAllowed = ObjectStackProtocolImplementation.isOverlayAllowed(request.type); + const artifactBacked = this.isArtifactBacked(request.type, request.name); + const legacyOverlayRemoval = ObjectStackProtocolImplementation + .mergesOverlayAtRead(request.type); + if (artifactBacked && !overlayAllowed && !legacyOverlayRemoval) { + const err = new Error( + `Metadata item '${request.type}/${request.name}' is provided by a code package ` + + `and the type has not opted into per-org overlay writes. ` + + `See docs/adr/0005-metadata-customization-overlay.md.` + ); + (err as any).code = 'NOT_OVERRIDABLE'; + (err as any).status = 403; + throw err; + } + } + // ─────────────────────────────────────────────────────────────────── // ADR-0010 — metadata protection (Phase 1: L3 item-level lock) // ─────────────────────────────────────────────────────────────────── @@ -16061,86 +16282,15 @@ export class ObjectStackProtocolImplementation implements } if (this.environmentId !== undefined) { - const artifactBacked = this.isArtifactBacked(request.type, request.name); - if (artifactBacked && !overlayAllowed) { - // [#8184] THE PACKAGE DOOR — the SECOND refusal point for one - // condition, and the reason this card exists. - // - // `SysMetadataRepository.assertAllowed` reads the base the - // caller NAMED and answers `ITEM_LOCKED` (`lockSource: - // 'package'`) when it is read-only (#7682, then #8146's - // hatch ruling). That door is topology-INDEPENDENT — and it - // was unreachable here, because this branch throws first on - // every kernel with an `environmentId`. So one request - // answered `ITEM_LOCKED` on a host-config / CLI-assembled - // kernel and the undiscriminated `NOT_OVERRIDABLE` on a - // project/cloud per-env one: the refusal VOCABULARY keyed off - // a row-scoping key, which is the #5086 / #6710 finding - // (see the block comment above) arriving on the error codes. - // A client that learns to handle `ITEM_LOCKED` on one - // deployment never saw it on the other. - // - // ⚠️ MIRRORED, NOT RE-INVENTED. Same predicate - // ({@link isWritablePackage}, the ADR-0070 rule in one - // place), same emitter — `readOnlyBaseOverrideError` is - // called, not copied — so the code, the status, the - // `lockSource`, the `packageId` and the sentence cannot drift - // between the two doors. Two independently-authored refusals - // for one condition is how `NOT_OVERRIDABLE`-everywhere - // started. - // - // THE LIMB ORDERING IS THE RULE, and it is the same ordering - // the repository states: BELOW every registry limb, ABOVE the - // hatch limb. - // • Below the registry limb — this whole branch is guarded - // by `!overlayAllowed`, so an `allowOrgOverride` type - // never reaches the door. That is ADR-0005: an org - // overlay of a code-shipped item ALWAYS names the - // read-only package it customizes, and a door one limb - // higher would close the overlay model outright. Pinned. - // • Above the hatch limb — `isOverlayAllowed` folds - // `OS_METADATA_WRITABLE` in, so an OPEN hatch takes the - // write past this branch entirely, down to the repository - // door, which applies the same rule with `hatchOpen: - // true` and its own remedy. The hatch therefore still - // never unlocks package writability on this topology - // either (#8146 NARROW), and both directions of that - // remedy selection are pinned in - // `sys-metadata-repository.package-writability.test.ts`. - // That is also why `hatchOpen` is passed as a literal - // `false` here rather than recomputed: reaching this line - // PROVES the hatch is closed, and a recomputed value - // would be dead code dressed as a decision. - // - // ⛔ NARROW, exactly as the repository is: only a write that - // NAMES a read-only base is re-coded. A package-less write - // keeps `NOT_OVERRIDABLE` verbatim. Refusing a hatch write - // that names NO read-only base (BROAD) retires the hatch's - // only documented use and needs a maintainer decision plus a - // docs/ADR change — never arrived at from here. - // - // `runtime-only` needs no limb here: this branch is guarded by - // `artifactBacked`, so the intent is always - // `override-artifact`. The create side of the door is the - // ADR-0070 D1 gate further down this method, which is already - // topology-independent and already answers - // `WRITABLE_PACKAGE_REQUIRED` / 422 on every kernel. - const namedBase = typeof request.packageId === 'string' && request.packageId.length > 0; - if (namedBase && !this.isWritablePackage(request.packageId)) { - throw SysMetadataRepository.readOnlyBaseOverrideError( - request.type, request.packageId as string, false, - ); - } - const err = new Error( - `Metadata item '${request.type}/${request.name}' is provided by a code package ` - + `and the type has not opted into per-org overlay writes (allowOrgOverride=false). ` - + `Edit the source artifact and redeploy, or set OS_METADATA_WRITABLE to grant a runtime escape hatch. ` - + `See docs/adr/0005-metadata-customization-overlay.md.` - ); - (err as any).code = 'NOT_OVERRIDABLE'; - (err as any).status = 403; - throw err; - } + // [#8184] THE PACKAGE DOOR — the refusal of a write onto an item a + // code package ships, on a type with no per-org overlay channel. + // The verdict and its full record live in + // {@link refusePackagedBaseOverride}: [#20679] lifted out of this + // method UNCHANGED, so a second write door onto the same artifact + // asks this exact predicate and gets this exact emitter through + // {@link packagedBaseRefusal}, rather than a copy that agrees with + // this one only until either of them moves. + this.refusePackagedBaseOverride(request); // ADR-0010 L3 — per-item lock. Artifact `_lock` (or persisted // overlay `_lock`) blocks save independent of the L1 type-level @@ -21967,18 +22117,13 @@ export class ObjectStackProtocolImplementation implements // block (`environmentId === undefined`) and would otherwise be // refused there instead, leaving the fix half-done. See // {@link SysMetadataRepository.assertDeleteAllowed}. - const legacyOverlayRemoval = ObjectStackProtocolImplementation - .mergesOverlayAtRead(request.type); - if (artifactBacked && !overlayAllowed && !legacyOverlayRemoval) { - const err = new Error( - `Metadata item '${request.type}/${request.name}' is provided by a code package ` - + `and the type has not opted into per-org overlay writes. ` - + `See docs/adr/0005-metadata-customization-overlay.md.` - ); - (err as any).code = 'NOT_OVERRIDABLE'; - (err as any).status = 403; - throw err; - } + // + // [#20679] The verdict — the artifact-backed refusal AND the + // carve-out above — was lifted, unchanged, into + // {@link refusePackagedBaseRemoval}, so a second removal door onto + // the same packaged artifact asks this one through + // {@link packagedBaseRefusal} instead of carrying a copy. + this.refusePackagedBaseRemoval(request); if (!artifactBacked && !overlayAllowed && !runtimeCreateAllowed) { const err = new Error( `Metadata type '${request.type}' does not allow runtime creation or deletion.` diff --git a/packages/qa/dogfood/test/packaged-flow-write-door-parity.dogfood.test.ts b/packages/qa/dogfood/test/packaged-flow-write-door-parity.dogfood.test.ts new file mode 100644 index 00000000000..9f8f2d7b119 --- /dev/null +++ b/packages/qa/dogfood/test/packaged-flow-write-door-parity.dogfood.test.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#20679, ADR-0126 §2] Write-door parity on a PACKAGED flow, over the real +// showcase composition — the public checklist item +// `access-security.packaged-flow-write-door-parity`, clauses 1-4. +// +// ## Why this runs on a booted stack and not only in the runtime package +// +// `packages/runtime/src/domains/automation-packaged-base-lock.test.ts` pins the +// door against a real metadata protocol over a real `SchemaRegistry`. What it +// cannot answer is a question about the COMPOSITION: whether, on the stack an +// operator actually runs, the `/automation` door reaches the same protocol +// instance the `/meta` door answers from, and whether that protocol sees the +// showcase's flow as a packaged artifact. The door keeps today's behaviour when +// the composition carries no protocol, so a boot that resolved nothing would +// leave the door open and every unit test green. Only a real boot can show it +// does not. +// +// ## What each case pins +// +// 1 control — `PUT /meta/flow/:name` refuses the packaged flow with a +// ledgered code, and the live definition is unchanged. The answering code is +// recorded, not over-pinned (the checklist's own rule). +// 2 `PUT /automation/:name` with the same body is refused; the flow the engine +// serves is byte-identical to the capture taken before any probe. +// 3 `DELETE /automation/:name` is refused; the flow still serves. +// 4 no residue: the engine's enabled/bound row for the flow is unchanged. +// A flow the customer authored through the same door is created, updated and +// removed as before — the lock is not a closed door. +// +// Nothing is restored afterwards because nothing is mutated: a refused probe is +// the whole assertion, and the byte-identical read-backs prove it. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +// The showcase declares `connectors:` bound to these providers, and the +// automation service refuses to start without their factories (ADR-0097) — +// the same composition `packaged-activation-ledger-reach.dogfood.test.ts` boots. +import { ConnectorRestPlugin } from '@objectstack/connector-rest'; +import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; +import { ConnectorMcpPlugin } from '@objectstack/connector-mcp'; +import { fileURLToPath } from 'node:url'; + +/** + * The showcase's connectors carry package-relative file refs resolved against + * the process cwd — the same `chdir` its sibling automation-carrying boots do, + * restored in `afterAll`. + */ +const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); + +/** The checklist item's packaged flow (`com.example.showcase`). */ +const FLOW = 'showcase_urgent_task_alert'; +/** A flow no package ships — created through the door under test, and removed by it. */ +const CUSTOMER_FLOW = 'dogfood_customer_flow_20679'; + +/** The ledgered locked-base family the metadata door answers with (ADR-0112). */ +const LOCKED_BASE_CODES = ['NOT_OVERRIDABLE', 'ITEM_LOCKED']; + +/** + * The two transports answer in two envelopes, and this file reads each where + * it lives rather than tolerating both at one read: the `/automation` door is + * the dispatcher's (`{ success, error: { code, message } }`), while `/meta` on + * this composition is served by the REST server, whose refusal is + * `{ error: , code }` (`@objectstack/rest` `error-response.ts`). + */ +interface DispatcherEnvelope { + success?: boolean; + data?: Record; + error?: { code?: string; message?: string }; +} +interface RestRefusal { + error?: string; + code?: string; +} +const dataOf = (json: unknown) => (json as DispatcherEnvelope).data; +const dispatcherCode = (json: unknown) => (json as DispatcherEnvelope).error?.code; + +describe('a packaged flow keeps its locked base at every write door (showcase)', () => { + let stack: VerifyStack; + let token: string; + let prevCwd: string; + /** The flow as the engine served it before any probe — the no-residue reference. */ + let capture: Record; + /** Its enabled/bound row before any probe. */ + let statusBefore: unknown; + + const call = async (method: string, path: string, body?: unknown) => { + const res = await stack.apiAs(token, method, path, body); + const json: unknown = await res.json().catch(() => ({})); + return { status: res.status, json }; + }; + const statusRow = async () => { + const { status, json } = await call('GET', '/automation/_status'); + expect(status).toBe(200); + const flows = (dataOf(json) as { flows?: Array<{ name: string }> } | undefined)?.flows ?? []; + return flows.find((f) => f.name === FLOW); + }; + const probeBody = () => ({ ...capture, label: `${String(capture.label)} (probe)` }); + + beforeAll(async () => { + prevCwd = process.cwd(); + process.chdir(SHOWCASE_DIR); + stack = await bootStack(showcaseStack, { + automation: true, + extraPlugins: [ + new ConnectorRestPlugin(), + new ConnectorOpenApiPlugin(), + new ConnectorMcpPlugin({ declarativeStdio: ['node'] }), + ], + }); + token = await stack.signIn(); + + const read = await call('GET', `/automation/${FLOW}`); + expect(read.status, `the packaged flow must be registered: ${JSON.stringify(read.json)}`).toBe(200); + capture = dataOf(read.json)!; + statusBefore = await statusRow(); + expect(statusBefore, 'the packaged flow has no runtime status row').toBeDefined(); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + if (prevCwd) process.chdir(prevCwd); + }); + + it('control: the metadata door refuses the packaged flow with a ledgered code, and the definition is unchanged', async () => { + const put = await call('PUT', `/meta/flow/${FLOW}`, probeBody()); + + expect(put.status, JSON.stringify(put.json)).toBe(403); + // Recorded, not over-pinned: which member of the family answers is part + // of the evidence (package-less on this topology: `NOT_OVERRIDABLE`). + expect(LOCKED_BASE_CODES, JSON.stringify(put.json)).toContain((put.json as RestRefusal).code); + expect(dataOf((await call('GET', `/automation/${FLOW}`)).json)).toEqual(capture); + }); + + it('PUT /automation/:name on the same artifact is refused, and the engine still serves the captured definition', async () => { + const put = await call('PUT', `/automation/${FLOW}`, probeBody()); + + expect(put.status, JSON.stringify(put.json)).toBe(403); + expect(dispatcherCode(put.json)).toBe('NOT_OVERRIDABLE'); + const after = await call('GET', `/automation/${FLOW}`); + expect(after.status).toBe(200); + expect(dataOf(after.json)).toEqual(capture); + }); + + it('DELETE /automation/:name on the same artifact is refused, and the flow still serves', async () => { + const del = await call('DELETE', `/automation/${FLOW}`); + + expect(del.status, JSON.stringify(del.json)).toBe(403); + expect(dispatcherCode(del.json)).toBe('NOT_OVERRIDABLE'); + const after = await call('GET', `/automation/${FLOW}`); + expect(after.status).toBe(200); + expect(dataOf(after.json)).toEqual(capture); + }); + + it('no residue: the flow\'s enabled/bound row is the one it had before the probes', async () => { + expect(await statusRow()).toEqual(statusBefore); + }); + + it('a flow no package ships is still created, updated and removed through the same door', async () => { + const definition = { + name: CUSTOMER_FLOW, + label: 'Customer Flow', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: {} }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }; + + const created = await call('POST', '/automation', definition); + expect(created.status, JSON.stringify(created.json)).toBe(200); + + const updated = await call('PUT', `/automation/${CUSTOMER_FLOW}`, { ...definition, label: 'Customer Flow (edited)' }); + expect(updated.status, JSON.stringify(updated.json)).toBe(200); + expect(dataOf((await call('GET', `/automation/${CUSTOMER_FLOW}`)).json)?.label).toBe('Customer Flow (edited)'); + + const removed = await call('DELETE', `/automation/${CUSTOMER_FLOW}`); + expect(removed.status, JSON.stringify(removed.json)).toBe(200); + expect((await call('GET', `/automation/${CUSTOMER_FLOW}`)).status).toBe(404); + }); +}); diff --git a/packages/runtime/src/domains/automation-packaged-base-lock.test.ts b/packages/runtime/src/domains/automation-packaged-base-lock.test.ts new file mode 100644 index 00000000000..d5c0b064121 --- /dev/null +++ b/packages/runtime/src/domains/automation-packaged-base-lock.test.ts @@ -0,0 +1,359 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20679, ADR-0126 §2] Write-door parity on a PACKAGED flow: `PUT` and + * `DELETE /automation/:name` refuse the same packaged artifact the metadata + * door refuses — the public checklist item + * `access-security.packaged-flow-write-door-parity`, clauses 2 and 3 — and so + * does `POST /automation` onto its name, which would otherwise overwrite it. + * + * ## What this pins, at door level + * + * ADR-0126 §2 puts a packaged flow in Regime C: "the packaged base is locked — + * in-place edit refused loudly at the write door". `PUT /meta/flow/:name` + * refused a flow a code package ships; the two `/automation` definition doors + * went straight to the engine behind the `manage_metadata` authoring gate + * alone. The fix ASKS the metadata protocol's own verdict + * (`packagedBaseRefusal`, the predicate and emitter `saveMetaItem` / + * `deleteMetaItem` use) before the engine is called. + * + * ## Why the harness is REAL where it matters + * + * Every "is this locked?" answer below comes from a real + * `ObjectStackProtocolImplementation` over a real `SchemaRegistry`, with the + * packaged flow registered the way an artifact loader registers it (a package + * id, which `applyProtection` stamps). A protocol DOUBLE would pin only that + * this domain calls a method — it could not show that the two doors agree, + * which is the whole claim. The automation service is a spy, because the point + * of every refusal is that the engine was never entered: "change first, refuse + * second" would satisfy a status-only assertion and still be the defect. + * + * `HttpDispatcher.handleAutomation` is the handler the dispatcher plugin mounts + * for `PUT` / `DELETE ${prefix}/automation/:name` (`dispatcher-plugin.ts`), so + * this drives the live route body; what it does not drive is the Hono glue in + * front of it, which carries no logic for these two routes. + * + * ## Both topologies + * + * The `/meta` door refuses a packaged flow on a host-config kernel + * (`environmentId` undefined — the showcase's shape) at the repository, and on + * an environment kernel in the protocol itself. The verdict asked here is + * topology-independent, so each refusal case runs on both. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SchemaRegistry } from '@objectstack/objectql'; + +import { HttpDispatcher } from '../http-dispatcher.js'; +import type { HttpProtocolContext } from '../http-dispatcher.js'; + +/** A flow a code package ships — registered with a package id, as an artifact loader does. */ +const PACKAGED = 'pkg_alert_flow'; +const PACKAGE_ID = 'com.example.pkg'; +/** The customer's own flow — lives in the engine only (authored through `POST /automation`). */ +const CUSTOMER = 'customer_flow'; +/** A runtime-authored registry item with no package provenance. */ +const RUNTIME_ROW = 'runtime_row_flow'; +/** A tenant-authored item bound to a package — `_provenance: 'org'`, never an artifact. */ +const TENANT_BOUND = 'tenant_bound_flow'; + +const definitionOf = (name: string, label = 'Original') => + ({ name, label, type: 'autolaunched', nodes: [], edges: [] }); + +/** An author holding the capability the doors demand, so every refusal below is the LOCK. */ +const AUTHOR = (): HttpProtocolContext => ({ + request: {}, + executionContext: { userId: 'u_admin', systemPermissions: ['manage_metadata'] }, +} as HttpProtocolContext); + +type Topology = { label: string; environmentId: string | undefined }; +const TOPOLOGIES: Topology[] = [ + { label: 'host-config kernel (no environmentId)', environmentId: undefined }, + { label: 'environment kernel', environmentId: 'env_1' }, +]; + +function makeRegistry(): SchemaRegistry { + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + registry.registerItem('flow', definitionOf(PACKAGED), 'name', PACKAGE_ID); + registry.registerItem('flow', definitionOf(RUNTIME_ROW), 'name'); + registry.registerItem('flow', { ...definitionOf(TENANT_BOUND), _provenance: 'org' }, 'name', 'app.customer'); + return registry; +} + +interface Harness { + dispatcher: HttpDispatcher; + protocol: ObjectStackProtocolImplementation; + registerFlow: ReturnType; + unregisterFlow: ReturnType; + toggleFlow: ReturnType; + /** The definition the ENGINE holds — read from the store, never a response. */ + held: (name: string) => unknown; +} + +/** + * @param protocol `'real'` (default), `'absent'` (a composition with no metadata + * protocol), or `'broken'` (the slot is wired and its resolution FAILS). + */ +function boot( + { environmentId, protocol: mode = 'real' }: { environmentId?: string; protocol?: 'real' | 'absent' | 'broken' } = {}, +): Harness { + const flows = new Map( + [PACKAGED, CUSTOMER, RUNTIME_ROW, TENANT_BOUND].map((n) => [n, definitionOf(n)]), + ); + const registerFlow = vi.fn((name: string, definition: unknown) => { + flows.set(name, definition); + return definition; + }); + const unregisterFlow = vi.fn((name: string) => { flows.delete(name); }); + const toggleFlow = vi.fn(async () => undefined); + const getFlow = vi.fn(async (name: string) => flows.get(name) ?? null); + + const protocol = new ObjectStackProtocolImplementation( + { registry: makeRegistry() } as never, + () => new Map(), + environmentId, + ); + + const services: Record = { + automation: { handlerReady: true, registerFlow, unregisterFlow, toggleFlow, getFlow }, + }; + if (mode === 'real') services.protocol = protocol; + const resolve = (name: string): unknown => services[name]; + const kernel = { + getService: resolve, + getServiceAsync: async (name: string) => { + if (name === 'protocol' && mode === 'broken') throw new Error('protocol factory failed'); + return resolve(name); + }, + context: { getService: resolve }, + }; + + return { + dispatcher: new HttpDispatcher(kernel as never), + protocol, + registerFlow, unregisterFlow, toggleFlow, + held: (name: string) => flows.get(name), + }; +} + +const statusOf = (response: any): unknown => response?.status; +const errorOf = (response: any): { code?: unknown; message?: unknown } => response?.body?.error ?? {}; + +afterEach(() => { + delete process.env.OS_METADATA_WRITABLE; + ObjectStackProtocolImplementation.resetEnvWritableCache(); +}); + +describe('a PACKAGED flow — the base is locked at both /automation definition doors', () => { + for (const topology of TOPOLOGIES) { + describe(topology.label, () => { + it('PUT /automation/:name answers 403 NOT_OVERRIDABLE and registers nothing', async () => { + const h = boot({ environmentId: topology.environmentId }); + const before = h.held(PACKAGED); + + const { response } = await h.dispatcher.handleAutomation( + `/${PACKAGED}`, 'PUT', definitionOf(PACKAGED, 'Changed in place'), AUTHOR(), undefined, + ); + + expect(statusOf(response)).toBe(403); + expect(errorOf(response).code).toBe('NOT_OVERRIDABLE'); + // Refuse first, mutate never: the engine was not entered, and + // the live definition is the one that was there before. + expect(h.registerFlow).not.toHaveBeenCalled(); + expect(h.held(PACKAGED)).toBe(before); + + const read = await h.dispatcher.handleAutomation(`/${PACKAGED}`, 'GET', undefined, AUTHOR(), undefined); + expect(statusOf(read.response)).toBe(200); + expect((read.response as any).body.data.label).toBe('Original'); + }); + + it('DELETE /automation/:name answers 403 NOT_OVERRIDABLE and the flow still serves', async () => { + const h = boot({ environmentId: topology.environmentId }); + + const { response } = await h.dispatcher.handleAutomation( + `/${PACKAGED}`, 'DELETE', undefined, AUTHOR(), undefined, + ); + + expect(statusOf(response)).toBe(403); + expect(errorOf(response).code).toBe('NOT_OVERRIDABLE'); + expect(h.unregisterFlow).not.toHaveBeenCalled(); + + const read = await h.dispatcher.handleAutomation(`/${PACKAGED}`, 'GET', undefined, AUTHOR(), undefined); + expect(statusOf(read.response)).toBe(200); + }); + }); + } + + it('ONE emitter: each door answers exactly the refusal the metadata door raises for the same artifact', async () => { + // On an environment kernel `saveMetaItem` / `deleteMetaItem` refuse in + // the protocol itself, before any store is touched — so the metadata + // door's own refusal can be compared with this door's answer directly. + // Same code, same status, same SENTENCE: one verdict, relayed, not a + // second refusal that agrees today. + const h = boot({ environmentId: 'env_1' }); + + const save = await h.protocol + .saveMetaItem({ type: 'flow', name: PACKAGED, item: definitionOf(PACKAGED, 'Changed in place') }) + .then(() => undefined, (e: any) => e); + const put = await h.dispatcher.handleAutomation( + `/${PACKAGED}`, 'PUT', definitionOf(PACKAGED, 'Changed in place'), AUTHOR(), undefined, + ); + expect(save).toBeDefined(); + expect({ code: save.code, status: save.status, message: save.message }).toEqual({ + code: errorOf(put.response).code, + status: statusOf(put.response), + message: errorOf(put.response).message, + }); + + const del = await h.protocol + .deleteMetaItem({ type: 'flow', name: PACKAGED }) + .then(() => undefined, (e: any) => e); + const remove = await h.dispatcher.handleAutomation(`/${PACKAGED}`, 'DELETE', undefined, AUTHOR(), undefined); + expect(del).toBeDefined(); + expect({ code: del.code, status: del.status, message: del.message }).toEqual({ + code: errorOf(remove.response).code, + status: statusOf(remove.response), + message: errorOf(remove.response).message, + }); + }); + + it('POST / onto a packaged flow\'s name — a create that would overwrite — is refused as a locked base; a new name is created', async () => { + const h = boot(); + const before = h.held(PACKAGED); + + const overwrite = await h.dispatcher.handleAutomation( + '', 'POST', definitionOf(PACKAGED, 'Overwritten via create'), AUTHOR(), undefined, + ); + expect(statusOf(overwrite.response)).toBe(403); + expect(errorOf(overwrite.response).code).toBe('NOT_OVERRIDABLE'); + expect(h.registerFlow).not.toHaveBeenCalled(); + expect(h.held(PACKAGED)).toBe(before); + + // Control: a name no code package ships is created exactly as before. + const created = await h.dispatcher.handleAutomation( + '', 'POST', definitionOf('brand_new_flow', 'New'), AUTHOR(), undefined, + ); + expect(statusOf(created.response)).toBe(200); + expect(h.registerFlow).toHaveBeenCalledWith('brand_new_flow', expect.objectContaining({ label: 'New' })); + }); + + it('keys on the NAME the artifact loader registered — provenance stamps in the request body decide nothing', async () => { + // The verdict is asked with `{ type, name, operation }` only: whether a + // code package ships the name is read from the registry's loader-made + // entry, never from what the caller sends. So a body claiming tenant + // provenance cannot walk a packaged flow past the lock… + const h = boot({ environmentId: 'env_1' }); + const disguised = await h.dispatcher.handleAutomation( + `/${PACKAGED}`, 'PUT', + { ...definitionOf(PACKAGED, 'Disguised'), _provenance: 'org', _packageId: 'sys_metadata' }, + AUTHOR(), undefined, + ); + expect(statusOf(disguised.response)).toBe(403); + expect(errorOf(disguised.response).code).toBe('NOT_OVERRIDABLE'); + expect(h.registerFlow).not.toHaveBeenCalled(); + + // …and a body claiming a package cannot lock the customer's own flow. + const claimed = await h.dispatcher.handleAutomation( + `/${CUSTOMER}`, 'PUT', + { ...definitionOf(CUSTOMER, 'Claimed'), _packageId: PACKAGE_ID, _provenance: 'package' }, + AUTHOR(), undefined, + ); + expect(statusOf(claimed.response)).toBe(200); + expect(h.registerFlow).toHaveBeenCalledTimes(1); + }); + + it('the envelope check still answers first — a body that is not a definition is VALIDATION_FAILED, as on /meta', async () => { + // `/meta` refuses a null item before its lock; this door's twin is the + // "expected a flow definition object" check, and it keeps its place. + const h = boot(); + await expect( + h.dispatcher.handleAutomation(`/${PACKAGED}`, 'PUT', ['not', 'a', 'definition'], AUTHOR(), undefined), + ).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }); + expect(h.registerFlow).not.toHaveBeenCalled(); + }); +}); + +describe('what the lock leaves open', () => { + it('the customer\'s own flow (no code package ships it) is updated and removed as before', async () => { + const h = boot(); + + const put = await h.dispatcher.handleAutomation( + `/${CUSTOMER}`, 'PUT', definitionOf(CUSTOMER, 'Edited'), AUTHOR(), undefined, + ); + expect(statusOf(put.response)).toBe(200); + expect(h.registerFlow).toHaveBeenCalledWith(CUSTOMER, expect.objectContaining({ label: 'Edited' })); + + const del = await h.dispatcher.handleAutomation(`/${CUSTOMER}`, 'DELETE', undefined, AUTHOR(), undefined); + expect(statusOf(del.response)).toBe(200); + expect(h.unregisterFlow).toHaveBeenCalledWith(CUSTOMER); + }); + + it('a registry item with no package provenance, and a tenant-authored one bound to a package, are not locked', async () => { + const h = boot({ environmentId: 'env_1' }); + for (const name of [RUNTIME_ROW, TENANT_BOUND]) { + const put = await h.dispatcher.handleAutomation( + `/${name}`, 'PUT', definitionOf(name, 'Edited'), AUTHOR(), undefined, + ); + expect(statusOf(put.response), name).toBe(200); + } + expect(h.registerFlow).toHaveBeenCalledTimes(2); + }); + + it('POST /:name/clone — the sanctioned customization path (ADR-0126 §7.1) — still authors a sibling', async () => { + const h = boot(); + const { response } = await h.dispatcher.handleAutomation( + `/${PACKAGED}/clone`, 'POST', { name: 'my_alert_copy', label: 'My Alert' }, AUTHOR(), undefined, + ); + expect(statusOf(response)).toBe(200); + expect(h.registerFlow).toHaveBeenCalledWith('my_alert_copy', expect.objectContaining({ name: 'my_alert_copy' })); + expect(h.held(PACKAGED)).toEqual(definitionOf(PACKAGED)); + }); + + it('POST /:name/toggle — the activation switch, not a definition write — is not this lock', async () => { + const h = boot(); + const { response } = await h.dispatcher.handleAutomation( + `/${PACKAGED}/toggle`, 'POST', { enabled: false }, AUTHOR(), undefined, + ); + expect(statusOf(response)).toBe(200); + expect(h.toggleFlow).toHaveBeenCalledWith(PACKAGED, false); + }); + + it('the operator hatch the refusal names (OS_METADATA_WRITABLE) opens this door exactly as it opens /meta', async () => { + // The refusal prescribes the hatch, so the prescription must be TRUE at + // this door: the verdict reads the same `isOverlayAllowed` the metadata + // door reads, hatch included — never a copy that forgets it. + process.env.OS_METADATA_WRITABLE = 'flow'; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + const h = boot(); + + const { response } = await h.dispatcher.handleAutomation( + `/${PACKAGED}`, 'PUT', definitionOf(PACKAGED, 'Operator edit'), AUTHOR(), undefined, + ); + expect(statusOf(response)).toBe(200); + expect(h.registerFlow).toHaveBeenCalledTimes(1); + }); +}); + +describe('composition edges', () => { + it('no metadata protocol in the composition: nothing to be at parity with, behaviour unchanged', async () => { + const h = boot({ protocol: 'absent' }); + const { response } = await h.dispatcher.handleAutomation( + `/${PACKAGED}`, 'PUT', definitionOf(PACKAGED, 'Edited'), AUTHOR(), undefined, + ); + expect(statusOf(response)).toBe(200); + expect(h.registerFlow).toHaveBeenCalledTimes(1); + }); + + it('a protocol slot that is wired and fails to resolve does NOT fail open — nothing is written', async () => { + const h = boot({ protocol: 'broken' }); + await expect( + h.dispatcher.handleAutomation(`/${PACKAGED}`, 'PUT', definitionOf(PACKAGED, 'Edited'), AUTHOR(), undefined), + ).rejects.toThrow('protocol factory failed'); + await expect( + h.dispatcher.handleAutomation(`/${PACKAGED}`, 'DELETE', undefined, AUTHOR(), undefined), + ).rejects.toThrow('protocol factory failed'); + expect(h.registerFlow).not.toHaveBeenCalled(); + expect(h.unregisterFlow).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index c42cdf156ea..f03e0c44f86 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -57,6 +57,9 @@ import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry. // and its write-path inverse — reused, never restated, for the definitions this // domain serves and overwrites. import { carryForwardRedactedValues, redactMetadataItem } from '@objectstack/metadata-protocol'; +// [#20679] Only the verdict's SIGNATURE — the locked-base refusal is asked of +// the `protocol` service at request time, never re-derived here. +import type { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; /** * Translate a trigger request body into the canonical `AutomationContext` the @@ -1443,6 +1446,68 @@ function flowDefinitionRefusal(err: any): unknown { ]); } +/** + * [#20679, ADR-0126 §2] THE LOCKED BASE at this domain's definition-write and + * removal doors — `PUT /:name`, `DELETE /:name`, and `POST /` onto a name the + * engine already holds (a create onto an existing name is an overwrite). + * + * ADR-0126 §2 puts a packaged flow in Regime C: "the packaged base is locked — + * in-place edit refused loudly at the write door". The metadata door kept that + * promise (`PUT /meta/flow/:name` on a flow a code package ships answers `403` + * `NOT_OVERRIDABLE`) and these doors did not: behind the `manage_metadata` + * authoring gate they went straight to the engine's `registerFlow` / + * `unregisterFlow`, which hold no lock and must not grow one — the boot pull + * registers every packaged flow through that same method. So an administrator + * refused at one door onto the artifact could rewrite, or remove, the same + * packaged flow in the live engine through another. + * + * ## One predicate, asked of its owner + * + * The verdict is the metadata protocol's, and it is ASKED, never re-derived + * here: `packagedBaseRefusal` answers with the same predicate (is the name + * artifact-backed, does the type have an overlay channel) and the same + * emitter `saveMetaItem` / `deleteMetaItem` use, so the two doors cannot + * disagree about which flows are locked or what the refusal says. The refusal + * is relayed as the producer built it — code, status and sentence — so this + * domain stamps no code of its own. What stays open is exactly what the ADR + * keeps open: a flow no code package ships (the customer's own) is written and + * removed as before; `POST /:name/clone` authors a sibling under a new name + * (§7.1); `POST /:name/toggle` is the activation switch (§7.2), not a + * definition write. + * + * ## Refuse first, then mutate + * + * Asked before the engine is called, so a refused write registers nothing and + * a refused removal unregisters nothing. On `DELETE` that also places it ahead + * of the engine's own ADR-0126 §7.3 refusal (`DELETE_RESTRICTED` / 409, a + * packaged subflow a packaged caller still reaches): a packaged base is locked + * whoever calls it, so that refusal is reached at this door only where this one + * admits the removal — with `OS_METADATA_WRITABLE` opening the `flow` type. + * + * ## Resolved as an authorization FACT + * + * `resolveServiceOrLoud`, not the `resolveService` probe: a `protocol` slot + * that is WIRED and failed to resolve is re-raised, so the write fails rather + * than proceeding as though nothing were locked. A composition with no metadata + * protocol at all — or one whose protocol brings no locked-base verdict — has + * no metadata door to be at parity with and no packaged base it can name, and + * keeps today's behaviour. Unscoped, exactly as the `/meta` domain resolves + * the slot, so both doors ask the SAME protocol instance about one artifact. + */ +async function refusePackagedFlowBaseChange( + deps: DomainHandlerDeps, + context: HttpProtocolContext, + name: string, + operation: 'save' | 'delete', +): Promise { + const protocol: Partial> | undefined = + await deps.resolveServiceOrLoud(context, 'protocol'); + if (typeof protocol?.packagedBaseRefusal !== 'function') return undefined; + const refusal = protocol.packagedBaseRefusal({ type: FLOW_METADATA_TYPE, name, operation }); + if (!refusal) return undefined; + return { handled: true, response: deps.errorFromThrown(refusal) }; +} + /** * [#9378] The ONE mapper both trigger doors answer through — `POST * /:name/trigger` and the legacy `POST /trigger/:name`, which @@ -1895,10 +1960,14 @@ export async function classifyResumeResult( * GET /:name → getFlow * POST / → createFlow (registerFlow) * ⚑ authoring write — `manage_metadata` (#10145) + * ⚑ packaged base locked — as `PUT /:name` * PUT /:name → updateFlow * ⚑ authoring write — `manage_metadata` (#10145) + * ⚑ packaged base locked — the `/meta` door's + * `403` refusal, ADR-0126 §2 (#20679) * DELETE /:name → deleteFlow (unregisterFlow) * ⚑ authoring write — `manage_metadata` (#10145) + * ⚑ packaged base locked — as `PUT /:name` * POST /:name/trigger → execute (legacy: trigger/:name also supported; * unknown name → 404, disabled → 409 `FLOW_DISABLED`, * no start node → 422 `FLOW_NO_START_NODE`, node config @@ -2173,6 +2242,13 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str // `edge.condition` strings lowered to their envelopes) — the same // shape `GET /automation/:name` serves — never an echo of the // caller's own pre-parse bytes. + // [#20679, ADR-0126 §2] Creating onto a name the engine already + // holds is an overwrite (below), so a packaged flow's name is + // locked here exactly as on `PUT /:name` — the same verdict, asked + // before anything is read or registered. A name no code package + // ships is created as before. + const locked = await refusePackagedFlowBaseChange(deps, context, body.name, 'save'); + if (locked) return locked; // [#20552] Creating onto a name the engine already holds is an // overwrite, so the round-trip rule applies here as on `PUT /:name`. const definition = await keepStoredFlowCredentials(automationService, body.name, body); @@ -3058,6 +3134,13 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str { field: '(body)', code: 'invalid_type', message: 'expected a flow definition object' }, ]); } + // [#20679, ADR-0126 §2] A packaged flow's base is locked: the + // metadata protocol's own verdict, asked before anything is + // read or registered — see `refusePackagedFlowBaseChange`. + // After the envelope check, as on `/meta` (a missing body is + // refused before the lock is consulted there too). + const locked = await refusePackagedFlowBaseChange(deps, context, name, 'save'); + if (locked) return locked; // [#8123] Same class as POST /: the engine's verdict on the // definition is served as a 400, not a 500 — reusing the // same route-agnostic `flowDefinitionRefusal` helper POST @@ -3087,6 +3170,10 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str // DELETE /:name → deleteFlow if (parts.length === 1 && m === 'DELETE') { if (typeof automationService.unregisterFlow === 'function') { + // [#20679, ADR-0126 §2] …and removed only if it is not a + // packaged base — refused before the engine is asked. + const locked = await refusePackagedFlowBaseChange(deps, context, name, 'delete'); + if (locked) return locked; automationService.unregisterFlow(name); return { handled: true, response: deps.success({ name, deleted: true }) }; }