diff --git a/.changeset/lucky-jokes-shave.md b/.changeset/lucky-jokes-shave.md new file mode 100644 index 0000000000..c6bfb67b4f --- /dev/null +++ b/.changeset/lucky-jokes-shave.md @@ -0,0 +1,53 @@ +--- +"@objectstack/objectql": minor +"@objectstack/runtime": minor +"@objectstack/spec": minor +--- + +Packaged actions can be switched off, on the same activation ledger as flows + +A packaged action can now be disabled for an installation, generalizing the +packaged-flow machinery to the second Regime C consumer (ADR-0126 §8 item 2, on +the maintainer's amendment ruling 3). The flip writes an install-level row to +the **same** `sys_metadata_activation` object with `metadata_type: 'action'` — +no new table, no new column, no schema change of any kind. Absence of a row +means the packaged default, active, so a deployment that never flips anything +behaves exactly as before, and an empty ledger changes nothing anywhere. + +The consult point is action DISPATCH, and it is present on every door that +dispatches a declared action: the REST `POST /actions/:object/:action` route and +the MCP `run_action` bridge. Both call one shared guard, and a disabled action +is refused `409 ACTION_DISABLED` before anything runs — before the handler body +(which executes trusted, RLS/FLS-bypassing), before a `type: 'flow'` action +reaches the automation engine, before the param contract is enforced and before +the subject record is read. The refusal names the ledger and the remedies. The +code is new, registered under `@objectstack/runtime` in the ADR-0112 ledger and +answered at both doors; it deliberately does **not** reuse `FLOW_DISABLED`, +which would tell an operator to go looking for a flow that does not exist. + +The consult reads a projection the ObjectQL engine holds and hydrates at boot, +so a disabled action stays disabled across a restart and across the handler +re-registration that every `metadata:reloaded` performs (ADR-0126 §6 wall 3 — +the ledger records the customer's choice, and nothing re-arms it silently). + +The write door is `POST /actions/_activation/:object/:action` with a +`{ enabled?: boolean }` body. Its first segment is reserved rather than deep in +the path because a machine name can never begin with `_`, so it cannot collide +with an object, an action or a record id. It carries the same two authority +tiers the flow toggle carries: `manage_metadata`, then the ADR-0126 §5 posture +rule — in the `group` and `isolated` postures the install-wide switch requires +the platform operator, while `single`, where install-level and org-level are the +same scope, is unchanged. That gate is now one implementation shared with +`POST /automation/:name/toggle`; the flow refusal text is unchanged. + +Two refusals are worth knowing about. The ledger addresses an action by its +machine name, so a name declared on more than one object is refused with +`409 RESOURCE_CONFLICT` naming the objects, rather than switching all of them off +silently. And a flip that cannot be made durable — no ledger table reachable — +is answered as a failure instead of a 200, because a switch reported as durable +that reverts on the next restart is the failure this whole family exists to +remove. + +Action **cloning** is not part of this: ADR-0126 §8 leaves it unchartered, so +disable is the only primitive here and authoring a new sibling action stays +exactly as it is today. diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 580eace1df..56e998bd4e 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +287 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +288 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | @@ -89,6 +89,7 @@ const result = ApiErrorSchema.parse(data); * `INTEGRATION_ERROR` * `WEBHOOK_DELIVERY_FAILED` * `ACCOUNT_LOCKED` +* `ACTION_DISABLED` * `ALREADY_REVERTED` * `AMBIGUOUS_MATCH` * `ANALYTICS_QUERY_FAILED` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 80ee4e0354..648d290f6d 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -193,6 +193,7 @@ const result = ErrorCode.parse(data); * `INTEGRATION_ERROR` * `WEBHOOK_DELIVERY_FAILED` * `ACCOUNT_LOCKED` +* `ACTION_DISABLED` * `ALREADY_REVERTED` * `AMBIGUOUS_MATCH` * `ANALYTICS_QUERY_FAILED` diff --git a/packages/objectql/src/action-activation.test.ts b/packages/objectql/src/action-activation.test.ts new file mode 100644 index 0000000000..1232bb3f6d --- /dev/null +++ b/packages/objectql/src/action-activation.test.ts @@ -0,0 +1,387 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#12160] ADR-0126 §8 item 2 — the packaged-ACTION activation ledger, from +// the maintainer's amendment ruling 3 (2026-08-25, verbatim and untranslated): +// +// > 「动作 可能是需要开关的,因为有的 action 我不想启用。」 +// +// ## What this file pins, and why each half exists +// +// 1. **The ROW contract** (ADR-0126 §4), because the ledger is shared: an +// `action` writer that touched a flow row, or read one, would corrupt a +// neighbour's state through a table both are told to treat as generic. +// Pinned on both sides — the discriminator is in the read AND in the write. +// 2. **`organization_id` stays NULL**, which is the whole of §5's install-level +// scope on this line. Asserted as an ABSENT key rather than a null value: +// writing it explicitly would be a different row shape, and the one the +// reserved per-org dimension is not. +// 3. **Absence means ACTIVE.** The stock-boot state is "no rows", and it must +// change nothing anywhere (§4). A projection that defaulted the other way +// would switch off an entire installation's actions on its first boot. +// 4. **The write is durable BEFORE it is local.** A store that throws must +// leave the projection untouched, or the engine reports an activation state +// the ledger does not carry — the #10243 shape with persistence bolted on, +// which ADR-0126 §7.2 exists to remove. +// 5. **Survives re-registration**, which is the in-process half of "survives a +// restart": `resyncAuthoredActions` re-registers handlers on every +// `metadata:reloaded`, and a re-registered handler must stay switched off. + +import { describe, it, expect, vi } from 'vitest'; +// The engine's OWN update-dispatch predicate, so the fake below cannot be +// looser than ObjectQL.update (`pnpm check:engine-double-contract`). Imported +// from metadata-core rather than from `@objectstack/objectql`: this test lives +// INSIDE objectql, and the package re-exporting it is this one. +import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; + +import { ObjectQL } from './engine.js'; +import { + ActionActivationProjection, + InMemoryActionActivationStore, + ObjectStoreActionActivationStore, + type ActionActivationStore, +} from './action-activation.js'; + +const TABLE = 'sys_metadata_activation'; + +/** + * The double's WHERE predicate, at MODULE scope on purpose. + * + * `pnpm check:where-matcher` judges a matcher by LIFTING it — transpiling it + * with the same-file declarations it references and running a combinator + * battery against it. Declared inside the factory below, the lift has to carry + * that factory's scope with it, which reaches `vi` and fails to evaluate: the + * gate then reports the matcher UNJUDGED, and unjudged is never treated as + * passing. At module scope it lifts cleanly and is judged on its behaviour, + * which is the point of the gate. + */ +function matches(row: any, where: any): boolean { + return Object.entries(where ?? {}).every(([k, v]) => { + // This double implements EQUALITY ONLY, and REFUSES anything else + // rather than quietly mismatching it. A matcher with no combinator + // branch reads `$or` / `$in` as a FIELD NAME, compares + // `row.$or` (undefined) against the operand, matches nothing, and + // leaves the suite asserting on an empty result set with nothing + // erroring — the silent-wrong class `pnpm check:where-matcher` + // exists to catch, and the worse of its two shapes because it is + // an ABSENCE no syntactic guard can see. Refusing is the + // conformance shape most discovered matchers already take (187 of the + // 300 that predate this one), and it is the cheap answer for a double that only + // ever sees scalar equality: the store's two reads are + // `{ metadata_type }` and `{ metadata_type, name }`. Same refusal + // the flow twin's fake carries, and the branch belongs HERE, in + // the predicate, not in the enclosing `find`. + if (k.startsWith('$') || (v !== null && typeof v === 'object')) { + throw new Error( + `makeStoreEngine: unsupported WHERE combinator '${k}' — this double implements equality only`, + ); + } + return row?.[k] === v; + }); +} + +/** + * A store engine that records what it was asked, and answers `find` from a + * fixed row set filtered by the WHERE it was given. + * + * The filter is applied rather than ignored on purpose: a store that scoped its + * read by `metadata_type` and a double that answered every row regardless would + * pin nothing about the discriminator — the exact hole this file's first + * assertion exists to close. + */ +function makeStoreEngine(rows: any[] = []) { + const calls: Array<{ op: string; object: string; data?: any; options?: any }> = []; + const engine = { + find: vi.fn(async (object: string, options?: any) => { + calls.push({ op: 'find', object, options }); + return rows.filter((r) => matches(r, options?.where)); + }), + insert: vi.fn(async (object: string, data: any, options?: any) => { + calls.push({ op: 'insert', object, data, options }); + return { id: 'row_new', ...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 + // (importing objectql's own re-export from inside objectql would be + // a self-import). Same pin the flow twin's fake carries. + assertEngineUpdateDispatch(data, options); + calls.push({ op: 'update', object, data, options }); + return data; + }), + }; + return { engine, calls }; +} + +describe('ObjectStoreActionActivationStore — the ADR-0126 §4 row contract', () => { + it('reads only `metadata_type: \'action\'` rows, and never a flow neighbour', async () => { + const { engine } = makeStoreEngine([ + { id: 'r1', metadata_type: 'action', name: 'convert_lead', package_id: 'crm', active: false }, + { id: 'r2', metadata_type: 'flow', name: 'convert_lead', package_id: 'crm', active: false }, + ]); + + const rows = await new ObjectStoreActionActivationStore(engine).list(); + + expect(rows).toEqual([{ name: 'convert_lead', packageId: 'crm', active: false }]); + expect(engine.find).toHaveBeenCalledWith(TABLE, expect.objectContaining({ + where: { metadata_type: 'action' }, + })); + }); + + it('SKIPS a row carrying an organization_id — the per-org dimension is reserved (§5)', async () => { + const { engine } = makeStoreEngine([ + { id: 'r1', metadata_type: 'action', name: 'install_wide', package_id: 'crm', active: false }, + { + id: 'r2', metadata_type: 'action', name: 'org_scoped', package_id: 'crm', + active: false, organization_id: 'org_northwind', + }, + ]); + + const rows = await new ObjectStoreActionActivationStore(engine).list(); + + // Reading it as install-level would apply ONE organization's choice to + // the whole installation — #10243 arrived at from the read side. + expect(rows.map((r) => r.name)).toEqual(['install_wide']); + }); + + it('reads a driver `0` as DISABLED, not as truthy-by-accident', async () => { + // SQLite/libsql round-trip booleans as 0/1; a `!row.active === false` + // style test would arm every action a SQLite deployment disabled. + const { engine } = makeStoreEngine([ + { id: 'r1', metadata_type: 'action', name: 'off_zero', package_id: 'p', active: 0 }, + { id: 'r2', metadata_type: 'action', name: 'on_one', package_id: 'p', active: 1 }, + { id: 'r3', metadata_type: 'action', name: 'on_default', package_id: 'p' }, + ]); + + const rows = await new ObjectStoreActionActivationStore(engine).list(); + + expect(rows.find((r) => r.name === 'off_zero')?.active).toBe(false); + expect(rows.find((r) => r.name === 'on_one')?.active).toBe(true); + // No `active` column value at all — the column defaults to true, so + // only an explicit false disarms. + expect(rows.find((r) => r.name === 'on_default')?.active).toBe(true); + }); + + it('INSERTS the discriminator and NO organization_id when there is no row yet', async () => { + const { engine } = makeStoreEngine([]); + + await new ObjectStoreActionActivationStore(engine).setActive({ + name: 'convert_lead', packageId: 'crm', active: false, + }); + + expect(engine.insert).toHaveBeenCalledTimes(1); + const [object, data] = engine.insert.mock.calls[0]; + expect(object).toBe(TABLE); + expect(data).toEqual({ + metadata_type: 'action', name: 'convert_lead', package_id: 'crm', active: false, + }); + // Absent, not null: omitting the column is what leaves it NULL, and the + // `unique: 'organization'` index collapses NULL for row identity. + expect(Object.keys(data)).not.toContain('organization_id'); + expect(engine.update).not.toHaveBeenCalled(); + }); + + it('UPDATES the existing install-level row rather than writing a second one', async () => { + const { engine } = makeStoreEngine([ + { id: 'row_1', metadata_type: 'action', name: 'convert_lead', package_id: 'crm', active: false }, + ]); + + await new ObjectStoreActionActivationStore(engine).setActive({ + name: 'convert_lead', packageId: 'crm', active: true, + }); + + expect(engine.insert).not.toHaveBeenCalled(); + expect(engine.update).toHaveBeenCalledWith( + TABLE, + { id: 'row_1', active: true, package_id: 'crm' }, + expect.anything(), + ); + }); + + it('re-enabling UPDATES the row instead of deleting it — the ledger records choices (§6 wall 3)', async () => { + const { engine, calls } = makeStoreEngine([ + { id: 'row_1', metadata_type: 'action', name: 'convert_lead', package_id: 'crm', active: false }, + ]); + + await new ObjectStoreActionActivationStore(engine).setActive({ + name: 'convert_lead', packageId: 'crm', active: true, + }); + + // The row is REWRITTEN, never removed: an administrator's choice to + // re-enable is itself a recorded choice. The store's engine slice is + // three methods wide and carries no `delete` at all, so "it stopped + // deleting" cannot regress into "it deletes again" without the + // interface changing — this asserts the observable half. + expect(calls.map((c) => c.op)).toEqual(['find', 'update']); + expect(calls[1].data).toEqual({ id: 'row_1', active: true, package_id: 'crm' }); + }); + + it('probe() reads the table so a missing ledger surfaces at BOOT', async () => { + const engine = { + find: vi.fn(async () => { throw new Error('no such table: sys_metadata_activation'); }), + insert: vi.fn(), update: vi.fn(), + }; + + await expect(new ObjectStoreActionActivationStore(engine).probe()) + .rejects.toThrow(/no such table: sys_metadata_activation/); + }); +}); + +describe('ActionActivationProjection — absence means ACTIVE', () => { + it('an engine with no ledger attached disables nothing and hydrates to nothing', async () => { + const projection = new ActionActivationProjection(); + + expect(await projection.hydrate()).toEqual([]); + expect(projection.isEnabled('anything_at_all')).toBe(true); + expect(projection.durable).toBe(false); + }); + + it('an EMPTY ledger changes nothing anywhere (ADR-0126 §4, the stock boot)', async () => { + const projection = new ActionActivationProjection(); + projection.attach(new InMemoryActionActivationStore()); + + expect(await projection.hydrate()).toEqual([]); + expect(projection.isEnabled('convert_lead')).toBe(true); + expect(projection.disabledNames()).toEqual([]); + }); + + it('hydrate() switches off exactly the rows that say so, and names them', async () => { + const store = new InMemoryActionActivationStore(); + await store.setActive({ name: 'convert_lead', packageId: 'crm', active: false }); + await store.setActive({ name: 'send_quote', packageId: 'crm', active: true }); + const projection = new ActionActivationProjection(); + projection.attach(store); + + const off = await projection.hydrate(); + + expect(off).toEqual(['convert_lead']); + expect(projection.isEnabled('convert_lead')).toBe(false); + expect(projection.isEnabled('send_quote')).toBe(true); + }); + + it('a row flipped back to active on a LATER boot re-arms the action', async () => { + const store = new InMemoryActionActivationStore(); + await store.setActive({ name: 'convert_lead', packageId: 'crm', active: false }); + const first = new ActionActivationProjection(); + first.attach(store); + await first.hydrate(); + expect(first.isEnabled('convert_lead')).toBe(false); + + await store.setActive({ name: 'convert_lead', packageId: 'crm', active: true }); + const second = new ActionActivationProjection(); + second.attach(store); + await second.hydrate(); + + expect(second.isEnabled('convert_lead')).toBe(true); + }); + + it('setActive writes the DURABLE row before the projection, and a failed write changes nothing', async () => { + const failing: ActionActivationStore = { + list: async () => [], + setActive: async () => { throw new Error('datasource unreachable'); }, + }; + const projection = new ActionActivationProjection(); + projection.attach(failing); + + await expect(projection.setActive({ name: 'convert_lead', packageId: 'crm', active: false })) + .rejects.toThrow(/datasource unreachable/); + // The load-bearing half: a reported flip that did not persist is the + // failure this leg exists to remove, so the process state must not + // have moved either. + expect(projection.isEnabled('convert_lead')).toBe(true); + }); + + it('refuses the flip when no ledger is attached — ⛔ no in-process fallback', async () => { + const projection = new ActionActivationProjection(); + + const thrown = await projection + .setActive({ name: 'convert_lead', packageId: 'crm', active: false }) + .catch((e) => e); + + // ADR-0112 envelope: code AND status, so the door serves it honestly + // instead of a 500. + expect(thrown).toBeInstanceOf(Error); + expect((thrown as any).code).toBe('SERVICE_UNAVAILABLE'); + expect((thrown as any).status).toBe(503); + expect(String(thrown.message)).toMatch(/sys_metadata_activation/); + expect(projection.isEnabled('convert_lead')).toBe(true); + }); + + it('the refusal sentence names the ledger and the remedies — and ⛔ never a clone', () => { + const projection = new ActionActivationProjection(); + + const message = projection.describeDisabled('convert_lead'); + + expect(message).toContain("Action 'convert_lead' is disabled"); + expect(message).toContain('sys_metadata_activation'); + expect(message).toContain('ADR-0126 §8'); + // Action-CLONE is not chartered (§8 item 2). Recommending one would + // advertise machinery that does not exist — the FLOW refusal says + // "clone", and this one must not inherit that sentence. + expect(message).not.toMatch(/clone/i); + }); +}); + +describe('ObjectQL engine — the projection the dispatch doors consult', () => { + it('answers `true` for everything on a stock boot', async () => { + const engine = new ObjectQL(); + + expect(engine.isActionEnabled('convert_lead')).toBe(true); + expect(await engine.hydrateActionActivations()).toEqual([]); + expect(engine.listDisabledActions()).toEqual([]); + }); + + it('hydrates the ledger and REFUSES to re-arm on handler re-registration', async () => { + const engine = new ObjectQL(); + const store = new InMemoryActionActivationStore(); + await store.setActive({ name: 'convert_lead', packageId: 'crm', active: false }); + engine.setActionActivationStore(store); + + expect(await engine.hydrateActionActivations()).toEqual(['convert_lead']); + expect(engine.isActionEnabled('convert_lead')).toBe(false); + + // `resyncAuthoredActions` tears down and re-registers the whole + // metadata-service action set on every `metadata:reloaded`. The ledger + // projection is not part of that churn, and this is the in-process half + // of "the disable survives a restart". + engine.registerAction('crm_lead', 'convert_lead', async () => ({ ran: true }), 'metadata-service'); + + expect(engine.isActionEnabled('convert_lead')).toBe(false); + expect(engine.describeDisabledAction('convert_lead')).toContain('ADR-0126 §8'); + }); + + it('setActionActive flips the row and the projection together, in that order', async () => { + const engine = new ObjectQL(); + const store = new InMemoryActionActivationStore(); + engine.setActionActivationStore(store); + + await engine.setActionActive({ name: 'convert_lead', packageId: 'crm', active: false }); + expect(engine.isActionEnabled('convert_lead')).toBe(false); + expect(await store.list()).toEqual([{ name: 'convert_lead', packageId: 'crm', active: false }]); + + await engine.setActionActive({ name: 'convert_lead', packageId: 'crm', active: true }); + expect(engine.isActionEnabled('convert_lead')).toBe(true); + expect(await store.list()).toEqual([{ name: 'convert_lead', packageId: 'crm', active: true }]); + }); + + it('⛔ executeAction does NOT consult the ledger — the key is not the identity (ADR-0110 D2)', async () => { + // A target-bound script action registers under its `target`, so a check + // inside `executeAction` would be asking a question the arguments + // cannot answer. The consult lives at the declaration-resolving doors; + // this pins that the engine seam deliberately stays out of it, so a + // later edit that "helpfully" adds a check there has to argue with a + // test instead of silently half-gating every target-bound action. + const engine = new ObjectQL(); + const store = new InMemoryActionActivationStore(); + await store.setActive({ name: 'convert_lead', packageId: 'crm', active: false }); + engine.setActionActivationStore(store); + await engine.hydrateActionActivations(); + const handler = vi.fn(async () => ({ ran: true })); + engine.registerAction('crm_lead', 'convert_lead_impl', handler); + + await expect(engine.executeAction('crm_lead', 'convert_lead_impl', {})).resolves.toEqual({ ran: true }); + expect(handler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/objectql/src/action-activation.ts b/packages/objectql/src/action-activation.ts new file mode 100644 index 0000000000..105efa5ce3 --- /dev/null +++ b/packages/objectql/src/action-activation.ts @@ -0,0 +1,376 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0126 §8 item 2] Durable activation ledger for PACKAGED ACTIONS — + * `sys_metadata_activation` rows carrying `metadata_type: 'action'`. + * + * The maintainer's amendment ruling 3 (2026-08-25, verbatim and untranslated) + * is the pull this implements: + * + * > 「动作 可能是需要开关的,因为有的 action 我不想启用。」 + * + * ADR-0126 §8 item 2 then says how: *"same `sys_metadata_activation` ledger, + * same §5 write authority, a consult point at action dispatch"* — the flow leg + * generalized, ⛔ never a second mechanism. What the action leg does NOT get is + * the clone half: §8 keeps it pre-chartered until real pull appears, so nothing + * here copies, designates or links an artifact to another one. + * + * ## Row shape — ⛔ this module writes COLUMNS, never schema + * + * `metadata_type: 'action'` · `name` · `package_id` · `organization_id` · + * `active`, exactly the five ADR-0126 §4 declares (the object itself lives in + * `packages/platform-objects`, which is why this leg needs zero `packages/spec` + * surface). 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 what lets + * {@link ObjectStoreActionActivationStore.setActive} treat "the row for this + * action" as at most one row. + * - **Absence of a row means ACTIVE.** Nothing here ever writes a row to say + * "active by default", and a `list()` returning nothing is the normal + * stock-boot state, not an error. Re-enabling updates the row to + * `active: true` rather than deleting it, so the ledger records the + * administrator's CHOICE instead of erasing it (§6 wall 3). + * + * ## What identifies an action here, and the one case it refuses to guess + * + * The ledger addresses an artifact by its **machine name** (§4), and ADR-0110 + * D1 says the same thing about actions: *"Identity is always the declarative + * `name`; which key the handler happens to live under is derived from the + * already-resolved declaration"*. So a row's `name` is `action.name` — never a + * handler key, never a `:` composite (that would encode two + * facts in a column declared to hold one, and the per-object dimension, like + * the per-org one, is an additive column later if pull ever appears). + * + * The consequence is stated rather than hidden: two same-named actions on + * different objects address ONE row. The write door refuses that case loudly + * instead of silently disabling both — see `refuseAmbiguousActionActivation` + * in `@objectstack/runtime`'s `/actions` domain, which is where a caller and a + * resolvable object set exist to name. + * + * ## Relationship to the flow twin — one contract, two implementations, on record + * + * `packages/services/service-automation/src/flow-activation-store.ts` is the + * same store one tier up, landed first (#12296). This module deliberately does + * NOT import it and is not imported by it: `service-automation` does not depend + * on `@objectstack/objectql`, and the engine must not depend on a service, so + * neither direction is available today. What holds the two together is the row + * contract in ADR-0126 §4 and the pins on both sides; consolidating them onto + * one implementation (in a package both may depend on) is filed as its own + * card rather than smuggled into this leg. + * + * ## Why the ENGINE holds the projection + * + * Because the engine plugin is the one place that is unconditionally present + * wherever actions can execute — the same reasoning ADR-0110 D5 recorded when + * it moved the action-governance inventory off `AppPlugin` ("registered + * CONDITIONALLY … on the platform's own dev loop the inventory never ran"). + * An activation projection that some boots never hydrate is strictly worse + * than that: a disabled action would ARM. The engine also owns the action + * handler registry, so the projection sits beside the map it governs. + */ + +/** + * The ledger table. A NAME, not an import: this package does not depend on + * `@objectstack/platform-objects` (which declares the object) and must not — + * the same posture the flow twin takes, and the same one this engine already + * takes for `sys_metadata` / `sys_secret`. + */ +export const ACTION_ACTIVATION_TABLE = 'sys_metadata_activation'; +const TABLE = ACTION_ACTIVATION_TABLE; + +/** + * The ledger's `metadata_type` discriminator for this consumer. Every read and + * write here is scoped by it: the ledger is generic (ADR-0126 §4) and this + * module never assumes it owns the table — flow rows share it today, permission + * rows may later. + */ +const METADATA_TYPE = 'action'; + +/** Infrastructure rows, not tenant data — the `sys_metadata_activation` posture. */ +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; + +/** + * [ADR-0126 §4] One packaged action'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 + * `'action'` by the store and `organization_id` is never written on this line + * (§5), so those two never reach the projection. + */ +export interface ActionActivationRow { + /** The packaged action's declarative machine name (ADR-0110 D1). */ + name: string; + /** The package that ships the base artifact. */ + packageId: string; + /** Is the packaged action armed for this installation. */ + active: boolean; +} + +/** + * [ADR-0126 §8] The durable off-switch for packaged actions. + * + * Absence of a row means the packaged default — ACTIVE — so an engine with no + * store attached, or a store with no rows, dispatches exactly as a stock boot + * always has. + */ +export interface ActionActivationStore { + /** Every install-level action activation row (`organization_id IS NULL`). */ + list(): Promise; + /** Insert or update the install-level row for one packaged action. */ + setActive(row: ActionActivationRow): Promise; +} + +/** + * The exact slice of the engine this store needs: a keyed read, an insert and + * an update. 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 ActionActivationStoreEngine { + 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 ActionActivationStore} — process-lifetime only, for tests + * and for hosts with no durable plane. It is NOT a sanctioned production + * off-switch: what it lacks versus the ObjectStore implementation is + * DURABILITY, which is exactly the property ADR-0126 §6 wall 3 asks for. + */ +export class InMemoryActionActivationStore implements ActionActivationStore { + private readonly rows = new Map(); + + async list(): Promise { + return [...this.rows.values()]; + } + + async setActive(row: ActionActivationRow): Promise { + this.rows.set(row.name, { ...row }); + } +} + +/** + * Durable {@link ActionActivationStore} 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 door + * and by nothing else. + */ +export class ObjectStoreActionActivationStore implements ActionActivationStore { + constructor(private readonly engine: ActionActivationStoreEngine) {} + + /** + * Every install-level action row. Read once at boot to hydrate the + * projection. + * + * 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: ActionActivationRow[] = []; + 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 || === 0` + // test the flow twin uses, so a `0` is not mistaken for `true`. + active: !(r.active === false || r.active === 0), + }); + } + return out; + } + + /** + * Insert or update the install-level row for one packaged action. + * + * 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 could not tell "already there" from a real store + * failure. + * + * ⛔ `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: ActionActivationRow): 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) in this composition. + */ + async probe(): Promise { + await this.engine.find(TABLE, { where: {}, limit: 1, context: SYSTEM_CTX }); + } +} + +/** + * [ADR-0126 §8] The engine's local projection of the action activation ledger. + * + * ## Why a projection rather than a read per dispatch + * + * Every dispatch door already resolves a declaration, gates on ADR-0066 D4 and + * loads the subject record; adding a datasource round-trip to each invocation + * would make the off-switch cost proportional to traffic. The projection is + * written from exactly two places — {@link hydrate} (boot, from the ledger) and + * {@link setActive} (which writes the durable row FIRST and updates the set only + * after that write returns) — so it cannot drift into being an independent, + * process-local off-switch, which is the #10243 mechanism ADR-0126 retires. + * + * ⚠️ It is deliberately NOT re-read per `metadata:reloaded`: a reload + * re-registers HANDLERS, and a re-registered handler must stay disabled. The + * projection outliving the registry churn is the property the "survives + * re-register" pin asserts. + */ +export class ActionActivationProjection { + /** Actions the ledger marks inactive, keyed by declarative name. */ + private readonly disabled = new Set(); + private store: ActionActivationStore | null = null; + + /** Attach the durable ledger. Hosts call this at start(), after ObjectQL is up. */ + attach(store: ActionActivationStore): void { + this.store = store; + } + + /** Is a durable ledger attached — i.e. can a flip be made to persist? */ + get durable(): boolean { + return this.store !== null; + } + + /** + * Load the ledger into the projection. Returns the names it switched off so + * the host can say so in its boot audit. + * + * An empty ledger — the stock-boot case — disables nothing, which is + * ADR-0126 §4's "an empty ledger changes nothing anywhere". + */ + async hydrate(): Promise { + if (!this.store) return []; + const rows = await this.store.list(); + const off: string[] = []; + for (const row of rows) { + if (row.active) { + this.disabled.delete(row.name); + continue; + } + this.disabled.add(row.name); + off.push(row.name); + } + return off; + } + + /** + * Is this action armed for this installation? Absence of a row means the + * packaged default — ACTIVE (§4). + */ + isEnabled(name: string): boolean { + return !this.disabled.has(name); + } + + /** Every action name the ledger currently switches off (operability reads). */ + disabledNames(): string[] { + return [...this.disabled]; + } + + /** + * Flip one packaged action's activation — THE sanctioned off-switch. + * + * The durable row is written 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. + * + * ⛔ No in-process fallback. The flow twin degrades to a process-local flip + * with a warning because `toggleFlow` is a service-contract method a host + * with no ObjectQL still has to answer; this projection lives INSIDE + * ObjectQL, so "there is no durable plane" is not a legitimate mode here — + * it is a deployment whose ledger table was never registered or synced. + * Reporting a durable install-wide switch that did not persist is the exact + * failure ADR-0126 §6 wall 3 exists to close, so this throws instead. + */ + async setActive(row: ActionActivationRow): Promise { + if (!this.store) { + throw Object.assign( + new Error( + `Cannot ${row.active ? 'enable' : 'disable'} packaged action '${row.name}' — no activation ledger is ` + + `attached to this engine (sys_metadata_activation, ADR-0126 §4), so the flip could not be made durable ` + + `and would silently revert on the next restart. Check that the ledger object is registered for this ` + + `deployment and that schema sync ran for its datasource.`, + ), + // ADR-0112 envelope: code AND status. The capability is absent + // on this deployment, which is 503's meaning — not the caller's + // fault, and retryable once the table exists. + { code: 'SERVICE_UNAVAILABLE', status: 503 }, + ); + } + await this.store.setActive(row); + if (row.active) this.disabled.delete(row.name); + else this.disabled.add(row.name); + } + + /** + * The refusal SENTENCE a disabled action is answered with, produced once + * here so both dispatch doors state it identically (the flow twin's + * `describeDisabledFlow` shape). + * + * It names the ledger, the ADR and the remedies — and ⛔ never a clone: + * action-clone is not chartered (ADR-0126 §8 item 2), so recommending one + * would advertise machinery that does not exist. Authoring an ordinary + * sibling action is open exactly as it is today, and that is what it says. + */ + describeDisabled(name: string): string { + return ( + `Action '${name}' is disabled — it is switched off for this installation in the packaged-metadata ` + + `activation ledger (sys_metadata_activation, ADR-0126 §8). Re-enable the packaged action to arm it again, ` + + `or author your own action instead.` + ); + } +} diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 5a8b87de01..d45aff81c2 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -39,6 +39,9 @@ import { // `packages/spec/src/data/bulk-write-hook-conformance.ts` so BOTH phases and // both verbs enforce one definition; the engine raises, the contract decides. import { MAX_BULK_PER_ROW_HOOK_ROWS, resolveBulkPerRowHookBudget } from '@objectstack/spec/data'; +// [ADR-0126 §8] The packaged-action activation ledger: its row contract, its +// stores and the engine-held projection the dispatch doors consult. +import { ActionActivationProjection, type ActionActivationRow, type ActionActivationStore } from './action-activation.js'; import { assertListComparandShapes, assertFilterIsMaterializable } from './filter-comparand-shape.js'; import { assertTemporalComparandsInterpretable } from './temporal-comparand-door.js'; // Seek pagination for the walks that must read EVERY row — the autonumber seed @@ -2270,6 +2273,20 @@ export class ObjectQL implements IObjectQLEngine { // Action registry: key = "objectName:actionName" private actions = new Map Promise | any; package?: string }>(); + /** + * [ADR-0126 §8] The packaged-ACTION activation ledger's local projection — + * which declared actions this installation has switched OFF. + * + * It sits beside the handler registry above deliberately: the ledger governs + * what that map is allowed to dispatch, and ADR-0110 D5 already settled that + * action-adjacent boot work belongs to the engine plugin (the one component + * unconditionally present wherever actions execute). Hydrated at boot by + * `ObjectQLPlugin`; consulted by the dispatch doors through + * {@link isActionEnabled}. See `./action-activation.ts` for the row contract + * and for why identity is the declarative action NAME. + */ + private readonly actionActivation = new ActionActivationProjection(); + // Function registry: name → handler. Used by `bindHooksToEngine` to // resolve string-named hook handlers (the JSON-safe form). Populated by // `defineStack({ functions })` via `AppPlugin`, or directly via @@ -3090,6 +3107,78 @@ export class ObjectQL implements IObjectQLEngine { } } + // ======================================== + // [ADR-0126 §8] Packaged-action activation + // ======================================== + // + // Five thin members over {@link ActionActivationProjection}. They are on the + // engine — rather than reached as a free-standing service — because the + // dispatch doors already hold `ql` and nothing else they hold is + // per-environment, unconditional and alive at boot at the same time. + // + // ⛔ None of them is a consult POINT. `executeAction` above deliberately does + // not call `isActionEnabled`: it receives a HANDLER KEY (ADR-0110 D2 — the + // key may be the declaration's `target`, not its `name`), and the ledger + // addresses an action by its declarative name, so a check there would ask a + // question the arguments cannot answer and would miss every target-bound + // action. The consult lives where a resolved DECLARATION exists — the two + // dispatch doors in `@objectstack/runtime` — and each one is pinned. + + /** + * Attach the durable activation ledger (ADR-0126 §4). Hosts call this at + * start(), once the datasource behind `sys_metadata_activation` is reachable + * — see `ObjectQLPlugin`, which probes the table before attaching so a + * missing ledger degrades visibly instead of at the first flip. + */ + setActionActivationStore(store: ActionActivationStore): void { + this.actionActivation.attach(store); + } + + /** + * [ADR-0126 §8] Load the ledger into the engine's projection, returning the + * names it switched off so the host can report them in its boot audit. + * + * Called once per boot AFTER the authored-action re-sync, for the reason the + * governance inventory runs there: the registry is final for the boot, so the + * audit line describes the deployment that will actually serve requests. + */ + async hydrateActionActivations(): Promise { + return this.actionActivation.hydrate(); + } + + /** + * Is this DECLARED action armed for this installation (ADR-0126 §8)? + * Absence of a ledger row means the packaged default — active — so a stock + * boot answers `true` for everything, exactly as it always has. + */ + isActionEnabled(actionName: string): boolean { + return this.actionActivation.isEnabled(actionName); + } + + /** Every action name the ledger currently switches off (operability reads). */ + listDisabledActions(): string[] { + return this.actionActivation.disabledNames(); + } + + /** + * The `ACTION_DISABLED` refusal SENTENCE, produced by the projection so both + * dispatch doors state one refusal rather than two that agree today. + */ + describeDisabledAction(actionName: string): string { + return this.actionActivation.describeDisabled(actionName); + } + + /** + * [ADR-0126 §8] Flip a packaged action's activation — the durable row FIRST, + * the projection only after that write returns. Throws (503 + * `SERVICE_UNAVAILABLE`) when no ledger is attached rather than reporting a + * durable switch that did not persist; the write AUTHORITY (§5) is enforced + * one tier up, at the route, because it needs the caller. + */ + async setActionActive(row: ActionActivationRow): Promise { + await this.actionActivation.setActive(row); + } + /** * "Which of these tombstoned `sys_file` rows is something still holding?" * (#11427) — supplied by the storage plugin, never derived here. diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 1d87a9be9b..979c4e60f9 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -15,6 +15,22 @@ export { } from './action-governance.js'; export type { GovernanceLogger } from './action-governance.js'; +// [ADR-0126 §8] Packaged-action activation ledger — the row contract, the two +// stores and the engine-held projection. The dispatch doors in +// @objectstack/runtime consult the projection through the engine; the plugin +// below attaches the durable store. +export { + ACTION_ACTIVATION_TABLE, + InMemoryActionActivationStore, + ObjectStoreActionActivationStore, + ActionActivationProjection, +} from './action-activation.js'; +export type { + ActionActivationRow, + ActionActivationStore, + ActionActivationStoreEngine, +} from './action-activation.js'; + // Export Registry export { SchemaRegistry, diff --git a/packages/objectql/src/plugin-action-activation.test.ts b/packages/objectql/src/plugin-action-activation.test.ts new file mode 100644 index 0000000000..6881d78620 --- /dev/null +++ b/packages/objectql/src/plugin-action-activation.test.ts @@ -0,0 +1,169 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#12160] ADR-0126 §8 — how the engine plugin ATTACHES the packaged-action +// activation ledger, and what each failure to attach costs. +// +// The projection and the row contract are pinned in `action-activation.test.ts`. +// What is pinned here is the boot DECISION, which has three distinct outcomes +// that must not collapse into one: +// +// 1. **The ledger object is not part of this composition.** Ordinary: the +// object is registered by whichever composition consumes it. Nothing is +// read — deliberately, because `find` against a table that does not exist +// is a driver fault the ENGINE logs at `error` on its way out, and a boot +// that never asked for this capability must not print one. +// 2. **Registered, but unreadable.** A real misconfiguration (schema sync did +// not run). The store is NOT attached, so the write door refuses loudly +// instead of reporting a durable flip that reverts on restart. +// 3. **Registered and readable.** Attach, hydrate, and say in the boot log +// which actions this installation will refuse. +// +// And one property that cuts across them: a failed attach is **not a recorded +// verdict**. `metadata:reloaded` re-attempts it, so a ledger object registered +// after `kernel:ready` is picked up rather than written off by a read taken one +// moment too early (AGENTS.md, "Startup registry reads"). + +import { describe, it, expect, vi } from 'vitest'; + +import { ObjectQLPlugin } from './plugin.js'; +import type { ObjectQL } from './engine.js'; + +type AnyRecord = Record; + +const TABLE = 'sys_metadata_activation'; + +function makeQl(opts: { registered?: boolean; rows?: AnyRecord[]; findThrows?: boolean } = {}) { + const setActionActivationStore = vi.fn(); + const find = vi.fn(async () => { + if (opts.findThrows) throw new Error(`no such table: ${TABLE}`); + return opts.rows ?? []; + }); + const ql: AnyRecord = { + find, + insert: vi.fn(), + update: vi.fn(), + setActionActivationStore, + // The real engine's projection, reduced to what the plugin drives. + _disabled: [] as string[], + async hydrateActionActivations() { + const rows = await find(); + const off = rows.filter((r: AnyRecord) => r.active === false).map((r: AnyRecord) => r.name); + (ql._disabled as string[]).push(...off); + return off; + }, + registry: { + getObject: (name: string) => (opts.registered && name === TABLE ? { name } : undefined), + }, + }; + return { ql, setActionActivationStore, find }; +} + +const makeCtx = () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + getService: vi.fn(() => { throw new Error('no service'); }), + hook: vi.fn(), +}) as AnyRecord; + +const attach = (ql: AnyRecord, ctx: AnyRecord) => + (new ObjectQLPlugin({ ql: ql as unknown as ObjectQL }) as AnyRecord).hydrateActionActivations(ctx); + +describe('ObjectQLPlugin — attaching the packaged-action activation ledger', () => { + it('reads NOTHING when the ledger object is not registered here', async () => { + const { ql, setActionActivationStore, find } = makeQl({ registered: false }); + const ctx = makeCtx(); + + await attach(ql, ctx); + + // The load-bearing assertion. A probe here would make the engine log a + // driver fault at `error` on every boot of every composition that does + // not use this capability — training operators to skim `error`, which + // is the over-application AGENTS.md warns about. + expect(find).not.toHaveBeenCalled(); + expect(setActionActivationStore).not.toHaveBeenCalled(); + expect(ctx.logger.warn).not.toHaveBeenCalled(); + expect(ctx.logger.error).not.toHaveBeenCalled(); + expect(ctx.logger.debug).toHaveBeenCalled(); + }); + + it('WARNS and does not attach when the ledger is registered but unreadable', async () => { + const { ql, setActionActivationStore } = makeQl({ registered: true, findThrows: true }); + const ctx = makeCtx(); + + await attach(ql, ctx); + + expect(setActionActivationStore).not.toHaveBeenCalled(); + expect(ctx.logger.warn).toHaveBeenCalledTimes(1); + const [message] = ctx.logger.warn.mock.calls[0]; + // The consequence, concretely, and the fix — the two things a + // degradation line owes. + expect(message).toContain(TABLE); + expect(message).toContain('UNAVAILABLE'); + expect(message).toContain('schema sync'); + // ⛔ Not `error`: nothing claimed to have persisted, and the write door + // refuses at the moment a durability claim would be made. + expect(ctx.logger.error).not.toHaveBeenCalled(); + }); + + it('attaches, hydrates and NAMES what the installation will refuse', async () => { + const { ql, setActionActivationStore } = makeQl({ + registered: true, + rows: [ + { metadata_type: 'action', name: 'convert_lead', package_id: 'crm', active: false }, + { metadata_type: 'action', name: 'send_quote', package_id: 'crm', active: true }, + ], + }); + const ctx = makeCtx(); + + await attach(ql, ctx); + + expect(setActionActivationStore).toHaveBeenCalledTimes(1); + const info = ctx.logger.info.mock.calls.map(([m]: [string]) => m).join('\n'); + expect(info).toContain('convert_lead'); + expect(ctx.logger.warn).not.toHaveBeenCalled(); + }); + + it('says nothing at all on a stock boot — an empty ledger changes nothing anywhere', async () => { + const { ql, setActionActivationStore } = makeQl({ registered: true, rows: [] }); + const ctx = makeCtx(); + + await attach(ql, ctx); + + expect(setActionActivationStore).toHaveBeenCalledTimes(1); + expect(ctx.logger.info).not.toHaveBeenCalled(); + expect(ctx.logger.warn).not.toHaveBeenCalled(); + }); + + it('a failed attach is NOT a verdict — a later reload picks the ledger up', async () => { + // One plugin instance across both attempts, which is what a real boot + // has: `kernel:ready` fires first, `metadata:reloaded` later. + let registered = false; + const setActionActivationStore = vi.fn(); + const rows = [{ metadata_type: 'action', name: 'convert_lead', package_id: 'crm', active: false }]; + const ql: AnyRecord = { + find: vi.fn(async () => rows), + insert: vi.fn(), update: vi.fn(), + setActionActivationStore, + hydrateActionActivations: vi.fn(async () => ['convert_lead']), + registry: { getObject: (name: string) => (registered && name === TABLE ? { name } : undefined) }, + }; + const plugin = new ObjectQLPlugin({ ql: ql as unknown as ObjectQL }) as AnyRecord; + const ctx = makeCtx(); + + await plugin.hydrateActionActivations(ctx); + expect(setActionActivationStore).not.toHaveBeenCalled(); + + // The composition registers the object later — a reload, a package + // install, a plugin that starts after `kernel:ready`. + registered = true; + await plugin.hydrateActionActivations(ctx); + + expect(setActionActivationStore).toHaveBeenCalledTimes(1); + expect(ql.hydrateActionActivations).toHaveBeenCalledTimes(1); + + // …and it does not re-attach on every subsequent reload: once the + // projection IS the ledger, a repeat read would be noise. + await plugin.hydrateActionActivations(ctx); + expect(setActionActivationStore).toHaveBeenCalledTimes(1); + expect(ql.hydrateActionActivations).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 2e792b8a95..c33b31a470 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -10,6 +10,14 @@ import { LifecycleService } from './lifecycle/lifecycle-service.js'; import { lifecycleSettingsManifest } from './lifecycle/lifecycle-settings.js'; import type { DanglingReferenceAuditOptions } from './integrity/dangling-reference-audit.js'; import { runActionGovernanceInventory } from './action-governance.js'; +// [ADR-0126 §8] The packaged-action activation ledger's durable store. The +// engine holds the projection; this plugin is what attaches the store and +// hydrates it once the deployment has finished registering objects. +import { + ObjectStoreActionActivationStore, + ACTION_ACTIVATION_TABLE, + type ActionActivationStoreEngine, +} from './action-activation.js'; import type { IMetadataService } from '@objectstack/spec/contracts'; import type { ServiceObject } from '@objectstack/spec/data'; @@ -496,6 +504,17 @@ export class ObjectQLPlugin implements Plugin { await this.reconcileFederatedBindings(ctx); await this.resyncAuthoredHooks(ctx); await this.resyncAuthoredActions(ctx); + // [ADR-0126 §8] Apply the packaged-ACTION activation ledger to the + // registry that re-sync just finalized. + // + // Ordering is load-bearing, and for the same reason the inventory below + // runs here: after `resyncAuthoredActions` the set of dispatchable + // actions is final for this boot, so the audit line names the actions + // this process will actually refuse. Running at `kernel:ready` (not in + // `start()`) is what lets the ledger's own object — registered by + // whichever composition uses it, and schema-synced by its own start — + // exist before the table is read. + await this.hydrateActionActivations(ctx); // [ADR-0110 D5] Governance inventory — AFTER the authored-action // re-sync, so the registry it audits is final for this boot. It lived // in AppPlugin first, which is registered conditionally; on the `os @@ -525,6 +544,12 @@ export class ObjectQLPlugin implements Plugin { ctx.hook('metadata:reloaded', async (payload?: unknown) => { await this.resyncAuthoredHooks(ctx); await this.resyncAuthoredActions(ctx); + // [ADR-0126 §8] Re-attempt the ledger attach — a no-op once attached. + // A reload is exactly when an object that was not registered at + // `kernel:ready` becomes visible, so this is what keeps that earlier + // "not there" from hardening into a recorded verdict nothing revisits + // (AGENTS.md, startup registry reads). + await this.hydrateActionActivations(ctx); // 15.1 third-party eval: an object added while `os dev` runs was // invisible until a manual restart. Two gaps compounded: // 1. MetadataPlugin's artifact reload ingests through @@ -2249,6 +2274,116 @@ export class ObjectQLPlugin implements Plugin { */ private lastGovernanceFingerprint = ''; + /** + * [ADR-0126 §8] Whether the packaged-action activation ledger is attached to + * the engine yet. `false` is NOT a verdict — it means "not yet", and the + * attempt is repeated on every `metadata:reloaded`, which is when an object + * registered after `kernel:ready` becomes visible. + */ + private actionActivationAttached = false; + + /** + * [ADR-0126 §8 item 2] Attach the packaged-action activation ledger and load + * it into the engine's projection. + * + * ## The two failure modes are DIFFERENT facts and are logged differently + * + * 1. **The ledger table cannot be read at all** (`probe()` throws). The store + * is NOT attached, so nothing can claim a durable flip: the write door + * refuses loudly (503) at the moment an administrator tries one, instead + * of reporting a switch that silently reverts on restart. That is a + * FUNCTIONAL degradation — a capability this deployment does not have — + * so it is `warn` per the AGENTS.md degradation-log-level rule. It is + * deliberately not `error`: `sys_metadata_activation` is registered by + * whichever composition consumes it (the automation service registers it + * today), so an ObjectQL host without that composition is a legitimate + * deployment, and an `error` on every one of them is exactly the + * over-application that trains operators to skim `error`. + * + * 2. **The table reads, but the hydrate read fails.** Now rows may exist and + * the projection is empty, so every action is ARMED — including any an + * administrator switched off. Persisted state and runtime state disagree + * while everything looks healthy, which is the durability arm of the same + * rule: `error`, naming the consequence. The flow twin logs its identical + * case identically. + * + * Never fatal either way: a boot that cannot read the ledger still serves, + * and says what it lost. + */ + private async hydrateActionActivations(ctx: PluginContext): Promise { + const ql: any = this.ql; + if (!ql || typeof ql.hydrateActionActivations !== 'function') return; + // Idempotent: once a ledger is attached, the projection IS the ledger and a + // later reload has nothing to redo. + if (this.actionActivationAttached) return; + + // ── Is the ledger even part of this composition? ──────────────────────── + // Asked of the object registry BEFORE any read, for two reasons: + // + // 1. `find` on a table that does not exist is a driver FAULT, and the + // engine logs it at `error` on its way out. A composition that simply + // does not register `sys_metadata_activation` (no automation service + // — that plugin is the object's registrant today) would print that + // error on every single boot, for a capability it never asked for. + // An unregistered object is a fact this can read without touching the + // datasource at all. + // 2. It splits the two cases the log levels below distinguish: absent + // from this composition (ordinary, `debug`) versus registered but + // unreadable (a real misconfiguration, `warn`). + // + // ⚠️ The absence is NOT recorded as a verdict (AGENTS.md "startup registry + // reads"): nothing is cached, and `metadata:reloaded` calls this again, so + // a ledger object registered later in the process is picked up then rather + // than being permanently written off by a read taken too early. + let registered = false; + try { + registered = Boolean(ql.registry?.getObject?.(ACTION_ACTIVATION_TABLE)); + } catch { + registered = false; // a registry that cannot answer is not an answer + } + if (!registered) { + ctx.logger.debug?.( + `[ObjectQLPlugin] no ${ACTION_ACTIVATION_TABLE} object in this composition — packaged-action enable/disable ` + + 'is not available here (ADR-0126 §8). Nothing was read; this is re-checked on the next metadata reload.', + ); + return; + } + + const store = new ObjectStoreActionActivationStore(ql as ActionActivationStoreEngine); + try { + await store.probe(); + ql.setActionActivationStore(store); + this.actionActivationAttached = true; + } catch (e: unknown) { + ctx.logger.warn( + `[ObjectQLPlugin] ${ACTION_ACTIVATION_TABLE} is registered but could not be read — packaged-ACTION ` + + 'enable/disable is UNAVAILABLE on this deployment (ADR-0126 §8): no action can be switched off, and an ' + + 'attempt to flip one is refused rather than kept in process. Check that schema sync ran for its datasource; ' + + "the driver's own failure is in this record's meta.", + { error: e instanceof Error ? e.message : String(e) }, + ); + return; + } + + try { + const off = await ql.hydrateActionActivations(); + if (Array.isArray(off) && off.length > 0) { + ctx.logger.info( + `[ObjectQLPlugin] Activation ledger: ${off.length} packaged action(s) are switched off for this ` + + `installation and will be refused at dispatch — ${off.map((n: string) => `'${n}'`).join(', ')}.`, + ); + } + } catch (e: unknown) { + ctx.logger.error( + '[ObjectQLPlugin] the packaged-action activation ledger could not be read — every declared action is ARMED, ' + + 'including any an administrator switched off, and dispatch will look completely normal. The ledger read ' + + "failure is in this record's meta.", + undefined, + { error: e instanceof Error ? e.message : String(e) }, + ); + } + } + /** * [ADR-0110 D5] Audit the engine's action-handler registry against the * declarations it can dispatch for, and warn about the orphans on both diff --git a/packages/qa/dogfood/test/action-params-contract.dogfood.test.ts b/packages/qa/dogfood/test/action-params-contract.dogfood.test.ts index 20fe20d58a..bc0188669a 100644 --- a/packages/qa/dogfood/test/action-params-contract.dogfood.test.ts +++ b/packages/qa/dogfood/test/action-params-contract.dogfood.test.ts @@ -69,4 +69,48 @@ describe('dogfood: action param contract enforced at dispatch (ADR-0104 D2)', () delete process.env.OS_ALLOW_LAX_ACTION_PARAMS; } }); + + // ── [#12160] The ADR-0126 §8 activation door, on the REAL mount ─────────── + // + // `POST /actions/_activation/:object/:action` is NOT a registered pattern. + // Nothing mounts it: a 3-segment activation path is matched by + // `/api/v1/actions/:object/:action/:recordId` with `_activation` bound to + // `:object`, and it only reaches the activation arm because that mount + // rebuilds the dispatch path from its matched params, byte for byte. The + // route ledger says so with `servedBy`, and #7526's parity gate holds that + // declaration to the live router. + // + // What the ledger row CANNOT say is that the arm still answers, and that is + // the half worth pinning here: the unit suites drive `handleActionsRequest` + // directly, so they would stay green if the mount ever stopped forwarding + // the path faithfully (passing structured params instead, say) and every + // activation call started being served as an INVOCATION of an action named + // after the object segment. These two assertions fail the moment that + // happens, because both messages are ones only the activation arm produces. + describe('[#12160] the reserved `_activation` segment survives the real mount', () => { + it('the 2-segment shape answers the activation door, not an invocation', async () => { + const res = await stack.apiAs(token, 'POST', '/actions/_activation/showcase_mark_done', {}); + const text = await res.text(); + + // The invocation path's answer for this URL would be a 404 naming a + // missing DECLARATION (`add \`defineAction\``) for action + // `showcase_mark_done` on object `_activation`. + expect(res.status, text).toBe(400); + expect(text).toContain('/actions/_activation/:object/:action'); + }); + + it('the 3-segment shape reaches the activation door for a real object', async () => { + const res = await stack.apiAs( + token, 'POST', '/actions/_activation/showcase_task/no_such_action', { enabled: false }, + ); + const text = await res.text(); + + expect(res.status, text).toBe(404); + // The activation door's own wording — the invocation door's 404 for an + // undeclared action says `add \`defineAction\`` instead, so this + // distinguishes "my arm ran" from "swallowed by the sibling". + expect(text).toContain('nothing to switch off'); + expect(text).not.toContain('defineAction'); + }); + }); }); diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index ced98a8d8b..20ce1f47b5 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -407,6 +407,90 @@ export function actionPermissionError(_deps: ActionExecutionDeps, actionDef: any ); } +/** + * [ADR-0126 §8 item 2] The activation refusal a DISABLED packaged action is + * answered with — `409 ACTION_DISABLED`. + * + * ## Why 409, and why its own code + * + * The artifact exists and is well-formed; only its current STATE conflicts with + * running it, and flipping the switch makes the identical request succeed — + * 409's meaning, and the same reading `FLOW_DISABLED` got in the #9378 table. + * The code is the action's own rather than a borrowed `FLOW_DISABLED`: a + * `script` action refused under a code naming a flow would send an operator + * looking for a flow that does not exist, and a machine-readable surface must + * not lie about which artifact it is talking about (Route & surface ownership + * rule 4). It joins the `*_DISABLED` family already registered in the ADR-0112 + * ledger (`FLOW_DISABLED`, `OBJECT_API_DISABLED`, `OBJECT_PACKAGE_DISABLED`) — + * one census row, no new condition class. + */ +export const ACTION_DISABLED_CODE = 'ACTION_DISABLED'; +export const ACTION_DISABLED_STATUS = 409; + +/** The shape both doors serve — the wire envelope's three fields (ADR-0112). */ +export interface DisabledActionRefusal { + code: typeof ACTION_DISABLED_CODE; + status: typeof ACTION_DISABLED_STATUS; + message: string; +} + +/** + * [ADR-0126 §8 item 2] THE CONSULT POINT for the action activation ledger — + * asked by every door that dispatches a DECLARED action. + * + * ## Where this sits, and why it is not one seam deeper + * + * The flow leg could put its consult at `execute()`, "the one seam every entry + * path crosses". Actions have no such seam that can answer the question: the + * two per-type primitives below it (`executeRegisteredAction` → + * `ql.executeAction`, and `dispatchFlowAction`) are addressed by HANDLER KEY + * and by target flow name respectively, and ADR-0110 D2 is explicit that a + * registration key is NOT an action's identity ("`AppPlugin` auto-registers + * body actions under `name`, while user code registers a target-bound script + * action under `target`"). The ledger addresses the declarative NAME, so the + * consult belongs exactly where a resolved DECLARATION exists: the REST + * `/actions` door and the MCP `run_action` bridge, each pinned by its own test. + * Placing it lower would silently miss every target-bound action — a gate that + * looks present and is not. + * + * ⚠️ ObjectQL's `ScopedRepo.execute()` — a hook/action BODY reaching another + * handler in-process via `ctx.api.object(x).execute(...)` — is the third + * `executeAction` caller and is deliberately NOT a consult point: it dispatches + * by key with no declaration, carries no caller identity, and is package code + * calling package code (the class ADR-0126 §2 keeps outside the model for + * `hook`). Recorded rather than left to be discovered. + * + * ## Ordering: authorization first, activation second + * + * Both doors call this AFTER the ADR-0066 D4 capability gate. An unentitled + * caller therefore learns nothing about which packaged actions this + * installation has switched off — the same reason `/automation`'s gates run + * ahead of its service probe (a 403 must not become an oracle). + * + * Returns `undefined` when the action may run. An engine that cannot answer + * (no `isActionEnabled` — a host on an older engine, or a test double) yields + * `undefined` too: absence of a ledger means the packaged default, ACTIVE + * (ADR-0126 §4), which is what a stock boot has always done. + */ +export function disabledActionRefusal( + _deps: ActionExecutionDeps, + ql: any, + actionDef: any, +): DisabledActionRefusal | undefined { + const name = actionDef?.name; + if (typeof name !== 'string' || name === '') return undefined; + if (typeof ql?.isActionEnabled !== 'function') return undefined; + if (ql.isActionEnabled(name) !== false) return undefined; + + // The SENTENCE is the engine's (`describeDisabledAction`), so both doors + // state one refusal instead of two that agree today; the fallback covers an + // engine that answers the boolean but not the prose. + const message = typeof ql.describeDisabledAction === 'function' + ? String(ql.describeDisabledAction(name)) + : `Action '${name}' is disabled for this installation (ADR-0126 §8).`; + return { code: ACTION_DISABLED_CODE, status: ACTION_DISABLED_STATUS, message }; +} + /** * [#2849 / ADR-0011] AI-exposure gate for the MCP action surface. Returns a * human-readable error string unless the action's author explicitly opted it @@ -1160,6 +1244,28 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps, const gateError = actionPermissionError(deps, action, ec, objectName); if (gateError) throw new Error(gateError); + // [ADR-0126 §8 item 2] ACTIVATION CONSULT — door 2 of 2. A packaged action + // the installation switched off is refused here, before the param contract + // and before the subject record is read: nothing about a disabled action + // should run, and an agent must not be able to probe its param shape. + // + // The engine is resolved for this — the same `getObjectQL` the script + // branch below uses — because the projection lives on it (ADR-0110 D5's + // "the engine plugin is the component unconditionally present wherever + // actions execute"). A flow-type action is refused here too: the switch is + // the ACTION's, independent of whatever its target flow's own ledger row + // says, so this must sit AHEAD of the type branch. + { + const activationEngine: any = await deps.getObjectQL(requestContext, envId).catch(() => undefined); + const refusal = disabledActionRefusal(deps, activationEngine, action); + // Thrown with `code` + `status` so the ADR-0112 envelope survives the + // bridge: `resolveThrownHttpError` reads both, and the MCP tool surface + // gets a clean tool-error instead of a 500 (the #7535/#8055 shape). + if (refusal) { + throw Object.assign(new Error(refusal.message), { code: refusal.code, status: refusal.status }); + } + } + // [ADR-0104 D2] Declared param contract — same enforcement as the REST // route. AI/MCP is the caller most likely to send a plausible-but-wrong // bag, and a rejection is corrective feedback the agent consumes in-loop, diff --git a/packages/runtime/src/domains/action-activation-dispatch.test.ts b/packages/runtime/src/domains/action-activation-dispatch.test.ts new file mode 100644 index 0000000000..3f06d74cb4 --- /dev/null +++ b/packages/runtime/src/domains/action-activation-dispatch.test.ts @@ -0,0 +1,276 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#12160] ADR-0126 §8 item 2 — a packaged ACTION switched off for this +// installation is refused AT DISPATCH, on every door that dispatches one. +// +// ## Why this file is organised per DOOR +// +// The flow leg could pin its consult once: `execute()` is "the one seam every +// entry path crosses". Actions have no such seam that can answer the question — +// the two primitives underneath (`ql.executeAction`, `dispatchFlowAction`) are +// addressed by HANDLER KEY and by target flow name, and ADR-0110 D2 is explicit +// that a registration key is not an action's identity. So the consult sits +// where a resolved DECLARATION exists — the REST `/actions` route and the MCP +// `run_action` bridge — and the ADR's own rule for that shape is "a test per +// door". Both doors call ONE function (`disabledActionRefusal`), which is what +// keeps them from drifting; these tests are what prove each one calls it. +// +// ## What each case asserts, and why the negative half is not optional +// +// Every refusal asserts `status` AND `code` (the ADR-0112 envelope) AND that +// nothing dispatched — a gate that refused after the handler ran would satisfy +// a status-only assertion and still be the defect. And the enabled-by-absence +// cases are pinned just as hard: a stock boot has no ledger rows, so a +// projection that defaulted the other way would switch off every action in +// every deployment, which is the one regression this feature could cause. + +import { describe, it, expect, vi } from 'vitest'; +import type { AutomationResult } from '@objectstack/spec/contracts'; + +import { HttpDispatcher } from '../http-dispatcher.js'; +import type { HttpProtocolContext } from '../http-dispatcher.js'; +import { invokeBusinessAction, disabledActionRefusal } from '../action-execution.js'; + +const OBJECT = 'crm_lead'; +/** + * A script action the MCP bridge can actually reach: it needs a handler + * binding (`target` or `body`) to be headless-invokable, and `ai.exposed` to + * clear the #2849 exposure gate. Both are gates the activation consult sits + * BEHIND, so a fixture that failed them would test nothing about the ledger — + * it would only re-test the gates in front of it. + */ +const SCRIPT_ACTION = { + name: 'convert_lead', label: 'Convert Lead', objectName: OBJECT, type: 'script', + target: 'convert_lead_impl', + ai: { exposed: true, description: 'Convert a qualified lead.' }, +}; +const FLOW_ACTION = { + name: 'route_lead', label: 'Route Lead', objectName: OBJECT, type: 'flow', target: 'crm_lead_router', +}; +const GATED_ACTION = { + name: 'purge_lead', label: 'Purge Lead', objectName: OBJECT, type: 'script', + requiredPermissions: ['manage_data'], +}; + +/** + * One deployment, both doors — the shape the real composition presents: the + * engine carries the activation projection (ADR-0110 D5's "the engine plugin is + * the component unconditionally present wherever actions execute"), and every + * door reaches it through the `ql` it already holds. + * + * `disabled` is the ledger's answer, spelled as the projection's own two + * members so the doubles cannot be looser than the engine they stand in for. + * `legacyEngine` drops both members entirely — an older engine, or a host that + * never attached a store — which must behave exactly like a stock boot. + */ +function boot(opts: { disabled?: string[]; legacyEngine?: boolean; flowResult?: AutomationResult } = {}) { + const disabled = new Set(opts.disabled ?? []); + const executeAction = vi.fn(async () => ({ ran: 'script' })); + const execute = vi.fn(async (): Promise => opts.flowResult ?? { success: true, output: {} }); + + const objectDef = { name: OBJECT, actions: [SCRIPT_ACTION, FLOW_ACTION, GATED_ACTION] }; + const ql: any = { + executeAction, + getSchema: (name: string) => (name === OBJECT ? objectDef : undefined), + registry: { getObject: (name: string) => (name === OBJECT ? objectDef : undefined), getItem: () => undefined }, + find: vi.fn(async () => []), + insert: vi.fn(), update: vi.fn(), delete: vi.fn(), + }; + if (!opts.legacyEngine) { + ql.isActionEnabled = (name: string) => !disabled.has(name); + ql.describeDisabledAction = (name: string) => + `Action '${name}' is disabled — it is switched off for this installation in the packaged-metadata ` + + `activation ledger (sys_metadata_activation, ADR-0126 §8). Re-enable the packaged action to arm it again, ` + + `or author your own action instead.`; + } + + const metadata: any = { + load: vi.fn(async () => null), + loadDiagnosed: vi.fn(async () => ({ data: null, degraded: false, errors: [] })), + loadMany: vi.fn(async () => []), + listObjects: vi.fn(async () => [objectDef]), + getObject: vi.fn(async () => objectDef), + }; + const automation: any = { handlerReady: true, execute, getFlow: vi.fn(async () => ({ name: FLOW_ACTION.target })) }; + const resolve = (n: string) => + n === 'objectql' || n === 'data' ? ql + : n === 'metadata' ? metadata + : n === 'automation' ? automation + : null; + const kernel: any = { + getService: resolve, + getServiceAsync: async (n: string) => resolve(n), + context: { getService: resolve }, + }; + return { dispatcher: new HttpDispatcher(kernel), ql, executeAction, execute, metadata }; +} + +const CTX = (systemPermissions: string[] = []): HttpProtocolContext => ({ + request: {}, + environmentId: 'platform', + executionContext: { userId: 'u_member', systemPermissions }, +} as unknown as HttpProtocolContext); + +const statusOf = (r: any): unknown => r?.response?.status; +const codeOf = (r: any): unknown => r?.response?.body?.error?.code ?? r?.response?.body?.error?.details?.code; +const messageOf = (r: any): string => String(r?.response?.body?.error?.message ?? ''); + +describe('door 1 — REST `POST /actions/:object/:action`', () => { + it('refuses a DISABLED packaged action 409 ACTION_DISABLED, and never dispatches it', async () => { + const { dispatcher, executeAction } = boot({ disabled: [SCRIPT_ACTION.name] }); + + const res = await dispatcher.handleActions(`/${OBJECT}/${SCRIPT_ACTION.name}`, 'POST', {}, CTX()); + + expect(statusOf(res)).toBe(409); + expect(codeOf(res)).toBe('ACTION_DISABLED'); + // The load-bearing half: refused BEFORE the handler. A body that ran + // and was then refused would satisfy the two assertions above and still + // be the defect — action bodies execute TRUSTED (RLS/FLS-bypassing). + expect(executeAction).not.toHaveBeenCalled(); + }); + + it('the refusal names the ledger, the ADR and the remedy — ⛔ and never a clone', async () => { + const { dispatcher } = boot({ disabled: [SCRIPT_ACTION.name] }); + + const message = messageOf(await dispatcher.handleActions( + `/${OBJECT}/${SCRIPT_ACTION.name}`, 'POST', {}, CTX(), + )); + + expect(message).toContain('sys_metadata_activation'); + expect(message).toContain('ADR-0126 §8'); + expect(message).toContain('Re-enable the packaged action'); + // Action-clone is NOT chartered (§8 item 2) — the sentence must not + // advertise machinery that does not exist. + expect(message).not.toMatch(/clone/i); + }); + + it('an ENABLED action is untouched — a stock boot dispatches exactly as before', async () => { + const { dispatcher, executeAction } = boot({ disabled: ['some_other_action'] }); + + const res = await dispatcher.handleActions(`/${OBJECT}/${SCRIPT_ACTION.name}`, 'POST', {}, CTX()); + + expect(statusOf(res)).toBe(200); + expect(executeAction).toHaveBeenCalledTimes(1); + }); + + it('an engine with NO projection behaves as a stock boot (absence means ACTIVE)', async () => { + const { dispatcher, executeAction } = boot({ legacyEngine: true }); + + const res = await dispatcher.handleActions(`/${OBJECT}/${SCRIPT_ACTION.name}`, 'POST', {}, CTX()); + + expect(statusOf(res)).toBe(200); + expect(executeAction).toHaveBeenCalledTimes(1); + }); + + it('a disabled `type: flow` action is refused by its OWN switch, and the flow never runs', async () => { + const { dispatcher, execute } = boot({ disabled: [FLOW_ACTION.name] }); + + const res = await dispatcher.handleActions(`/${OBJECT}/${FLOW_ACTION.name}`, 'POST', {}, CTX()); + + // Not FLOW_DISABLED: the target flow is armed, the ACTION is not, and + // answering with the flow's code would send an operator to the wrong + // artifact entirely. + expect(statusOf(res)).toBe(409); + expect(codeOf(res)).toBe('ACTION_DISABLED'); + expect(execute).not.toHaveBeenCalled(); + }); + + it('an unentitled caller still gets 403 — the switch is not an oracle', async () => { + // Ordering pin: the ADR-0066 D4 capability gate runs BEFORE the + // activation consult, so a caller who may not invoke the action cannot + // use the response to learn which packaged actions this installation + // has switched off. + const { dispatcher, executeAction } = boot({ disabled: [GATED_ACTION.name] }); + + const res = await dispatcher.handleActions(`/${OBJECT}/${GATED_ACTION.name}`, 'POST', {}, CTX()); + + expect(statusOf(res)).toBe(403); + expect(codeOf(res)).not.toBe('ACTION_DISABLED'); + expect(executeAction).not.toHaveBeenCalled(); + }); + + it('an entitled caller gets the activation refusal for the same action', async () => { + const { dispatcher, executeAction } = boot({ disabled: [GATED_ACTION.name] }); + + const res = await dispatcher.handleActions( + `/${OBJECT}/${GATED_ACTION.name}`, 'POST', {}, CTX(['manage_data']), + ); + + expect(statusOf(res)).toBe(409); + expect(codeOf(res)).toBe('ACTION_DISABLED'); + expect(executeAction).not.toHaveBeenCalled(); + }); +}); + +describe('door 2 — the MCP `run_action` bridge', () => { + /** + * The bridge's own wiring, as `domains/mcp.ts` assembles it: the resolved + * ObjectQL, the metadata service and the caller's ExecutionContext. Driving + * `invokeBusinessAction` directly is what pins THIS door rather than the + * REST one — the two share the guard, not the call path. + */ + const runViaMcp = async (h: ReturnType, name: string) => { + const deps: any = { + resolveService: async (_ctx: unknown, service: string) => + (service === 'metadata' ? h.metadata : service === 'automation' ? null : h.ql), + getObjectQL: async () => h.ql, + }; + return invokeBusinessAction( + deps, CTX() as any, name, {}, + { + driver: h.ql, + ec: { userId: 'u_agent', systemPermissions: [] }, + getMeta: () => h.metadata, + callData: async () => ({}), + }, + ); + }; + + it('refuses a DISABLED packaged action with the ADR-0112 envelope, and never dispatches', async () => { + const h = boot({ disabled: [SCRIPT_ACTION.name] }); + + const thrown = await runViaMcp(h, SCRIPT_ACTION.name).catch((e) => e); + + expect(thrown).toBeInstanceOf(Error); + // `code` + `status` ride the throw so the bridge answers a clean + // tool-error and `resolveThrownHttpError` serves the same 409 the REST + // door serves — one refusal, two transports. + expect((thrown as any).code).toBe('ACTION_DISABLED'); + expect((thrown as any).status).toBe(409); + expect(String(thrown.message)).toContain('sys_metadata_activation'); + expect(h.executeAction).not.toHaveBeenCalled(); + }); + + it('an ENABLED action still runs through the bridge', async () => { + const h = boot({ disabled: ['some_other_action'] }); + + const result: any = await runViaMcp(h, SCRIPT_ACTION.name); + + expect(result?.ok).toBe(true); + expect(h.executeAction).toHaveBeenCalledTimes(1); + }); +}); + +describe('the shared guard itself', () => { + it('is silent for an engine that cannot answer, and for a nameless declaration', () => { + const deps: any = {}; + + expect(disabledActionRefusal(deps, {}, SCRIPT_ACTION)).toBeUndefined(); + expect(disabledActionRefusal(deps, { isActionEnabled: () => false }, {})).toBeUndefined(); + }); + + it('answers 409 ACTION_DISABLED with the ENGINE\'s own sentence', () => { + const ql = { + isActionEnabled: () => false, + describeDisabledAction: (n: string) => `bespoke sentence for ${n}`, + }; + + const refusal = disabledActionRefusal({} as any, ql, SCRIPT_ACTION); + + expect(refusal).toEqual({ + code: 'ACTION_DISABLED', + status: 409, + message: `bespoke sentence for ${SCRIPT_ACTION.name}`, + }); + }); +}); diff --git a/packages/runtime/src/domains/action-activation-posture-gate.test.ts b/packages/runtime/src/domains/action-activation-posture-gate.test.ts new file mode 100644 index 0000000000..0e10c4df8d --- /dev/null +++ b/packages/runtime/src/domains/action-activation-posture-gate.test.ts @@ -0,0 +1,374 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#12160] ADR-0126 §5 — WRITE AUTHORITY for the packaged-ACTION activation +// switch, `POST /actions/_activation/:object/:action`, plus the door's own +// contract (shape, body, existence, ambiguity, durability). +// +// ## 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 +// `manage_metadata` gate is the right authority. +// - `group` / +// `isolated` — a real multi-organization deployment, where the switch +// crosses tenants. The platform OPERATOR is required. +// +// This is the flow leg's matrix (#12157), run against the action door, because +// ADR-0126 §8 item 2 puts actions under the SAME §5 authority — and since +// 2026-08-25 both doors run ONE gate implementation (`./activation-gate.ts`). +// Testing it per door is what proves each door actually calls it: a shared +// helper nobody invokes is the same hole as no helper at all. +// +// ## What the refusal cases assert +// +// `status` AND `code` (the ADR-0112 envelope), AND that the engine's +// `setActionActive` was never entered — a gate that refused after the ledger +// was written would still be #10243, with persistence. + +import { describe, it, expect, vi } from 'vitest'; + +import { HttpDispatcher } from '../http-dispatcher.js'; +import type { HttpProtocolContext } from '../http-dispatcher.js'; + +const OBJECT = 'crm_lead'; +const ACTION = 'convert_lead'; +const DECLARATION = { name: ACTION, label: 'Convert Lead', objectName: OBJECT, type: 'script', _packageId: 'crm' }; + +interface Harness { + dispatcher: HttpDispatcher; + setActionActive: ReturnType; + executeAction: 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, + opts: { extraObjects?: any[]; setActionActive?: ReturnType; omitLedger?: boolean } = {}, +): Harness { + const setActionActive = opts.setActionActive ?? vi.fn(async () => undefined); + const executeAction = vi.fn(async () => ({ ran: 'script' })); + const objectDef = { name: OBJECT, actions: [DECLARATION], _packageId: 'crm' }; + const objects = [objectDef, ...(opts.extraObjects ?? [])]; + + const ql: any = { + executeAction, + getSchema: (name: string) => objects.find((o) => o.name === name), + registry: { getObject: (name: string) => objects.find((o) => o.name === name), getItem: () => undefined }, + isActionEnabled: () => true, + describeDisabledAction: (n: string) => `Action '${n}' is disabled`, + find: vi.fn(async () => []), insert: vi.fn(), update: vi.fn(), delete: vi.fn(), + }; + if (!opts.omitLedger) ql.setActionActive = setActionActive; + + const metadata: any = { + load: vi.fn(async () => null), + loadDiagnosed: vi.fn(async () => ({ data: null, degraded: false, errors: [] })), + loadMany: vi.fn(async () => []), + listObjects: vi.fn(async () => objects), + getObject: vi.fn(async () => objectDef), + }; + + const services: Record = { objectql: ql, data: ql, metadata }; + if (posture) services.tenancy = { posture }; + const resolve = (name: string): unknown => services[name] ?? null; + const kernel = { + getService: resolve, + getServiceAsync: async (name: string) => resolve(name), + context: { getService: resolve }, + }; + + return { dispatcher: new HttpDispatcher(kernel as never), setActionActive, executeAction }; +} + +/** + * A tenant org admin who DOES hold `manage_metadata` — so the capability tier + * passes and the §5 gate is the only thing left. That is the whole point: the + * two gates ask different questions, and this must not pass merely because the + * other one refused. + */ +const TENANT_ADMIN = (): HttpProtocolContext => ({ + request: {}, + environmentId: 'platform', + executionContext: { + userId: 'u_northwind_owner', + positions: ['org_owner', 'org_admin'], + permissions: ['organization_admin'], + systemPermissions: ['manage_metadata'], + organizationId: 'org_northwind', + }, +} as unknown as HttpProtocolContext); + +/** The same admin WITHOUT the authoring capability — the first tier's subject. */ +const PLAIN_MEMBER = (): HttpProtocolContext => ({ + request: {}, + environmentId: 'platform', + executionContext: { userId: 'u_member', positions: ['org_member'], systemPermissions: [] }, +} as unknown as HttpProtocolContext); + +/** The platform operator (ADR-0068 D2: `platform_admin`, NOT a tenant role). */ +const PLATFORM_OPERATOR = (): HttpProtocolContext => ({ + request: {}, + environmentId: 'platform', + executionContext: { + userId: 'u_saas_operator', + positions: ['platform_admin'], + permissions: ['admin_full_access'], + systemPermissions: ['manage_metadata'], + organizationId: null, + }, +} as unknown as HttpProtocolContext); + +/** Engine self-invocation — never settable from the wire. */ +const SYSTEM = (): HttpProtocolContext => ({ + request: {}, + environmentId: 'platform', + executionContext: { userId: 'usr_system', isSystem: true }, +} as unknown as HttpProtocolContext); + +const statusOf = (r: any): unknown => r?.response?.status; +const codeOf = (r: any): unknown => r?.response?.body?.error?.code ?? r?.response?.body?.error?.details?.code; +const messageOf = (r: any): string => String(r?.response?.body?.error?.message ?? ''); + +const flip = (h: Harness, ctx: HttpProtocolContext, enabled = false, path = `/_activation/${OBJECT}/${ACTION}`) => + h.dispatcher.handleActions(path, 'POST', { enabled }, ctx); + +describe('ADR-0126 §5 — the action 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 res = await flip(h, TENANT_ADMIN()); + + expect(statusOf(res)).toBe(200); + expect(h.setActionActive).toHaveBeenCalledWith({ name: ACTION, packageId: 'crm', active: false }); + }); + + it('so may the platform operator', async () => { + const h = boot('single'); + + expect(statusOf(await flip(h, PLATFORM_OPERATOR()))).toBe(200); + expect(h.setActionActive).toHaveBeenCalled(); + }); + + it('no tenancy service at all behaves like `single` (ADR-0093 D4/D5)', async () => { + const h = boot(null); + + // Refusing here would lock every single-tenant operator out of + // their own switch, and an unenforceable wall resolves to `single`. + expect(statusOf(await flip(h, TENANT_ADMIN()))).toBe(200); + expect(h.setActionActive).toHaveBeenCalled(); + }); + + it('REFUSES a caller without `manage_metadata`, in every posture', async () => { + const h = boot('single'); + + const res = await flip(h, PLAIN_MEMBER()); + + expect(statusOf(res)).toBe(403); + expect(codeOf(res)).toBe('PERMISSION_DENIED'); + expect(messageOf(res)).toContain('manage_metadata'); + expect(h.setActionActive).not.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 writes the row', async () => { + const h = boot(posture); + + const res = await flip(h, TENANT_ADMIN()); + + expect(statusOf(res)).toBe(403); + expect(codeOf(res)).toBe('PERMISSION_DENIED'); + // The load-bearing assertion: refused BEFORE the write. + expect(h.setActionActive).not.toHaveBeenCalled(); + }); + + it('the refusal names the posture, the reason, and what the caller CAN do', async () => { + const h = boot(posture); + + const message = messageOf(await flip(h, TENANT_ADMIN())); + + expect(message).toContain(posture); + expect(message).toContain('INSTALL-WIDE'); + expect(message).toContain('ADR-0126 §5'); + // ⛔ NOT the flow leg's remedy. Action-clone is unchartered + // (§8 item 2), so this refusal points at the operator and at + // authoring a sibling action — never at a clone door that does + // not exist. + expect(message).not.toMatch(/clone/i); + expect(message).toMatch(/platform operator/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); + + expect(statusOf(await flip(h, PLATFORM_OPERATOR()))).toBe(200); + expect(h.setActionActive).toHaveBeenCalled(); + }); + + it('ALLOWS engine self-invocation', async () => { + const h = boot(posture); + + expect(statusOf(await flip(h, SYSTEM()))).toBe(200); + expect(h.setActionActive).toHaveBeenCalled(); + }); + + it('gates ENABLE as well as disable — the switch is install-wide in both directions', async () => { + const h = boot(posture); + + const res = await flip(h, TENANT_ADMIN(), true); + + expect(statusOf(res)).toBe(403); + expect(h.setActionActive).not.toHaveBeenCalled(); + }); + + it('does NOT gate ordinary invocation — this is an activation gate, not an execution one', async () => { + const h = boot(posture); + + const res = await h.dispatcher.handleActions(`/${OBJECT}/${ACTION}`, 'POST', {}, TENANT_ADMIN()); + + // Sweeping a run surface into a metadata gate would lock every + // ordinary user out of the actions built for them — the one + // thing the #10243 ruling did not do. + expect(statusOf(res)).toBe(200); + expect(h.executeAction).toHaveBeenCalled(); + }); + }); + } +}); + +describe('the activation door\'s own contract', () => { + it('`_activation` cannot be read as an invocation — it is not a legal machine name', async () => { + const h = boot('single'); + + // Two segments would be `/:object/:action` on any other path. Machine + // names are `^[a-z][a-z0-9_]*$`, so `_activation` can never BE an + // object; the door owns the whole prefix and says what shape it wants. + const res = await h.dispatcher.handleActions('/_activation/convert_lead', 'POST', {}, TENANT_ADMIN()); + + expect(statusOf(res)).toBe(400); + expect(messageOf(res)).toContain('/actions/_activation/:object/:action'); + expect(h.executeAction).not.toHaveBeenCalled(); + expect(h.setActionActive).not.toHaveBeenCalled(); + }); + + it('a deeper path under `_activation` is still GATED, not silently invoked', async () => { + const h = boot('isolated'); + + const res = await h.dispatcher.handleActions( + `/_activation/${OBJECT}/${ACTION}/extra`, 'POST', { enabled: false }, TENANT_ADMIN(), + ); + + // A gate narrower than its route is a bypass: the predicate has no + // depth bound, so this is refused by the §5 gate rather than reaching + // the shape check as an ungated call. + expect(statusOf(res)).toBe(403); + expect(h.setActionActive).not.toHaveBeenCalled(); + }); + + it('refuses an unknown key rather than silently inverting the caller\'s intent (#3899)', async () => { + const h = boot('single'); + + const res = await h.dispatcher.handleActions( + `/_activation/${OBJECT}/${ACTION}`, 'POST', { enable: false }, TENANT_ADMIN(), + ); + + // `{"enable": false}` — one letter off — must not read as "enable it". + expect(statusOf(res)).toBe(400); + expect(codeOf(res)).toBe('VALIDATION_FAILED'); + expect(h.setActionActive).not.toHaveBeenCalled(); + }); + + it('refuses a non-boolean `enabled`', async () => { + const h = boot('single'); + + const res = await h.dispatcher.handleActions( + `/_activation/${OBJECT}/${ACTION}`, 'POST', { enabled: 'false' }, TENANT_ADMIN(), + ); + + expect(statusOf(res)).toBe(400); + expect(codeOf(res)).toBe('VALIDATION_FAILED'); + expect(h.setActionActive).not.toHaveBeenCalled(); + }); + + it('an empty body ENABLES — the documented legacy shape, same as the flow toggle', async () => { + const h = boot('single'); + + const res = await h.dispatcher.handleActions(`/_activation/${OBJECT}/${ACTION}`, 'POST', {}, TENANT_ADMIN()); + + expect(statusOf(res)).toBe(200); + expect(h.setActionActive).toHaveBeenCalledWith({ name: ACTION, packageId: 'crm', active: true }); + }); + + it('an UNDECLARED action is 404, and writes no row', async () => { + const h = boot('single'); + + const res = await h.dispatcher.handleActions( + `/_activation/${OBJECT}/no_such_action`, 'POST', { enabled: false }, TENANT_ADMIN(), + ); + + // A typo must not read as a server fault (the #7535 shape), and it must + // certainly not mint a ledger row for an artifact that does not exist. + expect(statusOf(res)).toBe(404); + expect(h.setActionActive).not.toHaveBeenCalled(); + }); + + it('REFUSES an ambiguous name instead of switching off artifacts the caller did not name', async () => { + const twin = { + name: 'crm_account', + actions: [{ name: ACTION, label: 'Convert Lead', objectName: 'crm_account', type: 'script' }], + }; + const h = boot('single', { extraObjects: [twin] }); + + const res = await h.dispatcher.handleActions( + `/_activation/${OBJECT}/${ACTION}`, 'POST', { enabled: false }, TENANT_ADMIN(), + ); + + expect(statusOf(res)).toBe(409); + // The standard-catalog member for a state conflict — ⛔ no new code is + // minted for a case the catalog already names. + expect(codeOf(res)).toBe('RESOURCE_CONFLICT'); + expect(messageOf(res)).toContain('crm_account'); + expect(h.setActionActive).not.toHaveBeenCalled(); + }); + + it('reports a write that could not be made durable, instead of a 200', async () => { + const failing = vi.fn(async () => { + throw Object.assign(new Error('no activation ledger is attached to this engine'), { + code: 'SERVICE_UNAVAILABLE', status: 503, + }); + }); + const h = boot('single', { setActionActive: failing }); + + const res = await flip(h, TENANT_ADMIN()); + + // Reporting a durable install-wide switch that never persisted is the + // exact failure ADR-0126 §6 wall 3 exists to close. + expect(statusOf(res)).toBe(503); + expect(codeOf(res)).toBe('SERVICE_UNAVAILABLE'); + }); + + it('says so plainly when the engine has no activation ledger at all', async () => { + const h = boot('single', { omitLedger: true }); + + const res = await flip(h, TENANT_ADMIN()); + + expect(statusOf(res)).toBe(501); + expect(messageOf(res)).toContain('ADR-0126 §8'); + }); +}); diff --git a/packages/runtime/src/domains/actions.ts b/packages/runtime/src/domains/actions.ts index 0144efdd06..9c61cc218d 100644 --- a/packages/runtime/src/domains/actions.ts +++ b/packages/runtime/src/domains/actions.ts @@ -14,6 +14,16 @@ * - `POST /actions/:object/:action/:recordId` — record-scoped action with id in URL * - `POST /actions/global/:action` — object-less ("global") action * - `POST /actions//:action` — object-less action, empty segment + * - `POST /actions/_activation/:object/:action` — [ADR-0126 §8] enable/disable + * the PACKAGED action: one `sys_metadata_activation` row, gated by + * `manage_metadata` + the §5 platform-operator posture rule. The only + * non-invocation shape here, and the only one whose first segment cannot + * be a name (machine names cannot start with `_`). + * + * Every invocation shape above consults that ledger once the declaration is + * resolved: a packaged action switched off for this installation is refused + * `409 ACTION_DISABLED` before anything dispatches (`disabledActionRefusal`, + * `../action-execution.ts`, which the MCP `run_action` bridge calls too). * * The route dispatches on the declared action TYPE (#3915), the same way the * MCP `run_action` bridge does — `script` through the handler registry, @@ -53,10 +63,282 @@ import { } from '@objectstack/core'; import * as actionExec from '../action-execution.js'; import { actorUserFromExecutionContext, resolveActorDisplayName } from '../security/actor-user.js'; -import { validationFailureDetails } from '../validation-failure.js'; +import { validationFailure, validationFailureDetails, VALIDATION_FAILED_STATUS } from '../validation-failure.js'; +// [ADR-0126 §5] The shared activation write-authority gates — the same two +// tiers `/automation`'s toggle door passes, one implementation. +import { + refuseUngrantedActivationWrite, + refuseUngrantedActivationAuthoring, + ACTION_ACTIVATION_SUBJECT, +} from './activation-gate.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; +/** + * [ADR-0126 §8 item 2] The reserved first segment of the activation door, + * `POST /actions/_activation/:object/:action`. + * + * ## Why a leading underscore, and why the FIRST segment + * + * Every other shape this domain serves is an INVOCATION, and the segments are + * caller-supplied names: `/:object/:action`, `/:object/:action/:recordId`. A + * door spelled as a deeper segment (`/:object/:action/toggle`) would sit in the + * `recordId` position, where the value is an arbitrary string — so a record + * whose id is literally `toggle` would collide with it, and the collision would + * be silent. The first segment cannot collide with anything: object and action + * machine names are `SnakeCaseIdentifierSchema`, `^[a-z][a-z0-9_]*$`, which + * cannot begin with `_`. So `_activation` is unreachable as an object name, and + * `/actions/_activation` is unreachable as an object-less invocation of an + * action named `_activation`. + * + * The predicate has **no upper bound on depth**, deliberately, and matches the + * arm exactly: the `/automation` toggle gate records what a mismatch costs — "a + * gate narrower than its route is a bypass and a gate wider than its route is + * an over-block". Everything under `_activation` is gated here; the arm itself + * refuses a wrong-shaped path with a 400 that names the shape. + */ +const ACTION_ACTIVATION_SEGMENT = '_activation'; + +function isActionActivationWrite(parts: string[], method: string): boolean { + return method === 'POST' && parts[0] === ACTION_ACTIVATION_SEGMENT; +} + +/** + * [ADR-0126 §8 item 2] `POST /actions/_activation/:object/:action` — flip one + * packaged action's install-level activation row. + * + * ## What this door writes, and what it deliberately does not + * + * One `sys_metadata_activation` row: `metadata_type: 'action'`, the action's + * declarative NAME, its package, `active`. ⛔ No definition is touched (§6 wall + * 2: `sys_metadata` stays the sole definition ledger), ⛔ no clone is created — + * the action-clone half is NOT chartered (§8 item 2), so this door has no + * sibling that authors anything. + * + * ## Order of operations, and why each step is where it is + * + * 1. **Both authority gates, first.** `manage_metadata` (#10243: switching a + * shipped artifact off is functionally equivalent to deleting it), then the + * ADR-0126 §5 posture gate. Ahead of the body checks and ahead of any + * lookup, so a refused caller writes nothing and learns nothing — neither + * the body contract nor whether the named action exists here. + * 2. **Body contract.** `{ enabled?: boolean }`, unknown keys refused. The + * flow toggle's #3899 lesson verbatim: on an unchecked body `{"enable": + * false}` — one letter off — ENABLED the artifact and answered 200, and the + * caller trying to switch something OFF is exactly the caller this must not + * silently invert. + * 3. **Declaration**, through the SAME `resolveRouteActionDeclaration` the + * invocation door uses, so the two can never disagree about which actions + * exist. Unknown → 404 (the #7535 shape: a typo must not read as a server + * fault), metadata plane unavailable → 503 (an outage is not a verdict). + * 4. **Ambiguity refusal.** The ledger addresses an artifact by machine name + * (§4). Two objects declaring the same action name therefore address ONE + * row, so this refuses instead of silently switching both off. + * 5. **The durable write**, through the engine, which writes the row before + * touching its projection. + * + * ⛔ It does not require the action to be PACKAGED. Neither does the flow + * toggle: a row for a runtime-authored artifact is harmless (absence means + * active, and the row records a real administrator choice), while a + * provenance test here would refuse the flip for anything the registry cannot + * classify — failing closed on the wrong axis. + */ +async function handleActionActivationWrite( + deps: DomainHandlerDeps, + ql: any, + parts: string[], + body: any, + context: HttpProtocolContext, +): Promise { + // ── 1. authority ──────────────────────────────────────────────────────── + const authoringRefusal = refuseUngrantedActivationAuthoring(deps, context, ACTION_ACTIVATION_SUBJECT); + if (authoringRefusal) return authoringRefusal; + const postureRefusal = await refuseUngrantedActivationWrite(deps, context, ACTION_ACTIVATION_SUBJECT); + if (postureRefusal) return postureRefusal; + + // The shape, refused with the shape named. `parts` is + // `['_activation', object, action]` — the object segment is mandatory even + // for an object-less action, which spells it `global` exactly as the + // invocation door does (#3913). + if (parts.length !== 3 || !parts[1] || !parts[2]) { + return { + handled: true, + response: deps.error( + 'Path must be /actions/_activation/:object/:action (use `global` for an object-less action)', + 400, + ), + }; + } + const objectName = parts[1]; + const actionName = parts[2]; + + // ── 2. body ───────────────────────────────────────────────────────────── + // Built through the shared `validationFailure` constructor and its own + // `details` reader, so this door's 400s carry the same `VALIDATION_FAILED` + // envelope and `fields[]` shape as every other one — ⛔ never a hand-rolled + // details literal that agrees with them by eye (#3878/#3899). + const invalidBody = ( + message: string, + fields: Array<{ field: string; code: string; message: string }>, + ): HttpDispatcherResult => ({ + handled: true, + response: deps.error(message, VALIDATION_FAILED_STATUS, validationFailureDetails(validationFailure(message, fields))), + }); + + const toggleBody = body ?? {}; + if (typeof toggleBody !== 'object' || Array.isArray(toggleBody)) { + return invalidBody('Invalid activation body — expected { enabled?: boolean }', [ + { field: '(body)', code: 'invalid_type', message: 'expected an object' }, + ]); + } + const unknownKeys = Object.keys(toggleBody).filter((k) => k !== 'enabled'); + if (unknownKeys.length > 0) { + return invalidBody( + `Unknown key${unknownKeys.length > 1 ? 's' : ''} ${unknownKeys.map((k) => `\`${k}\``).join(', ')} — the activation body is { enabled?: boolean }`, + // `unknown_field` — the ADR-0114 catalog member for "a key the + // target does not declare". + unknownKeys.map((k) => ({ + field: k, + code: 'unknown_field', + message: 'not an activation field — did you mean `enabled`?', + })), + ); + } + if ('enabled' in toggleBody && typeof (toggleBody as Record).enabled !== 'boolean') { + return invalidBody('`enabled` must be a boolean (JSON true/false, not a string)', [ + { field: 'enabled', code: 'invalid_type', message: 'expected a boolean' }, + ]); + } + const enabled = (toggleBody as { enabled?: boolean }).enabled ?? true; + + // ── 3. declaration ────────────────────────────────────────────────────── + const declaration = await actionExec.resolveRouteActionDeclaration(deps, context, { + ql, + objectName, + actionName, + envId: context?.environmentId, + }); + if (declaration.degraded) { + return { + handled: true, + response: deps.error( + `Cannot verify the declaration for action '${actionName}' on '${objectName}' — the metadata plane is ` + + `unavailable (${declaration.reason ?? 'unknown failure'}). Refusing rather than writing an activation ` + + `row for an action nobody can confirm exists.`, + 503, + ), + }; + } + if (!declaration.action) { + return { + handled: true, + response: deps.error( + `Action '${actionName}' on '${objectName}' has no declaration — there is nothing to switch ` + + `${enabled ? 'on' : 'off'}. The activation ledger addresses DECLARED actions (ADR-0126 §4).`, + 404, + ), + }; + } + + // ── 4. ambiguity ──────────────────────────────────────────────────────── + const ambiguity = await refuseAmbiguousActionActivation(deps, context, actionName, objectName); + if (ambiguity) return ambiguity; + + // ── 5. the durable write ──────────────────────────────────────────────── + const packageId = String( + (declaration.action as { _packageId?: unknown })?._packageId ?? + (declaration.obj as { _packageId?: unknown } | undefined)?._packageId ?? + '', + ); + if (typeof ql.setActionActive !== 'function') { + // An engine too old to carry the projection cannot make this durable, + // and a 200 here would report a switch that never existed. + return { + handled: true, + response: deps.error( + `This deployment's data engine does not implement the packaged-metadata activation ledger ` + + `(ADR-0126 §8), so a packaged action cannot be switched off here.`, + 501, + ), + }; + } + try { + await ql.setActionActive({ name: actionName, packageId, active: enabled }); + } catch (err: any) { + // The engine declares its own class for the one refusal it raises (no + // ledger attached → 503 SERVICE_UNAVAILABLE); anything else is a store + // failure, and a failed write must never read as a successful flip. + const status = typeof err?.status === 'number' ? err.status : 503; + const code = typeof err?.code === 'string' ? err.code : 'SERVICE_UNAVAILABLE'; + return { + handled: true, + response: deps.error(err?.message ?? String(err), status, { code }), + }; + } + return { handled: true, response: deps.success({ name: actionName, objectName, enabled }) }; +} + +/** + * [ADR-0126 §4] Refuse a flip whose NAME does not identify one action. + * + * The ledger's row identity is `(metadata_type, name, organization_id)` — one + * row per machine name — and ADR-0110 D1 says the same about actions: identity + * is the declarative `name`. Two objects may nevertheless declare the same + * action name, and then one row would address both. The three ways out were + * weighed and only this one is honest: + * + * - encoding `:` into the `name` column puts two facts in a + * column declared to hold one, and the ADR is explicit that a new dimension + * is an ADDITIVE column later, never a smuggled encoding (§4, §5); + * - disabling both is a silent cross-artifact effect — the class of failure + * this whole regime exists to close; + * - refusing names the conflict at the moment of the attempt, which is the + * posture §5 already prescribes for the per-org case ("refuses loudly at + * the moment of the attempt, naming the trigger type — never a silent + * fallback"). + * + * Measured before choosing: name collisions across objects are rare in the + * platform's own catalog, so this refuses an edge case rather than the common + * path. `RESOURCE_CONFLICT` is the standard-catalog member for it — ⛔ no new + * error code is minted for a case that already has one. + * + * Best-effort by construction: the collection needs a metadata service, and a + * deployment without one cannot enumerate declarations at all. It then flips + * the row the caller asked for rather than inventing a conflict — the same + * posture `resolveActionByName` takes when it cannot see a second declaration. + */ +async function refuseAmbiguousActionActivation( + deps: DomainHandlerDeps, + context: HttpProtocolContext, + actionName: string, + objectName: string, +): Promise { + let declarations: Array<{ action: any; objectName: string }> = []; + try { + const meta = await deps.resolveService(context, 'metadata', context?.environmentId); + if (!meta) return undefined; + declarations = await actionExec.collectActionDeclarations(deps, meta); + } catch { + return undefined; // cannot enumerate → no conflict can be asserted + } + const owners = [...new Set( + declarations.filter((d) => d.action?.name === actionName).map((d) => d.objectName), + )]; + if (owners.length < 2) return undefined; + + return { + handled: true, + response: deps.error( + `Action '${actionName}' is declared on ${owners.length} objects (${owners.map((o) => `'${o}'`).join(', ')}), ` + + `and the activation ledger addresses an action by its machine name (ADR-0126 §4) — one row would switch ` + + `every one of them, not just the one on '${objectName}'. Refusing rather than changing artifacts you did ` + + `not name. Give the actions distinct machine names, or leave them armed.`, + 409, + { code: 'RESOURCE_CONFLICT' }, + ), + }; +} + export function createActionsDomain(deps: DomainHandlerDeps): DomainRoute { return { prefix: '/actions', @@ -170,6 +452,23 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string return { handled: true, response: deps.error('Data engine not available', 503) }; } + // [ADR-0126 §8 item 2] THE ACTIVATION WRITE DOOR — enable/disable one + // packaged action, which is a `sys_metadata_activation` row and nothing + // else. Answered before the invocation path derives an object/action pair, + // so `_activation` can never be read as a name (it cannot BE one — see the + // predicate above). + // + // Placed after the engine resolution rather than ahead of it, unlike + // `/automation`'s gates: what sits above is the SAME "data engine not + // available" answer every caller of this domain already gets, so it + // fingerprints nothing an unentitled caller could not learn by invoking any + // action at all. Everything that IS an oracle — the body contract, whether + // a given action is declared here, whether it is switched off — stays + // behind the two gates inside. + if (isActionActivationWrite(parts, method.toUpperCase())) { + return handleActionActivationWrite(deps, ql, parts, body, _context); + } + // [ADR-0066 D4] Dual-surface action gate — the server is the source of // truth. Resolve the action's declared `requiredPermissions` from the // object schema and reject (403) when the caller's systemPermissions @@ -256,6 +555,28 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string if (gateError) { return { handled: true, response: deps.error(gateError, 403) }; } + + // [ADR-0126 §8 item 2] ACTIVATION CONSULT — door 1 of 2 (the MCP + // `run_action` bridge is the other; see `disabledActionRefusal`, which + // both call, for why the consult sits at the DECLARATION and not at + // the handler-key seam below). + // + // Deliberately AFTER the D4 capability gate — an unentitled caller must + // not learn which packaged actions this installation switched off — and + // BEFORE the type branch, the param contract and the record load: a + // disabled action runs nothing, discloses no param shape, and reads no + // record. It applies to every declared type, so a `flow` action is + // refused by its OWN switch regardless of what its target flow's + // ledger row says. + const activationRefusal = actionExec.disabledActionRefusal(deps, ql, actionDef); + if (activationRefusal) { + return { + handled: true, + response: deps.error(activationRefusal.message, activationRefusal.status, { + code: activationRefusal.code, + }), + }; + } } // [#3915] Action-TYPE dispatch. Per spec every non-`script` type diff --git a/packages/runtime/src/domains/activation-gate.ts b/packages/runtime/src/domains/activation-gate.ts new file mode 100644 index 0000000000..80886ddc75 --- /dev/null +++ b/packages/runtime/src/domains/activation-gate.ts @@ -0,0 +1,203 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0126 §5] THE WRITE-AUTHORITY GATE for the packaged-metadata activation + * switch — shared by every door that writes a `sys_metadata_activation` row. + * + * It lived inline in `domains/automation.ts` when flows were the only consumer + * (#12157). ADR-0126 §8 item 2 then brought actions onto the SAME ledger with + * the SAME §5 authority, and a second copy of a security gate is two policies + * that happen to agree today — the shape this repo removes on sight (the + * `/automation` domain's own `isRunStateRead` comment argues it in as many + * words). So the gate moved HERE, unchanged in behaviour, and both doors call + * it. What varies per door is one clause of the refusal SENTENCE, which is + * data, not logic: see {@link ActivationSubject}. + * + * ## What it enforces + * + * The activation row these routes write 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 caller's own + * `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 ADR-0126 §9 walls this family 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. + */ + +// [ADR-0126 §5] The 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 type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; +import type { DomainHandlerDeps } from '../domain-handler-registry.js'; + +/** [ADR-0126 §5] Refusal vocabulary for the activation (enable/disable) gates. */ +export const ACTIVATION_DENY_STATUS = 403; +export const ACTIVATION_DENY_CODE = 'PERMISSION_DENIED'; + +/** + * [ADR-0066 D1 / #10145] The authoring capability every door onto the metadata + * plane demands — and, since the #10243 ruling, the activation switch too: + * *"Disabling a shipped flow is functionally equivalent to deleting it for as + * long as it stays off"*, and `DELETE` already required it. Actions inherit the + * sentence with one word changed. + */ +export const ACTIVATION_AUTHORING_CAPABILITY = 'manage_metadata'; + +/** + * The per-door half of the refusal: WHAT is being switched, and what the + * refused caller can do instead. + * + * `remedy` is a whole sentence rather than a noun because the sanctioned path + * genuinely differs by artifact and ADR-0126 §7 requires a refusal to name one: + * a flow can be cloned under a new name (§7.1), while **action-clone is not + * chartered** (§8 item 2) — recommending one for actions would advertise + * machinery that does not exist, which is the failure Prime Directive #10 names. + */ +export interface ActivationSubject { + /** Noun phrase completing "Enabling or disabling …" — e.g. `a packaged flow`. */ + subject: string; + /** One sentence naming what the refused caller CAN do. */ + remedy: string; +} + +/** The flow door's wording (#12157) — byte-identical to what it shipped with. */ +export const FLOW_ACTIVATION_SUBJECT: ActivationSubject = { + subject: 'a packaged flow', + remedy: 'To customize this flow for your organization, clone it under a new name instead.', +}; + +/** + * The action door's wording (ADR-0126 §8 item 2). + * + * ⛔ It does NOT recommend a clone. The action-clone half is deliberately + * unchartered ("it stays on §3's pre-chart discipline until real pull + * appears"), so the honest alternatives are the operator, and authoring an + * ordinary sibling action — which Regime C leaves open exactly as today. + */ +export const ACTION_ACTIVATION_SUBJECT: ActivationSubject = { + subject: 'a packaged action', + remedy: + 'To switch a packaged action off for this installation, ask your platform operator; authoring your own action ' + + 'alongside it stays open to you.', +}; + +/** + * The §5 gate itself. + * + * Returns a refusal to short-circuit on, `undefined` to proceed — the shape + * every gate in this family uses, so no route can consume a denial as a value. + * Engine self-invocation (`isSystem`, never settable from the wire) bypasses, + * as it does at every neighbouring gate. + * + * ⚠️ Callers MUST run this BEFORE the write is attempted and before body + * validation, so a refused caller writes nothing and learns nothing about the + * contract. "Write first, refuse second" is the worst shape here — it is + * #10243 with an audit trail. + */ +export async function refuseUngrantedActivationWrite( + deps: DomainHandlerDeps, + context: HttpProtocolContext, + artifact: ActivationSubject, +): 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 ${artifact.subject} 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. ${artifact.remedy}`, + ACTIVATION_DENY_STATUS, + { code: ACTIVATION_DENY_CODE }, + ), + }; +} + +/** + * [#10145 / #10243] The capability tier that sits IN FRONT of the §5 gate: the + * caller must hold `manage_metadata` before the posture question is even asked. + * + * The `/automation` domain enforces this through its own + * `isFlowAuthoringWrite` predicate, because there the activation door is one + * arm of a whole authoring family (create / update / delete / toggle / clone) + * that shares one policy. The `/actions` domain has no such family — it is an + * EXECUTION surface whose per-action `requiredPermissions` gate answers a + * different question entirely — so its activation door carries this gate + * directly, with the same capability and the same refusal envelope. + * + * Synchronous: the capability rides the caller's own `systemPermissions` + * (CAPABILITIES, not permission-SET names — #4705), so nothing is resolved. + * The message names the CAPABILITY it wants and nothing about the caller + * (#7450). + */ +export function refuseUngrantedActivationAuthoring( + deps: DomainHandlerDeps, + context: HttpProtocolContext, + artifact: ActivationSubject, +): HttpDispatcherResult | undefined { + const ec: any = context?.executionContext; + if (ec?.isSystem) return undefined; + if (new Set(ec?.systemPermissions ?? []).has(ACTIVATION_AUTHORING_CAPABILITY)) return undefined; + + return { + handled: true, + response: deps.error( + `Enabling or disabling ${artifact.subject} requires the \`${ACTIVATION_AUTHORING_CAPABILITY}\` capability — ` + + `switching a shipped artifact off is functionally equivalent to deleting it for as long as it stays off ` + + `(#10243).`, + ACTIVATION_DENY_STATUS, + { code: ACTIVATION_DENY_CODE }, + ), + }; +} diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index aeca846ee1..3bc7782ac3 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -12,13 +12,10 @@ 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'; +// [ADR-0126 §5] The shared activation write-authority gate — one +// implementation, one refusal envelope, per-door wording. See its header for +// the posture rule and the #10243 measurement behind it. +import { refuseUngrantedActivationWrite, FLOW_ACTIVATION_SUBJECT } from './activation-gate.js'; import { CoreServiceName } from '@objectstack/spec/system'; import type { IAutomationService, ISecurityService } from '@objectstack/spec/contracts'; import { isServiceServeable } from '../service-serveable.js'; @@ -469,93 +466,24 @@ function isFlowActivationWrite(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. + * The gate itself now lives in `./activation-gate.ts`: ADR-0126 §8 item 2 put + * ACTIONS on the same ledger under the same §5 authority, and a second copy of + * a security gate is two policies that happen to agree today. Behaviour and + * refusal text here are unchanged — what this door supplies is the per-artifact + * clause ({@link FLOW_ACTIVATION_SUBJECT}), which is the only part that ever + * differed. The shared module's header carries the full rationale: why the + * operator test is a POSITION, why an absent posture fails open, and what + * #10243 measured. */ -async function refuseUngrantedActivationWrite( +const refuseUngrantedFlowActivationWrite = ( 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 }, - ), - }; -} +): Promise => + refuseUngrantedActivationWrite(deps, context, FLOW_ACTIVATION_SUBJECT); function refuseUngrantedFlowWrite( deps: DomainHandlerDeps, @@ -1052,7 +980,7 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str // 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); + const refusal = await refuseUngrantedFlowActivationWrite(deps, context); if (refusal) return refusal; } diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index f06d61046f..8a40141c8c 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -383,6 +383,29 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ // what was wrong was that no ledger said it existed. { route: 'POST /actions//:action', domain: '/actions', disposition: 'sdk', client: 'actions.invokeGlobal', note: 'the object-less spelling of the global-action call (#3913) — same handler and same `global` key as the row above, reached without naming an object' }, + { route: 'POST /actions/_activation/:object/:action', domain: '/actions', disposition: 'server-only', + servedBy: '/api/v1/actions/:object/:action/:recordId', + note: '[#12160] ADR-0126 §8 item 2 — enable/disable ONE packaged action, the only non-invocation shape this domain serves. ' + + '⚠️ `servedBy`, and MEASURED on a real boot (#7526 caught it): no pattern of this spelling is registered. The ' + + 'dispatcher mounts three `/actions` patterns, and a 3-segment activation path is matched by the LAST of them with ' + + '`_activation` bound to `:object` — but that mount rebuilds the dispatch path from the matched params ' + + '(`/actions/${object}/${action}/${recordId}`), so the path `handleActionsRequest` parses is byte-identical to the ' + + 'one the caller sent and the `_activation` arm still fires. Probed against a booted showcase: the 2-segment shape ' + + 'answers this door\'s own 400 (`Path must be /actions/_activation/:object/:action`) and a 3-segment undeclared ' + + 'action answers this door\'s own 404 (`nothing to switch off`), neither of which the invocation path can produce. ' + + 'A 4-segment path resolves to NOTHING and 404s at the router. Pinned end-to-end through the real mount in ' + + '`qa/dogfood/test/action-params-contract.dogfood.test.ts`, so if that reconstruction ever stops being faithful the ' + + 'pin reddens rather than this note going quietly stale. ' + + 'Body `{ enabled?: boolean }`; it writes a `sys_metadata_activation` row and nothing else (⛔ no definition write, ⛔ no clone: ' + + 'the action-clone half is unchartered). Two authority tiers, the same pair `POST /automation/:name/toggle` carries: ' + + '`manage_metadata`, then the ADR-0126 §5 posture rule requiring the platform operator in `group`/`isolated` — one shared ' + + 'implementation in `domains/activation-gate.ts`, not a second copy. The `_activation` segment is RESERVED rather than a deeper ' + + 'path segment because machine names cannot begin with `_` (`SnakeCaseIdentifierSchema`), so it can collide with no object, action ' + + 'or record id. NOT JS-SDK surface on this leg, and that is stated rather than left as an open gap: the operational driver ' + + 'ADR-0126 §7.4 charters is the Setup page for packaged metadata — a console surface that calls the platform API directly, the ' + + 'posture `GET /meta/types` already carries. ⚠️ Read it as scope, not as a closed question: this card declares no client method ' + + 'and implies none (`automation.toggle` is the flow analog, so a later card may well want one); adding one reclassifies this row ' + + 'to `sdk`. Pinned in `domains/action-activation-posture-gate.test.ts`' }, // ── apps (declarative endpoints — the mount seam, NOT a dispatch() route) ── // Read this row literally; it describes what is WIRED, not what is planned. diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index ebf10d2d1a..8fb636710e 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -251,6 +251,22 @@ export const ERROR_CODE_LEDGER = { 'VALIDATION_FAILED', // record-level validation; carries `fields[]` (#3977) ], '@objectstack/runtime': [ + // [ADR-0126 §8 item 2] a packaged ACTION this installation switched off, + // refused at dispatch — the activation-ledger consult in + // `action-execution.ts`, answered 409 by BOTH action doors (the REST + // `/actions/:object/:action` route and the MCP `run_action` bridge, which + // throws it with `status`/`code` declared so `resolveThrownHttpError` + // serves the same envelope). Never carries `status: 'failed'`: nothing + // dispatched. + // + // ⛔ NOT a re-spelling of `FLOW_DISABLED`, and the distinction is the point + // of registering it: a `script` action refused under a code naming a flow + // sends an operator hunting a flow that does not exist. It joins the + // `*_DISABLED` family (`OBJECT_API_DISABLED`, `OBJECT_PACKAGE_DISABLED`, + // `FLOW_DISABLED`) — each names WHICH thing is off, which is what an + // operator acts on, and none of them is a synonym of `RESOURCE_CONFLICT` + // in the detector's sense or in meaning. + 'ACTION_DISABLED', 'EXPIRED_OR_REVOKED', // share link // [#9415] the trigger door refused to dispatch a flow that is switched off // — `respondToFlowTrigger` (`domains/automation.ts`) reads the engine's diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index e50d3c9949..4233e7e55e 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -377,6 +377,12 @@ const DISPATCHER_DOMAIN_DIR = 'packages/runtime/src/domains'; const DISPATCHER_DOMAINS = { 'actions.ts': { handBuilt: 0 }, + // [#12160] Not a domain either — the ADR-0126 §5 activation write-authority + // gates, shared by the `/automation` toggle door and the `/actions` + // activation door so one security policy has one implementation. Like + // `unavailable.ts` it answers refusals through `deps.error`, which is this + // check's own thesis applied to a refusal that two domains emit. + 'activation-gate.ts': { handBuilt: 0 }, 'analytics.ts': { handBuilt: 0 }, 'automation.ts': { handBuilt: 0 }, 'data.ts': { handBuilt: 0 }, diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 5dc4f4e81d..158259a74d 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -951,6 +951,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/objectql/src/action-activation.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/objectql/src/layered-overlay-integration.test.ts", "verb": "delete",