diff --git a/.changeset/20761-flow-provenance-server-held.md b/.changeset/20761-flow-provenance-server-held.md new file mode 100644 index 00000000000..2516292e272 --- /dev/null +++ b/.changeset/20761-flow-provenance-server-held.md @@ -0,0 +1,22 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/service-automation': minor +'@objectstack/runtime': patch +--- + +fix(automation): which flows are packaged is the package loader's fact, never the flow definition's own, and every flow written through an authoring door is authored in the deployment (#20761) + +Clause-②: yes (widening) + +A flow counts as packaged only when a managed package's loader registered it (ADR-0126 §2, ADR-0131 D6). Before this change, a flow definition written through an authoring door could carry a code package's provenance, and the automation engine then treated that flow as the package's. + +- **The automation engine reads the loader's set.** The ADR-0126 §7.3 subflow guards, the arming gate, the activation switch and the package an activation row names now come from the packages the loader registered. The provenance a flow definition carries is kept for display only. `AutomationEngine` gains `setPackagedFlowSource(reader)` and `packagedFlowOwner(name)`, and the package exports the `PackagedFlowSource` type. `AutomationServicePlugin` attaches the reader for you: it asks the metadata protocol when the engine needs the answer. An engine with no reader attached treats no flow as packaged. +- **One authoring rule for flows.** `ObjectStackProtocolImplementation` gains two methods. `packagedArtifactOwner({ type, name })` names the package whose loader registered an item. `tenantAuthoredWriteRefusal({ type, name, item, packageId? })` is the rule every flow write door asks: the automation create, update and clone doors, and the metadata door's flow write. + - A write to a name a package ships is refused as a locked base. The answer is `packagedBaseRefusal`'s own (`403 NOT_OVERRIDABLE`), so sending a shipped flow's definition back is refused. + - A definition that claims a code package's provenance for a name no package ships is refused with `422 INVALID_METADATA`, and nothing is written. Before, the automation doors kept the claim and the metadata door removed it without saying so. + - A customer flow's definition sent back as it was read is accepted as before. That includes a stored flow bound to one of your own packages, whose read carries that binding. + - `packagedBaseRefusal` also takes an optional `packageId`, the base a save names. +- **The metadata door's other types are unchanged.** Only flows are judged by this rule. Migrating stored rows and duplicating a package are not affected either. +- **A clone is saved.** `POST /automation/:name/clone` now writes its copy as a stored flow of the deployment, through the metadata protocol's save, with no package provenance. The copy reads back on the metadata door and is still there after a restart. Before, it lived only in the running engine and was gone after a restart. If the save fails, the clone is withdrawn and the failure is returned. + +**If a write of yours is now refused with `422 INVALID_METADATA`:** remove the package provenance from the flow definition and send it again. To customize a packaged flow, clone it under a new name. 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 01ac3de7e2f..f80e894a49b 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 fourteen', () => { + it('every `/meta` request-boundary verb with a required `type` calls the fold — all sixteen', () => { // ⭐ 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,12 +280,16 @@ describe('#9157 — the population, re-derived from the code rather than from th // in-process caller hands it a type spelling too. 'getMetaItemsForExecution', 'historyMetaItem', + // [#20761] The loader's-set read and the one authoring rule every + // flow write door asks (below) — both take the type a caller names. + 'packagedArtifactOwner', // [#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', + 'tenantAuthoredWriteRefusal', ]); }); diff --git a/packages/metadata-protocol/src/protocol.tenant-authored-write.test.ts b/packages/metadata-protocol/src/protocol.tenant-authored-write.test.ts new file mode 100644 index 00000000000..3c065238063 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.tenant-authored-write.test.ts @@ -0,0 +1,201 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20761, ADR-0126 §2, ADR-0131 D6] `tenantAuthoredWriteRefusal` — the ONE + * authoring rule every flow write door asks (the automation create, update and + * clone doors, and `/meta`), and `packagedArtifactOwner` — the loader's-set read + * it, the locked-base verdict and the automation engine's classification share. + * + * The matrix (the ruling recorded on the card, points 2 and 5): + * - a name the loader's set holds is a locked base — `packagedBaseRefusal`'s + * own answer, reused (so a shipped flow's round trip is refused); + * - any other name, with stamps that would classify the body as code-shipped, + * is refused `INVALID_METADATA` / 422 — unless the stamps agree with the + * server's fact: the package a stored row of that name is bound to, or the + * base the write itself names; + * - everything else is admitted, and every type but `flow` is untouched. + * + * The registry double serves only `getArtifactItem` — what the real + * `SchemaRegistry` returns for an artifact a code package registered. The + * engine double serves only `findOne` over `sys_metadata` rows (and records + * `insert`, which a refused save must never reach). `@objectstack/objectql` + * cannot be imported here: it depends on this package. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; + +const PACKAGE_ID = 'com.example.pkg'; + +const flowBody = (name: string, extra: Record = {}) => ({ + name, + label: name, + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + ...extra, +}); + +/** What the loader registered: one packaged flow and one packaged view. */ +const ARTIFACTS = new Map>([ + ['flow', new Map([['pkg_flow', flowBody('pkg_flow', { _packageId: PACKAGE_ID, _provenance: 'package' })]])], + ['view', new Map([['pkg_view', { name: 'pkg_view', _packageId: PACKAGE_ID, _provenance: 'package' }]])], +]); + +/** The stamps a caller would send to claim the real package's provenance. */ +const ASSERTED = { _packageId: PACKAGE_ID, _provenance: 'package' }; + +interface StoredRow { type: string; name: string; package_id: string | null } + +function protocolWith(opts: { rows?: StoredRow[]; findOne?: () => Promise; environmentId?: string } = {}) { + const rows = opts.rows ?? []; + const insert = vi.fn(async () => ({ id: 'never' })); + const engine = { + registry: { getArtifactItem: (type: string, name: string) => ARTIFACTS.get(type)?.get(name) }, + findOne: opts.findOne ?? (async (table: string, query: { where: Record }) => { + assertEngineFindOnePredicate(table, query); + if (table !== 'sys_metadata') return null; + return rows.find((r) => Object.entries(query.where).every(([k, v]) => (r as any)[k] === v)) ?? null; + }), + insert, + }; + const protocol = new ObjectStackProtocolImplementation(engine as never, () => new Map(), opts.environmentId); + return { protocol, insert }; +} + +const shape = (e: any) => (e ? { code: e.code, status: e.status } : null); + +afterEach(() => { + delete process.env.OS_METADATA_WRITABLE; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); +}); + +describe('packagedArtifactOwner — the loader\'s set, read', () => { + it('names the package that ships a flow, and nothing for a name no package ships', () => { + const { protocol } = protocolWith(); + expect(protocol.packagedArtifactOwner({ type: 'flow', name: 'pkg_flow' })).toBe(PACKAGE_ID); + expect(protocol.packagedArtifactOwner({ type: 'flows', name: 'pkg_flow' })).toBe(PACKAGE_ID); + expect(protocol.packagedArtifactOwner({ type: 'flow', name: 'customer_flow' })).toBeUndefined(); + }); +}); + +describe('tenantAuthoredWriteRefusal — every flow written through an authoring door is tenant-authored', () => { + it('a name the loader\'s set holds is a locked base: the round trip of a shipped flow is refused with the lock\'s own answer', async () => { + for (const environmentId of [undefined, 'env_1']) { + const { protocol } = protocolWith({ environmentId }); + const served = ARTIFACTS.get('flow')!.get('pkg_flow'); + + const refusal: any = await protocol.tenantAuthoredWriteRefusal({ type: 'flow', name: 'pkg_flow', item: served }); + const lock: any = protocol.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }); + + expect(shape(refusal)).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(refusal.message).toBe(lock.message); + } + }); + + it('with the operator hatch open the lock admits the write, and the body\'s stamps decide nothing', async () => { + process.env.OS_METADATA_WRITABLE = 'flow'; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + const { protocol } = protocolWith(); + expect(await protocol.tenantAuthoredWriteRefusal({ type: 'flow', name: 'pkg_flow', item: flowBody('pkg_flow', ASSERTED) })).toBeNull(); + }); + + it('a body claiming a package\'s provenance for a name no package ships is refused INVALID_METADATA / 422', async () => { + const { protocol } = protocolWith(); + const refusal: any = await protocol.tenantAuthoredWriteRefusal({ + type: 'flow', name: 'customer_flow', item: flowBody('customer_flow', ASSERTED), + }); + expect(shape(refusal)).toEqual({ code: 'INVALID_METADATA', status: 422 }); + // A stamp with no provenance key beside it still classifies as code-shipped. + expect(shape(await protocol.tenantAuthoredWriteRefusal({ + type: 'flow', name: 'customer_flow', item: flowBody('customer_flow', { _packageId: 'app.anything' }), + }))).toEqual({ code: 'INVALID_METADATA', status: 422 }); + }); + + it('folds the type at the producer — a plural spelling cannot address around the rule', async () => { + const { protocol } = protocolWith(); + expect(shape(await protocol.tenantAuthoredWriteRefusal({ + type: 'flows', name: 'customer_flow', item: flowBody('customer_flow', ASSERTED), + }))).toEqual({ code: 'INVALID_METADATA', status: 422 }); + }); + + it('a tenant row\'s own stamps are a no-op: tenant provenance, the stored-row sentinel, and no stamps at all', async () => { + const { protocol } = protocolWith(); + for (const stamps of [{ _packageId: 'app.crm', _provenance: 'org' }, { _packageId: 'sys_metadata' }, {}]) { + expect(await protocol.tenantAuthoredWriteRefusal({ + type: 'flow', name: 'customer_flow', item: flowBody('customer_flow', stamps), + })).toBeNull(); + } + }); + + it('a package stamp that echoes the binding of the stored row of that name agrees with the server, and is a no-op', async () => { + const { protocol } = protocolWith({ rows: [{ type: 'flow', name: 'bound_flow', package_id: 'com.tenant.base' }] }); + expect(await protocol.tenantAuthoredWriteRefusal({ + type: 'flow', name: 'bound_flow', item: flowBody('bound_flow', { _packageId: 'com.tenant.base' }), + })).toBeNull(); + // …and one that names ANOTHER package disagrees with it. + expect(shape(await protocol.tenantAuthoredWriteRefusal({ + type: 'flow', name: 'bound_flow', item: flowBody('bound_flow', ASSERTED), + }))).toEqual({ code: 'INVALID_METADATA', status: 422 }); + }); + + it('a package stamp naming the base the write itself names agrees with that write', async () => { + const { protocol } = protocolWith(); + expect(await protocol.tenantAuthoredWriteRefusal({ + type: 'flow', name: 'new_flow', item: flowBody('new_flow', { _packageId: 'com.tenant.base' }), packageId: 'com.tenant.base', + })).toBeNull(); + expect(shape(await protocol.tenantAuthoredWriteRefusal({ + type: 'flow', name: 'new_flow', item: flowBody('new_flow', ASSERTED), packageId: 'com.tenant.base', + }))).toEqual({ code: 'INVALID_METADATA', status: 422 }); + }); + + it('a store that cannot be read is re-raised, never turned into a verdict; an unprovisioned one holds no row', async () => { + const down = protocolWith({ findOne: async () => { throw new Error('connect ECONNREFUSED'); } }); + await expect(down.protocol.tenantAuthoredWriteRefusal({ + type: 'flow', name: 'customer_flow', item: flowBody('customer_flow', ASSERTED), + })).rejects.toMatchObject({ status: 503 }); + + const missing = protocolWith({ findOne: async () => { throw new Error('no such table: sys_metadata'); } }); + expect(shape(await missing.protocol.tenantAuthoredWriteRefusal({ + type: 'flow', name: 'customer_flow', item: flowBody('customer_flow', ASSERTED), + }))).toEqual({ code: 'INVALID_METADATA', status: 422 }); + }); + + it('every other metadata type is untouched — the same stamps on a view are not this rule\'s to judge', async () => { + const { protocol } = protocolWith(); + expect(await protocol.tenantAuthoredWriteRefusal({ type: 'view', name: 'customer_view', item: { name: 'customer_view', ...ASSERTED } })).toBeNull(); + expect(await protocol.tenantAuthoredWriteRefusal({ type: 'view', name: 'pkg_view', item: { name: 'pkg_view', ...ASSERTED } })).toBeNull(); + }); +}); + +describe('saveMetaItem applies the rule to a flow, before anything is written', () => { + it('refuses the assertion with the rule\'s own envelope and writes nothing', async () => { + for (const environmentId of [undefined, 'env_1']) { + const { protocol, insert } = protocolWith({ environmentId }); + const thrown: any = await protocol.saveMetaItem({ + type: 'flow', name: 'customer_flow', item: flowBody('customer_flow', ASSERTED), + }).then(() => undefined, (e) => e); + + expect(shape(thrown)).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(insert).not.toHaveBeenCalled(); + } + }); + + it('asks the rule on an authoring save, and not on the two server-stated rewrites of stored rows', async () => { + const ask = (request: Record) => { + const { protocol } = protocolWith(); + const spy = vi.spyOn(protocol, 'tenantAuthoredWriteRefusal'); + return protocol.saveMetaItem({ type: 'flow', name: 'customer_flow', item: flowBody('customer_flow'), ...request } as never) + .then(() => spy.mock.calls.length, () => spy.mock.calls.length); + }; + expect(await ask({})).toBe(1); + expect(await ask({ writeFace: 'meta-envelope' })).toBe(1); + expect(await ask({ source: 'migrate-stored' })).toBe(0); + expect(await ask({ writeFace: 'package-duplicate' })).toBe(0); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 8eeae62ea43..3de33bfd8ad 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -74,6 +74,11 @@ import { // it prints it at. findUndeclarableFieldType, isObjectFieldTypeRefused, + // [#20761, ADR-0126 §2] The ADR-0029 D9.6 reading of a body's own + // provenance stamps — asked by {@link + // ObjectStackProtocolImplementation.tenantAuthoredWriteRefusal} to tell a + // body that CLAIMS to be code-shipped from one that does not. + isCodeArtifactBody, } from '@objectstack/metadata-core'; // [#5532] One vocabulary of "which driver read errors are benign", shared with // `sys-metadata-repository.ts` in this package and with `DatabaseLoader` in @@ -14246,19 +14251,30 @@ export class ObjectStackProtocolImplementation implements * 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 + * - a named base (`?package=`) on a door that carries none: the * package-less refusal (`NOT_OVERRIDABLE`) is the whole answer there. + * [#20761] A save that DOES name one — `/meta`'s own write, reaching + * this verdict through {@link tenantAuthoredWriteRefusal} — passes it + * as `packageId`, and gets the named-base answer `saveMetaItem`'s + * package door gives (`ITEM_LOCKED` on a read-only base), so asking + * here never moves that door's vocabulary. * * @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 { + packagedBaseRefusal(request: { type: string; name: string; operation: 'save' | 'delete'; packageId?: string | null }): 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 }); + else { + this.refusePackagedBaseOverride({ + type: folded.type, + name: folded.name, + ...(folded.packageId ? { packageId: folded.packageId } : {}), + }); + } } catch (err) { if (ObjectStackProtocolImplementation.isPackagedBaseRefusal(err)) return err; throw err; @@ -14266,6 +14282,139 @@ export class ObjectStackProtocolImplementation implements return null; } + /** + * [#20761, ADR-0126 §2 / §7.3] THE LOADER'S SET, read: the package that + * ships `(type, name)` as a code artifact, or `undefined` when none does. + * + * For a flow, "packaged" means exactly "loaded by the loader from a + * managed package" — there is no other legitimate route. This answers + * from the entries the artifact loader registered under a package id + * ({@link lookupArtifactItem} → `SchemaRegistry.getArtifactItem`, immune to + * plain-key overlay shadows and blind to tenant-authored rows) — the SAME + * lookup {@link packagedBaseRefusal}'s `isArtifactBacked` asks, so the + * lock and every classification built on this answer read one set. ⛔ + * Never the body a caller sends: an authoring door's body cannot move it. + * + * The automation engine is the reader beside the lock: its §7.3 subflow + * guards, its activation door and the activation row's package + * attribution classify a flow by this answer (the automation plugin hands + * the engine a reader over it), never by the stamps the flow's own body + * carries. Read at question time, never cached, so it follows the + * registry across a package install, upgrade and reload exactly as the + * lock does. + */ + packagedArtifactOwner(request: { type: string; name: string }): string | undefined { + const folded = canonicalizeMetaRequestType(request); + const artifact = this.lookupArtifactItem(folded.type, folded.name) as { _packageId?: unknown } | undefined; + const packageId = artifact?._packageId; + return typeof packageId === 'string' && packageId !== '' ? packageId : undefined; + } + + /** + * [#20761, ADR-0126 §2, ADR-0131 D6] THE ONE RULE every authoring door + * applies to a `flow` it writes: a flow written through an authoring door + * is TENANT-AUTHORED — no exception, on any door. The automation create + * and update doors, the clone door and `/meta` all ask this method; none + * re-derives it. + * + * 1. **A name the loader's set holds** ({@link packagedArtifactOwner}) is a + * locked base, and the answer is {@link packagedBaseRefusal}'s — + * reused, never re-implemented. That covers a round trip of a shipped + * flow: its served body echoes stamps that AGREE with the set, and the + * write is still an in-place edit of a locked base. With the + * `OS_METADATA_WRITABLE` hatch open the lock admits the write, and it + * is still tenant-authored: the body's stamps decide nothing. + * 2. **Any other name, with a body whose stamps would classify it as + * code-shipped** (`isCodeArtifactBody`, ADR-0029 D9.6), is refused + * LOUDLY. The body asserts a provenance the platform never + * established — a flow no managed package loaded, claiming one did. + * ⛔ Never honoured (the engine would count it as a vendor flow under + * §7.3 and attribute an activation row to the named package), and ⛔ + * never silently stripped (the caller would never learn that what it + * sent is not what was stored). + * 3. **Everything else is admitted.** A tenant row's own stamps, echoed + * on a round trip, agree with the server's fact, so they are a no-op + * here. Most classify the body as tenant-authored already + * (`_provenance: 'org'`, the `sys_metadata` sentinel). One does not, + * and it is MEASURED, not hypothetical: a stored flow bound to a + * package is served with its binding surfaced as `_packageId` and no + * `_provenance` (the `/meta` read's parity with the list path), so its + * round trip carries stamps `isCodeArtifactBody` reads as code-shipped. + * Those stamps name the package the SERVER binds that row to, so they + * agree with its fact: {@link storedFlowBindingAgrees} asks the store, + * and the write this door is making — a `/meta` save naming that same + * package as its base — agrees by the same token. + * + * `INVALID_METADATA` / 422 — registered to this package in the ADR-0112 + * ledger, ⛔ no code is minted — for the reason `saveMetaItem`'s + * layered-envelope refusal uses it: the body carries keys the server owns + * and a client may not persist. Not a 403: no caller can be authorized + * into asserting a provenance. Not a 409: nothing about the target's + * current state is contested — the name is free. + * + * Scoped to `flow` (the 2026-09-30 ruling recorded on the card, point 5): + * `/meta`'s handling of every OTHER type is unchanged, so for those this + * returns `null` and the caller proceeds exactly as before. + * + * @returns the refusal to relay verbatim, or `null` when the write may + * proceed on these grounds. + */ + async tenantAuthoredWriteRefusal(request: { type: string; name: string; item: unknown; packageId?: string | null }): Promise { + const folded = canonicalizeMetaRequestType(request); + const singular = PLURAL_TO_SINGULAR[folded.type] ?? folded.type; + if (singular !== 'flow') return null; + if (this.packagedArtifactOwner(folded) !== undefined) { + return this.packagedBaseRefusal({ + type: folded.type, + name: folded.name, + operation: 'save', + ...(folded.packageId ? { packageId: folded.packageId } : {}), + }); + } + if (!isCodeArtifactBody(folded.item)) return null; + const stamped = String((folded.item as { _packageId?: unknown })._packageId); + if (folded.packageId === stamped) return null; + if (await this.storedFlowBindingAgrees(folded.name, stamped)) return null; + const err = new Error( + `Flow '${folded.name}' was sent with a code-package provenance, but no managed package ships a flow of ` + + `that name. A flow written through an authoring door is authored in this deployment: which flows a ` + + `package ships is established by the package loader, never by the definition a caller sends. ` + + `Remove the provenance keys (_packageId, _packageVersion, _provenance) from the definition and send it ` + + `again. Nothing was written. See docs/adr/0126-packaged-metadata-customization-model.md.`, + ); + (err as any).code = 'INVALID_METADATA'; + (err as any).status = 422; + return err; + } + + /** + * [#20761] Does a stored flow row named `name` bind to `packageId` — the + * server's own fact behind a `_packageId` stamp a tenant row's served body + * echoes (see rule 3 of {@link tenantAuthoredWriteRefusal})? + * + * Asked only on the rare path — a body whose stamps would classify it as + * code-shipped, for a name the loader's set does not hold — so an + * ordinary write pays no read. Any state and any scope count: a draft + * bound to the package is as much the server's fact as the live row. + * + * An UNPROVISIONED store answers "no", and the refusal stands: a store with + * no `sys_metadata` table holds no row the stamps could agree with. Any + * OTHER read failure propagates ({@link rethrowUnlessMetadataStoreUnprovisioned}), + * never an invented "no" — refusing a legitimate round trip because the + * store was unreachable would state a disagreement nobody measured. + */ + private async storedFlowBindingAgrees(name: string, packageId: string): Promise { + try { + const row = await this.engine.findOne('sys_metadata', { + where: { type: 'flow', name, package_id: packageId }, + }); + return row != null; + } catch (error) { + this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); + return false; + } + } + /** * The two refusals {@link refusePackagedBaseOverride} and {@link * refusePackagedBaseRemoval} can raise — `NOT_OVERRIDABLE`, and @@ -16166,6 +16315,32 @@ export class ObjectStackProtocolImplementation implements // the set. Placed alongside the decoration strip so the // destructive-change diff, the schema gate, the authoring gate and the // persisted body all still see one document. + // + // [#20761, ADR-0126 §2] …except for a FLOW, which is asked the one + // authoring rule FIRST, while the body still carries the stamps the + // caller sent: a flow no managed package loaded, sent with stamps that + // would classify it as code-shipped, is refused loudly rather than + // stripped silently, and a flow the loader's set holds is a locked + // base. A tenant row's own stamps echoed on a round trip pass and are + // stripped below as for every type. Every other type is untouched — + // {@link tenantAuthoredWriteRefusal} answers `null` for it. + // + // Asked of AUTHORING writes only. The two server-stated rewrites of + // rows this store already holds — {@link migrateStoredMetadata} + // (`source: 'migrate-stored'`, the only caller that states a source) + // and {@link duplicatePackage} (`writeFace: 'package-duplicate'`) — + // are no caller's claim about anything: a row stored before the + // #16702 strip may still carry stale stamps, and rewriting it keeps + // the silent strip it always had rather than failing the rewrite. + if (request.source === undefined && request.writeFace !== 'package-duplicate') { + const authored = await this.tenantAuthoredWriteRefusal({ + type: request.type, + name: request.name, + item: request.item, + ...(request.packageId ? { packageId: request.packageId } : {}), + }); + if (authored) throw authored; + } request.item = stripDerivedProvenance(request.item); // [#6562] …and OUR OWN injected system columns, for the same reason and // at the same moment. `governServedItem` now serves the EFFECTIVE object diff --git a/packages/qa/dogfood/test/flow-provenance-server-held.dogfood.test.ts b/packages/qa/dogfood/test/flow-provenance-server-held.dogfood.test.ts new file mode 100644 index 00000000000..0c9caf938f5 --- /dev/null +++ b/packages/qa/dogfood/test/flow-provenance-server-held.dogfood.test.ts @@ -0,0 +1,294 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#20761, ADR-0126 §2 / §7.1 / §7.3, ADR-0131 D6] A flow's package provenance +// is the server's fact — over the real showcase composition. +// +// ## Why this runs on a booted stack +// +// The unit pins sit beside each module: the one authoring rule in +// `@objectstack/metadata-protocol` (`protocol.tenant-authored-write.test.ts`), +// the doors in `@objectstack/runtime` (`automation-tenant-authored-write.test.ts`) +// and the engine's classification in `@objectstack/service-automation` +// (`packaged-flow-source.test.ts`). What none of them can answer is a question +// about the COMPOSITION: whether, on the stack an operator runs, every flow +// write door reaches the same protocol instance, whether the automation engine +// is really handed the loader's set through the plugin wiring, and whether a +// clone lands as a row a cold boot reads back. A composition that wired none +// of it would leave every unit test green. +// +// ## What each case pins (the ruling recorded on the card, point 4) +// +// - a definition asserting a package's provenance for a name no package ships +// is refused on the create door, on the update door and on `/meta`, and +// nothing is written; +// - a round trip of a SHIPPED flow is refused as a locked base on both doors; +// - a round trip of a CUSTOMER flow is accepted unchanged on both doors; +// - the activation door treats a customer flow as customer-authored, and no +// activation row is attributed to a package from an authoring path; +// - a clone of a shipped flow is saved as a tenant row: it reads back on the +// metadata door, and it is still there after a cold boot on the same file; +// - `/meta` on another metadata type keeps its old handling (the control). + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { isCodeArtifactBody } from '@objectstack/metadata-core'; +import { ConnectorRestPlugin } from '@objectstack/connector-rest'; +import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; +import { ConnectorMcpPlugin } from '@objectstack/connector-mcp'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** Package-relative connector refs resolve against the cwd — see the sibling boots. */ +const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); + +/** A flow the showcase package ships (`com.example.showcase`). */ +const SHIPPED = 'showcase_urgent_task_alert'; +/** Names no package ships, each used by one case. */ +const ASSERTED = 'dogfood_asserted_flow_20761'; +const CUSTOMER = 'dogfood_customer_flow_20761'; +const META_ASSERTED = 'dogfood_meta_asserted_flow_20761'; +const META_CUSTOMER = 'dogfood_meta_customer_flow_20761'; +const CLONE = 'dogfood_clone_20761'; +const META_BOUND = 'dogfood_bound_flow_20761'; +/** A package this test creates through the package door — a tenant's own base, not a managed package. */ +const TENANT_PACKAGE = 'com.dogfood.flows20761'; + +const LEDGER = 'sys_metadata_activation'; +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; + +/** A package the showcase composition really loads — the claim a caller must not be able to make. */ +const REAL_PACKAGE = 'com.example.showcase'; +const ASSERTION = { _packageId: REAL_PACKAGE, _provenance: 'package' }; + +const definition = (name: string) => ({ + name, + label: `Dogfood ${name}`, + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: {} }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], +}); + +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; +const restCode = (json: unknown) => (json as RestRefusal).code; + +const plugins = () => [ + new ConnectorRestPlugin(), + new ConnectorOpenApiPlugin(), + new ConnectorMcpPlugin({ declarativeStdio: ['node'] }), +]; + +async function boot(databaseFile: string): Promise { + return bootStack(showcaseStack, { automation: true, databaseFile, extraPlugins: plugins() }); +} + +describe('a flow\'s package provenance is the server\'s fact at every door (showcase)', () => { + let stack: VerifyStack; + let token: string; + let prevCwd: string; + let dir: string; + let dbFile: string; + + 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 }; + }; + /** `GET /meta/flow/:name` — the served document, unwrapped from whichever envelope carries it. */ + const metaRead = async (name: string) => { + const read = await call('GET', `/meta/flow/${name}`); + const json = read.json as Record; + const doc = (json?.data ?? json?.item ?? json) as Record; + return { status: read.status, doc: (doc?.item ?? doc) as Record }; + }; + const ledgerRows = async (name: string) => { + const ql = (await stack.kernel.getServiceAsync('objectql')) as unknown as { + find(object: string, options?: unknown): Promise>>; + }; + return ql.find(LEDGER, { where: { metadata_type: 'flow', name }, context: SYSTEM_CTX }); + }; + + beforeAll(async () => { + prevCwd = process.cwd(); + process.chdir(SHOWCASE_DIR); + dir = mkdtempSync(join(tmpdir(), 'dogfood-20761-')); + dbFile = join(dir, 'showcase.db'); + stack = await boot(dbFile); + token = await stack.signIn(); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + if (prevCwd) process.chdir(prevCwd); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('the create door refuses a definition asserting a package for a name no package ships, and registers nothing', async () => { + const created = await call('POST', '/automation', { ...definition(ASSERTED), ...ASSERTION }); + + expect(created.status, JSON.stringify(created.json)).toBe(422); + expect(dispatcherCode(created.json)).toBe('INVALID_METADATA'); + expect((await call('GET', `/automation/${ASSERTED}`)).status).toBe(404); + expect(await ledgerRows(ASSERTED)).toEqual([]); + }); + + it('the update door refuses the same assertion on a customer flow, and the flow is unchanged', async () => { + const created = await call('POST', '/automation', definition(CUSTOMER)); + expect(created.status, JSON.stringify(created.json)).toBe(200); + const before = dataOf((await call('GET', `/automation/${CUSTOMER}`)).json); + + const updated = await call('PUT', `/automation/${CUSTOMER}`, { ...before, label: 'edited', ...ASSERTION }); + + expect(updated.status, JSON.stringify(updated.json)).toBe(422); + expect(dispatcherCode(updated.json)).toBe('INVALID_METADATA'); + expect(dataOf((await call('GET', `/automation/${CUSTOMER}`)).json)).toEqual(before); + }); + + it('a customer flow\'s own round trip through the automation door is accepted unchanged', async () => { + const served = dataOf((await call('GET', `/automation/${CUSTOMER}`)).json)!; + expect(isCodeArtifactBody(served)).toBe(false); + + const put = await call('PUT', `/automation/${CUSTOMER}`, served); + + expect(put.status, JSON.stringify(put.json)).toBe(200); + expect(dataOf((await call('GET', `/automation/${CUSTOMER}`)).json)).toEqual(served); + }); + + it('a round trip of a shipped flow through the automation door is refused as a locked base', async () => { + const served = dataOf((await call('GET', `/automation/${SHIPPED}`)).json)!; + + const put = await call('PUT', `/automation/${SHIPPED}`, served); + + expect(put.status, JSON.stringify(put.json)).toBe(403); + expect(dispatcherCode(put.json)).toBe('NOT_OVERRIDABLE'); + expect(dataOf((await call('GET', `/automation/${SHIPPED}`)).json)).toEqual(served); + }); + + it('the activation door treats the customer flow as customer-authored, and writes no activation row', async () => { + const off = await call('POST', `/automation/${CUSTOMER}/toggle`, { enabled: false }); + + expect(off.status, JSON.stringify(off.json)).toBe(409); + expect(dispatcherCode(off.json)).toBe('RESOURCE_CONFLICT'); + expect(await ledgerRows(CUSTOMER)).toEqual([]); + }); + + it('the metadata door applies the same rule to a flow: the assertion is refused and nothing is written', async () => { + const put = await call('PUT', `/meta/flow/${META_ASSERTED}`, { ...definition(META_ASSERTED), ...ASSERTION }); + + expect(put.status, JSON.stringify(put.json)).toBe(422); + expect(restCode(put.json)).toBe('INVALID_METADATA'); + expect((await metaRead(META_ASSERTED)).status).toBe(404); + }); + + it('a customer flow\'s own round trip through the metadata door is accepted', async () => { + const created = await call('PUT', `/meta/flow/${META_CUSTOMER}`, definition(META_CUSTOMER)); + expect(created.status, JSON.stringify(created.json)).toBe(200); + const served = await metaRead(META_CUSTOMER); + expect(served.status).toBe(200); + expect(isCodeArtifactBody(served.doc)).toBe(false); + + const put = await call('PUT', `/meta/flow/${META_CUSTOMER}`, served.doc); + + expect(put.status, JSON.stringify(put.json)).toBe(200); + expect((await metaRead(META_CUSTOMER)).doc.label).toBe(served.doc.label); + }); + + it('a customer flow bound to a tenant package round-trips through the metadata door, its binding echoed', async () => { + const pkg = await call('POST', '/packages', { id: TENANT_PACKAGE, name: 'Dogfood flows', version: '1.0.0', type: 'app' }); + expect(pkg.status, JSON.stringify(pkg.json)).toBeLessThan(300); + const created = await call('PUT', `/meta/flow/${META_BOUND}?package=${TENANT_PACKAGE}`, definition(META_BOUND)); + expect(created.status, JSON.stringify(created.json)).toBe(200); + // MEASURED: the served body surfaces the row's binding as a package + // stamp, which reads as code-shipped on its own — the edge the rule + // must not break. + const served = await metaRead(META_BOUND); + expect(served.status).toBe(200); + expect(isCodeArtifactBody(served.doc)).toBe(true); + + const echoed = await call('PUT', `/meta/flow/${META_BOUND}?package=${TENANT_PACKAGE}`, served.doc); + expect(echoed.status, JSON.stringify(echoed.json)).toBe(200); + const bare = await call('PUT', `/meta/flow/${META_BOUND}`, served.doc); + expect(bare.status, JSON.stringify(bare.json)).toBe(200); + }); + + it('a round trip of a shipped flow through the metadata door is refused as a locked base', async () => { + const served = await metaRead(SHIPPED); + expect(served.status).toBe(200); + + const put = await call('PUT', `/meta/flow/${SHIPPED}`, served.doc); + + expect(put.status, JSON.stringify(put.json)).toBe(403); + expect(['NOT_OVERRIDABLE', 'ITEM_LOCKED']).toContain(restCode(put.json)); + }); + + it('control: the metadata door keeps its old handling of the same assertion on another type', async () => { + const name = 'dogfood_control_20761'; + const put = await call('PUT', `/meta/dashboard/${name}`, { + name, + label: 'Control', + widgets: [], + ...ASSERTION, + }); + + expect(put.status, JSON.stringify(put.json)).toBe(200); + }); + + it('a clone of a shipped flow is saved as a tenant row that reads back on the metadata door', async () => { + const res = await call('POST', `/automation/${SHIPPED}/clone`, { name: CLONE, label: 'Dogfood clone' }); + expect(res.status, JSON.stringify(res.json)).toBe(200); + + const served = await metaRead(CLONE); + expect(served.status, JSON.stringify(served.doc)).toBe(200); + expect(isCodeArtifactBody(served.doc)).toBe(false); + // The clone is the customer's: the activation door refuses it too. + const off = await call('POST', `/automation/${CLONE}/toggle`, { enabled: false }); + expect(off.status, JSON.stringify(off.json)).toBe(409); + expect(await ledgerRows(CLONE)).toEqual([]); + }); + + it('the clone survives a cold boot on the same database file', async () => { + await stack.stop(); + stack = await boot(dbFile); + token = await stack.signIn(); + + const automation = await call('GET', `/automation/${CLONE}`); + expect(automation.status, JSON.stringify(automation.json)).toBe(200); + expect(dataOf(automation.json)?.label).toBe('Dogfood clone'); + expect((await metaRead(CLONE)).status).toBe(200); + // …and it is still classified as the customer's after the restart. + const off = await call('POST', `/automation/${CLONE}/toggle`, { enabled: false }); + expect(off.status, JSON.stringify(off.json)).toBe(409); + expect(dispatcherCode(off.json)).toBe('RESOURCE_CONFLICT'); + }, 180_000); + + it('after the cold boot, the package-bound customer flow round-trips through the automation door and is still the customer\'s', async () => { + const read = await call('GET', `/automation/${META_BOUND}`); + expect(read.status, JSON.stringify(read.json)).toBe(200); + const served = dataOf(read.json)!; + + const put = await call('PUT', `/automation/${META_BOUND}`, served); + expect(put.status, JSON.stringify(put.json)).toBe(200); + + // Its body names a package, and the engine still does not count it as + // one: only the loader's set makes a flow packaged. + const off = await call('POST', `/automation/${META_BOUND}/toggle`, { enabled: false }); + expect(off.status, JSON.stringify(off.json)).toBe(409); + expect(dispatcherCode(off.json)).toBe('RESOURCE_CONFLICT'); + expect(await ledgerRows(META_BOUND)).toEqual([]); + }); +}); diff --git a/packages/runtime/src/domains/automation-packaged-base-lock.test.ts b/packages/runtime/src/domains/automation-packaged-base-lock.test.ts index d5c0b064121..d78d45047f7 100644 --- a/packages/runtime/src/domains/automation-packaged-base-lock.test.ts +++ b/packages/runtime/src/domains/automation-packaged-base-lock.test.ts @@ -109,8 +109,12 @@ function boot( const toggleFlow = vi.fn(async () => undefined); const getFlow = vi.fn(async (name: string) => flows.get(name) ?? null); + // [#20761] `findOne` answers the one store read the authoring rule makes — + // "is a stored row of this name bound to the package a body's stamp + // names?" — with "no row", the state of a deployment whose customer flows + // live in the engine only. const protocol = new ObjectStackProtocolImplementation( - { registry: makeRegistry() } as never, + { registry: makeRegistry(), findOne: async () => null } as never, () => new Map(), environmentId, ); @@ -254,13 +258,19 @@ describe('a PACKAGED flow — the base is locked at both /automation definition expect(h.registerFlow).not.toHaveBeenCalled(); // …and a body claiming a package cannot lock the customer's own flow. + // [#20761] Nor is it written with that claim: every flow written + // through an authoring door is tenant-authored, so the claim is + // refused loudly and the engine is never entered — the one authoring + // rule, pinned in `automation-tenant-authored-write.test.ts`. 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); + expect(statusOf(claimed.response)).toBe(422); + expect(errorOf(claimed.response).code).toBe('INVALID_METADATA'); + expect(h.registerFlow).not.toHaveBeenCalled(); + expect(h.held(CUSTOMER)).toEqual(definitionOf(CUSTOMER)); }); it('the envelope check still answers first — a body that is not a definition is VALIDATION_FAILED, as on /meta', async () => { @@ -302,6 +312,11 @@ describe('what the lock leaves open', () => { it('POST /:name/clone — the sanctioned customization path (ADR-0126 §7.1) — still authors a sibling', async () => { const h = boot(); + // [#20761] The clone is also SAVED as a tenant row, through the + // protocol's own save — pinned in + // `automation-tenant-authored-write.test.ts`; stood in for here, where + // the store is not this file's subject. + vi.spyOn(h.protocol, 'saveMetaItem').mockResolvedValue({ success: true } as never); const { response } = await h.dispatcher.handleAutomation( `/${PACKAGED}/clone`, 'POST', { name: 'my_alert_copy', label: 'My Alert' }, AUTHOR(), undefined, ); diff --git a/packages/runtime/src/domains/automation-tenant-authored-write.test.ts b/packages/runtime/src/domains/automation-tenant-authored-write.test.ts new file mode 100644 index 00000000000..3dbdc65fd20 --- /dev/null +++ b/packages/runtime/src/domains/automation-tenant-authored-write.test.ts @@ -0,0 +1,180 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20761, ADR-0126 §2 / §7.1, ADR-0131 D6] The `/automation` definition-write + * doors apply the ONE authoring rule — the metadata protocol's + * `tenantAuthoredWriteRefusal`, the function `/meta`'s flow write asks too — and + * the clone door saves its copy as a tenant row. + * + * ## What this pins, at door level + * + * - `POST /` and `PUT /:name` refuse a definition claiming a package's + * provenance for a name no package ships, relaying the rule's envelope, and + * the engine is never entered; + * - a round trip of a SHIPPED flow is refused as a locked base; + * - a customer flow's own round trip — no stamps, tenant provenance, the + * stored-row sentinel — is accepted unchanged; + * - `POST /:name/clone` asks the same rule of the copy, registers it, and saves + * it through the protocol's own save as a tenant row: no provenance on the + * saved body, env-wide; a save that fails withdraws the registration and + * relays the failure, so no clone is reported that would not survive. + * + * Harness as in `automation-packaged-base-lock.test.ts`: a REAL protocol over a + * REAL `SchemaRegistry` answers every verdict, with the packaged flow + * registered the way an artifact loader registers it; the automation service + * is a spy, because every refusal's point is that the engine was never entered. + * The store behind the protocol's save is not this file's subject — the + * showcase pin (`flow-provenance-server-held.dogfood.test.ts`) reads the saved + * row back over HTTP and across a cold boot — so the save is spied here. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SchemaRegistry } from '@objectstack/objectql'; +import { isCodeArtifactBody } from '@objectstack/metadata-core'; + +import { HttpDispatcher } from '../http-dispatcher.js'; +import type { HttpProtocolContext } from '../http-dispatcher.js'; + +const PACKAGED = 'pkg_alert_flow'; +const PACKAGE_ID = 'com.example.pkg'; +const CUSTOMER = 'customer_flow'; + +/** The stamps a caller would send to claim the real package's provenance. */ +const ASSERTED = { _packageId: PACKAGE_ID, _provenance: 'package' }; + +const definitionOf = (name: string, label = 'Original') => + ({ name, label, type: 'autolaunched', nodes: [], edges: [] }); + +const AUTHOR = (): HttpProtocolContext => ({ + request: {}, + executionContext: { userId: 'u_admin', systemPermissions: ['manage_metadata'] }, +} as HttpProtocolContext); + +function boot() { + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + // As an artifact loader registers it: under a package id, which + // `applyProtection` stamps. + registry.registerItem('flow', definitionOf(PACKAGED), 'name', PACKAGE_ID); + + const flows = new Map([ + [PACKAGED, { ...definitionOf(PACKAGED), ...ASSERTED }], + [CUSTOMER, definitionOf(CUSTOMER)], + ]); + const registerFlow = vi.fn((name: string, definition: unknown) => { + flows.set(name, definition); + return definition; + }); + const unregisterFlow = vi.fn((name: string) => { flows.delete(name); }); + const getFlow = vi.fn(async (name: string) => flows.get(name) ?? null); + + const protocol = new ObjectStackProtocolImplementation( + { registry, findOne: async () => null } as never, + () => new Map(), + undefined, + ); + const saveMetaItem = vi.spyOn(protocol, 'saveMetaItem').mockResolvedValue({ success: true } as never); + const rule = vi.spyOn(protocol, 'tenantAuthoredWriteRefusal'); + + const services: Record = { + automation: { handlerReady: true, registerFlow, unregisterFlow, getFlow }, + protocol, + }; + const resolve = (name: string): unknown => services[name]; + const kernel = { getService: resolve, getServiceAsync: async (name: string) => resolve(name), context: { getService: resolve } }; + + return { + dispatcher: new HttpDispatcher(kernel as never), + registerFlow, unregisterFlow, saveMetaItem, rule, + held: (name: string) => flows.get(name), + }; +} + +const statusOf = (response: any): unknown => response?.status; +const errorOf = (response: any): { code?: unknown } => response?.body?.error ?? {}; +const dataOf = (response: any): any => response?.body?.data; + +describe('[#20761] the /automation definition-write doors apply the one authoring rule', () => { + it('POST / refuses a definition claiming a package for a name no package ships, and registers nothing', async () => { + const h = boot(); + const { response } = await h.dispatcher.handleAutomation( + '', 'POST', { ...definitionOf('new_flow'), ...ASSERTED }, AUTHOR(), undefined, + ); + + expect(statusOf(response)).toBe(422); + expect(errorOf(response).code).toBe('INVALID_METADATA'); + expect(h.registerFlow).not.toHaveBeenCalled(); + expect(h.held('new_flow')).toBeUndefined(); + expect(h.rule).toHaveBeenCalledWith(expect.objectContaining({ type: 'flow', name: 'new_flow' })); + }); + + it('PUT /:name refuses the same claim on a customer flow, and the engine still holds its definition', async () => { + const h = boot(); + const { response } = await h.dispatcher.handleAutomation( + `/${CUSTOMER}`, 'PUT', { ...definitionOf(CUSTOMER, 'Claimed'), ...ASSERTED }, AUTHOR(), undefined, + ); + + expect(statusOf(response)).toBe(422); + expect(errorOf(response).code).toBe('INVALID_METADATA'); + expect(h.registerFlow).not.toHaveBeenCalled(); + expect(h.held(CUSTOMER)).toEqual(definitionOf(CUSTOMER)); + }); + + it('a round trip of a shipped flow is refused as a locked base, on both definition doors', async () => { + const h = boot(); + const served = h.held(PACKAGED) as Record; + for (const [path, method] of [[`/${PACKAGED}`, 'PUT'], ['', 'POST']] as const) { + const { response } = await h.dispatcher.handleAutomation(path, method, served, AUTHOR(), undefined); + expect(statusOf(response), `${method} ${path}`).toBe(403); + expect(errorOf(response).code).toBe('NOT_OVERRIDABLE'); + } + expect(h.registerFlow).not.toHaveBeenCalled(); + }); + + it('a customer flow\'s own round trip is accepted unchanged — no stamps, tenant provenance, or the stored-row sentinel', async () => { + for (const stamps of [{}, { _packageId: 'app.customer', _provenance: 'org' }, { _packageId: 'sys_metadata' }]) { + const h = boot(); + const body = { ...definitionOf(CUSTOMER, 'Echoed'), ...stamps }; + const { response } = await h.dispatcher.handleAutomation(`/${CUSTOMER}`, 'PUT', body, AUTHOR(), undefined); + + expect(statusOf(response), JSON.stringify(stamps)).toBe(200); + expect(h.registerFlow).toHaveBeenCalledWith(CUSTOMER, body); + } + }); +}); + +describe('[#20761, ADR-0126 §7.1] the clone door saves its copy as a tenant row', () => { + it('a clone of a shipped flow is registered AND saved through the protocol, env-wide, with no provenance', 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.saveMetaItem).toHaveBeenCalledTimes(1); + const saved = h.saveMetaItem.mock.calls[0]![0] as { type: string; name: string; item: Record; organizationId?: unknown; packageId?: unknown }; + expect(saved).toMatchObject({ type: 'flow', name: 'my_alert_copy' }); + expect(saved.organizationId).toBeUndefined(); + expect(saved.packageId).toBeUndefined(); + expect(isCodeArtifactBody(saved.item)).toBe(false); + expect(Object.keys(saved.item).filter((k) => k === '_packageId' || k === '_provenance')).toEqual([]); + // The copy was judged by the one rule, and admitted. + expect(h.rule).toHaveBeenCalledWith(expect.objectContaining({ type: 'flow', name: 'my_alert_copy' })); + expect(dataOf(response)?.flow?.name).toBe('my_alert_copy'); + }); + + it('a save that fails withdraws the registration and relays the failure — no clone is reported that would not survive', async () => { + const h = boot(); + h.saveMetaItem.mockRejectedValueOnce(Object.assign(new Error('refused by the store'), { code: 'NOT_OVERRIDABLE', status: 403 })); + + const { response } = await h.dispatcher.handleAutomation( + `/${PACKAGED}/clone`, 'POST', { name: 'my_alert_copy', label: 'My Alert' }, AUTHOR(), undefined, + ); + + expect(statusOf(response)).toBe(403); + expect(errorOf(response).code).toBe('NOT_OVERRIDABLE'); + expect(h.unregisterFlow).toHaveBeenCalledWith('my_alert_copy'); + expect(h.held('my_alert_copy')).toBeUndefined(); + }); +}); diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index f03e0c44f86..e3260fd3b49 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -1508,6 +1508,48 @@ async function refusePackagedFlowBaseChange( return { handled: true, response: deps.errorFromThrown(refusal) }; } +/** + * [#20761, ADR-0126 §2, ADR-0131 D6] THE AUTHORING RULE at this domain's + * definition-write doors — `POST /`, `PUT /:name` and the copy + * `POST /:name/clone` writes: a flow written through an authoring door is + * tenant-authored, no exception. + * + * Asked of the metadata protocol's `tenantAuthoredWriteRefusal` — the ONE + * function `/meta`'s flow write asks too — and relayed verbatim, so no door + * re-derives it and this domain stamps no code of its own: + * + * - a name the loader's set holds is a locked base: `packagedBaseRefusal`'s + * answer, which this door asked directly before (#20679) and now reaches + * through the rule that reuses it — so a round trip of a shipped flow is + * refused as a locked base; + * - any other name, with a definition whose provenance stamps would classify + * it as code-shipped, is refused loudly (`INVALID_METADATA` / 422): which + * flows a package ships is the loader's fact, never the caller's claim; + * - everything else — a customer flow's own round trip included — proceeds. + * + * Asked before the engine is called, so a refused write registers nothing. + * A protocol that brings the locked-base verdict but not this rule keeps + * #20679's lock, asked as before; a composition with no protocol keeps + * today's behaviour, exactly as {@link refusePackagedFlowBaseChange} states — + * and the engine classifies no flow as packaged from a definition's stamps + * either way, so nothing a caller sends here can make it count as one. + */ +async function refuseUnauthoredFlowWrite( + deps: DomainHandlerDeps, + context: HttpProtocolContext, + name: string, + definition: unknown, +): Promise { + const protocol: Partial> | undefined = + await deps.resolveServiceOrLoud(context, 'protocol'); + if (typeof protocol?.tenantAuthoredWriteRefusal !== 'function') { + return refusePackagedFlowBaseChange(deps, context, name, 'save'); + } + const refusal = await protocol.tenantAuthoredWriteRefusal({ type: FLOW_METADATA_TYPE, name, item: definition }); + 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 @@ -2247,7 +2289,11 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str // 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'); + // [#20761] …through the one authoring rule, which reuses that + // verdict and also refuses a definition claiming a package's + // provenance for a name no package ships — see + // `refuseUnauthoredFlowWrite`. + const locked = await refuseUnauthoredFlowWrite(deps, context, body.name, body); 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`. @@ -2539,6 +2585,19 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str } const clone = cloneFlowDefinition(source, { name: targetName, label: targetLabel }); + // [#20761, ADR-0126 §7.1] The copy is TENANT-AUTHORED, stated by + // the server: `cloneFlowDefinition` drops the base's whole + // protection envelope, so the one authoring rule every door + // asks admits it — and would refuse it, loudly, if the base's + // provenance ever rode across. Asked before the engine is + // called, like every other definition write here. The + // metadata protocol is resolved HERE, before anything is + // registered, so a slot that fails to resolve cannot leave a + // registered-but-unsaved clone behind. + const authored = await refuseUnauthoredFlowWrite(deps, context, targetName, clone); + if (authored) return authored; + const store: Partial> | undefined = + await deps.resolveServiceOrLoud(context, 'protocol'); // Engine verdicts are answered as a 400, not rethrown — the // create arm's reasoning (`flowDefinitionRefusal`) applies // verbatim, and it matters more here: a clone that the engine @@ -2553,6 +2612,28 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str response: deps.errorFromThrown(flowDefinitionRefusal(e), VALIDATION_FAILED_STATUS), }; } + // [#20761, ADR-0126 §7.1] …and SAVED as a tenant row. Registered + // in the engine alone the clone was a process-local fact: the + // metadata door read its name as absent, and the next boot — + // which binds flows from the stored metadata — did not know it, + // so the copy an administrator made to customize a locked flow + // vanished at the first restart. It is written through the + // metadata protocol's own save, env-wide (a flow has no per-org + // channel), so it lands as an ordinary `sys_metadata` row the + // boot hydrates, `/meta` reads back, and the same authoring rule + // judges. Engine first, store second: the engine's registration + // is the stricter validation, and a save that then fails + // withdraws the registration, so the answer never reports a + // clone that will not survive. A composition with no metadata + // store to save into keeps the engine-only clone it always had. + if (typeof store?.saveMetaItem === 'function') { + try { + await store.saveMetaItem({ type: FLOW_METADATA_TYPE, name: targetName, item: clone }); + } catch (e) { + automationService.unregisterFlow?.(targetName); + return { handled: true, response: deps.errorFromThrown(e) }; + } + } // ⛔ NO ANCESTRY on the way out either (ADR-0126 amendment // ruling 2, §9): the response names the flow that was created // and says nothing about what it was copied from. There is no @@ -3139,7 +3220,9 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str // 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'); + // [#20761] Through the one authoring rule, which reuses that + // verdict — see `refuseUnauthoredFlowWrite`. + const locked = await refuseUnauthoredFlowWrite(deps, context, name, definition); 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 diff --git a/packages/runtime/src/flow-clone.ts b/packages/runtime/src/flow-clone.ts index 02479d7b5b1..6bd17cf9653 100644 --- a/packages/runtime/src/flow-clone.ts +++ b/packages/runtime/src/flow-clone.ts @@ -64,9 +64,16 @@ * literal list here — an envelope key added to the spec is stripped by this * module the day it lands, with no edit and no second list to drift. * - * ⚠️ This is the one place the implementation reads more into ADR-0126 than the - * card spelled out; it is flagged on the PR for the reviewer. Everything else - * below is the ADR verbatim. + * [#20761] What was once the one place this module read more into ADR-0126 + * than its card spelled out is now the rule itself: a flow written through an + * authoring door is tenant-authored, and the one authoring rule every door asks + * (`tenantAuthoredWriteRefusal` in `@objectstack/metadata-protocol`) refuses a + * definition whose stamps claim a package for a name no package ships — so a + * copy that carried the base's envelope would be refused, not saved. The clone + * door asks that rule of the copy this module builds and then saves it as an + * ordinary tenant row. Pinned in `domains/automation-flow-clone.test.ts` (the + * envelope drop) and `domains/automation-tenant-authored-write.test.ts` (the + * rule and the save). * * ## 3. REFERENCES ARE NOT RE-POINTED * diff --git a/packages/services/service-automation/src/engine-residual-log-cause.test.ts b/packages/services/service-automation/src/engine-residual-log-cause.test.ts index 95e32483600..586167768bc 100644 --- a/packages/services/service-automation/src/engine-residual-log-cause.test.ts +++ b/packages/services/service-automation/src/engine-residual-log-cause.test.ts @@ -58,6 +58,7 @@ import type { NodeExecutor, SuspendedRun, SuspendedRunStore, FlowTrigger } from import type { AutomationContext } from '@objectstack/spec/contracts'; import { defineActionDescriptor } from '@objectstack/spec/automation'; import { registerScreenNodes } from './builtin/screen-nodes.js'; +import { withLoaderSetFromPull } from './loader-set.test-support.js'; // ── fixtures ─────────────────────────────────────────────────────────────── @@ -421,7 +422,7 @@ describe('#6499 sites 5–8 — plugin-supplied code seams, all #4632 FUNCTIONAL }); it("site 8: deactivateFlowTrigger — the trigger's thrown stop() rides meta", async () => { - const engine = new AutomationEngine(jsonLogger()); + const engine = withLoaderSetFromPull(new AutomationEngine(jsonLogger())); const trigger: FlowTrigger = { type: 'record_change', start() {}, stop() { throw new Error(MULTILINE_DRIVER); } }; engine.registerTrigger(trigger); // [#20726] Unbound through the toggle, which switches packaged flows only. diff --git a/packages/services/service-automation/src/engine.test.ts b/packages/services/service-automation/src/engine.test.ts index d3a2443bbc1..65c85b49fdd 100644 --- a/packages/services/service-automation/src/engine.test.ts +++ b/packages/services/service-automation/src/engine.test.ts @@ -52,7 +52,7 @@ describe('AutomationEngine', () => { let engine: AutomationEngine; beforeEach(() => { - engine = new AutomationEngine(createTestLogger()); + engine = withLoaderSetFromPull(new AutomationEngine(createTestLogger())); }); describe('Execution-log ring buffer (P1-2)', () => { @@ -1317,7 +1317,7 @@ describe('AutomationEngine - Execution History', () => { }; beforeEach(() => { - engine = new AutomationEngine(createTestLogger()); + engine = withLoaderSetFromPull(new AutomationEngine(createTestLogger())); }); describe('getFlow', () => { @@ -2806,6 +2806,7 @@ describe('Action Descriptor Registry (ADR-0018)', () => { import type { FlowTrigger, FlowTriggerBinding } from './engine.js'; import type { AutomationContext } from '@objectstack/spec/contracts'; +import { withLoaderSetFromPull } from './loader-set.test-support.js'; /** * A recording fake trigger: captures bindings/callbacks handed to it by the @@ -2863,7 +2864,7 @@ describe('AutomationEngine - Flow Trigger Wiring', () => { let engine: AutomationEngine; beforeEach(() => { - engine = new AutomationEngine(createTestLogger()); + engine = withLoaderSetFromPull(new AutomationEngine(createTestLogger())); }); it('binds a record-change flow to a matching trigger with a parsed binding', () => { @@ -3169,7 +3170,7 @@ describe('#9378 — execute() classifies terminal exits for the trigger transpor let engine: AutomationEngine; beforeEach(() => { - engine = new AutomationEngine(createTestLogger()); + engine = withLoaderSetFromPull(new AutomationEngine(createTestLogger())); }); /** start → `bad` (no executor registered for its type) → end. */ diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 26e9be7dc17..364eeaa56f8 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -51,13 +51,11 @@ import { FlowValueSlotSchema, VALUE_ENVELOPE_REFUSAL } from '@objectstack/spec/a // shared refusal to prevent. See `checkStructuralCondition` in `registerFlow`. import { EvaluatedExpressionInputSchema, EVALUATED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec'; import { applyConversionsToFlow, type ConversionNotice, type ConversionConflictNotice } from '@objectstack/spec'; -// [ADR-0126 §7.3] "Does a code package ship this flow?" for the subflow guard. -// Routed through the local precedence module rather than importing -// `isCodeArtifactBody` from `@objectstack/objectql` directly: that package is a -// devDependency here, and `describeFlowContender` is already this package's one -// wrapper over the canonical ADR-0029 D9.6 test — so the guard and the boot -// pull cannot drift into two answers about what "packaged" means. -import { describeFlowContender } from './flow-precedence.js'; +// [ADR-0126 §7.3, #20761] "Does a code package ship this flow?" is NOT read +// off a definition here. The subflow guards, the arming gate and the +// activation door ask {@link AutomationEngine.packagedFlowOwner} — the loader's +// set, attached by the host — because a definition reaches `registerFlow` +// through authoring doors too, and its provenance stamps are the caller's. import type { FlowRegionParsed } from '@objectstack/spec/automation'; import type { Connector, @@ -2244,6 +2242,13 @@ export interface FlowContender { packageId?: string; } +/** + * [#20761] A reader over the loader's set: the package that ships the flow + * `name` as a code artifact, or `undefined` when no managed package loaded a + * flow of that name. See {@link AutomationEngine.setPackagedFlowSource}. + */ +export type PackagedFlowSource = (name: string) => string | undefined; + /** * [#11997] What the ADR-0005 overlay precedence decided for one bare flow name. * @@ -2352,6 +2357,13 @@ export class AutomationEngine implements IAutomationService { * a flip rather than reporting a durability it does not have. */ private flowActivationStore: FlowActivationStore | null = null; + /** + * [#20761] The reader over the loader's set — see + * {@link setPackagedFlowSource}. `undefined` until a host attaches one, and + * with none attached NO flow is packaged: an engine no loader feeds holds + * no flow a managed package loaded. + */ + private packagedFlowSource?: PackagedFlowSource; /** * Re-entrancy guard for record-triggered flows (complements the intra-run * {@link MAX_NODE_REENTRIES} back-edge guard, which cannot see a self-trigger @@ -4406,7 +4418,7 @@ export class AutomationEngine implements IAutomationService { */ unregisterFlow(name: string): void { const flow = this.flows.get(name); - if (flow && describeFlowContender(flow).source === 'package') { + if (flow && this.isPackagedFlow(name)) { const callers = this.packagedSubflowCallers(name); if (callers.length > 0) this.refuseUnderReachingCallers(name, 'remove', callers, new Map()); } @@ -4617,6 +4629,42 @@ export class AutomationEngine implements IAutomationService { this.flowActivationStore = store; } + /** + * [#20761, ADR-0126 §7.2 / §7.3] Attach the reader over THE LOADER'S SET — + * the one server-held fact of which flows are packaged. Hosts call this at + * start(); the automation plugin hands over a reader that asks the metadata + * protocol's `packagedArtifactOwner` at question time, which answers from + * the entries the artifact loader registered — the same set the protocol's + * locked-base verdict reads. + * + * For a flow, "packaged" means exactly "loaded by the loader from a managed + * package". Every classification this engine makes — the §7.3 subflow + * guards in both directions, the arming gate, the activation door, and the + * package the activation row is attributed to — reads {@link + * packagedFlowOwner}, and so this reader. ⛔ Never the stamps a definition + * carries: a definition reaches {@link registerFlow} through authoring doors + * too, and its `_packageId` / `_provenance` are the caller's bytes. They are + * kept on the definition for display only. + */ + setPackagedFlowSource(source: PackagedFlowSource | undefined): void { + this.packagedFlowSource = source; + } + + /** + * [#20761] The package that ships the flow `name`, per the loader's set + * ({@link setPackagedFlowSource}); `undefined` for a flow authored in this + * deployment — and for every flow while no reader is attached. + */ + packagedFlowOwner(name: string): string | undefined { + const owner = this.packagedFlowSource?.(name); + return typeof owner === 'string' && owner !== '' ? owner : undefined; + } + + /** [#20761] Is `name` a packaged flow — {@link packagedFlowOwner}, as a verdict. */ + private isPackagedFlow(name: string): boolean { + return this.packagedFlowOwner(name) !== undefined; + } + /** * [ADR-0126 §7.2] Load the ledger into {@link flowLedgerDisabled}. * @@ -4744,7 +4792,7 @@ export class AutomationEngine implements IAutomationService { const callers: string[] = []; for (const [callerName, flow] of this.flows) { if (callerName === name) continue; - if (describeFlowContender(flow).source !== 'package') continue; + if (!this.isPackagedFlow(callerName)) continue; if (this.subflowTargets(flow).includes(name)) callers.push(callerName); } return callers; @@ -4942,7 +4990,7 @@ export class AutomationEngine implements IAutomationService { const disabled: Array<{ name: string; ledger: boolean; status?: string }> = []; for (const target of new Set(this.subflowTargets(flow))) { const child = this.flows.get(target); - if (!child || describeFlowContender(child).source !== 'package') continue; + if (!child || !this.isPackagedFlow(target)) continue; if (this.isFlowEnabled(target)) continue; const ledger = this.flowLedgerDisabled.has(target); const statusDisabled = this.flowStatusDisabled.get(target) === true; @@ -4968,7 +5016,7 @@ export class AutomationEngine implements IAutomationService { if (target === to) return true; if (seen.has(target) || !this.flowLedgerDisabled.has(target)) continue; const next = this.flows.get(target); - if (!next || describeFlowContender(next).source !== 'package') continue; + if (!next || !this.isPackagedFlow(target)) continue; seen.add(target); pending.push(target); } @@ -4994,9 +5042,9 @@ export class AutomationEngine implements IAutomationService { * disabling a shipped flow being equivalent to deleting it, and enabling * is the opposite act. */ - private refuseEnableOntoDisabledSubflow(name: string, flow: FlowParsed): void { + private refuseEnableOntoDisabledSubflow(name: string): void { if (!this.flowLedgerDisabled.has(name)) return; - if (describeFlowContender(flow).source !== 'package') return; + if (!this.isPackagedFlow(name)) return; const disabled = this.disabledPackagedSubflows(name); if (disabled.length === 0) return; @@ -5080,7 +5128,7 @@ export class AutomationEngine implements IAutomationService { */ private declineOntoDisabledSubflows(flowName: string, triggerType: string): boolean { const flow = this.flows.get(flowName); - const disabled = flow && describeFlowContender(flow).source === 'package' + const disabled = flow && this.isPackagedFlow(flowName) ? this.disabledPackagedSubflows(flowName) : []; if (disabled.length === 0) { @@ -5159,13 +5207,14 @@ export class AutomationEngine implements IAutomationService { * flow's update door instead — `PUT /automation/:name`, which drives * {@link registerFlow} with the complete definition. * - * "No package provenance" is `describeFlowContender(flow).source !== - * 'package'` — the one discriminator the §7.3 guards already ask - * (`isCodeArtifactBody`, ADR-0029 D9.6), ⛔ never a second reading. So a - * runtime row carrying the `sys_metadata` sentinel, and a tenant-authored - * row bound to an app package, are refused exactly as a flow with no - * package envelope at all is: provenance decides, not whether a package - * id happens to be non-empty. + * "No package ships it" is {@link packagedFlowOwner} answering nothing — + * the one reading the §7.3 guards already ask, ⛔ never a second one. + * [#20761] That is the loader's set, never the definition's own stamps: a + * runtime row carrying the `sys_metadata` sentinel, a tenant-authored row + * bound to an app package, and a definition an authoring door registered + * with a package's stamps on it are all refused exactly as a flow with no + * package envelope at all is — the server's fact decides, not what the + * body says. * * ADR-0112 envelope: code AND status. `RESOURCE_CONFLICT` (409) is the * standard catalog's "the request conflicts with the resource's current @@ -5191,8 +5240,8 @@ export class AutomationEngine implements IAutomationService { * (ADR-0126 §7.1), which no row holds. Still nothing is written: which * rows this door should clear is not this refusal's to decide. */ - private refuseCustomerAuthoredToggle(name: string, flow: FlowParsed, enabled: boolean): void { - if (describeFlowContender(flow).source === 'package') return; + private refuseCustomerAuthoredToggle(name: string, enabled: boolean): void { + if (this.isPackagedFlow(name)) return; const updateDoor = `its update door, PUT /automation/${name}, which takes the complete definition`; const ownSwitch = this.flowLedgerDisabled.has(name) ? `This flow's own switch is its definition's status, published through ${updateDoor} — but it is ` + @@ -5260,7 +5309,11 @@ export class AutomationEngine implements IAutomationService { // is never awaited for it, and the refusal lands before the ledger // write and before any in-process change — with a ledger attached or // in the degraded mode without one. Nothing half-flips. - this.refuseCustomerAuthoredToggle(name, flow, enabled); + this.refuseCustomerAuthoredToggle(name, enabled); + // Past the refusal the loader's set names a package, and the ledger row + // is attributed to exactly that one — read with the verdict, so the + // run-store await below cannot separate the two. + const packageOwner = this.packagedFlowOwner(name) as string; // [ADR-0126 §7.3] The subflow guard runs in BOTH directions, because // a subflow pair breaks from either end. Disabling a child breaks the @@ -5270,7 +5323,7 @@ export class AutomationEngine implements IAutomationService { // the flows that call IT, so enabling a child is not guarded by its // callers. if (enabled) { - this.refuseEnableOntoDisabledSubflow(name, flow); + this.refuseEnableOntoDisabledSubflow(name); } else { // A switched-off caller guards only while it holds a parked run, // so only then are the run stores read — and only that read is @@ -5291,7 +5344,10 @@ export class AutomationEngine implements IAutomationService { if (this.flowActivationStore) { await this.flowActivationStore.setActive({ name, - packageId: String((flow as { _packageId?: unknown })._packageId ?? ''), + // [#20761] The package the LOADER's set names, never the + // definition's stamps — so no row is attributed to a package + // from an authoring door. Read once, up top, with the verdict. + packageId: packageOwner, active: enabled, }); } else { diff --git a/packages/services/service-automation/src/flow-activation-late-trigger.test.ts b/packages/services/service-automation/src/flow-activation-late-trigger.test.ts index b1738830535..98184af3329 100644 --- a/packages/services/service-automation/src/flow-activation-late-trigger.test.ts +++ b/packages/services/service-automation/src/flow-activation-late-trigger.test.ts @@ -28,6 +28,7 @@ import type { FlowTrigger, FlowTriggerBinding, FlowActivationStore } from './eng import { InMemoryFlowActivationStore } from './flow-activation-store.js'; import type { AutomationContext } from '@objectstack/spec/contracts'; import { withScheduledWorkOn } from './deployment-switch.test-support.js'; +import { withLoaderSetFromPull } from './loader-set.test-support.js'; function createTestLogger(): any { const l: any = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; @@ -96,7 +97,7 @@ describe('a trigger type registered AFTER hydrateFlowActivations() does not arm const store = new InMemoryFlowActivationStore(); await store.setActive({ name: 'off', packageId: 'crm', active: false }); - const engine = new AutomationEngine(createTestLogger()); + const engine = withLoaderSetFromPull(new AutomationEngine(createTestLogger())); engine.setFlowActivationStore(store); engine.registerFlow('off', packagedFlow('off', startConfig)); engine.registerFlow('on', packagedFlow('on', startConfig)); @@ -115,7 +116,7 @@ describe('a trigger type registered AFTER hydrateFlowActivations() does not arm } it('a STATUS-disabled flow is not armed by a later trigger registration either — the gate is `isFlowEnabled`, both dimensions', async () => { - const engine = new AutomationEngine(createTestLogger()); + const engine = withLoaderSetFromPull(new AutomationEngine(createTestLogger())); engine.registerFlow('retired', packagedFlow('retired', RECORD_CHANGE, { status: 'obsolete' })); engine.registerFlow('live', packagedFlow('live', RECORD_CHANGE, { status: 'active' })); @@ -132,7 +133,7 @@ describe('the enable path still arms', () => { it('re-enabling a flow hydrated as disabled arms it on the trigger that registered after hydration', async () => { const store = new InMemoryFlowActivationStore(); await store.setActive({ name: 'f', packageId: 'crm', active: false }); - const engine = new AutomationEngine(createTestLogger()); + const engine = withLoaderSetFromPull(new AutomationEngine(createTestLogger())); engine.setFlowActivationStore(store); engine.registerFlow('f', packagedFlow('f', RECORD_CHANGE)); await engine.hydrateFlowActivations(); @@ -149,7 +150,7 @@ describe('the enable path still arms', () => { }); it('re-enabling the LEDGER bit of a flow whose STATUS is obsolete does not arm it — it still cannot run', async () => { - const engine = new AutomationEngine(createTestLogger()); + const engine = withLoaderSetFromPull(new AutomationEngine(createTestLogger())); engine.setFlowActivationStore(new InMemoryFlowActivationStore()); const trigger = recordingTrigger('record_change'); engine.registerTrigger(trigger.trigger); @@ -176,7 +177,7 @@ describe('a cold restart over the same store keeps a switched-off flow enabled:f * outlive the process. */ async function boot(store: FlowActivationStore) { - const engine = new AutomationEngine(createTestLogger()); + const engine = withLoaderSetFromPull(new AutomationEngine(createTestLogger())); engine.setFlowActivationStore(store); const defs = [packagedFlow('urgent_alert', RECORD_CHANGE), packagedFlow('welcome', RECORD_CHANGE)]; for (const def of defs) engine.registerFlow(def.name, def); // the boot pull @@ -217,7 +218,7 @@ describe('the trigger-fired failure line claims a run-history row only when one it('a FLOW_DISABLED refusal reaching the callback (an event in flight when the flow was switched off) is not logged as a failure', async () => { const logger = createTestLogger(); - const engine = new AutomationEngine(logger); + const engine = withLoaderSetFromPull(new AutomationEngine(logger)); engine.setFlowActivationStore(new InMemoryFlowActivationStore()); const trigger = recordingTrigger('record_change'); engine.registerTrigger(trigger.trigger); @@ -241,7 +242,7 @@ describe('the trigger-fired failure line claims a run-history row only when one it('a run that dispatched and failed still logs at ERROR and still says its failure is in the run history — which holds it', async () => { const logger = createTestLogger(); - const engine = new AutomationEngine(logger); + const engine = withLoaderSetFromPull(new AutomationEngine(logger)); const trigger = recordingTrigger('record_change'); engine.registerTrigger(trigger.trigger); engine.registerNodeExecutor({ type: 'exploder', async execute() { throw new Error('boom'); } } as never); @@ -270,7 +271,7 @@ describe('the trigger-fired failure line claims a run-history row only when one it('a failure refused before it dispatched makes no run-history claim — no row was written', async () => { const logger = createTestLogger(); - const engine = new AutomationEngine(logger); + const engine = withLoaderSetFromPull(new AutomationEngine(logger)); const trigger = recordingTrigger('record_change'); engine.registerTrigger(trigger.trigger); engine.registerFlow('gone', packagedFlow('gone', RECORD_CHANGE)); diff --git a/packages/services/service-automation/src/flow-activation-ledger.test.ts b/packages/services/service-automation/src/flow-activation-ledger.test.ts index e78e505fa3b..3142c61959c 100644 --- a/packages/services/service-automation/src/flow-activation-ledger.test.ts +++ b/packages/services/service-automation/src/flow-activation-ledger.test.ts @@ -34,6 +34,7 @@ import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { withScheduledWorkOn } from './deployment-switch.test-support.js'; +import { withLoaderSetFromPull } from './loader-set.test-support.js'; function createTestLogger(): any { const l: any = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; @@ -81,7 +82,7 @@ function recordingTrigger(type: string) { /** An engine with the in-memory ledger attached and the four triggers registered. */ function engineWithLedger() { - const engine = new AutomationEngine(createTestLogger()); + const engine = withLoaderSetFromPull(new AutomationEngine(createTestLogger())); const store = new InMemoryFlowActivationStore(); engine.setFlowActivationStore(store); const triggers = { @@ -117,7 +118,7 @@ describe('ADR-0126 §4 — absence of a row = active (an empty ledger changes no }); it('an engine with NO store attached behaves exactly as a stock boot', async () => { - const engine = new AutomationEngine(createTestLogger()); + const engine = withLoaderSetFromPull(new AutomationEngine(createTestLogger())); engine.registerFlow('welcome', packagedFlow('welcome')); // Nothing to hydrate, and nothing refused. @@ -271,7 +272,7 @@ describe('ADR-0126 §7.2 — the install-level row unbinds the trigger', () => { // A brand-new engine: the retired map's "cold boot reads enabled: true // again" was recorded (commit 266436a7f) as mitigating-but-not-exculpating. It must no // longer be true. - const engine = new AutomationEngine(createTestLogger()); + const engine = withLoaderSetFromPull(new AutomationEngine(createTestLogger())); const trigger = recordingTrigger('record_change'); engine.registerTrigger(trigger.trigger); engine.setFlowActivationStore(store); @@ -435,7 +436,7 @@ describe('ADR-0126 §7.3 (disable direction) — a switched-off packaged caller /** An engine that can run and PARK, over the given durable run store (none: the hot cache alone). */ function parkingEngine(runStore?: SuspendedRunStore) { - const engine = new AutomationEngine(createTestLogger(), runStore); + const engine = withLoaderSetFromPull(new AutomationEngine(createTestLogger(), runStore)); const ledger = new InMemoryFlowActivationStore(); engine.setFlowActivationStore(ledger); registerSubflowNode(engine, nodeCtx); @@ -988,7 +989,7 @@ describe('ADR-0126 §4/§5 — the row this line writes', () => { }); it('a failing durable write ABORTS the flip — the engine never reports state the ledger lacks', async () => { - const engine = new AutomationEngine(createTestLogger()); + const engine = withLoaderSetFromPull(new AutomationEngine(createTestLogger())); const trigger = recordingTrigger('record_change'); engine.registerTrigger(trigger.trigger); engine.setFlowActivationStore({ @@ -1006,7 +1007,7 @@ describe('ADR-0126 §4/§5 — the row this line writes', () => { it('with no store attached the flip still applies, and WARNS that it is not durable', async () => { const logger = createTestLogger(); - const engine = new AutomationEngine(logger); + const engine = withLoaderSetFromPull(new AutomationEngine(logger)); engine.registerFlow('f', packagedFlow('f')); await engine.toggleFlow('f', false); @@ -1059,7 +1060,7 @@ describe('#10243 — the process-local `flowEnabled` map is retired', () => { }); it('the engine exposes no way to set activation state without the ledger', () => { - const engine = new AutomationEngine(createTestLogger()); + const engine = withLoaderSetFromPull(new AutomationEngine(createTestLogger())); // The retired mechanism's public shape, in every spelling a caller // might reach for. `toggleFlow` is the sanctioned door and it writes // the ledger; nothing else may exist beside it. diff --git a/packages/services/service-automation/src/flow-label-on-result.test.ts b/packages/services/service-automation/src/flow-label-on-result.test.ts index fd4df09fdeb..c4d34624950 100644 --- a/packages/services/service-automation/src/flow-label-on-result.test.ts +++ b/packages/services/service-automation/src/flow-label-on-result.test.ts @@ -38,6 +38,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { AutomationEngine } from './engine.js'; import type { NodeExecutor } from './engine.js'; import { installBuiltinNodes } from './builtin/index.js'; +import { withLoaderSetFromPull } from './loader-set.test-support.js'; function silentLogger() { return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any; @@ -79,7 +80,7 @@ describe('AutomationResult.flowLabel — the authored flow label rides the resul let flakyCalls: number; beforeEach(() => { - engine = new AutomationEngine(silentLogger()); + engine = withLoaderSetFromPull(new AutomationEngine(silentLogger())); installBuiltinNodes(engine, pluginCtx()); flakyCalls = 0; engine.registerNodeExecutor({ diff --git a/packages/services/service-automation/src/flow-terminal-messages.test.ts b/packages/services/service-automation/src/flow-terminal-messages.test.ts index f59b1797db1..32805dec331 100644 --- a/packages/services/service-automation/src/flow-terminal-messages.test.ts +++ b/packages/services/service-automation/src/flow-terminal-messages.test.ts @@ -52,6 +52,7 @@ import { AutomationEngine } from './engine.js'; import { InMemorySuspendedRunStore } from './suspended-run-store.js'; import type { AutomationContext } from '@objectstack/spec/contracts'; import { defineActionDescriptor } from '@objectstack/spec/automation'; +import { withLoaderSetFromPull } from './loader-set.test-support.js'; function createTestLogger(): any { return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, child: () => createTestLogger() }; @@ -101,7 +102,7 @@ function messageFlow( * leaves through `executeWithoutRetry`'s success exit rather than `execute()`'s. */ function engineWith(outcome: 'pass' | 'fail' | 'fail_then_pass') { - const engine = new AutomationEngine(createTestLogger()); + const engine = withLoaderSetFromPull(new AutomationEngine(createTestLogger())); const runs = { count: 0 }; engine.registerNodeExecutor({ type: 'script', @@ -247,7 +248,7 @@ describe('#9414 — the symmetry anchor: resume() already did this', () => { // this path instead of widening that one — from passing silently. it('a resumed run still carries successMessage on its terminal exit', async () => { - const engine = new AutomationEngine(createTestLogger(), new InMemorySuspendedRunStore()); + const engine = withLoaderSetFromPull(new AutomationEngine(createTestLogger(), new InMemorySuspendedRunStore())); engine.registerNodeExecutor({ type: 'approval_pause', descriptor: defineActionDescriptor({ diff --git a/packages/services/service-automation/src/index.ts b/packages/services/service-automation/src/index.ts index 2161e4dc16b..bb9e28891be 100644 --- a/packages/services/service-automation/src/index.ts +++ b/packages/services/service-automation/src/index.ts @@ -70,6 +70,10 @@ export type { // consumer needs the name to annotate a result or switch exhaustively over // `reason` — the three negatives exist precisely to be branched on. ConsumedSuspensionInspection, + // [#20761] The reader `AutomationEngine.setPackagedFlowSource` takes — the + // method is barrel-reachable, so a host building a custom composition + // needs the name to hand it the loader's set. + PackagedFlowSource, } from './engine.js'; // [#11997] ADR-0005 overlay precedence for same-named flow definitions. The boot diff --git a/packages/services/service-automation/src/loader-set.test-support.ts b/packages/services/service-automation/src/loader-set.test-support.ts new file mode 100644 index 00000000000..735b472ded3 --- /dev/null +++ b/packages/services/service-automation/src/loader-set.test-support.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20761] Test-only stand-in for THE LOADER'S SET — the one server-held fact + * of which flows are packaged, which the engine reads through + * `AutomationEngine.setPackagedFlowSource` (production: the automation plugin's + * `packagedFlowReader`, over the metadata protocol's `packagedArtifactOwner`). + * + * ⛔ Not exported from `index.ts` and outside the tsup entry, so it is compiled + * by the test program and shipped by nothing. + * + * The suites that attach it pin the §7.3 subflow guards, the arming gate and + * the activation door — the LOGIC that runs once a flow is known to be + * packaged — and each of their `registerFlow` calls with a package's stamps + * plays the part of the boot pull, which registers exactly the bodies the + * artifact loader stamped. So the set here is "the flows registered with a + * code package's stamps", read off the engine's own flow map, which is what + * the loader's set looks like to an engine the boot pull filled. + * + * ⚠️ That equivalence is what this helper ASSUMES, never what it proves. That + * the engine reads the attached set — and NOT a definition's stamps — is + * pinned on its own, in `packaged-flow-source.test.ts`, with an explicit set + * that disagrees with the stamps in both directions. + */ + +import { isCodeArtifactBody } from '@objectstack/metadata-core'; +import type { AutomationEngine } from './engine.js'; + +/** + * Attach a loader's set to `engine` that holds every flow it has registered + * with a code package's stamps. Returns the engine, so a construction can + * stay one expression. + */ +export function withLoaderSetFromPull(engine: E): E { + engine.setPackagedFlowSource((name) => { + const flow = (engine as unknown as { flows: Map }).flows.get(name); + if (!isCodeArtifactBody(flow)) return undefined; + return String((flow as { _packageId: unknown })._packageId); + }); + return engine; +} diff --git a/packages/services/service-automation/src/node-type-vocabulary-seal-warning.test.ts b/packages/services/service-automation/src/node-type-vocabulary-seal-warning.test.ts index 2c98462c68e..1c1892a304b 100644 --- a/packages/services/service-automation/src/node-type-vocabulary-seal-warning.test.ts +++ b/packages/services/service-automation/src/node-type-vocabulary-seal-warning.test.ts @@ -30,6 +30,7 @@ import { LiteKernel } from '@objectstack/core'; import type { Plugin, PluginContext } from '@objectstack/core'; import { AutomationEngine } from './engine.js'; import { AutomationServicePlugin } from './plugin.js'; +import { withLoaderSetFromPull } from './loader-set.test-support.js'; /** Substring that identifies the #4792 line, wherever logs are captured. */ const OMISSION_MARKER = 'node-type vocabulary was never sealed'; @@ -143,7 +144,7 @@ describe('#4792 — a never-sealed node-type vocabulary announces itself at the it('does not fire for an unknown or disabled flow name — the trigger is a real run', async () => { const lines: string[] = []; - const engine = new AutomationEngine(loggerCapturing(lines)); + const engine = withLoaderSetFromPull(new AutomationEngine(loggerCapturing(lines))); // [#20726] Disabled through the toggle, which switches packaged flows only. engine.registerFlow('off', { ...trivialFlow('off'), _packageId: 'crm' }); await engine.toggleFlow('off', false); diff --git a/packages/services/service-automation/src/packaged-flow-source.test.ts b/packages/services/service-automation/src/packaged-flow-source.test.ts new file mode 100644 index 00000000000..27209a3f9c4 --- /dev/null +++ b/packages/services/service-automation/src/packaged-flow-source.test.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#20761, ADR-0126 §7.2 / §7.3] Which flows are PACKAGED is the loader's set — +// the reader attached through `setPackagedFlowSource` — and never the stamps a +// definition carries. +// +// A definition reaches `registerFlow` through authoring doors too, so its +// provenance stamps are the caller's bytes. Every pin below attaches an +// EXPLICIT set that disagrees with the stamps, in both directions, and reads +// the answer off the engine's own outputs: the §7.3 refusal, the activation +// door's refusal envelope and the activation ledger's rows. +// +// The suites that pin the guard LOGIC stand the stamps in for the set +// (`loader-set.test-support.ts`); this file is what makes that stand-in honest. + +import { describe, it, expect, vi } from 'vitest'; +import { AutomationEngine } from './engine.js'; +import { InMemoryFlowActivationStore } from './flow-activation-store.js'; +import { packagedFlowReader } from './plugin.js'; + +function createTestLogger(): any { + const l: any = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + l.child = () => l; + return l; +} + +function flowBody(name: string, extra: Record = {}) { + return { + name, + label: name, + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: {} }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + ...extra, + }; +} + +function callerBody(name: string, target: string, extra: Record = {}) { + return { + ...flowBody(name, extra), + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: {} }, + { id: 'call', type: 'subflow', label: 'Call', config: { flowName: target } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'call' }, + { id: 'e2', source: 'call', target: 'end' }, + ], + }; +} + +/** The stamps a caller could send to claim a package's provenance. */ +const ASSERTED = { _packageId: 'crm', _provenance: 'package' }; + +/** An engine with a ledger and an EXPLICIT loader's set. */ +function engineWithSet(set: Record) { + const engine = new AutomationEngine(createTestLogger()); + const store = new InMemoryFlowActivationStore(); + engine.setFlowActivationStore(store); + engine.setPackagedFlowSource((name) => set[name]); + return { engine, store }; +} + +async function refusalOf(p: Promise): Promise { + try { + await p; + } catch (e) { + return e as Error & { code?: unknown; status?: unknown }; + } + throw new Error('expected a refusal, and the call was accepted'); +} + +describe('[#20761] the engine classifies a flow by the loader\'s set, not by its definition\'s stamps', () => { + it('a customer flow stamped as a package\'s does not hold a packaged subflow\'s disable', async () => { + const { engine, store } = engineWithSet({ shared_step: 'crm' }); + engine.registerFlow('shared_step', flowBody('shared_step')); + engine.registerFlow('my_process', callerBody('my_process', 'shared_step', ASSERTED)); + + await expect(engine.toggleFlow('shared_step', false)).resolves.toBeUndefined(); + expect(await store.list()).toEqual([{ name: 'shared_step', packageId: 'crm', active: false }]); + }); + + it('control: a caller the set holds, with no stamps at all, does hold it', async () => { + const { engine, store } = engineWithSet({ shared_step: 'crm', vendor_process: 'crm' }); + engine.registerFlow('shared_step', flowBody('shared_step')); + engine.registerFlow('vendor_process', callerBody('vendor_process', 'shared_step')); + + const refusal = await refusalOf(engine.toggleFlow('shared_step', false)); + + expect(refusal.code).toBe('DELETE_RESTRICTED'); + expect(refusal.status).toBe(409); + expect(await store.list()).toEqual([]); + }); + + it('the removal door is guarded the same way: a stamped customer caller holds nothing, a set-held caller does', async () => { + const { engine } = engineWithSet({ shared_step: 'crm', vendor_process: 'crm' }); + engine.registerFlow('shared_step', flowBody('shared_step')); + engine.registerFlow('vendor_process', callerBody('vendor_process', 'shared_step')); + expect(() => engine.unregisterFlow('shared_step')).toThrow(expect.objectContaining({ code: 'DELETE_RESTRICTED', status: 409 })); + + const other = engineWithSet({ shared_step: 'crm' }); + other.engine.registerFlow('shared_step', flowBody('shared_step')); + other.engine.registerFlow('my_process', callerBody('my_process', 'shared_step', ASSERTED)); + expect(() => other.engine.unregisterFlow('shared_step')).not.toThrow(); + expect(await other.engine.getFlow('shared_step')).toBeNull(); + }); + + it('the activation door refuses a customer flow stamped as a package\'s, and writes no row', async () => { + const { engine, store } = engineWithSet({}); + engine.registerFlow('my_flow', flowBody('my_flow', ASSERTED)); + + for (const enabled of [false, true]) { + const refusal = await refusalOf(engine.toggleFlow('my_flow', enabled)); + expect(refusal.code).toBe('RESOURCE_CONFLICT'); + expect(refusal.status).toBe(409); + } + expect(await store.list()).toEqual([]); + }); + + it('an activation row is attributed to the package the SET names, never to the one the stamps name', async () => { + const { engine, store } = engineWithSet({ vendor_flow: 'crm' }); + engine.registerFlow('vendor_flow', flowBody('vendor_flow', { _packageId: 'someone_else', _provenance: 'package' })); + + await engine.toggleFlow('vendor_flow', false); + + expect(await store.list()).toEqual([{ name: 'vendor_flow', packageId: 'crm', active: false }]); + }); + + it('a flow the set holds is packaged with no stamps on its definition at all', async () => { + const { engine, store } = engineWithSet({ vendor_flow: 'crm' }); + engine.registerFlow('vendor_flow', flowBody('vendor_flow')); + + await engine.toggleFlow('vendor_flow', false); + + expect(await store.list()).toEqual([{ name: 'vendor_flow', packageId: 'crm', active: false }]); + expect(engine.packagedFlowOwner('vendor_flow')).toBe('crm'); + }); + + it('with no set attached no flow is packaged: the activation door refuses even a stamped one', async () => { + const engine = new AutomationEngine(createTestLogger()); + const store = new InMemoryFlowActivationStore(); + engine.setFlowActivationStore(store); + engine.registerFlow('vendor_flow', flowBody('vendor_flow', ASSERTED)); + + const refusal = await refusalOf(engine.toggleFlow('vendor_flow', false)); + + expect(refusal.code).toBe('RESOURCE_CONFLICT'); + expect(await store.list()).toEqual([]); + expect(engine.packagedFlowOwner('vendor_flow')).toBeUndefined(); + }); +}); + +describe('[#20761] the automation plugin hands the engine the metadata protocol\'s reading of the loader\'s set', () => { + const ctxWith = (service: unknown) => ({ + getService: (name: string) => { + if (name !== 'protocol' || service === undefined) throw new Error(`service '${name}' not found`); + return service as never; + }, + }); + + it('asks the protocol\'s packagedArtifactOwner for the flow type, at question time', () => { + const packagedArtifactOwner = vi.fn((q: { type: string; name: string }) => (q.name === 'vendor_flow' ? 'crm' : undefined)); + const read = packagedFlowReader(ctxWith({ packagedArtifactOwner })); + + expect(read('vendor_flow')).toBe('crm'); + expect(read('my_flow')).toBeUndefined(); + expect(packagedArtifactOwner).toHaveBeenCalledWith({ type: 'flow', name: 'vendor_flow' }); + }); + + it('a composition with no protocol, or a protocol without the reader, packages nothing', () => { + expect(packagedFlowReader(ctxWith(undefined))('vendor_flow')).toBeUndefined(); + expect(packagedFlowReader(ctxWith({}))('vendor_flow')).toBeUndefined(); + }); + + it('a protocol that registers AFTER the reader was built is still read — nothing is recorded at boot', () => { + let service: unknown; + const read = packagedFlowReader({ + getService: (name: string) => { + if (name !== 'protocol' || service === undefined) throw new Error(`service '${name}' not found`); + return service as never; + }, + }); + expect(read('vendor_flow')).toBeUndefined(); + + service = { packagedArtifactOwner: () => 'crm' }; + + expect(read('vendor_flow')).toBe('crm'); + }); +}); diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index 7f2c390ce43..fbc5c125eef 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -445,6 +445,35 @@ export function findInertDeclaredConnectors( .map((c) => c.name as string); } +/** + * [#20761, ADR-0126 §7.2 / §7.3] The engine's reader over THE LOADER'S SET: + * which package ships a flow name, asked of the metadata protocol's + * `packagedArtifactOwner` — the answer its locked-base verdict reads, drawn + * from the entries the artifact loader registered. ⛔ No second set is kept + * here, and nothing is read off a definition. + * + * Resolved at QUESTION time, never at boot: the protocol service may register + * after this plugin's `init()`, and a registry read turned into a recorded + * "not there" would be the startup-registry verdict AGENTS.md forbids. A + * composition with no protocol — or one whose protocol brings no such reader — + * holds no flow a managed package loaded as far as this engine can know, so + * the answer is "none": the §7.3 guards then protect nothing and the + * activation door refuses every flow, which fails closed rather than trusting + * a definition's own stamps. + */ +export function packagedFlowReader(ctx: Pick): (name: string) => string | undefined { + return (name) => { + let protocol: { packagedArtifactOwner?: (request: { type: string; name: string }) => string | undefined } | undefined; + try { + protocol = ctx.getService('protocol'); + } catch { + return undefined; + } + if (typeof protocol?.packagedArtifactOwner !== 'function') return undefined; + return protocol.packagedArtifactOwner({ type: 'flow', name }); + }; +} + /** * AutomationServicePlugin — Core engine plugin * @@ -617,6 +646,12 @@ export class AutomationServicePlugin implements Plugin { // Register as global service — other plugins access via ctx.getService('automation') ctx.registerService('automation', this.engine); + // [#20761, ADR-0126 §7.2 / §7.3] Which flows are PACKAGED is the + // loader's set, read through the metadata protocol — the same answer + // its locked-base verdict reads — never the stamps on a definition. + // See `packagedFlowReader`. + this.engine.setPackagedFlowSource(packagedFlowReader(ctx)); + // [#20552] Every metadata read exit withholds the inbound hook's secret // from a served flow from here on — the `flow` entry of the per-type // redactor registry, registered in `init()` as that registry asks. diff --git a/packages/services/service-automation/src/subflow-guard-every-door.test.ts b/packages/services/service-automation/src/subflow-guard-every-door.test.ts index 5f31d3dd231..28c2accbbb8 100644 --- a/packages/services/service-automation/src/subflow-guard-every-door.test.ts +++ b/packages/services/service-automation/src/subflow-guard-every-door.test.ts @@ -39,6 +39,7 @@ import { registerMapNode } from './builtin/map-node.js'; import { defineActionDescriptor } from '@objectstack/spec/automation'; import type { AutomationContext } from '@objectstack/spec/contracts'; import { withScheduledWorkOn } from './deployment-switch.test-support.js'; +import { withLoaderSetFromPull } from './loader-set.test-support.js'; function createTestLogger(): any { const l: any = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; @@ -129,7 +130,7 @@ function flow( /** An engine over an in-memory activation ledger, with a `record_change` trigger registered. */ function engineWithLedger() { const logger = createTestLogger(); - const engine = new AutomationEngine(logger); + const engine = withLoaderSetFromPull(new AutomationEngine(logger)); const ledger = new InMemoryFlowActivationStore(); engine.setFlowActivationStore(ledger); registerSubflowNode(engine, nodeCtx); @@ -236,7 +237,7 @@ describe('ADR-0126 §7.3 at REGISTRATION — the arming gate declines a packaged const ledger = new InMemoryFlowActivationStore(); await ledger.setActive({ name: 'shared_step', packageId: 'crm', active: false }); const logger = createTestLogger(); - const engine = new AutomationEngine(logger); + const engine = withLoaderSetFromPull(new AutomationEngine(logger)); engine.setFlowActivationStore(ledger); registerSubflowNode(engine, nodeCtx); @@ -260,7 +261,7 @@ describe('ADR-0126 §7.3 at REGISTRATION — the arming gate declines a packaged const ledger = new InMemoryFlowActivationStore(); await ledger.setActive({ name: 'shared_step', packageId: 'crm', active: false }); const logger = createTestLogger(); - const engine = new AutomationEngine(logger); + const engine = withLoaderSetFromPull(new AutomationEngine(logger)); engine.setFlowActivationStore(ledger); registerSubflowNode(engine, nodeCtx); // A host whose trigger is registered before its flows are pulled. @@ -458,6 +459,14 @@ describe('ADR-0126 §7.3 through the artifact reload (metadata:reloaded)', () => async getMetaItemsForExecution(q: { type: string }) { return { items: q.type === 'flow' ? flows : [] }; }, + // [#20761] The loader's set, as the real protocol answers it: + // the package that ships the name, read from what this fake + // artifact carries — the reader the plugin hands the engine. + packagedArtifactOwner(q: { type: string; name: string }) { + if (q.type !== 'flow') return undefined; + const hit = (flows as Array<{ name?: string; _packageId?: string }>).find((f) => f.name === q.name); + return hit?._packageId || undefined; + }, }, setFlows: (next: unknown[]) => { flows = next; }, }; diff --git a/packages/services/service-automation/src/suspended-run-store.test.ts b/packages/services/service-automation/src/suspended-run-store.test.ts index fda55608a81..4ada3a65ca9 100644 --- a/packages/services/service-automation/src/suspended-run-store.test.ts +++ b/packages/services/service-automation/src/suspended-run-store.test.ts @@ -16,6 +16,7 @@ import { RESUME_AUTHORITY_SERVICE } from '@objectstack/spec/contracts'; import { assertEngineDeleteDispatch } from '@objectstack/metadata-core'; import { InMemoryFlowActivationStore } from './flow-activation-store.js'; import { registerSubflowNode } from './builtin/subflow-node.js'; +import { withLoaderSetFromPull } from './loader-set.test-support.js'; /** * The `resumeAuthority: 'any'` declaration every pausing fixture below needs @@ -1504,7 +1505,7 @@ describe('#20725 the parked-run read behind the §7.3 disable guard asks for the /** One process lifetime over the shared table: a packaged caller that parks, then calls its subflow. */ function build() { - const e = new AutomationEngine(logger, new ObjectStoreSuspendedRunStore(engine, logger)); + const e = withLoaderSetFromPull(new AutomationEngine(logger, new ObjectStoreSuspendedRunStore(engine, logger))); e.setFlowActivationStore(new InMemoryFlowActivationStore()); registerSubflowNode(e, nodeCtx); e.registerNodeExecutor({ diff --git a/packages/services/service-automation/src/toggle-door-packaged-only.test.ts b/packages/services/service-automation/src/toggle-door-packaged-only.test.ts index c1a98c5bcff..c1326a887f8 100644 --- a/packages/services/service-automation/src/toggle-door-packaged-only.test.ts +++ b/packages/services/service-automation/src/toggle-door-packaged-only.test.ts @@ -30,6 +30,7 @@ import { AutomationEngine } from './engine.js'; import type { FlowTrigger, FlowTriggerBinding } from './engine.js'; import { InMemoryFlowActivationStore } from './flow-activation-store.js'; import type { AutomationContext } from '@objectstack/spec/contracts'; +import { withLoaderSetFromPull } from './loader-set.test-support.js'; function createTestLogger(): any { const l: any = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; @@ -72,8 +73,9 @@ const packaged = (name: string, extra: Record = {}) => flowBody /** * The three shapes a flow authored in this deployment reaches the engine in — - * none of them package provenance by the canonical test (`isCodeArtifactBody`, - * which the §7.3 guards already ask through `describeFlowContender`): + * none of them in the loader's set, which this suite's stand-in reads off the + * canonical test (`isCodeArtifactBody`; see `loader-set.test-support.ts` — the + * engine itself reads the set, never a definition's stamps, #20761): * - no package envelope at all (the automation create door, the clone door); * - the `sys_metadata` sentinel a runtime-authored row is registered under; * - a tenant-authored row bound to an app package (`_provenance: 'org'`). @@ -95,7 +97,7 @@ function stateOf(engine: AutomationEngine, name: string) { /** An engine with the in-memory ledger and a record trigger attached. */ function engineWithLedger() { const logger = createTestLogger(); - const engine = new AutomationEngine(logger); + const engine = withLoaderSetFromPull(new AutomationEngine(logger)); const store = new InMemoryFlowActivationStore(); engine.setFlowActivationStore(store); const records = recordingTrigger(); @@ -146,7 +148,7 @@ describe('[#20726] pin 1 — the toggle door refuses a customer-authored flow, a it('with NO ledger attached (the degraded mode) the refusal is the same, and nothing flips in process', async () => { const logger = createTestLogger(); - const engine = new AutomationEngine(logger); + const engine = withLoaderSetFromPull(new AutomationEngine(logger)); const records = recordingTrigger(); engine.registerTrigger(records.trigger); engine.registerFlow('customer_flow', flowBody('customer_flow'));