diff --git a/.changeset/heavy-eels-shave.md b/.changeset/heavy-eels-shave.md new file mode 100644 index 0000000000..24bc98dfbc --- /dev/null +++ b/.changeset/heavy-eels-shave.md @@ -0,0 +1,41 @@ +--- +"@objectstack/service-automation": minor +"@objectstack/runtime": minor +--- + +Packaged flows can be switched off durably, and the process-local off-switch is retired + +Disabling a packaged flow now writes an install-level row to the +`sys_metadata_activation` ledger (ADR-0126 §4/§7.2) instead of setting a +process-local map. The engine consults that ledger at the `execute()` seam — +the one seam every entry path crosses (record-change, schedule, time-relative, +api, subflow) — and refuses a disabled flow there with the existing +`FLOW_DISABLED` code; the ledger case is distinguished by the message, so no +new error code joins the ADR-0112 ledger. An install-level disable also unbinds +the flow's trigger, and re-enabling rebinds it. Absence of a row means the +packaged default, active, so a deployment that never flips anything behaves +exactly as before. + +This retires the mechanism behind #10243 rather than refining it. The old +`flowEnabled` map was not a row, so no organization wall scoped it: on a walled +multi-organization deployment a tenant org owner could switch a shipped flow +off environment-wide and an unrelated tenant read it off. The durable row +replaces it, and because a durable install-wide switch writable by tenants +would be that leak with persistence, the write is now authority-gated: +`POST /automation/:name/toggle` requires the platform operator in the `group` +and `isolated` postures, while the `single` posture — where install-level and +org-level are the same scope — is unchanged for the org admin who already holds +`manage_metadata`. The refusal names the posture and points at the clone path. + +Disabling a flow that packaged flows still call as a subflow is refused, and +the refusal names the callers (ADR-0126 §7.3). Without it a vendor flow breaks +mid-run at its subflow node with an inexplicable late failure. The check is a +definition scan at disable time over both `subflow` and `map` nodes; no +reference index is built. Enabling is never guarded. + +One behaviour change worth calling out: a disable now survives +unregister-and-re-register, which is what a package upgrade, a Studio publish +and the boot pull all do. ADR-0126 §6 requires it — the ledger records the +customer's choice, and no upgrade un-makes a choice — but it is the opposite of +what the retired in-process map did, where any re-registration silently +re-armed the flow. diff --git a/packages/runtime/src/domains/automation-activation-posture-gate.test.ts b/packages/runtime/src/domains/automation-activation-posture-gate.test.ts new file mode 100644 index 0000000000..7ebd09e25c --- /dev/null +++ b/packages/runtime/src/domains/automation-activation-posture-gate.test.ts @@ -0,0 +1,246 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#12157] ADR-0126 §5 — WRITE AUTHORITY for the packaged-flow activation +// switch, `POST /automation/:name/toggle`. +// +// ## The rule, and why it is posture-conditional +// +// The row this route writes is INSTALL-LEVEL (`organization_id NULL`): one +// row, one environment, every tenant. So the authority it demands scales with +// how far that reach goes: +// +// - `single` — one logical tenant, so install-level and org-level are the +// SAME scope. The org admin who already passed the #10145 +// `manage_metadata` gate is the right authority. +// - `group` / +// `isolated` — a real multi-organization deployment, where the switch +// crosses tenants. The platform OPERATOR is required. +// +// ## What this is made durable against +// +// #10243, measured over HTTP: on a real `isolated` posture a tenant org owner +// switched a shipped flow off through this very route and an unrelated tenant +// in a DIFFERENT organization read it off — environment-wide reach from a +// tenant caller. That leak went through a PROCESS-LOCAL map, so a cold boot +// undid it ("mitigating but not exculpating"). ADR-0126 makes the switch +// DURABLE, which removes that accidental limit — so a tenant-writable +// install-wide row would be the same leak WITH persistence, i.e. strictly +// worse than what was measured. This gate is what stops that. +// +// ## What the refusal cases assert +// +// `status` AND `code` (the ADR-0112 envelope), AND that `toggleFlow` was never +// entered — a gate that refused after the ledger was already written would +// still be the defect and would still satisfy a status-only assertion. + +import { describe, it, expect, vi } from 'vitest'; + +import { HttpDispatcher } from '../http-dispatcher.js'; +import type { HttpProtocolContext } from '../http-dispatcher.js'; + +const FLOW = 'vendor_lead_router'; +const DEFINITION = { name: FLOW, label: 'Vendor Lead Router', type: 'autolaunched', nodes: [], edges: [] }; + +interface Harness { + dispatcher: HttpDispatcher; + toggleFlow: ReturnType; +} + +/** + * A dispatcher whose `tenancy` service reports the given posture. + * + * `posture: null` is a deployment with NO tenancy service — the shape + * `resolve-execution-context.ts` resolves to "no posture-conditional refusal", + * and (ADR-0093 D4/D5) the same deployment shape as `single`. + */ +function boot(posture: 'single' | 'group' | 'isolated' | null): Harness { + const toggleFlow = vi.fn(async () => undefined); + + const services: Record = { + automation: { + handlerReady: true, + toggleFlow, + getFlow: vi.fn(async (name: string) => (name === FLOW ? DEFINITION : undefined)), + }, + }; + if (posture) services.tenancy = { posture }; + + 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), toggleFlow }; +} + +/** + * A tenant org admin who DOES hold `manage_metadata` — so the #10145 gate one + * tier up passes and this gate is the only thing left. That is the whole point: + * the two gates ask different questions, and this test must not pass merely + * because the other one refused. + */ +const TENANT_ADMIN = (): HttpProtocolContext => ({ + request: {}, + executionContext: { + userId: 'u_northwind_owner', + positions: ['org_owner', 'org_admin'], + permissions: ['organization_admin'], + systemPermissions: ['manage_metadata'], + organizationId: 'org_northwind', + }, +} as HttpProtocolContext); + +/** The platform operator (ADR-0068 D2: `platform_admin`, NOT a tenant role). */ +const PLATFORM_OPERATOR = (): HttpProtocolContext => ({ + request: {}, + executionContext: { + userId: 'u_saas_operator', + positions: ['platform_admin'], + permissions: ['admin_full_access'], + systemPermissions: ['manage_metadata'], + organizationId: null, + }, +} as HttpProtocolContext); + +/** Engine self-invocation — never settable from the wire. */ +const SYSTEM = (): HttpProtocolContext => ({ + request: {}, + executionContext: { userId: 'usr_system', isSystem: true }, +} as HttpProtocolContext); + +const statusOf = (response: unknown): unknown => (response as any)?.status; +const codeOf = (response: unknown): unknown => { + const r = response as any; + return r?.body?.error?.code ?? r?.body?.error?.details?.code; +}; +const messageOf = (response: unknown): string => { + const r = response as any; + return String(r?.body?.error?.message ?? ''); +}; + +const toggle = (h: Harness, ctx: HttpProtocolContext) => + h.dispatcher.handleAutomation(`/${FLOW}/toggle`, 'POST', { enabled: false }, ctx, undefined); + +describe('ADR-0126 §5 — the activation write is operator-gated in walled postures', () => { + describe('`single` posture — the org admin suffices', () => { + it('a tenant admin with `manage_metadata` may flip the switch', async () => { + const h = boot('single'); + + const { response } = await toggle(h, TENANT_ADMIN()); + + expect(statusOf(response)).toBe(200); + expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false); + }); + + it('so may the platform operator', async () => { + const h = boot('single'); + + const { response } = await toggle(h, PLATFORM_OPERATOR()); + + expect(statusOf(response)).toBe(200); + expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false); + }); + + it('no tenancy service at all behaves like `single` (ADR-0093 D4/D5)', async () => { + const h = boot(null); + + const { response } = await toggle(h, TENANT_ADMIN()); + + // Refusing here would lock every single-tenant operator out of + // their own switch, and an unenforceable wall resolves to `single`. + expect(statusOf(response)).toBe(200); + expect(h.toggleFlow).toHaveBeenCalled(); + }); + }); + + for (const posture of ['group', 'isolated'] as const) { + describe(`\`${posture}\` posture — the install-wide switch needs the operator`, () => { + it('REFUSES a tenant org admin, loudly, and never enters toggleFlow', async () => { + const h = boot(posture); + + const { response } = await toggle(h, TENANT_ADMIN()); + + expect(statusOf(response)).toBe(403); + expect(codeOf(response)).toBe('PERMISSION_DENIED'); + // The load-bearing assertion: refused BEFORE the write. A gate + // that wrote the row and then refused would satisfy the two + // above and still be #10243. + expect(h.toggleFlow).not.toHaveBeenCalled(); + }); + + it('the refusal names the posture, the reason, and the sanctioned path', async () => { + const h = boot(posture); + + const { response } = await toggle(h, TENANT_ADMIN()); + const message = messageOf(response); + + expect(message).toContain(posture); + expect(message).toContain('INSTALL-WIDE'); + expect(message).toContain('ADR-0126 §5'); + // ADR-0126 §7: a refusal names what the caller CAN do. Here + // that is the clone path (§7.1), which needs no operator. + expect(message).toMatch(/clone/i); + // #7450 — a denial says nothing about the caller's own + // positions or permission-set names. + expect(message).not.toContain('org_owner'); + expect(message).not.toContain('organization_admin'); + }); + + it('ALLOWS the platform operator', async () => { + const h = boot(posture); + + const { response } = await toggle(h, PLATFORM_OPERATOR()); + + expect(statusOf(response)).toBe(200); + expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false); + }); + + it('ALLOWS engine self-invocation', async () => { + const h = boot(posture); + + const { response } = await toggle(h, SYSTEM()); + + expect(statusOf(response)).toBe(200); + expect(h.toggleFlow).toHaveBeenCalled(); + }); + + it('gates ENABLE as well as disable — the switch is install-wide in both directions', async () => { + const h = boot(posture); + + const { response } = await h.dispatcher.handleAutomation( + `/${FLOW}/toggle`, 'POST', { enabled: true }, TENANT_ADMIN(), undefined, + ); + + expect(statusOf(response)).toBe(403); + expect(h.toggleFlow).not.toHaveBeenCalled(); + }); + + it('does NOT gate the clone door — cloning is the path the refusal recommends', async () => { + const h = boot(posture); + + const { response } = await h.dispatcher.handleAutomation( + `/${FLOW}/clone`, 'POST', { name: 'my_lead_router', label: 'My Lead Router' }, + TENANT_ADMIN(), undefined, + ); + + // Whatever the clone route answers, it must not be THIS gate: + // a clone creates an ordinary new artifact and takes nothing + // away from any tenant (§7.1). + expect(codeOf(response)).not.toBe('PERMISSION_DENIED'); + }); + + it('does not over-block the legacy execution door for a flow named `toggle`', async () => { + const h = boot(posture); + + await h.dispatcher.handleAutomation('/trigger/toggle', 'POST', {}, TENANT_ADMIN(), undefined); + + // `POST /automation/trigger/toggle` RUNS a flow literally named + // `toggle`; gating it would over-block an execution door, which + // is the one thing the #10243 ruling did not do. + expect(h.toggleFlow).not.toHaveBeenCalled(); + }); + }); + } +}); diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 324706b666..aeca846ee1 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -12,6 +12,13 @@ import { shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, } from '@objectstack/core'; +// [ADR-0126 §5] The activation gate's two inputs: the deployment's EFFECTIVE +// tenancy posture (the same resolver `resolve-execution-context.ts` uses, so +// admission and this gate can never disagree) and the built-in identity name +// that means "platform operator, NOT a tenant user role" (ADR-0068 D2). +import { effectiveTenancyPosture } from '@objectstack/core'; +import { postureEnforcesWall } from '@objectstack/spec/security'; +import { BUILTIN_IDENTITY_PLATFORM_ADMIN } from '@objectstack/spec/identity'; import { CoreServiceName } from '@objectstack/spec/system'; import type { IAutomationService, ISecurityService } from '@objectstack/spec/contracts'; import { isServiceServeable } from '../service-serveable.js'; @@ -399,6 +406,30 @@ function isFlowAuthoringWrite(parts: string[], method: string): boolean { return false; } +/** + * [ADR-0126 §5] Which routes the ACTIVATION gate covers: the enable/disable + * door, and only it. + * + * A second predicate rather than an arm of {@link isFlowAuthoringWrite} + * because the two ask different questions and cover different route sets. That + * one asks "is this an authoring write?" (create / update / delete / toggle / + * clone → `manage_metadata`); this one asks "does this write an INSTALL-WIDE + * activation row?", which is true of the toggle alone. ⛔ `clone` is + * deliberately NOT here: a clone creates an ordinary new artifact under a new + * name (§7.1) and takes nothing away from any tenant, so gating it on the + * platform operator would refuse the very customization path this refusal + * message recommends. + * + * Spelled exactly like the toggle arm of the predicate above — no upper bound + * on depth, `parts[0] !== 'trigger'` — for the reasons documented there: a + * gate narrower than its route is a bypass, and a gate wider than its route + * over-blocks the legacy execution door. + */ +function isFlowActivationWrite(parts: string[], method: string): boolean { + if (method === 'POST' && parts[1] === 'toggle') return parts[0] !== 'trigger'; + return false; +} + /** * [#10145] The authoring-write gate: refuse a caller without * {@link FLOW_AUTHORING_CAPABILITY}. @@ -438,6 +469,94 @@ function isFlowAuthoringWrite(parts: string[], method: string): boolean { * measured — and (c) the definition contract is not enumerable by probing * 422s from outside the authoring cohort. */ +/** [ADR-0126 §5] Refusal vocabulary for the activation (enable/disable) gate. */ +const ACTIVATION_DENY_STATUS = 403; +const ACTIVATION_DENY_CODE = 'PERMISSION_DENIED'; + +/** + * [ADR-0126 §5] THE WRITE-AUTHORITY GATE for the packaged-flow activation + * switch — `POST /automation/:name/toggle`. + * + * ## What it enforces + * + * The activation row this route writes is **install-level** + * (`organization_id NULL`, §5): one row, one environment, every tenant. So the + * authority required to write it scales with how many tenants that reach + * covers: + * + * - **`single` posture** — one logical tenant, so install-level and + * org-level are the SAME scope. The org admin who already passed the + * #10145 `manage_metadata` gate one tier up is the right authority, and + * this gate is inert. + * - **`group` / `isolated`** — a real multi-organization deployment. Here + * the write requires the PLATFORM OPERATOR, because a tenant org admin + * flipping an install-wide switch is precisely #10243: that incident + * measured a tenant org owner switching a shipped flow off + * ENVIRONMENT-WIDE, read back by an unrelated tenant in a different + * organization. ADR-0126 §5 makes that durable in the correct direction — + * and a durable install-wide row writable by tenants would be the same + * leak WITH persistence, which is strictly worse than what was measured. + * + * ## Why the operator test is a POSITION and not a capability + * + * ADR-0126 §5 says "the platform-operator capability"; the platform's actual + * operator identity is the ADR-0068 D2 built-in `platform_admin` POSITION, + * documented verbatim as "Platform operator (SaaS admin). NOT a tenant user + * role", unscoped, sourced from the unscoped `admin_full_access` grant. No + * capability in `PLATFORM_CAPABILITIES` carries that meaning: `manage_metadata` + * is the one the tier above already requires, and a tenant org admin can hold + * it — so spelling this gate as a capability check would either re-ask the + * question already answered or invent a capability name, which would be a + * `packages/spec` change this leg is walled out of. The position IS the + * platform's operator concept; this gate reads it rather than minting a + * synonym. + * + * ## Fail-open on an ABSENT posture is deliberate, not a gap + * + * No `tenancy` service ⇒ no posture ⇒ no posture-conditional refusal, matching + * `resolve-execution-context.ts` verbatim. Under ADR-0093 D4/D5 a + * requested-but-unenforceable wall resolves to `single` anyway, so an absent + * posture and `single` are the same deployment shape — and refusing there + * would lock every single-tenant operator out of their own switch. + * + * Returns a refusal to short-circuit on, `undefined` to proceed — the shape of + * this file's other two gates. Engine self-invocation (`isSystem`, never + * settable from the wire) bypasses, as it does at every neighbouring gate. + */ +async function refuseUngrantedActivationWrite( + deps: DomainHandlerDeps, + context: HttpProtocolContext, +): Promise { + const ec: any = context?.executionContext; + if (ec?.isSystem) return undefined; + + let posture; + try { + posture = effectiveTenancyPosture(await deps.resolveService(context, 'tenancy')); + } catch { + posture = undefined; + } + if (!posture || !postureEnforcesWall(posture)) return undefined; + + const positions: string[] = Array.isArray(ec?.positions) ? ec.positions : []; + if (positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN)) return undefined; + + // The message names the posture and the sanctioned path — the loud-refusal + // shape ADR-0126 §7 asks for throughout — and says nothing about the + // caller's own positions or permission sets (#7450). + return { + handled: true, + response: deps.error( + `Enabling or disabling a packaged flow writes an INSTALL-WIDE activation row, and this deployment runs the ` + + `'${posture}' tenancy posture, where that reaches every organization. It requires the platform operator ` + + `(ADR-0126 §5) — an organization administrator cannot flip an install-wide switch. To customize this flow ` + + `for your organization, clone it under a new name instead.`, + ACTIVATION_DENY_STATUS, + { code: ACTIVATION_DENY_CODE }, + ), + }; +} + function refuseUngrantedFlowWrite( deps: DomainHandlerDeps, context: HttpProtocolContext, @@ -924,6 +1043,19 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str if (refusal) return refusal; } + // [ADR-0126 §5] ACTIVATION-WRITE GATE — the install-wide enable/disable + // switch needs the platform operator in a walled posture. Placed directly + // AFTER the authoring gate and BEFORE the service probe / body checks, for + // the reasons that gate documents: an unentitled caller must not learn + // whether automation is mounted here, and must not get a body-validation + // answer that maps out the contract. Strictly narrower than the gate + // above, never a replacement for it — a caller must hold `manage_metadata` + // AND, in `group`/`isolated`, be the platform operator. + if (isFlowActivationWrite(parts, m)) { + const refusal = await refuseUngrantedActivationWrite(deps, context); + if (refusal) return refusal; + } + const automationService = await deps.getService(context, CoreServiceName.enum.automation); // [#4058] Empty slot — or a slot filled by a self-declared non-handler // (`handlerReady: false`, ADR-0076 D12), which is the same amount of diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index 848384c314..aae37c910d 100644 --- a/packages/services/service-automation/package.json +++ b/packages/services/service-automation/package.json @@ -21,6 +21,7 @@ "@objectstack/core": "workspace:*", "@objectstack/formula": "workspace:*", "@objectstack/metadata-core": "workspace:*", + "@objectstack/platform-objects": "workspace:*", "@objectstack/spec": "workspace:*" }, "devDependencies": { diff --git a/packages/services/service-automation/src/engine.test.ts b/packages/services/service-automation/src/engine.test.ts index 4a89187430..e7f53e02e2 100644 --- a/packages/services/service-automation/src/engine.test.ts +++ b/packages/services/service-automation/src/engine.test.ts @@ -1463,16 +1463,33 @@ describe('AutomationEngine - Execution History', () => { }); }); - describe('unregisterFlow cleans up enabled state', () => { - it('should remove enabled state on unregister', async () => { + describe('unregisterFlow and activation state', () => { + /** + * [ADR-0126 §7.2 / #12158] REPLACED, not re-spelled. This case used to + * assert the opposite — that unregistering a flow FORGOT it had been + * switched off, so re-registering it came back enabled. That was a + * faithful pin of the retired `flowEnabled` map: an in-process bit with + * no durable home, which is exactly the mechanism #10243 measured + * leaking and ADR-0126 §7.2 retires. + * + * Under the activation ledger the answer inverts, and it is ADR-0126 §6 + * wall 3 that forces the inversion rather than a preference: the ledger + * records the customer's CHOICE, and "no upgrade un-makes a choice". + * Unregister-then-register is precisely what a package upgrade, a + * Studio publish and the boot pull all do — so a disable that did not + * survive this path would be un-made by every upgrade, which is the + * wall's stated prohibition. + */ + it('keeps a ledger disable across unregister + re-register (§6 wall 3)', async () => { engine.registerFlow('test_flow', simpleFlow); await engine.toggleFlow('test_flow', false); engine.unregisterFlow('test_flow'); - // Re-register should default to enabled engine.registerFlow('test_flow', simpleFlow); + const result = await engine.execute('test_flow'); - expect(result.success).toBe(true); + expect(result.success).toBe(false); + expect(result.code).toBe('FLOW_DISABLED'); }); }); }); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index d7fc50d3cf..7db006ccf6 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -22,6 +22,13 @@ import type { Logger } from '@objectstack/spec/contracts'; import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, collectFlowGraphs, findRegionEntry, defineActionDescriptor } from '@objectstack/spec/automation'; import { resolveFlowNodeExpressions } from '@objectstack/spec/automation'; 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'; import type { FlowRegionParsed } from '@objectstack/spec/automation'; import type { Connector, @@ -1169,6 +1176,44 @@ export interface FlowDispatchStore { claim(key: string): Promise; } +/** + * [ADR-0126 §4] One packaged flow's install-level activation row, as the engine + * sees it. The ledger's own columns are `metadata_type` / `name` / + * `package_id` / `organization_id` / `active`; `metadata_type` is fixed to + * `'flow'` by the store and `organization_id` is never written on this line + * (§5), so those two never reach the engine. + */ +export interface FlowActivationRow { + /** The packaged flow's machine name. */ + name: string; + /** The package that ships the base artifact. */ + packageId: string; + /** Is the packaged flow armed for this installation. */ + active: boolean; +} + +/** + * [ADR-0126 §7.2] The durable off-switch for packaged flows — the mechanism + * that REPLACES the process-local `flowEnabled` map #10243 measured leaking + * across tenants. + * + * Backed by `sys_metadata_activation` in production (see + * `ObjectStoreFlowActivationStore`), so a disabled packaged flow stays + * disabled across a restart — the property the retired in-process map could + * not have, and the one that made its "mitigating but not exculpating" cold + * boot the only thing limiting the #10243 leak. + * + * Absence of a row means the packaged default — ACTIVE — so an engine with no + * store attached, or a store with no rows, behaves exactly as a stock boot + * always has. + */ +export interface FlowActivationStore { + /** Every install-level flow activation row (`organization_id IS NULL`). */ + list(): Promise; + /** Insert or update the install-level row for one packaged flow. */ + setActive(row: FlowActivationRow): Promise; +} + /** * TTL for the engine's IN-PROCESS dispatch-claim fallback (#10220). Every * dispatch key embeds a calendar day, so a key stops being producible once its @@ -1294,7 +1339,54 @@ export class AutomationEngine implements IAutomationService { * {@link getFlowRuntimeStates} can show it. */ private flowShadowing = new Map(); - private flowEnabled = new Map(); + /** + * [ADR-0126 §7.2] Flows the durable ACTIVATION LEDGER currently marks + * inactive — the local projection of `sys_metadata_activation`, keyed by + * flow name. Membership means "a row says `active: false`"; absence means + * the packaged default, ACTIVE (§4). + * + * ## ⛔ This is NOT the retired `flowEnabled` map under a new name + * + * That distinction is the whole point of #10243, so it is spelled out + * rather than left to a reader's charity. The retired map was the TRUTH: + * `toggleFlow` wrote it and nothing else recorded the bit, so the + * off-switch was a name-keyed, unscoped, process-local value that one + * tenant could set and every other tenant read. Three things are different + * here, and each is load-bearing: + * + * 1. **It is a projection, not a source.** The only writers are + * {@link hydrateFlowActivations} (boot, from the ledger) and + * {@link toggleFlow} (which writes the durable row FIRST and updates + * this only after that write succeeds). There is no path that changes + * this set without the ledger changing, so it cannot drift into being + * an independent off-switch. + * 2. **The write door is gated.** Reaching `toggleFlow` from the wire + * goes through the automation domain's activation gate: in `group` / + * `isolated` postures the write requires the platform operator, so + * the tenant-org-admin caller #10243 measured is refused before any + * of this runs (ADR-0126 §5). + * 3. **It survives a restart** — because the row does. The retired map's + * cold-boot amnesia was recorded as "mitigating but not exculpating"; + * durability is what turns the flip into a recorded administrator + * CHOICE (§6 wall 3) instead of a process accident. + * + * ## Why a projection at all, rather than reading the ledger per execute() + * + * Because the ledger has to be consulted at BIND time, not only at run + * time: §7.2 requires the install-level row to unbind the trigger, so the + * engine must know a flow's activation state at boot — before any + * `execute()` — to decide whether to arm its trigger at all. Given that + * boot read is required anyway, `execute()` consults its result rather + * than issuing a datasource read on every single flow run. + */ + private flowLedgerDisabled = new Set(); + /** + * The durable ledger behind {@link flowLedgerDisabled}. `null` until a host + * attaches one via {@link setFlowActivationStore} — an engine with no + * ObjectQL has no durable plane to write to, and says so at the moment of + * a flip rather than reporting a durability it does not have. + */ + private flowActivationStore: FlowActivationStore | null = null; /** * Re-entrancy guard for record-triggered flows (complements the intra-run * {@link MAX_NODE_REENTRIES} back-edge guard, which cannot see a self-trigger @@ -2574,17 +2666,22 @@ export class AutomationEngine implements IAutomationService { // any legacy flow with no explicit status — stay enabled, so existing flows // are unaffected (zero regression). This is how the Studio's on/off switch // persists: it flips `status` active↔obsolete, applied on the next publish - // rebind. A flip back OUT of a disabled status re-enables even if turned off; - // a runtime toggleFlow() override on a still-enabled flow is preserved. + // rebind. + // + // [ADR-0126 §7.2] Recording the STATUS dimension is now all this does. + // It used to also fold that dimension into the `flowEnabled` map and + // then work to preserve the OTHER dimension living in the same map — + // hence the old `wasStatusDisabled || !flowEnabled.has(name)` arm, + // whose job was to re-enable on a status flip without clobbering a + // runtime toggle. With the activation ledger holding the toggle + // dimension separately ({@link flowLedgerDisabled}) the two no longer + // share a slot, so there is nothing to reconcile: a status flip moves + // the status bit, a ledger flip moves the ledger bit, and + // {@link isFlowEnabled} composes them. That the reconciliation + // DISAPPEARS rather than moving is the sign the two were always + // distinct facts crammed into one map. const flowStatus = (parsed as { status?: string }).status; - const disabledByStatus = flowStatus === 'obsolete' || flowStatus === 'invalid'; - const wasStatusDisabled = this.flowStatusDisabled.get(name) === true; - this.flowStatusDisabled.set(name, disabledByStatus); - if (disabledByStatus) { - this.flowEnabled.set(name, false); - } else if (wasStatusDisabled || !this.flowEnabled.has(name)) { - this.flowEnabled.set(name, true); - } + this.flowStatusDisabled.set(name, flowStatus === 'obsolete' || flowStatus === 'invalid'); this.logger.info(`Flow registered: ${name} (version ${parsed.version})`); // ADR-0018 §M1 node-type check, inline — but ONLY once the vocabulary @@ -2598,7 +2695,7 @@ export class AutomationEngine implements IAutomationService { // registerFlow) IS against a complete vocabulary, so it warns at once. // Placed after the enable/disable resolution above so it can honor the // same "a flow that cannot run cannot fail" rule as the audit. - if (this.nodeTypeVocabularySealed && this.flowEnabled.get(name) !== false) { + if (this.nodeTypeVocabularySealed && this.isFlowEnabled(name)) { const known = this.knownNodeTypes(); const unknownTypes = this.unknownNodeTypes(parsed, known); if (unknownTypes.length > 0) { @@ -2607,8 +2704,12 @@ export class AutomationEngine implements IAutomationService { } // Re-bind in case the definition changed its trigger, then (re)activate. + // [ADR-0126 §7.2] A ledger-disabled flow is NOT re-armed here, which is + // what makes the unbind survive a republish and a restart: the boot + // pull re-registers every flow, so a hydrated ledger row has to be + // able to keep a trigger unbound through exactly this path. this.deactivateFlowTrigger(name); - if (this.flowEnabled.get(name) !== false) { + if (this.isFlowEnabled(name)) { this.activateFlowTrigger(name); } } @@ -2616,7 +2717,13 @@ export class AutomationEngine implements IAutomationService { unregisterFlow(name: string): void { this.deactivateFlowTrigger(name); this.flows.delete(name); - this.flowEnabled.delete(name); + // [ADR-0126 §7.2] `flowLedgerDisabled` is deliberately NOT cleared. It + // mirrors a durable row, and unregistering a flow does not delete that + // row — so dropping the projection here would silently re-arm a + // disabled packaged flow the moment it was re-registered (a hot + // reload, a Studio publish, the next boot pull), which is the one + // thing the durable switch exists to prevent. The projection is a Set + // of names; a genuinely deleted flow leaves one harmless string. this.flowStatusDisabled.delete(name); this.flowVersionHistory.delete(name); this.logger.info(`Flow unregistered: ${name}`); @@ -2651,7 +2758,7 @@ export class AutomationEngine implements IAutomationService { const shadowing = this.flowShadowing.get(name); return { name, - enabled: this.flowEnabled.get(name) !== false, + enabled: this.isFlowEnabled(name), bound: this.boundFlowTriggers.has(name), status: (this.flows.get(name) as { status?: string } | undefined)?.status, triggerType: resolved?.triggerType, @@ -2675,7 +2782,7 @@ export class AutomationEngine implements IAutomationService { getTriggerBindingAudit(): Array<{ flowName: string; triggerType: string; reason: string }> { const audit: Array<{ flowName: string; triggerType: string; reason: string }> = []; for (const name of this.flows.keys()) { - if (this.flowEnabled.get(name) === false) continue; + if (!this.isFlowEnabled(name)) continue; if (this.boundFlowTriggers.has(name)) continue; const resolved = this.resolveTriggerBinding(name); if (!resolved) continue; // manual / screen flow — nothing to bind @@ -2722,11 +2829,208 @@ export class AutomationEngine implements IAutomationService { return this.flows.get(name) ?? null; } + /** + * Attach the durable activation ledger (ADR-0126 §4). Hosts call this at + * start(), after ObjectQL is available; see the automation plugin. + */ + setFlowActivationStore(store: FlowActivationStore): void { + this.flowActivationStore = store; + } + + /** + * [ADR-0126 §7.2] Load the ledger into {@link flowLedgerDisabled}. + * + * Called once at boot, AFTER the flow pull, because the projection decides + * whether a flow's trigger is armed and re-arming happens per + * {@link registerFlow}. Returns the names it disarmed so the host can say + * so in its bootstrap audit. + * + * An empty ledger — the stock-boot case — disarms nothing and rebinds + * nothing, which is §4's "an empty ledger changes nothing anywhere". + */ + async hydrateFlowActivations(): Promise { + if (!this.flowActivationStore) return []; + const rows = await this.flowActivationStore.list(); + const disabled: string[] = []; + for (const row of rows) { + if (row.active) { + this.flowLedgerDisabled.delete(row.name); + continue; + } + this.flowLedgerDisabled.add(row.name); + // Only a flow this engine actually holds can be unbound; a row for + // a flow this deployment does not ship is kept in the projection + // (it costs one string, and the flow may be registered later by a + // hot reload) but has nothing to disarm now. + if (this.flows.has(row.name)) { + this.deactivateFlowTrigger(row.name); + disabled.push(row.name); + } + } + return disabled; + } + + /** + * Is this flow allowed to run — the composition of the two INDEPENDENT + * disable dimensions. + * + * `flowStatusDisabled` is the authoring state (`obsolete` / `invalid`), + * owned by the definition. `flowLedgerDisabled` is the installation's + * activation choice (ADR-0126 §4), owned by `sys_metadata_activation`. + * Either one disarms; neither can override the other, because they answer + * different questions and an "override" would mean one silently undoing a + * decision it does not own. + */ + private isFlowEnabled(name: string): boolean { + return this.flowStatusDisabled.get(name) !== true && !this.flowLedgerDisabled.has(name); + } + + /** + * The `FLOW_DISABLED` refusal MESSAGE, which is where the ledger-vs-status + * distinction rides (ADR-0126 §7.2 reuses the CODE deliberately, so the + * message is the only channel left). + */ + private describeDisabledFlow(flowName: string): string { + if (this.flowLedgerDisabled.has(flowName)) { + return ( + `Flow '${flowName}' is disabled — it is switched off for this installation in the ` + + `packaged-metadata activation ledger (sys_metadata_activation, ADR-0126 §7.2). ` + + `Re-enable the packaged flow to arm it again, or run a clone of it under a new name.` + ); + } + // Unchanged wording for the status dimension: it is what every existing + // consumer and test reads, and this leg has no reason to move it. + return `Flow '${flowName}' is disabled`; + } + + /** + * [ADR-0126 §7.3] Packaged flows that invoke `name` as a subflow. + * + * A DEFINITION SCAN, run at disable time — ⛔ deliberately not an index. + * ADR-0126 §9 records that no reference index exists and that building one + * is not chartered (#11665 §3.2); the flow map is small, this runs once per + * disable, and an index would be a durable structure to keep correct + * forever for a check that happens when an administrator clicks a switch. + * + * Two node types invoke another flow by name, and BOTH count: `subflow` + * (config.flowName) and `map`, whose own descriptor calls its per-item + * target "the per-item subflow" and reaches it through the same + * `engine.execute`. Scanning only `subflow` would let a `map` caller break + * exactly the way §7.3 exists to prevent. + * + * ⛔ The historical `flow` alias for `config.flowName` is NOT read here. + * ADR-0087 D2's conversion `flow-node-subflow-flow-alias` canonicalizes it + * at load, so only the canonical key reaches a registered definition; + * re-reading the alias in this consumer would be a tolerant fallback + * papering over a producer that is already correct. + * + * Only PACKAGED callers are reported: §7.3's rationale is that a VENDOR + * flow would break mid-run at its subflow node. A caller the customer + * authored is theirs to fix, and refusing on it would make the packaged + * artifact hostage to a tenant's own flow. + */ + private packagedSubflowCallers(name: string): string[] { + const callers: string[] = []; + for (const [callerName, flow] of this.flows) { + if (callerName === name) continue; + if (describeFlowContender(flow).source !== 'package') continue; + const nodes = (flow as { nodes?: unknown }).nodes; + if (!Array.isArray(nodes)) continue; + const invokes = nodes.some((node) => { + const n = node as { type?: unknown; config?: { flowName?: unknown } } | null; + if (!n || (n.type !== 'subflow' && n.type !== 'map')) return false; + return n.config?.flowName === name; + }); + if (invokes) callers.push(callerName); + } + return callers; + } + + /** + * [ADR-0126 §7.2] Flip a flow's activation — THE sanctioned off-switch. + * + * ## What changed, and why the durable write is inside this method + * + * This used to set a process-local map and nothing else, which is the + * mechanism #10243 measured leaking across tenants. It now writes the + * `sys_metadata_activation` row FIRST and updates the in-process + * projection only after that write returns. Putting the durable write here + * — rather than in the HTTP route that calls it — is deliberate: this is + * the service contract's off-switch (`IAutomationService.toggleFlow`), so + * a caller that reaches it any other way must get the same durable + * semantics. A route-side write would leave every non-HTTP caller on the + * retired in-process-only behaviour. + * + * ## Where the AUTHORITY gate is, and why it is not here + * + * ADR-0126 §5 gates the write on posture and the platform-operator + * capability. That gate lives at the automation domain's toggle route, + * because it needs the CALLER — and `toggleFlow(name, enabled)` is a + * `packages/spec` contract with no caller channel and no room to grow one + * on this line. In-process callers are trusted here exactly as they are at + * every other service method; the wire is the untrusted surface, and the + * wire goes through the gate. + * + * @throws when the flow is unknown, when §7.3's subflow guard refuses, or + * when the durable write fails — a reported flip that did not persist is + * the failure mode this whole leg exists to remove. + */ async toggleFlow(name: string, enabled: boolean): Promise { - if (!this.flows.has(name)) { + const flow = this.flows.get(name); + if (!flow) { throw new Error(`Flow '${name}' not found`); } - this.flowEnabled.set(name, enabled); + + // [ADR-0126 §7.3] The subflow cascade guard, on DISABLE only. Enable is + // never guarded — arming a flow cannot break a caller. + if (!enabled) { + const callers = this.packagedSubflowCallers(name); + if (callers.length > 0) { + const list = callers.map((c) => `'${c}'`).join(', '); + throw Object.assign( + new Error( + `Flow '${name}' cannot be disabled while ${callers.length} packaged flow` + + `${callers.length === 1 ? '' : 's'} still call${callers.length === 1 ? 's' : ''} it as a subflow: ${list}. ` + + `Disabling it would break ${callers.length === 1 ? 'that caller' : 'those callers'} mid-run at ` + + `${callers.length === 1 ? 'its' : 'their'} subflow node with a late, inexplicable failure ` + + `(ADR-0126 §7.3). Disable the calling flow${callers.length === 1 ? '' : 's'} first, or leave this one armed.`, + ), + // ADR-0112 envelope: code AND status. `DELETE_RESTRICTED` is + // the standard catalog's "cannot do this due to + // dependencies" member (409) — ⛔ no new ledger entry is + // minted here. Its `DELETE_` prefix fits because this + // repo's own #10243 ruling records that "disabling a + // shipped flow is functionally equivalent to deleting it + // for as long as it stays off". + { code: 'DELETE_RESTRICTED', status: 409, subflowCallers: callers }, + ); + } + } + + // The durable row FIRST. A store that throws aborts the flip with + // nothing changed in process, so the engine never reports an + // activation state the ledger does not carry. + if (this.flowActivationStore) { + await this.flowActivationStore.setActive({ + name, + packageId: String((flow as { _packageId?: unknown })._packageId ?? ''), + active: enabled, + }); + } else { + // Degrading to in-process is a legitimate mode for a host with no + // ObjectQL — degrading to it while REPORTING durability is not + // (the posture this package already takes for suspended runs). + // Note the #10243 leak is closed by the route's authority gate, + // not by durability, so this degraded mode is not that leak. + this.logger.warn( + `[Automation] flow '${name}' ${enabled ? 'enabled' : 'disabled'} IN PROCESS ONLY — no activation ledger is ` + + `attached (sys_metadata_activation), so this flip will NOT survive a restart.`, + ); + } + + if (enabled) this.flowLedgerDisabled.delete(name); + else this.flowLedgerDisabled.add(name); + this.logger.info(`Flow '${name}' ${enabled ? 'enabled' : 'disabled'}`); // A disabled flow should stop receiving trigger events; a re-enabled one // should resume. execute() also guards disabled flows, but unbinding @@ -3251,8 +3555,14 @@ export class AutomationEngine implements IAutomationService { return { success: false, error: `Flow '${flowName}' not found` }; } - // Check if flow is disabled - if (this.flowEnabled.get(flowName) === false) { + // Check if flow is disabled. + // + // [ADR-0126 §7.2] THE consult point for the activation ledger. This is + // the one seam every entry path crosses (#11665 §2.3) — record-change, + // schedule, time-relative, api and subflow all arrive here — which is + // why the ADR puts the runtime refusal beside this guard instead of + // teaching each trigger its own check. + if (!this.isFlowEnabled(flowName)) { // [#9415] NEVER DISPATCHED, and the producer says which kind — the // remaining half of #9378's classification. `status` stays ABSENT // here on purpose (see the `status: 'failed'` exit below): its @@ -3260,7 +3570,18 @@ export class AutomationEngine implements IAutomationService { // instead of guessing, so a later edit that stamps `'failed'` on // this exit "for consistency" must fail a test. What the exit // gained is a `code`, which is the classification's own channel. - return { success: false, code: 'FLOW_DISABLED', error: `Flow '${flowName}' is disabled` }; + // + // [ADR-0126 §7.2] The ledger refusal REUSES `FLOW_DISABLED` — ⛔ no + // new ADR-0112 ledger entry — so the distinction has to ride the + // MESSAGE. It is a real distinction to an operator: a status + // disable is an authoring state fixed in Studio, a ledger disable + // is an installation choice fixed by re-enabling the packaged + // flow, and the two have different remedies. + return { + success: false, + code: 'FLOW_DISABLED', + error: this.describeDisabledFlow(flowName), + }; } // #4792 — a real run is about to start, so if the vocabulary was never @@ -4989,7 +5310,7 @@ export class AutomationEngine implements IAutomationService { const known = this.knownNodeTypes(); const audit: UnknownNodeTypeAuditEntry[] = []; for (const [flowName, flow] of this.flows) { - if (this.flowEnabled.get(flowName) === false) continue; + if (!this.isFlowEnabled(flowName)) continue; const unknownTypes = this.unknownNodeTypes(flow, known); if (unknownTypes.length > 0) { audit.push({ flowName, unknownTypes, knownTypes: [...known] }); @@ -6627,7 +6948,7 @@ export class AutomationEngine implements IAutomationService { if (!flow) { return { success: false, error: `Flow '${flowName}' not found` }; } - if (this.flowEnabled.get(flowName) === false) { + if (!this.isFlowEnabled(flowName)) { // [#9415] Classified like `execute()`'s own disabled exit, for the // same reason #9378 classified this method's failure exit: a // selective classification is the one a later reader mistakes for @@ -6640,7 +6961,10 @@ export class AutomationEngine implements IAutomationService { // OUTER guard and would stay green with this line unclassified. // Reachability is what a future change would have to establish; // the spelling is already right. - return { success: false, code: 'FLOW_DISABLED', error: `Flow '${flowName}' is disabled` }; + // + // [ADR-0126 §7.2] Shares `execute()`'s message builder for the same + // parity reason: two spellings of one refusal is what #9378 was. + return { success: false, code: 'FLOW_DISABLED', error: this.describeDisabledFlow(flowName) }; } // [#9704] The SAME environment attempt 1 runs in — seeded through the diff --git a/packages/services/service-automation/src/flow-activation-ledger.test.ts b/packages/services/service-automation/src/flow-activation-ledger.test.ts new file mode 100644 index 0000000000..3e319a3bda --- /dev/null +++ b/packages/services/service-automation/src/flow-activation-ledger.test.ts @@ -0,0 +1,597 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#12157 / #12158] ADR-0126 §5 / §7.2 / §7.3 — the packaged-flow activation +// ledger: the durable off-switch, its runtime consult at the `execute()` seam, +// the trigger unbind, and the subflow guard on disable. +// +// WHAT THIS REPLACES, AND WHY THE REPLACEMENT NEEDED TESTS OF ITS OWN +// +// The engine used to keep its off-switch in a process-local `flowEnabled` map. +// #10243 measured the cost: the bit was NOT a row, so no organization wall +// scoped it — `toggleFlow` wrote a name-keyed in-process map and the automation +// service is ONE instance per environment, so on a real `isolated` posture a +// tenant org owner switched a shipped flow off and an unrelated tenant in a +// DIFFERENT organization read it off. ADR-0126 §7.2 RETIRES that mechanism +// rather than refining it. +// +// So the assertions below come in two families, and both are load-bearing: +// 1. the ledger DOES what the map did (refuse at execute(), unbind the +// trigger) — otherwise the retirement is a regression; and +// 2. the map is GONE as a mechanism, not merely bypassed — the grep-level +// pin at the bottom of this file, which is what stops a later edit from +// quietly reintroducing an in-process off-switch beside the durable one. + +import { describe, it, expect, vi } from 'vitest'; +import { AutomationEngine } from './engine.js'; +import type { FlowTrigger, FlowTriggerBinding, FlowActivationRow } from './engine.js'; +import { InMemoryFlowActivationStore, ObjectStoreFlowActivationStore } from './flow-activation-store.js'; +import { registerSubflowNode } from './builtin/subflow-node.js'; +import type { AutomationContext } from '@objectstack/spec/contracts'; +import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +function createTestLogger(): any { + const l: any = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + l.child = () => l; + return l; +} + +/** + * A minimal runnable flow. `start` config decides which trigger it binds to, + * which is how one helper covers every entry path below. + */ +function flowBody(name: string, startConfig: Record = {}, extra: Record = {}) { + return { + name, + label: name, + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: startConfig }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + ...extra, + }; +} + +/** The same flow, shipped by a code package (ADR-0029 D9.6 provenance). */ +function packagedFlow(name: string, startConfig: Record = {}, extra: Record = {}) { + return { ...flowBody(name, startConfig, extra), _packageId: 'crm' }; +} + +/** A recording trigger, so binding state is asserted for real rather than inferred. */ +function recordingTrigger(type: string) { + const bound = new Map Promise>(); + const trigger: FlowTrigger = { + type, + start(binding: FlowTriggerBinding, cb: (ctx: AutomationContext) => Promise) { + bound.set(binding.flowName, cb); + }, + stop(flowName: string) { + bound.delete(flowName); + }, + }; + return { trigger, isBound: (n: string) => bound.has(n) }; +} + +/** An engine with the in-memory ledger attached and the four triggers registered. */ +function engineWithLedger() { + const engine = new AutomationEngine(createTestLogger()); + const store = new InMemoryFlowActivationStore(); + engine.setFlowActivationStore(store); + const triggers = { + record_change: recordingTrigger('record_change'), + schedule: recordingTrigger('schedule'), + time_relative: recordingTrigger('time_relative'), + api: recordingTrigger('api'), + }; + for (const t of Object.values(triggers)) engine.registerTrigger(t.trigger); + return { engine, store, triggers }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// §4 — absence of a row means ACTIVE +// ───────────────────────────────────────────────────────────────────────────── + +describe('ADR-0126 §4 — absence of a row = active (an empty ledger changes nothing)', () => { + it('a stock boot with an EMPTY ledger arms and runs every flow', async () => { + const { engine, triggers } = engineWithLedger(); + engine.registerFlow('welcome', packagedFlow('welcome', { objectName: 'lead', triggerType: 'record-after-create' })); + + const disarmed = await engine.hydrateFlowActivations(); + + expect(disarmed).toEqual([]); + expect(triggers.record_change.isBound('welcome')).toBe(true); + expect((await engine.execute('welcome')).success).toBe(true); + }); + + it('an engine with NO store attached behaves exactly as a stock boot', async () => { + const engine = new AutomationEngine(createTestLogger()); + engine.registerFlow('welcome', packagedFlow('welcome')); + + // Nothing to hydrate, and nothing refused. + expect(await engine.hydrateFlowActivations()).toEqual([]); + expect((await engine.execute('welcome')).success).toBe(true); + }); + + it('re-enabling UPDATES the row rather than deleting it — the ledger records the choice', async () => { + const { engine, store } = engineWithLedger(); + engine.registerFlow('welcome', packagedFlow('welcome')); + + await engine.toggleFlow('welcome', false); + await engine.toggleFlow('welcome', true); + + // Still one row, now `active: true` — not an absent row. ADR-0126 §6 + // wall 3: the ledger records the customer's CHOICES. + expect(await store.list()).toEqual([{ name: 'welcome', packageId: 'crm', active: true }]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// §7.2 — the execute() consult, on every entry path +// ───────────────────────────────────────────────────────────────────────────── + +describe('ADR-0126 §7.2 — a ledger-disabled flow refuses at the execute() seam', () => { + // The four trigger types whose flows reach `execute()` through their own + // entry path. `execute()` is the ONE seam all of them cross (#11665 §2.3), + // which is why the ADR puts the consult here — but "they all cross it" is + // exactly the claim worth pinning, so each is driven separately. + const entryPaths: Array<[string, Record, keyof ReturnType['triggers']]> = [ + ['record-change', { objectName: 'lead', triggerType: 'record-after-create' }, 'record_change'], + ['schedule', { schedule: '0 9 * * *' }, 'schedule'], + ['time-relative', { timeRelative: { object: 'task', field: 'due_at' }, schedule: '0 * * * *' }, 'time_relative'], + ['api', { triggerType: 'api' }, 'api'], + ]; + + for (const [label, startConfig, triggerKey] of entryPaths) { + it(`refuses a disabled ${label} flow with FLOW_DISABLED, and re-enabling restores firing`, async () => { + const { engine, triggers } = engineWithLedger(); + engine.registerFlow('f', packagedFlow('f', startConfig)); + expect((await engine.execute('f')).success).toBe(true); + + await engine.toggleFlow('f', false); + const refused = await engine.execute('f'); + + expect(refused.success).toBe(false); + // ADR-0126 §7.2 reuses the code deliberately — ⛔ no new ADR-0112 + // ledger entry — so the CODE must be the existing one... + expect(refused.code).toBe('FLOW_DISABLED'); + // ...and the distinction has to ride the MESSAGE. + expect(refused.error).toContain('sys_metadata_activation'); + expect(refused.error).toContain('ADR-0126'); + // The trigger is unbound too, so it does not even fire (§7.2). + expect(triggers[triggerKey].isBound('f')).toBe(false); + + await engine.toggleFlow('f', true); + expect((await engine.execute('f')).success).toBe(true); + expect(triggers[triggerKey].isBound('f')).toBe(true); + }); + } + + it('refuses on the SUBFLOW entry path, and the caller fails with the child refusal composed in', async () => { + const { engine } = engineWithLedger(); + registerSubflowNode(engine, { logger: createTestLogger(), getService: () => undefined } as any); + + engine.registerFlow('child', packagedFlow('child')); + engine.registerFlow('parent', { + ...packagedFlow('parent'), + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: {} }, + { id: 'call', type: 'subflow', label: 'Call', config: { flowName: 'child' } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'call' }, + { id: 'e2', source: 'call', target: 'end' }, + ], + }); + expect((await engine.execute('parent')).success).toBe(true); + + // `child` has a packaged caller, so §7.3 refuses disabling it through + // `toggleFlow`. That guard is the SUBJECT of the next describe block; + // here the point is the runtime consult, so the ledger row is placed + // directly — the shape a second process (or a previous boot) leaves. + const store = new InMemoryFlowActivationStore(); + await store.setActive({ name: 'child', packageId: 'crm', active: false }); + engine.setFlowActivationStore(store); + await engine.hydrateFlowActivations(); + + const direct = await engine.execute('child'); + expect(direct.success).toBe(false); + expect(direct.code).toBe('FLOW_DISABLED'); + + // The parent's own run fails at its subflow node, carrying the child's + // refusal — the "inexplicable late failure" ADR-0126 §7.3 exists to + // keep an administrator from causing by accident. + const viaParent = await engine.execute('parent'); + expect(viaParent.success).toBe(false); + expect(String(viaParent.error)).toContain('child'); + }); + + it('a STATUS-disabled flow keeps its original message — the two disable reasons stay distinguishable', async () => { + const { engine } = engineWithLedger(); + engine.registerFlow('obsolete_flow', { ...packagedFlow('obsolete_flow'), status: 'obsolete' }); + + const refused = await engine.execute('obsolete_flow'); + + expect(refused.code).toBe('FLOW_DISABLED'); + expect(refused.error).toBe("Flow 'obsolete_flow' is disabled"); + // The point of the distinction: an operator reading this must not be + // sent to the activation ledger for an authoring-state problem. + expect(refused.error).not.toContain('sys_metadata_activation'); + }); + + it('a ledger-disabled flow stays disabled across re-registration (publish / hot reload / boot pull)', async () => { + const { engine, triggers } = engineWithLedger(); + const def = packagedFlow('f', { objectName: 'lead', triggerType: 'record-after-create' }); + engine.registerFlow('f', def); + await engine.toggleFlow('f', false); + + // The boot pull, a Studio publish and a dev hot reload all land here. + engine.registerFlow('f', def); + + expect(triggers.record_change.isBound('f')).toBe(false); + expect((await engine.execute('f')).code).toBe('FLOW_DISABLED'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// §7.2 — trigger unbind / rebind, and boot hydration +// ───────────────────────────────────────────────────────────────────────────── + +describe('ADR-0126 §7.2 — the install-level row unbinds the trigger', () => { + it('disable UNBINDS and enable REBINDS, asserted on the trigger itself', async () => { + const { engine, triggers } = engineWithLedger(); + engine.registerFlow('f', packagedFlow('f', { objectName: 'lead', triggerType: 'record-after-update' })); + expect(triggers.record_change.isBound('f')).toBe(true); + + await engine.toggleFlow('f', false); + expect(triggers.record_change.isBound('f')).toBe(false); + + await engine.toggleFlow('f', true); + expect(triggers.record_change.isBound('f')).toBe(true); + }); + + it('hydration at boot unbinds a flow a PREVIOUS process disabled — the durability the map lacked', async () => { + const store = new InMemoryFlowActivationStore(); + await store.setActive({ name: 'f', packageId: 'crm', active: false }); + + // A brand-new engine: the #10243 map's "cold boot reads enabled: true + // again" was recorded as mitigating-but-not-exculpating. It must no + // longer be true. + const engine = new AutomationEngine(createTestLogger()); + const trigger = recordingTrigger('record_change'); + engine.registerTrigger(trigger.trigger); + engine.setFlowActivationStore(store); + engine.registerFlow('f', packagedFlow('f', { objectName: 'lead', triggerType: 'record-after-create' })); + + const disarmed = await engine.hydrateFlowActivations(); + + expect(disarmed).toEqual(['f']); + expect(trigger.isBound('f')).toBe(false); + expect((await engine.execute('f')).code).toBe('FLOW_DISABLED'); + }); + + it('getFlowRuntimeStates reports a ledger-disabled flow as disabled and unbound', async () => { + const { engine } = engineWithLedger(); + engine.registerFlow('f', packagedFlow('f', { objectName: 'lead', triggerType: 'record-after-create' })); + await engine.toggleFlow('f', false); + + const [state] = engine.getFlowRuntimeStates(); + + expect(state.name).toBe('f'); + expect(state.enabled).toBe(false); + expect(state.bound).toBe(false); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// §7.3 — the subflow cascade guard +// ───────────────────────────────────────────────────────────────────────────── + +describe('ADR-0126 §7.3 — disabling a flow is refused while packaged flows call it as a subflow', () => { + /** A packaged caller invoking `target` through the given node type. */ + function callerFlow(name: string, target: string, nodeType: 'subflow' | 'map') { + return { + ...packagedFlow(name), + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: {} }, + { id: 'call', type: nodeType, label: 'Call', config: { flowName: target } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'call' }, + { id: 'e2', source: 'call', target: 'end' }, + ], + }; + } + + it('refuses, NAMES the caller, and carries the ADR-0112 envelope (code AND status)', async () => { + const { engine, store } = engineWithLedger(); + engine.registerFlow('shared_step', packagedFlow('shared_step')); + engine.registerFlow('vendor_process', callerFlow('vendor_process', 'shared_step', 'subflow')); + + await expect(engine.toggleFlow('shared_step', false)).rejects.toThrow(/vendor_process/); + + const thrown = await engine.toggleFlow('shared_step', false).catch((e) => e); + // ADR-0112 envelope: code AND status. `DELETE_RESTRICTED` is the + // standard catalog's "cannot, due to dependencies" member (409) — ⛔ no + // new ledger entry was minted for this refusal. + expect(thrown.code).toBe('DELETE_RESTRICTED'); + expect(thrown.status).toBe(409); + expect(thrown.subflowCallers).toEqual(['vendor_process']); + // Q2(c)'s rationale rides the message: WHY refusing beats letting the + // caller fail late, and what the administrator can do instead. + expect(thrown.message).toContain('subflow'); + expect(thrown.message).toContain('ADR-0126 §7.3'); + expect(thrown.message).toMatch(/Disable the calling flow/); + + // Refused means nothing moved: no row, still armed, still runnable. + expect(await store.list()).toEqual([]); + expect((await engine.execute('shared_step')).success).toBe(true); + }); + + it('names EVERY packaged caller, not just the first', async () => { + const { engine } = engineWithLedger(); + engine.registerFlow('shared_step', packagedFlow('shared_step')); + engine.registerFlow('caller_a', callerFlow('caller_a', 'shared_step', 'subflow')); + engine.registerFlow('caller_b', callerFlow('caller_b', 'shared_step', 'subflow')); + + const thrown = await engine.toggleFlow('shared_step', false).catch((e) => e); + + expect(thrown.subflowCallers).toEqual(['caller_a', 'caller_b']); + expect(thrown.message).toContain("'caller_a'"); + expect(thrown.message).toContain("'caller_b'"); + }); + + it('guards a `map` caller too — its per-item target is a subflow by the node\'s own definition', async () => { + const { engine } = engineWithLedger(); + engine.registerFlow('per_item', packagedFlow('per_item')); + engine.registerFlow('sweeper', callerFlow('sweeper', 'per_item', 'map')); + + const thrown = await engine.toggleFlow('per_item', false).catch((e) => e); + + // Scanning only `subflow` would let a `map` caller break exactly the + // way §7.3 exists to prevent — it reaches its target through the same + // `engine.execute`. + expect(thrown?.code).toBe('DELETE_RESTRICTED'); + expect(thrown.subflowCallers).toEqual(['sweeper']); + }); + + it('with NO callers, disable lands', async () => { + const { engine, store } = engineWithLedger(); + engine.registerFlow('lonely', packagedFlow('lonely')); + + await expect(engine.toggleFlow('lonely', false)).resolves.toBeUndefined(); + + expect(await store.list()).toEqual([{ name: 'lonely', packageId: 'crm', active: false }]); + expect((await engine.execute('lonely')).code).toBe('FLOW_DISABLED'); + }); + + it('a NON-packaged caller does not guard — a tenant\'s own flow cannot hold a packaged one hostage', async () => { + const { engine } = engineWithLedger(); + engine.registerFlow('shared_step', packagedFlow('shared_step')); + // Same graph, no `_packageId`: authored by the customer. + engine.registerFlow('my_own_process', { + ...callerFlow('my_own_process', 'shared_step', 'subflow'), + _packageId: undefined, + }); + + await expect(engine.toggleFlow('shared_step', false)).resolves.toBeUndefined(); + }); + + it('ENABLE is never guarded — arming a flow cannot break a caller', async () => { + const { engine } = engineWithLedger(); + engine.registerFlow('shared_step', packagedFlow('shared_step')); + engine.registerFlow('vendor_process', callerFlow('vendor_process', 'shared_step', 'subflow')); + + // Disabled out-of-band (a previous boot), then re-enabled with the + // caller still present: §7.3 attaches the guard to DISABLE only. + await expect(engine.toggleFlow('shared_step', true)).resolves.toBeUndefined(); + }); + + it('a flow calling ITSELF does not guard its own disable', async () => { + const { engine } = engineWithLedger(); + engine.registerFlow('recursive', callerFlow('recursive', 'recursive', 'subflow')); + + await expect(engine.toggleFlow('recursive', false)).resolves.toBeUndefined(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// §5 / §4 — what actually reaches the ledger table +// ───────────────────────────────────────────────────────────────────────────── + +describe('ADR-0126 §4/§5 — the row this line writes', () => { + /** + * The exact ObjectQL slice the store declares — find/insert/update, and + * deliberately NO `delete`: re-enabling updates the `active` bit, it never + * removes the row. + */ + function fakeEngine(rows: any[] = []) { + const inserted: any[] = []; + const updated: any[] = []; + return { + inserted, + updated, + rows, + find: vi.fn(async (_object: string, options: any) => { + const where = options?.where ?? {}; + return rows.filter((r) => + Object.entries(where).every(([k, v]) => { + // This double implements EQUALITY ONLY, and REFUSES + // anything else rather than quietly mismatching it. A + // matcher that read a combinator (`$in`, `$ne`, …) as a + // FIELD NAME would return `[]` and make a broken store + // look green — the silent-wrong class + // `pnpm check:where-matcher` exists to catch. Refusing + // is the conformance shape most discovered matchers + // already take, and the branch belongs HERE, in the + // predicate itself, not in the enclosing `find`. + if (k.startsWith('$') || (v !== null && typeof v === 'object')) { + throw new Error( + `fakeEngine: unsupported WHERE combinator '${k}' — this double implements equality only`, + ); + } + return r[k] === v; + }), + ); + }), + insert: vi.fn(async (_object: string, data: any) => { + inserted.push(data); + rows.push({ id: `row_${rows.length}`, ...data }); + return data; + }), + update: vi.fn(async (_object: string, data: any, options?: any) => { + // Routed through ObjectQL's OWN dispatch predicate, so this + // fake cannot be looser than the engine it stands in for + // (#4434 shipped a dead REST route with its suite green off + // exactly that gap). `pnpm check:engine-double-contract` is + // the gate; the predicate lives in metadata-core, which this + // package already depends on. + assertEngineUpdateDispatch(data, options); + updated.push(data); + return data; + }), + }; + } + + it('writes metadata_type `flow` and leaves organization_id UNSET (install-level, §5)', async () => { + const fake = fakeEngine(); + const store = new ObjectStoreFlowActivationStore(fake as any); + + await store.setActive({ name: 'welcome', packageId: 'crm', active: false }); + + expect(fake.inserted).toHaveLength(1); + expect(fake.inserted[0]).toEqual({ + metadata_type: 'flow', + name: 'welcome', + package_id: 'crm', + active: false, + }); + // ⛔ The absence of the key is what leaves the column NULL, which is + // the whole of §5's install-level scope on this line. Writing an + // organization here would be #10243 with persistence. + expect(fake.inserted[0]).not.toHaveProperty('organization_id'); + }); + + it('UPDATES the existing install-level row rather than inserting a second one', async () => { + const fake = fakeEngine([ + { id: 'r1', metadata_type: 'flow', name: 'welcome', package_id: 'crm', active: false, organization_id: null }, + ]); + const store = new ObjectStoreFlowActivationStore(fake as any); + + await store.setActive({ name: 'welcome', packageId: 'crm', active: true }); + + expect(fake.inserted).toHaveLength(0); + expect(fake.updated).toEqual([{ id: 'r1', active: true, package_id: 'crm' }]); + }); + + it('SKIPS rows carrying an organization_id — a per-org row is not an install-level answer', async () => { + const fake = fakeEngine([ + { id: 'r1', metadata_type: 'flow', name: 'install_wide', active: false, organization_id: null }, + { id: 'r2', metadata_type: 'flow', name: 'one_tenant_only', active: false, organization_id: 'org_42' }, + ]); + const store = new ObjectStoreFlowActivationStore(fake as any); + + const rows = await store.list(); + + // Reading `r2` as install-level would apply one organization's choice + // to the whole installation — the #10243 direction, from the read side. + expect(rows.map((r: FlowActivationRow) => r.name)).toEqual(['install_wide']); + }); + + it('reads a driver 0/1 boolean as disabled, not as active', async () => { + const fake = fakeEngine([ + { id: 'r1', metadata_type: 'flow', name: 'f', active: 0, organization_id: null }, + ]); + const store = new ObjectStoreFlowActivationStore(fake as any); + + // SQLite/libsql round-trip booleans as integers; a bare truthiness test + // is fine here but an `=== false` test would silently re-arm the flow. + expect((await store.list())[0].active).toBe(false); + }); + + it('a failing durable write ABORTS the flip — the engine never reports state the ledger lacks', async () => { + const engine = new AutomationEngine(createTestLogger()); + const trigger = recordingTrigger('record_change'); + engine.registerTrigger(trigger.trigger); + engine.setFlowActivationStore({ + list: async () => [], + setActive: async () => { throw new Error('datasource unavailable'); }, + }); + engine.registerFlow('f', packagedFlow('f', { objectName: 'lead', triggerType: 'record-after-create' })); + + await expect(engine.toggleFlow('f', false)).rejects.toThrow('datasource unavailable'); + + // Nothing moved in process: still armed, still bound, still runnable. + expect(trigger.isBound('f')).toBe(true); + expect((await engine.execute('f')).success).toBe(true); + }); + + 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); + engine.registerFlow('f', packagedFlow('f')); + + await engine.toggleFlow('f', false); + + expect((await engine.execute('f')).code).toBe('FLOW_DISABLED'); + // Degrading to in-process is a legitimate mode; degrading to it while + // REPORTING durability is not. + const warned = logger.warn.mock.calls.map((c: any[]) => String(c[0])).join('\n'); + expect(warned).toContain('IN PROCESS ONLY'); + expect(warned).toContain('sys_metadata_activation'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// #10243 — the retired mechanism is GONE, not shaded +// ───────────────────────────────────────────────────────────────────────────── + +describe('#10243 — the process-local `flowEnabled` map is retired', () => { + const engineSource = readFileSync( + fileURLToPath(new URL('./engine.ts', import.meta.url)), + 'utf8', + ); + + it('the identifier no longer exists as engine STATE', () => { + // A grep-level pin, because the thing being asserted is the absence of + // a mechanism and no runtime surface can show an absence. Prose + // mentions survive (the docblocks explaining the retirement name it on + // purpose); a field declaration or any read/write does not. + expect(engineSource).not.toMatch(/private\s+flowEnabled/); + expect(engineSource).not.toMatch(/this\.flowEnabled/); + }); + + it('the only writers of the activation projection are hydration and toggleFlow', () => { + // What made the retired map a leak was that it was the TRUTH and + // anything could set it. This pins that the replacement projection has + // exactly two writers, both of which go through the durable ledger — + // so it cannot drift into being an independent off-switch. + const writes = [...engineSource.matchAll(/this\.flowLedgerDisabled\.(add|delete)\(/g)]; + expect(writes.length).toBeGreaterThan(0); + + const writingMethods = engineSource + .split(/\n (?=[a-zA-Z]|\/\*\*)/) + .filter((chunk) => /this\.flowLedgerDisabled\.(add|delete)\(/.test(chunk)); + for (const chunk of writingMethods) { + expect( + /hydrateFlowActivations|toggleFlow/.test(chunk), + `an unexpected method writes flowLedgerDisabled:\n${chunk.slice(0, 400)}`, + ).toBe(true); + } + }); + + it('the engine exposes no way to set activation state without the ledger', () => { + const engine = 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. + expect((engine as any).setFlowEnabled).toBeUndefined(); + expect((engine as any).flowEnabled).toBeUndefined(); + expect(typeof engine.toggleFlow).toBe('function'); + expect(typeof engine.setFlowActivationStore).toBe('function'); + }); +}); diff --git a/packages/services/service-automation/src/flow-activation-store.ts b/packages/services/service-automation/src/flow-activation-store.ts new file mode 100644 index 0000000000..71fafbcdad --- /dev/null +++ b/packages/services/service-automation/src/flow-activation-store.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { FlowActivationStore, FlowActivationRow } from './engine.js'; + +/** + * [ADR-0126 §4/§7.2] Durable activation ledger for PACKAGED flows — + * `sys_metadata_activation`, install-level rows. + * + * ## What this replaces, and why the replacement is durable + * + * The engine used to carry its off-switch in a process-local + * `flowEnabled` map. #10243 measured what that costs: the bit was NOT a row, + * so no organization wall scoped it — `toggleFlow` wrote an in-process map + * keyed by flow NAME only, and the automation service is ONE instance per + * environment. On a real `isolated` posture a tenant org owner switched a + * shipped flow off and an unrelated tenant in a DIFFERENT organization read it + * off. ADR-0126 §7.2 retires that mechanism rather than refining it: the + * durable ledger row IS the sanctioned off-switch, and this module is how the + * engine reaches it. + * + * ## Row shape (ADR-0126 §4 — ⛔ this module writes columns, never schema) + * + * `metadata_type: 'flow'` · `name` · `package_id` · `organization_id` · + * `active`. Two properties of that shape are load-bearing here: + * + * - **`organization_id` is never written.** It is declared nullable and + * RESERVED (§5): every row this line writes is install-level, so the + * column stays NULL. The object's `unique: 'organization'` index collapses + * NULL through the driver's `COALESCE(organization_id, '__global__')`, so + * NULL rows are still unique per `(metadata_type, name)` — which is why + * {@link ObjectStoreFlowActivationStore.setActive} can treat "the row for + * this flow" as at most one row. + * - **Absence of a row means ACTIVE.** Nothing here ever writes a row to say + * "active by default", and {@link FlowActivationStore.list} returning + * nothing is the normal stock-boot state, not an error. Re-enabling a flow + * updates its row to `active: true` rather than deleting it, so the ledger + * records the administrator's choice instead of erasing it (§6 wall 3: + * the ledger records CHOICES). + * + * Two implementations, mirroring the `sys_flow_dispatch` pair next door: + * - {@link InMemoryFlowActivationStore} — tests and hosts with no ObjectQL. + * - {@link ObjectStoreFlowActivationStore} — the real `sys_metadata_activation`. + */ + +const TABLE = 'sys_metadata_activation'; + +/** + * The ledger's `metadata_type` for this consumer. Flows are the first consumer + * (ADR-0126 §7); the ledger is generic, so every read and write here is scoped + * by this discriminator and never assumes it owns the table. + */ +const METADATA_TYPE = 'flow'; + +/** Infrastructure rows, not tenant data — the `sys_flow_dispatch` posture. */ +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; + +/** + * The exact ObjectQL slice this store needs: a keyed read, an insert, and an + * update. Narrower than `SuspendedRunStoreEngine` on purpose, and deliberately + * WITHOUT `delete`: re-enabling updates the `active` bit, it never removes the + * row (see the module header), and demanding only what is used keeps every + * test double honest about that. + */ +export interface FlowActivationStoreEngine { + find(object: string, options?: any): Promise; + insert(object: string, data: any, options?: any): Promise; + update(object: string, data: any, options?: any): Promise; +} + +/** + * In-memory {@link FlowActivationStore} — process-lifetime only. + * + * ⚠️ This is NOT the retired `flowEnabled` map wearing a new name. The + * difference is the one #10243 turned on: this store is only ever reached + * through {@link AutomationEngine.toggleFlow}, which is reached from the wire + * only through a door that refuses a tenant admin in a walled posture + * (ADR-0126 §5). What it lacks versus the ObjectStore implementation is + * DURABILITY, not scoping — and a host running without ObjectQL has no + * durable plane to write to in the first place. + */ +export class InMemoryFlowActivationStore implements FlowActivationStore { + private readonly rows = new Map(); + + async list(): Promise { + return [...this.rows.values()]; + } + + async setActive(row: FlowActivationRow): Promise { + this.rows.set(row.name, { ...row }); + } +} + +/** + * Durable {@link FlowActivationStore} backed by the `sys_metadata_activation` + * object (ADR-0126 §4). + * + * All access uses a system context: the object is `managedBy: + * 'engine-owned'` and declares `apiMethods: ['get', 'list']`, i.e. the generic + * data API cannot write it at all — these rows are written by the ADR-0126 + * enable/disable action and by nothing else. + */ +export class ObjectStoreFlowActivationStore implements FlowActivationStore { + constructor(private readonly engine: FlowActivationStoreEngine) {} + + /** + * Every install-level flow row. Read once at boot to hydrate the engine's + * projection — see {@link AutomationEngine.hydrateFlowActivations} for why + * the engine holds a projection at all rather than reading this per + * `execute()`. + * + * Rows carrying an `organization_id` are SKIPPED, not merged: the per-org + * dimension is reserved and unwritten on this line (§5), so a row with one + * set was not written by this code. Reading it as install-level would apply + * one organization's choice to the whole installation — the #10243 + * direction, arrived at from the read side. A future per-org consumer adds + * its own scoped read; it does not widen this one. + */ + async list(): Promise { + const rows = await this.engine.find(TABLE, { + where: { metadata_type: METADATA_TYPE }, + context: SYSTEM_CTX, + }); + if (!Array.isArray(rows)) return []; + const out: FlowActivationRow[] = []; + for (const row of rows) { + const r = row as { name?: unknown; package_id?: unknown; active?: unknown; organization_id?: unknown }; + if (r.organization_id != null) continue; + if (typeof r.name !== 'string' || !r.name) continue; + out.push({ + name: r.name, + packageId: typeof r.package_id === 'string' ? r.package_id : '', + // The column defaults to `true`; only an explicit `false` disarms. + // A driver that round-trips booleans as 0/1 (SQLite/libsql) is read + // through the same `!== false`-style test the engine uses, so a `0` + // is not mistaken for `true` — see the falsy-explicit test below. + active: !(r.active === false || r.active === 0), + }); + } + return out; + } + + /** + * Insert or update the install-level row for one packaged flow. + * + * Read-then-write rather than a blind upsert because the object's uniqueness + * is a DECLARED index (`unique: 'organization'`), not a primary key this + * store controls: there is no id to collide on, so an insert-and-catch would + * not reliably distinguish "already there" from a real store failure the way + * `sys_flow_dispatch`'s id-keyed claim can. + * + * ⛔ `organization_id` is not in either payload. Omitting it is what leaves + * it NULL, which is the whole of §5's install-level scope on this line. + */ + async setActive(row: FlowActivationRow): Promise { + const existing = await this.engine.find(TABLE, { + where: { metadata_type: METADATA_TYPE, name: row.name }, + context: SYSTEM_CTX, + }); + const current = Array.isArray(existing) + ? existing.find((r: any) => r?.organization_id == null) + : undefined; + + if (current && (current as { id?: unknown }).id != null) { + await this.engine.update( + TABLE, + { id: (current as { id: unknown }).id, active: row.active, package_id: row.packageId }, + { context: SYSTEM_CTX }, + ); + return; + } + + await this.engine.insert( + TABLE, + { + metadata_type: METADATA_TYPE, + name: row.name, + package_id: row.packageId, + active: row.active, + }, + { context: SYSTEM_CTX }, + ); + } + + /** + * Read the backing table once so a misconfiguration surfaces at BOOT rather + * than as a failed toggle later. Throws the driver error verbatim — `no such + * table: sys_metadata_activation` means the object was never registered (or + * its schema never synced). + */ + async probe(): Promise { + await this.engine.find(TABLE, { where: {}, limit: 1, context: SYSTEM_CTX }); + } +} diff --git a/packages/services/service-automation/src/index.ts b/packages/services/service-automation/src/index.ts index ce24bcf923..81d4cba54a 100644 --- a/packages/services/service-automation/src/index.ts +++ b/packages/services/service-automation/src/index.ts @@ -19,6 +19,12 @@ export type { SuspendedRun, SuspendedRunStore, FlowDispatchStore, + // [ADR-0126 §7.2] The packaged-flow activation ledger port and its row — + // the durable off-switch that REPLACES the retired process-local + // `flowEnabled` map (#10243). Exported so a host can supply its own + // backing store, and so the shape a consumer reads is the platform's. + FlowActivationStore, + FlowActivationRow, RunRecord, StepLogEntry, UnknownNodeTypeAuditEntry, @@ -84,6 +90,13 @@ export { InMemoryFlowDispatchStore, ObjectStoreFlowDispatchStore } from './flow- export type { FlowDispatchStoreEngine } from './flow-dispatch-store.js'; export { SysFlowDispatch } from './sys-flow-dispatch.object.js'; +// [ADR-0126 §4/§7.2] Packaged-flow enable/disable. The durable ledger behind +// `AutomationEngine.toggleFlow` — the in-memory store is for tests and hosts +// with no ObjectQL; the ObjectQL-backed store writes `sys_metadata_activation` +// so a disabled packaged flow stays disabled across a restart. +export { InMemoryFlowActivationStore, ObjectStoreFlowActivationStore } from './flow-activation-store.js'; +export type { FlowActivationStoreEngine } from './flow-activation-store.js'; + // Kernel plugin — seeds all built-in nodes; this is the only plugin needed for // a fully-functional automation capability. export { AutomationServicePlugin, createPackageFileLoader } from './plugin.js'; diff --git a/packages/services/service-automation/src/plugin-startup-log-cause.test.ts b/packages/services/service-automation/src/plugin-startup-log-cause.test.ts index 50dee75279..bfa8ff72de 100644 --- a/packages/services/service-automation/src/plugin-startup-log-cause.test.ts +++ b/packages/services/service-automation/src/plugin-startup-log-cause.test.ts @@ -376,9 +376,24 @@ describe('#5661 — the startup probe error is ONE stderr record', () => { const mine = lines.filter((l) => l.includes(PROBE_PREFIX)); expect(mine, 'reported exactly once').toHaveLength(1); - expect(lines, 'one call, one physical line').toHaveLength(1); + // [ADR-0126 §7.2] Deliberately NOT `expect(lines).toHaveLength(1)` any + // more. An unreadable datasource now fails TWO independent boot probes + // — this one and the activation ledger's (`sys_metadata_activation`) — + // and each states its own distinct consequence, so a count over ALL of + // stderr pins the existence of an unrelated seam rather than anything + // about this record. + // + // What #5661 is actually about is that a driver's MULTI-LINE failure + // must not become N log records of which only the first carries a level + // head. That property is asserted directly here, and it is strictly + // stronger than the count it replaces: every captured line must be a + // well-formed record, so an escaped continuation fragment fails this + // even in a boot that emits several legitimate records. + for (const line of lines) { + expect(classifyLine(line), `orphan continuation line escaped: ${line}`).not.toBeNull(); + } - const record = JSON.parse(lines[0]) as { level: string; msg: string; error?: string; issues?: unknown }; + const record = JSON.parse(mine[0]) as { level: string; msg: string; error?: string; issues?: unknown }; expect(record.level).toBe('error'); expect(record.msg).not.toContain('\n'); // #4632 demands both of these IN the record's own first line, and moving diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index 321331bef7..846878a134 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -26,6 +26,13 @@ import { type SuspendedRunStoreEngine, } from './suspended-run-store.js'; import { ObjectStoreFlowDispatchStore } from './flow-dispatch-store.js'; +// [ADR-0126 §4] The activation ledger's object is declared in +// `packages/platform-objects`, beside its data-plane siblings — the ADR puts it +// there so it needs zero `packages/spec` surface. This plugin REGISTERS it for +// the same reason it registers `sys_automation_run`: the table has to exist +// wherever automation runs, which is exactly where the ledger is consumed. +import { SysMetadataActivation } from '@objectstack/platform-objects'; +import { ObjectStoreFlowActivationStore, type FlowActivationStoreEngine } from './flow-activation-store.js'; /** * #1928 — normalize an ObjectQL object's `fields` (a name-keyed map, or an @@ -542,7 +549,7 @@ export class AutomationServicePlugin implements Plugin { scope: 'system', defaultDatasource: 'cloud', namespace: 'sys', - objects: [SysAutomationRun, SysFlowDispatch], + objects: [SysAutomationRun, SysFlowDispatch, SysMetadataActivation], }); return true; } catch (err) { @@ -720,6 +727,37 @@ export class AutomationServicePlugin implements Plugin { // in-process dedup and says so once. this.engine.setFlowDispatchStore(new ObjectStoreFlowDispatchStore(dataEngine)); ctx.logger.info('[Automation] Flow-dispatch idempotency ledger enabled (sys_flow_dispatch)'); + // [ADR-0126 §4/§7.2] The packaged-flow activation ledger. + // Attached under the same guard as the two stores above — + // it needs the same engine surface and its object rode the + // same manifest registration, so `runObjectRegistered` + // vouches for this table too. Without it, `toggleFlow` + // degrades to an in-process flip and WARNS on every flip + // that the change will not survive a restart. + const activationStore = new ObjectStoreFlowActivationStore( + dataEngine as unknown as FlowActivationStoreEngine, + ); + try { + await activationStore.probe(); + this.engine.setFlowActivationStore(activationStore); + ctx.logger.info('[Automation] Packaged-flow activation ledger enabled (sys_metadata_activation)'); + } catch (err) { + // Unlike the suspended-run store, this one is NOT + // attached on a failed probe. The asymmetry is + // deliberate: an unreachable run store loses history, + // while an unreachable activation store would let + // `toggleFlow` report a durable install-wide switch + // that never persisted — and a disabled flow silently + // re-arming on the next boot is the failure this leg + // exists to close. Degraded means degraded and says so. + ctx.logger.error( + '[Automation] sys_metadata_activation could not be read at startup — packaged-flow enable/disable ' + + 'will NOT be durable and disabled flows will re-arm on restart. Check that schema sync ran for this ' + + "datasource; the driver's own failure is in this record's meta.", + undefined, + describeThrownForLog(err), + ); + } } } else { ctx.logger.info('[Automation] No ObjectQL engine — suspended runs kept in-memory only'); @@ -919,6 +957,34 @@ export class AutomationServicePlugin implements Plugin { ctx.logger.warn('[Automation] flow pull from ObjectQL registry failed', describeThrownForLog(err)); } + // [ADR-0126 §7.2] Apply the activation ledger to what was just pulled. + // + // Ordering is load-bearing and this is the only correct spot: AFTER the + // pull, because `registerFlow` arms every flow's trigger and a + // ledger-disabled flow must end up UNBOUND; and after the store was + // attached in the block above, because there is nothing to read + // otherwise. An empty ledger — the stock-boot case — disarms nothing, + // which is ADR-0126 §4's "an empty ledger changes nothing anywhere". + try { + const disarmed = await this.engine.hydrateFlowActivations(); + if (disarmed.length > 0) { + ctx.logger.info( + `[Automation] Activation ledger: ${disarmed.length} packaged flow(s) are switched off for this ` + + `installation and were left unbound — ${disarmed.map((n) => `'${n}'`).join(', ')}.`, + ); + } + } catch (err) { + // A read failure here must not be silent: every flow stays ARMED, + // so a flow an administrator switched off would fire. Loud, and + // `error` rather than `warn` for that reason. + ctx.logger.error( + '[Automation] the packaged-flow activation ledger could not be read — every pulled flow is ARMED, ' + + 'including any an administrator switched off. The ledger read failure is in this record\'s meta.', + undefined, + describeThrownForLog(err), + ); + } + // ── ADR-0097: materialize provider-bound declarative connector instances ── // Every plugin's init() has completed by start(), so connector plugins have // registered their provider factories (they do so in init()) and the ObjectQL diff --git a/packages/services/service-automation/vitest.config.ts b/packages/services/service-automation/vitest.config.ts new file mode 100644 index 0000000000..b023d8e61f --- /dev/null +++ b/packages/services/service-automation/vitest.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'vitest/config'; +import path from 'node:path'; + +/** + * [#12157] `plugin.ts` registers the ADR-0126 §4 activation ledger's object, + * `sys_metadata_activation`, which is DECLARED in `@objectstack/platform-objects` + * (the ADR puts it there, beside its data-plane siblings, so it needs zero + * `packages/spec` surface). The plugin tests therefore reach that package, and + * unaliased the workspace link resolves it to `dist/` — which makes the verdict + * a function of build state rather than of the source in this checkout. A + * `dist` merely BEHIND would run the ledger tests green against an object + * declaration that no longer matches the one shipping, which is exactly the + * reading they exist to pin. `pnpm check:test-source-alias` is the gate. + * + * ANCHORED regex, array form, deliberately: a bare string `find` matches by + * PREFIX, so with a FILE replacement it would also swallow any subpath and + * resolve it to `…/platform-objects/src/index.ts/` — `ENOTDIR` at run + * time, from a config that reads as correct. Mirrors the identical rule in + * `packages/services/service-messaging/vitest.config.ts`. + */ +export default defineConfig({ + resolve: { + alias: [ + { + find: /^@objectstack\/platform-objects$/, + replacement: path.resolve(__dirname, '../../platform-objects/src/index.ts'), + }, + ], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a21df8d4d4..2a4675f293 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2202,6 +2202,9 @@ importers: '@objectstack/metadata-core': specifier: workspace:* version: link:../../metadata-core + '@objectstack/platform-objects': + specifier: workspace:* + version: link:../../platform-objects '@objectstack/spec': specifier: workspace:* version: link:../../spec diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 1f9541c944..07795e0cab 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1871,6 +1871,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/services/service-automation/src/flow-activation-ledger.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/services/service-automation/src/run-summary.test.ts", "verb": "delete", diff --git a/scripts/error-status-unpinned-baseline.json b/scripts/error-status-unpinned-baseline.json index 3683ae21ab..1ebc816916 100644 --- a/scripts/error-status-unpinned-baseline.json +++ b/scripts/error-status-unpinned-baseline.json @@ -1,9 +1,8 @@ { - "note": "StandardErrorCode members documented with an HTTP status that NO producer this gate can read declares a status for \u2014 nothing pins the doc claim on either side. Shrink-only: a new entry is a gate failure, and a row that becomes pinned must be removed. Regenerate with `node scripts/check-error-status-conformance.mjs --update`.", + "note": "StandardErrorCode members documented with an HTTP status that NO producer this gate can read declares a status for — nothing pins the doc claim on either side. Shrink-only: a new entry is a gate failure, and a row that becomes pinned must be removed. Regenerate with `node scripts/check-error-status-conformance.mjs --update`.", "unpinned": [ "CONCURRENT_LIMIT_EXCEEDED", "CONCURRENT_MODIFICATION", - "DELETE_RESTRICTED", "DUPLICATE_RECORD", "DUPLICATE_VALUE", "EMAIL_NOT_VERIFIED",