Skip to content

Commit 8b01371

Browse files
committed
feat(actions): durable packaged-action disable — same activation ledger, dispatch-time consult
Generalizes the packaged-flow disable machinery to actions (ADR-0126 §8 item 2, maintainer amendment ruling 3). A packaged action can be switched off for an installation; the flip writes an install-level row to the SAME `sys_metadata_activation` object with `metadata_type: 'action'` — no schema change, no new column, no clone machinery. The consult sits at action DISPATCH, on every door that dispatches a declared action: the REST `/actions/:object/:action` route and the MCP `run_action` bridge, both through one shared guard. A disabled action is refused `409 ACTION_DISABLED` before the handler body runs (it executes trusted), before a flow-type action reaches the automation engine, before the param contract and before the record load — and after the ADR-0066 D4 capability gate, so the switch is not an oracle for unentitled callers. The code is registered rather than borrowed: `FLOW_DISABLED` would tell an operator to go hunting for a flow that does not exist. It is one census row in the ADR-0112 ledger, in the `*_DISABLED` family that already names which thing is off. The projection lives on the ObjectQL engine and is hydrated at boot by its plugin — the component ADR-0110 D5 already established as unconditionally present wherever actions execute — so a disable survives a restart and the handler re-registration every metadata reload performs. A ledger object absent from the composition is read as absent, not probed, and re-checked on the next reload rather than recorded as a verdict. The write door is `POST /actions/_activation/:object/:action`, its segment reserved because machine names cannot begin with `_`. It carries the same two authority tiers as the flow toggle — `manage_metadata`, then the ADR-0126 §5 posture rule — now sharing ONE gate implementation with `POST /automation/:name/toggle` instead of a second copy; the flow refusal text is byte-identical to what it shipped with. An ambiguous action name is refused `409 RESOURCE_CONFLICT` rather than switching off artifacts the caller did not name, and a flip that cannot be made durable is reported as a failure instead of a 200. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWRU3s15AJz7PGW7a7wdCh
1 parent 1524927 commit 8b01371

18 files changed

Lines changed: 2512 additions & 90 deletions

.changeset/lucky-jokes-shave.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/runtime": minor
4+
"@objectstack/spec": minor
5+
---
6+
7+
Packaged actions can be switched off, on the same activation ledger as flows
8+
9+
A packaged action can now be disabled for an installation, generalizing the
10+
packaged-flow machinery to the second Regime C consumer (ADR-0126 §8 item 2, on
11+
the maintainer's amendment ruling 3). The flip writes an install-level row to
12+
the **same** `sys_metadata_activation` object with `metadata_type: 'action'`
13+
no new table, no new column, no schema change of any kind. Absence of a row
14+
means the packaged default, active, so a deployment that never flips anything
15+
behaves exactly as before, and an empty ledger changes nothing anywhere.
16+
17+
The consult point is action DISPATCH, and it is present on every door that
18+
dispatches a declared action: the REST `POST /actions/:object/:action` route and
19+
the MCP `run_action` bridge. Both call one shared guard, and a disabled action
20+
is refused `409 ACTION_DISABLED` before anything runs — before the handler body
21+
(which executes trusted, RLS/FLS-bypassing), before a `type: 'flow'` action
22+
reaches the automation engine, before the param contract is enforced and before
23+
the subject record is read. The refusal names the ledger and the remedies. The
24+
code is new, registered under `@objectstack/runtime` in the ADR-0112 ledger and
25+
answered at both doors; it deliberately does **not** reuse `FLOW_DISABLED`,
26+
which would tell an operator to go looking for a flow that does not exist.
27+
28+
The consult reads a projection the ObjectQL engine holds and hydrates at boot,
29+
so a disabled action stays disabled across a restart and across the handler
30+
re-registration that every `metadata:reloaded` performs (ADR-0126 §6 wall 3 —
31+
the ledger records the customer's choice, and nothing re-arms it silently).
32+
33+
The write door is `POST /actions/_activation/:object/:action` with a
34+
`{ enabled?: boolean }` body. Its first segment is reserved rather than deep in
35+
the path because a machine name can never begin with `_`, so it cannot collide
36+
with an object, an action or a record id. It carries the same two authority
37+
tiers the flow toggle carries: `manage_metadata`, then the ADR-0126 §5 posture
38+
rule — in the `group` and `isolated` postures the install-wide switch requires
39+
the platform operator, while `single`, where install-level and org-level are the
40+
same scope, is unchanged. That gate is now one implementation shared with
41+
`POST /automation/:name/toggle`; the flow refusal text is unchanged.
42+
43+
Two refusals are worth knowing about. The ledger addresses an action by its
44+
machine name, so a name declared on more than one object is refused with
45+
`409 RESOURCE_CONFLICT` naming the objects, rather than switching all of them off
46+
silently. And a flip that cannot be made durable — no ledger table reachable —
47+
is answered as a failure instead of a 200, because a switch reported as durable
48+
that reverts on the next restart is the failure this whole family exists to
49+
remove.
50+
51+
Action **cloning** is not part of this: ADR-0126 §8 leaves it unchartered, so
52+
disable is the only primitive here and authoring a new sibling action stays
53+
exactly as it is today.

content/docs/references/api/contract.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data);
2727

2828
| Property | Type | Required | Description |
2929
| :--- | :--- | :--- | :--- |
30-
| **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) |
30+
| **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) |
3131
| **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) |
3232
| **message** | `string` || Readable error message |
3333
| **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);
8989
* `INTEGRATION_ERROR`
9090
* `WEBHOOK_DELIVERY_FAILED`
9191
* `ACCOUNT_LOCKED`
92+
* `ACTION_DISABLED`
9293
* `ALREADY_REVERTED`
9394
* `AMBIGUOUS_MATCH`
9495
* `ANALYTICS_QUERY_FAILED`

content/docs/references/api/error-code-ledger.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ const result = ErrorCode.parse(data);
193193
* `INTEGRATION_ERROR`
194194
* `WEBHOOK_DELIVERY_FAILED`
195195
* `ACCOUNT_LOCKED`
196+
* `ACTION_DISABLED`
196197
* `ALREADY_REVERTED`
197198
* `AMBIGUOUS_MATCH`
198199
* `ANALYTICS_QUERY_FAILED`

0 commit comments

Comments
 (0)