Skip to content

Commit e5ce2ed

Browse files
claude[bot]claude
andauthored
feat(actions): durable packaged-action disable — same activation ledger, dispatch-time consult (#12348)
* 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 * fix(actions): pin the ledger fake's update dispatch, and declare how the activation door is actually served Two CI failures, both real. 1. `check-engine-double-contract` — the activation-ledger fake's `update()` did not route through ObjectQL's own dispatch predicate, so the double could accept call shapes the engine refuses. Opened with `assertEngineUpdateDispatch(data, options)` from `@objectstack/metadata-core` (the predicate's home since #5619; importing objectql's own re-export from inside objectql would be a self-import), matching the flow twin's fake. The new pinned coverage is recorded in the pinned ledger via the sanctioned `--write`; the shrink-only baseline is untouched. 2. `route-ledger-live-mount-parity` (#7526) — the activation door was "LEDGERED BUT NOT MOUNTED, and DISGUISED". Root-caused on a booted showcase rather than guessed: nothing registers `/api/v1/actions/_activation/:object/:action`, and a 3-segment activation path is matched by `/api/v1/actions/:object/:action/:recordId` with `_activation` bound to `:object`. It still reaches the activation arm because that mount rebuilds the dispatch path from its matched params, byte for byte — measured on the wire: the 2-segment shape answers this door's own 400 naming the required shape, and a 3-segment undeclared action answers its 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. So the honest fix is the declaration, not a second mount: the ledger row carries `servedBy: /api/v1/actions/:object/:action/:recordId` with the mechanism written down. Because a `servedBy` note cannot say the arm still ANSWERS — and the unit suites drive the domain handler directly, which is why they stayed green through this — the two wire assertions are pinned end-to-end in the actions dogfood suite, on a boot that already exists. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWRU3s15AJz7PGW7a7wdCh * fix(objectql): make the activation-ledger fake REFUSE the WHERE combinators it does not implement `pnpm check:where-matcher` flagged the double's `matches` as silently wrong: with no combinator branch it read `$or` / `$in` as a FIELD NAME, compared `row.$or` (undefined) against the operand, matched nothing, and would have left a suite asserting on an empty result set with nothing erroring — the ABSENCE shape of that defect class, which no syntactic guard can see. Took the gate's own preferred remedy for a double that only ever sees scalar equality (the store's two reads are `{ metadata_type }` and `{ metadata_type, name }`): refuse rather than implement. The predicate now throws on a `$`-prefixed key or an object-valued comparand, the same refusal the flow twin's fake carries. ⛔ The gate's baseline is untouched. The matcher also moved to MODULE scope, and that is load-bearing rather than tidying: the gate judges a matcher by LIFTING it — transpiling it with the declarations it references and running a combinator battery against it. Declared inside the factory, the lift carried that factory's scope, reached `vi` and could not evaluate, so the first attempt at this fix turned the verdict from "silently wrong" into "unjudged" — which the gate never treats as passing. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWRU3s15AJz7PGW7a7wdCh --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d516483 commit e5ce2ed

20 files changed

Lines changed: 2619 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)