Skip to content

Commit 1524927

Browse files
feat(automation): durable packaged-flow disable — activation ledger, execute() consult, operator gate (#12296)
* feat(automation): durable packaged-flow disable via the activation ledger Flipping a packaged flow off/on now writes the install-level `sys_metadata_activation` row (ADR-0126 §4/§7.2) and the engine consults that ledger at the `execute()` seam, reusing the existing `FLOW_DISABLED` code with the distinction carried by the message. The install-level row also unbinds the trigger, and re-enabling rebinds it. This retires the process-local `flowEnabled` map (#10243) rather than shading it: the map was not a row, so no organization wall scoped it. Because a durable install-wide switch writable by tenants would be that leak with persistence, the toggle route is now operator-gated in the `group`/`isolated` postures (ADR-0126 §5); `single` is unchanged. Disabling a flow packaged flows still call as a subflow is refused with the callers named (ADR-0126 §7.3), by definition scan over `subflow` and `map` nodes — no reference index. Closes #12157 Closes #12158 * test(automation): conform the ledger test double and alias platform-objects - fakeEngine.update() routes through assertEngineUpdateDispatch, so the double cannot be looser than the engine it stands in for (check:engine-double-contract); the new pinned coverage is recorded in the pinned ledger. - Its WHERE predicate REFUSES combinators it does not implement instead of reading them as field names (check:where-matcher). - vitest aliases @objectstack/platform-objects to source, so the ledger tests are a verdict about this checkout rather than about dist build state (check:test-source-alias). * chore(gates): ratchet the error-status unpinned baseline down for DELETE_RESTRICTED The ADR-0126 §7.3 subflow-guard refusal declares `{ code: 'DELETE_RESTRICTED', status: 409 }`, which gives the code its first producer this gate can derive a status from. `check:error-status-conformance` therefore required the shrink-only unpinned baseline to drop it: 34 -> 33 entries. Regenerated with the sanctioned `node scripts/check-error-status-conformance.mjs --update` — the shrinking direction. No entry was added (an addition is an expansion, and that remedy is maintainer-only). The docs already publish 409 for this code (content/docs/protocol/kernel/error-handling.mdx), matching the producer, so the gate reconciles rather than reporting a mismatch. The `note` line is re-encoded by the writer (`—` -> a literal em dash); the parsed string is codepoint-identical, and it is the generator's own output, not a hand edit. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 783111d commit 1524927

15 files changed

Lines changed: 1715 additions & 33 deletions

.changeset/heavy-eels-shave.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
"@objectstack/runtime": minor
4+
---
5+
6+
Packaged flows can be switched off durably, and the process-local off-switch is retired
7+
8+
Disabling a packaged flow now writes an install-level row to the
9+
`sys_metadata_activation` ledger (ADR-0126 §4/§7.2) instead of setting a
10+
process-local map. The engine consults that ledger at the `execute()` seam —
11+
the one seam every entry path crosses (record-change, schedule, time-relative,
12+
api, subflow) — and refuses a disabled flow there with the existing
13+
`FLOW_DISABLED` code; the ledger case is distinguished by the message, so no
14+
new error code joins the ADR-0112 ledger. An install-level disable also unbinds
15+
the flow's trigger, and re-enabling rebinds it. Absence of a row means the
16+
packaged default, active, so a deployment that never flips anything behaves
17+
exactly as before.
18+
19+
This retires the mechanism behind #10243 rather than refining it. The old
20+
`flowEnabled` map was not a row, so no organization wall scoped it: on a walled
21+
multi-organization deployment a tenant org owner could switch a shipped flow
22+
off environment-wide and an unrelated tenant read it off. The durable row
23+
replaces it, and because a durable install-wide switch writable by tenants
24+
would be that leak with persistence, the write is now authority-gated:
25+
`POST /automation/:name/toggle` requires the platform operator in the `group`
26+
and `isolated` postures, while the `single` posture — where install-level and
27+
org-level are the same scope — is unchanged for the org admin who already holds
28+
`manage_metadata`. The refusal names the posture and points at the clone path.
29+
30+
Disabling a flow that packaged flows still call as a subflow is refused, and
31+
the refusal names the callers (ADR-0126 §7.3). Without it a vendor flow breaks
32+
mid-run at its subflow node with an inexplicable late failure. The check is a
33+
definition scan at disable time over both `subflow` and `map` nodes; no
34+
reference index is built. Enabling is never guarded.
35+
36+
One behaviour change worth calling out: a disable now survives
37+
unregister-and-re-register, which is what a package upgrade, a Studio publish
38+
and the boot pull all do. ADR-0126 §6 requires it — the ledger records the
39+
customer's choice, and no upgrade un-makes a choice — but it is the opposite of
40+
what the retired in-process map did, where any re-registration silently
41+
re-armed the flow.
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#12157] ADR-0126 §5 — WRITE AUTHORITY for the packaged-flow activation
4+
// switch, `POST /automation/:name/toggle`.
5+
//
6+
// ## The rule, and why it is posture-conditional
7+
//
8+
// The row this route writes is INSTALL-LEVEL (`organization_id NULL`): one
9+
// row, one environment, every tenant. So the authority it demands scales with
10+
// how far that reach goes:
11+
//
12+
// - `single` — one logical tenant, so install-level and org-level are the
13+
// SAME scope. The org admin who already passed the #10145
14+
// `manage_metadata` gate is the right authority.
15+
// - `group` /
16+
// `isolated` — a real multi-organization deployment, where the switch
17+
// crosses tenants. The platform OPERATOR is required.
18+
//
19+
// ## What this is made durable against
20+
//
21+
// #10243, measured over HTTP: on a real `isolated` posture a tenant org owner
22+
// switched a shipped flow off through this very route and an unrelated tenant
23+
// in a DIFFERENT organization read it off — environment-wide reach from a
24+
// tenant caller. That leak went through a PROCESS-LOCAL map, so a cold boot
25+
// undid it ("mitigating but not exculpating"). ADR-0126 makes the switch
26+
// DURABLE, which removes that accidental limit — so a tenant-writable
27+
// install-wide row would be the same leak WITH persistence, i.e. strictly
28+
// worse than what was measured. This gate is what stops that.
29+
//
30+
// ## What the refusal cases assert
31+
//
32+
// `status` AND `code` (the ADR-0112 envelope), AND that `toggleFlow` was never
33+
// entered — a gate that refused after the ledger was already written would
34+
// still be the defect and would still satisfy a status-only assertion.
35+
36+
import { describe, it, expect, vi } from 'vitest';
37+
38+
import { HttpDispatcher } from '../http-dispatcher.js';
39+
import type { HttpProtocolContext } from '../http-dispatcher.js';
40+
41+
const FLOW = 'vendor_lead_router';
42+
const DEFINITION = { name: FLOW, label: 'Vendor Lead Router', type: 'autolaunched', nodes: [], edges: [] };
43+
44+
interface Harness {
45+
dispatcher: HttpDispatcher;
46+
toggleFlow: ReturnType<typeof vi.fn>;
47+
}
48+
49+
/**
50+
* A dispatcher whose `tenancy` service reports the given posture.
51+
*
52+
* `posture: null` is a deployment with NO tenancy service — the shape
53+
* `resolve-execution-context.ts` resolves to "no posture-conditional refusal",
54+
* and (ADR-0093 D4/D5) the same deployment shape as `single`.
55+
*/
56+
function boot(posture: 'single' | 'group' | 'isolated' | null): Harness {
57+
const toggleFlow = vi.fn(async () => undefined);
58+
59+
const services: Record<string, unknown> = {
60+
automation: {
61+
handlerReady: true,
62+
toggleFlow,
63+
getFlow: vi.fn(async (name: string) => (name === FLOW ? DEFINITION : undefined)),
64+
},
65+
};
66+
if (posture) services.tenancy = { posture };
67+
68+
const resolve = (name: string): unknown => services[name];
69+
const kernel = {
70+
getService: resolve,
71+
getServiceAsync: async (name: string) => resolve(name),
72+
context: { getService: resolve },
73+
};
74+
75+
return { dispatcher: new HttpDispatcher(kernel as never), toggleFlow };
76+
}
77+
78+
/**
79+
* A tenant org admin who DOES hold `manage_metadata` — so the #10145 gate one
80+
* tier up passes and this gate is the only thing left. That is the whole point:
81+
* the two gates ask different questions, and this test must not pass merely
82+
* because the other one refused.
83+
*/
84+
const TENANT_ADMIN = (): HttpProtocolContext => ({
85+
request: {},
86+
executionContext: {
87+
userId: 'u_northwind_owner',
88+
positions: ['org_owner', 'org_admin'],
89+
permissions: ['organization_admin'],
90+
systemPermissions: ['manage_metadata'],
91+
organizationId: 'org_northwind',
92+
},
93+
} as HttpProtocolContext);
94+
95+
/** The platform operator (ADR-0068 D2: `platform_admin`, NOT a tenant role). */
96+
const PLATFORM_OPERATOR = (): HttpProtocolContext => ({
97+
request: {},
98+
executionContext: {
99+
userId: 'u_saas_operator',
100+
positions: ['platform_admin'],
101+
permissions: ['admin_full_access'],
102+
systemPermissions: ['manage_metadata'],
103+
organizationId: null,
104+
},
105+
} as HttpProtocolContext);
106+
107+
/** Engine self-invocation — never settable from the wire. */
108+
const SYSTEM = (): HttpProtocolContext => ({
109+
request: {},
110+
executionContext: { userId: 'usr_system', isSystem: true },
111+
} as HttpProtocolContext);
112+
113+
const statusOf = (response: unknown): unknown => (response as any)?.status;
114+
const codeOf = (response: unknown): unknown => {
115+
const r = response as any;
116+
return r?.body?.error?.code ?? r?.body?.error?.details?.code;
117+
};
118+
const messageOf = (response: unknown): string => {
119+
const r = response as any;
120+
return String(r?.body?.error?.message ?? '');
121+
};
122+
123+
const toggle = (h: Harness, ctx: HttpProtocolContext) =>
124+
h.dispatcher.handleAutomation(`/${FLOW}/toggle`, 'POST', { enabled: false }, ctx, undefined);
125+
126+
describe('ADR-0126 §5 — the activation write is operator-gated in walled postures', () => {
127+
describe('`single` posture — the org admin suffices', () => {
128+
it('a tenant admin with `manage_metadata` may flip the switch', async () => {
129+
const h = boot('single');
130+
131+
const { response } = await toggle(h, TENANT_ADMIN());
132+
133+
expect(statusOf(response)).toBe(200);
134+
expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false);
135+
});
136+
137+
it('so may the platform operator', async () => {
138+
const h = boot('single');
139+
140+
const { response } = await toggle(h, PLATFORM_OPERATOR());
141+
142+
expect(statusOf(response)).toBe(200);
143+
expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false);
144+
});
145+
146+
it('no tenancy service at all behaves like `single` (ADR-0093 D4/D5)', async () => {
147+
const h = boot(null);
148+
149+
const { response } = await toggle(h, TENANT_ADMIN());
150+
151+
// Refusing here would lock every single-tenant operator out of
152+
// their own switch, and an unenforceable wall resolves to `single`.
153+
expect(statusOf(response)).toBe(200);
154+
expect(h.toggleFlow).toHaveBeenCalled();
155+
});
156+
});
157+
158+
for (const posture of ['group', 'isolated'] as const) {
159+
describe(`\`${posture}\` posture — the install-wide switch needs the operator`, () => {
160+
it('REFUSES a tenant org admin, loudly, and never enters toggleFlow', async () => {
161+
const h = boot(posture);
162+
163+
const { response } = await toggle(h, TENANT_ADMIN());
164+
165+
expect(statusOf(response)).toBe(403);
166+
expect(codeOf(response)).toBe('PERMISSION_DENIED');
167+
// The load-bearing assertion: refused BEFORE the write. A gate
168+
// that wrote the row and then refused would satisfy the two
169+
// above and still be #10243.
170+
expect(h.toggleFlow).not.toHaveBeenCalled();
171+
});
172+
173+
it('the refusal names the posture, the reason, and the sanctioned path', async () => {
174+
const h = boot(posture);
175+
176+
const { response } = await toggle(h, TENANT_ADMIN());
177+
const message = messageOf(response);
178+
179+
expect(message).toContain(posture);
180+
expect(message).toContain('INSTALL-WIDE');
181+
expect(message).toContain('ADR-0126 §5');
182+
// ADR-0126 §7: a refusal names what the caller CAN do. Here
183+
// that is the clone path (§7.1), which needs no operator.
184+
expect(message).toMatch(/clone/i);
185+
// #7450 — a denial says nothing about the caller's own
186+
// positions or permission-set names.
187+
expect(message).not.toContain('org_owner');
188+
expect(message).not.toContain('organization_admin');
189+
});
190+
191+
it('ALLOWS the platform operator', async () => {
192+
const h = boot(posture);
193+
194+
const { response } = await toggle(h, PLATFORM_OPERATOR());
195+
196+
expect(statusOf(response)).toBe(200);
197+
expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false);
198+
});
199+
200+
it('ALLOWS engine self-invocation', async () => {
201+
const h = boot(posture);
202+
203+
const { response } = await toggle(h, SYSTEM());
204+
205+
expect(statusOf(response)).toBe(200);
206+
expect(h.toggleFlow).toHaveBeenCalled();
207+
});
208+
209+
it('gates ENABLE as well as disable — the switch is install-wide in both directions', async () => {
210+
const h = boot(posture);
211+
212+
const { response } = await h.dispatcher.handleAutomation(
213+
`/${FLOW}/toggle`, 'POST', { enabled: true }, TENANT_ADMIN(), undefined,
214+
);
215+
216+
expect(statusOf(response)).toBe(403);
217+
expect(h.toggleFlow).not.toHaveBeenCalled();
218+
});
219+
220+
it('does NOT gate the clone door — cloning is the path the refusal recommends', async () => {
221+
const h = boot(posture);
222+
223+
const { response } = await h.dispatcher.handleAutomation(
224+
`/${FLOW}/clone`, 'POST', { name: 'my_lead_router', label: 'My Lead Router' },
225+
TENANT_ADMIN(), undefined,
226+
);
227+
228+
// Whatever the clone route answers, it must not be THIS gate:
229+
// a clone creates an ordinary new artifact and takes nothing
230+
// away from any tenant (§7.1).
231+
expect(codeOf(response)).not.toBe('PERMISSION_DENIED');
232+
});
233+
234+
it('does not over-block the legacy execution door for a flow named `toggle`', async () => {
235+
const h = boot(posture);
236+
237+
await h.dispatcher.handleAutomation('/trigger/toggle', 'POST', {}, TENANT_ADMIN(), undefined);
238+
239+
// `POST /automation/trigger/toggle` RUNS a flow literally named
240+
// `toggle`; gating it would over-block an execution door, which
241+
// is the one thing the #10243 ruling did not do.
242+
expect(h.toggleFlow).not.toHaveBeenCalled();
243+
});
244+
});
245+
}
246+
});

0 commit comments

Comments
 (0)