Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/27842-disabled-actions-beside-item.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/spec': minor
---

feat(spec): the object meta reads declare `disabledActions`, the packaged actions the activation ledger switches off, beside the item

Clause-②: yes (widening)
Metadata-protocol: added

`GetMetaItemResponseSchema` and `GetMetaItemCachedResponseSchema` gain the optional `disabledActions` (an array of action names), and `GetMetaItemsResponseSchema` gains the optional `disabledActions` keyed by object name. On an `object` read it names the actions in `item.actions` that this deployment's activation ledger switches off (an administrator turned them off under Setup › Packaged automation), so a call to any of them is refused 409 `ACTION_DISABLED`. It sits beside the item, never inside it: `item.actions` is served as declared, a switched-off action included. The key is absent when no activation ledger answered, which never means "nothing is switched off"; `[]` means a ledger answered and none of this object's actions is off. On the cached default door it is read live and kept out of the ETag, so a read that carries it is never answered 304. `IObjectQLEngine` gains the optional member `getDisabledActions()`, which the metadata read calls through the kernel's service table: it returns the switched-off names, or `undefined` when no ledger is attached.
15 changes: 15 additions & 0 deletions .changeset/27842-disabled-actions-served.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/metadata-protocol': patch
'@objectstack/rest': patch
'@objectstack/objectql': patch
'@objectstack/runtime': patch
---

fix: `GET /meta/object` serves which packaged actions are switched off beside the item, and a switched-off action is refused with a plain sentence

Clause-②: yes (widening)
Metadata-protocol: none

- **Served beside the object** (`@objectstack/metadata-protocol`, `@objectstack/rest`). `GET /api/v1/meta/object/:name` serves `disabledActions` beside `item`: the names in `item.actions` that the deployment's activation ledger switches off. `GET /api/v1/meta/object` serves the same answer keyed by object name. The default cached door carries it too, read live on every call and kept out of the ETag, so a read that carries it is answered in full rather than `304 Not Modified`. The key is absent where no ledger answered (no engine, or an engine with no ledger attached), on a draft read, and on every type other than `object`. `item.actions` is unchanged. The protocol reads the engine's `getDisabledActions` through the kernel's service table by name and imports no engine.
- **The fact it serves** (`@objectstack/objectql`). `ObjectQL.getDisabledActions()` returns the names the engine's activation projection switches off, the same projection both dispatch doors consult before they refuse a call, or `undefined` when no ledger is attached. `listDisabledActions()` is unchanged.
- **The refusal sentence** (`@objectstack/objectql`, `@objectstack/runtime`). A switched-off action is still refused 409 `ACTION_DISABLED` at the REST and MCP doors, and the message now reads: "This action is turned off for this deployment. An administrator can turn it on again under Setup › Packaged automation, or you can use another action." It no longer names the ledger table or the ADR. The engine writes those, with the code and the action's name, to an `info` log line each time it refuses one.
3 changes: 3 additions & 0 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1332,6 +1332,7 @@ This schema accepts one of the following structures:
| **notModified** | `boolean` | optional (default: `false`) | True if resource has not been modified (304 response) |
| **version** | `string` | optional | Metadata version identifier |
| **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on the flow in `data`, read live on every call and never part of `etag` (see `FlowRegistrationSchema`). Present only for a `flow` read answered by a process that runs an automation service; a read that carries it is never answered `notModified`. Absent otherwise, and absence means no engine judged it, never `registered`. |
| **disabledActions** | `string[]` | optional | The names in the `actions` of the object in `data` that this deployment's activation ledger switches off (a call to any of them is refused 409 `ACTION_DISABLED`), read live on every call and never part of `etag`. Present only for an `object` read answered by a process whose data engine has an activation ledger attached, `[]` when none is switched off; a read that carries it is never answered `notModified`. Absent otherwise, and absence means no ledger answered, never that nothing is switched off. |

### Nested Shape: `GetMetaItemCachedResponse.etag`

Expand Down Expand Up @@ -1458,6 +1459,7 @@ This schema accepts one of the following structures:
| **resettable** | `boolean` | optional | Whether the item can be reset to its packaged default — true iff it is artifact-backed, i.e. there is a baseline to reset TO. |
| **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. A `null` beside `editable: false` is no such invitation: that item takes no save at this address, so no pin is prescribed for it. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. |
| **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. |
| **disabledActions** | `string[]` | optional | The names in `item.actions` that this deployment's activation ledger switches off (an administrator turned them off under Setup › Packaged automation): a call to any of them is refused 409 `ACTION_DISABLED`. Present only on an `object` read answered by a process whose data engine has an activation ledger attached, and then `[]` when none of this object's actions is switched off. Absent otherwise, and absence means no ledger answered, never that nothing is switched off. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read. The ledger is the answering process's engine's. Beside `item`, never inside it: `item.actions` is served as declared, a switched-off action included. |
| **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>; actor: string \| null; client?: object; … }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". |

### Nested Shape: `GetMetaItemResponse.sortability`
Expand Down Expand Up @@ -1522,6 +1524,7 @@ One changed key path of the pending draft, and the last write that changed it.
| **type** | `string` | ✅ | Metadata type name |
| **items** | `any[]` | ✅ | Array of metadata items |
| **registrations** | `Record<string, { state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }>` | optional | The automation engine's verdict on each listed flow, keyed by the item's `name`: one entry per listed item that is not a pending draft. Present only on a `flow` list answered by a process that runs an automation service; absent otherwise, and absence means no engine judged them, never `registered`. The verdict is the answering process's engine. Beside the items, never inside one: an item's `status` stays what its author declared. |
| **disabledActions** | `Record<string, string[]>` | optional | For each listed object, keyed by the item's `name`: the names in its `actions` that this deployment's activation ledger switches off, so a call to any of them is refused 409 `ACTION_DISABLED` (the item read's `disabledActions`). One entry per listed object that is not a pending draft, `[]` when none of its actions is switched off. Present only on an `object` list answered by a process whose data engine has an activation ledger attached; absent otherwise, and absence means no ledger answered, never that nothing is switched off. Beside the items, never inside one: each item's `actions` is served as declared. |

### Nested Shape: `GetMetaItemsResponse.registrations[string][state='registered']`

Expand Down
12 changes: 9 additions & 3 deletions docs/qa/platform-checklist/areas/api-backend.json
Original file line number Diff line number Diff line change
Expand Up @@ -1674,7 +1674,7 @@
"title": "A disabled packaged action is refused 409 ACTION_DISABLED at BOTH dispatch doors — after the capability gate (a 403 is never an oracle for the switch), before the param contract and the record load, durable across restart",
"since": "v17",
"status": "active",
"revision": 9,
"revision": 10,
"priority": "P0",
"surface": "api",
"personas": [
Expand Down Expand Up @@ -1706,9 +1706,9 @@
],
"acceptance": [
{
"clause": "the REST door refuses a disabled packaged action 409 ACTION_DISABLED and dispatches NOTHING: status 409, code ACTION_DISABLED (registered in the ADR-0112 ledger, error-code-ledger.zod.ts), message = the engine's own describeDisabledAction sentence naming the ledger — and no side effect lands (mark_done's declared done/progress write never ran)",
"clause": "the REST door refuses a disabled packaged action 409 ACTION_DISABLED and dispatches NOTHING: status 409, code ACTION_DISABLED (registered in the ADR-0112 ledger, error-code-ledger.zod.ts), message = the engine's own describeDisabledAction sentence, written for the end user: it says the action is turned off for this deployment and that an administrator can turn it on again under Setup › Packaged automation, and names no system table and no ADR (the engine's log line carries those) — and no side effect lands (mark_done's declared done/progress write never ran)",
"oracle": "api",
"verify": "step-3 trace: 409 + code ACTION_DISABLED + the disabled-action sentence; follow-up reads show no task mutated. packaged-action-disabled-dispatch-live.dogfood.test.ts scores it over HTTP: the exact code, the sentence (prefix \"Action 'showcase_mark_done' is disabled — \" and the ledger citation), and the real task re-read with done, progress and updated_at unchanged. packaged-activation-ledger-reach.dogfood.test.ts still asserts 409 and /ACTION_DISABLED|is disabled/ only",
"verify": "step-3 trace: 409 + code ACTION_DISABLED + the disabled-action sentence; follow-up reads show no task mutated. packaged-action-disabled-dispatch-live.dogfood.test.ts scores it over HTTP: the exact code, the sentence (first sentence \"This action is turned off for this deployment.\", the Setup › Packaged automation remedy, and no ledger table or ADR in it), and the real task re-read with done, progress and updated_at unchanged. packaged-activation-ledger-reach.dogfood.test.ts still asserts 409 and the code ACTION_DISABLED only",
"evidence": "the trace + the control re-reads"
},
{
Expand Down Expand Up @@ -1833,6 +1833,12 @@
"date": "2026-10-27",
"change": "acceptance[0]–[6] and negative[0]–[2] are pinned at the layer their api oracle names (the release-18.2.0 Tier 1 sweep #26468 scored a0, a3, a4, a6 and n1 partial below the HTTP layer, and a1, a5, n0 and n2 unpinned). The new packaged-action-disabled-dispatch-live.dogfood.test.ts drives the REST door, the live MCP transport and a cold boot on one database file; automated.ref names what it asserts and acceptance[0].verify names the pin that carries it. Measured at base c8583f80. No clause changed",
"ref": "#26572"
},
{
"revision": 10,
"date": "2026-10-30",
"change": "acceptance[0]'s clause and verify respelled for #27842: the disabled-action sentence is now written for the end user who clicked (what happened, that an administrator can turn the action on again under Setup › Packaged automation, or use another action) and names no system table and no ADR; the engine's describeDisabledAction writes the ledger table, ADR-0126 §8 and the ACTION_DISABLED code to its log line instead. The code and the 409 are unchanged, so every other clause stands; the clause's 'naming the ledger' was the only text that turned false. The dogfood pin's sentence helper and packaged-activation-ledger-reach.dogfood.test.ts's /ACTION_DISABLED|is disabled/ are flipped in the same change (the latter now reads the code alone)",
"ref": "#27842"
}
]
},
Expand Down
102 changes: 102 additions & 0 deletions packages/metadata-protocol/src/protocol-action-activation-reads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#27842] The data engine's packaged-action activation read, for the `object`
* meta reads: `disabledActions` beside the item read's envelope (both doors)
* and beside the list's items, keyed by object name
* (`GetMetaItemResponseSchema` / `GetMetaItemsResponseSchema` in
* `@objectstack/spec/api`).
*
* - `resolveDisabledActionsReader` — the engine's `getDisabledActions`, read
* ONCE per meta read, turned into a reader of one object body; nothing for
* every other type, and nothing where no ledger answered.
* - `disabledActionsOf` — the list's `disabledActions`: that reader's answer
* per listed, non-draft object.
*
* ## The seam
*
* The automation twin's (`protocol-automation-flow-reads.ts`,
* `getFlowRegistration`): the kernel's service table, read by name and per
* call, for an optional member the engine declares on its contract —
* `IObjectQLEngine.getDisabledActions` in `@objectstack/spec/contracts`. This
* package never imports `@objectstack/objectql` (which depends on it). The
* slot is `objectql`, the one the dispatch doors resolve the engine from
* before they consult the same projection, so the read and the 409 refusal
* answer from one fact.
*
* ## What it never does
*
* ⛔ It never reads or rewrites the body it is given: `actions[]` is served as
* declared, a switched-off action included (the Setup page lists from it to
* offer the switch back on), and no `visible` / `disabled` member is written
* into an action, which would ride the GET → edit → PUT round trip back into
* the stored body. ⛔ It never makes up an answer: where the engine declares
* no member, or answers `undefined` (no ledger attached), the read serves no
* key — absence is "no ledger answered", never "nothing is switched off".
*
* The bodies take the host-literal shape `protocol-automation-flow-reads.ts`
* documents, so the class keeps each method and its docblock.
*/

import type { IObjectQLEngine } from '@objectstack/spec/contracts';
import type { ObjectStackProtocolImplementation } from './protocol.js';
import { canonicalMetaType } from './protocol.js';

/**
* The `ObjectStackProtocolImplementation` members the functions below read —
* every one of them, and no other; each function's `this` is the `Pick` of
* the members it reads, typed AS the class member.
*/
export interface ActionActivationReadsHost {
readonly getServicesRegistry: ObjectStackProtocolImplementation['getServicesRegistry'];
readonly resolveDisabledActionsReader: ObjectStackProtocolImplementation['resolveDisabledActionsReader'];
}

/**
* The names of `body.actions[]` the switched-off set holds, in the order the
* body declares them, each once. A body with no `actions` array holds none.
*/
function switchedOffActionsOf(body: unknown, off: ReadonlySet<string>): string[] {
const actions = (body as { actions?: unknown } | null | undefined)?.actions;
if (!Array.isArray(actions)) return [];
const named = new Set<string>();
for (const action of actions) {
const name = (action as { name?: unknown } | null | undefined)?.name;
if (typeof name === 'string' && off.has(name)) named.add(name);
}
return [...named];
}

/** The body of `ObjectStackProtocolImplementation#resolveDisabledActionsReader`; the method's docblock there is its contract. */
export function resolveDisabledActionsReader(
this: Pick<ActionActivationReadsHost, 'getServicesRegistry'>,
type: string,
): ((body: unknown) => string[]) | undefined {
if (canonicalMetaType(type) !== 'object') return undefined;
const engine = this.getServicesRegistry?.().get('objectql') as
| Pick<IObjectQLEngine, 'getDisabledActions'>
| undefined;
const read = engine?.getDisabledActions;
if (typeof read !== 'function') return undefined;
const answer = read.call(engine);
if (!Array.isArray(answer)) return undefined;
const off = new Set<string>(answer);
return (body) => switchedOffActionsOf(body, off);
}

/** The body of `ObjectStackProtocolImplementation#disabledActionsOf`; the method's docblock there is its contract. */
export function disabledActionsOf(
this: Pick<ActionActivationReadsHost, 'resolveDisabledActionsReader'>,
type: string,
items: readonly unknown[],
): { disabledActions?: Record<string, string[]> } {
const read = this.resolveDisabledActionsReader(type);
if (!read) return {};
const entries: Array<[string, string[]]> = [];
for (const it of items) {
const name = (it as { name?: unknown } | null | undefined)?.name;
if (typeof name !== 'string' || (it as { _draft?: unknown })._draft === true) continue;
entries.push([name, read(it)]);
}
return { disabledActions: Object.fromEntries(entries) };
}
Loading
Loading