From 898cc78e0002d34dfec0d9861047901c21bfb298 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 03:48:42 +0000 Subject: [PATCH 1/6] wip(spec,objectql,metadata-protocol,rest,runtime): disabledActions beside the object read; plain disabled-action sentence Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .../src/protocol-action-activation-reads.ts | 102 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 68 +++++++++++- .../objectql/src/action-activation.test.ts | 57 +++++++++- packages/objectql/src/action-activation.ts | 28 +++-- packages/objectql/src/engine.ts | 27 ++++- packages/rest/src/meta-item-read-routes.ts | 21 +++- packages/runtime/src/action-execution.ts | 6 +- .../action-activation-dispatch.test.ts | 42 +++++--- .../src/domains/api-action-forward.test.ts | 6 +- packages/spec/src/api/protocol.zod.ts | 68 ++++++++++++ .../spec/src/contracts/objectql-engine.ts | 24 +++++ 11 files changed, 413 insertions(+), 36 deletions(-) create mode 100644 packages/metadata-protocol/src/protocol-action-activation-reads.ts diff --git a/packages/metadata-protocol/src/protocol-action-activation-reads.ts b/packages/metadata-protocol/src/protocol-action-activation-reads.ts new file mode 100644 index 00000000000..35f2e742e6f --- /dev/null +++ b/packages/metadata-protocol/src/protocol-action-activation-reads.ts @@ -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[] { + const actions = (body as { actions?: unknown } | null | undefined)?.actions; + if (!Array.isArray(actions)) return []; + const named = new Set(); + 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, + type: string, +): ((body: unknown) => string[]) | undefined { + if (canonicalMetaType(type) !== 'object') return undefined; + const engine = this.getServicesRegistry?.().get('objectql') as + | Pick + | 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(answer); + return (body) => switchedOffActionsOf(body, off); +} + +/** The body of `ObjectStackProtocolImplementation#disabledActionsOf`; the method's docblock there is its contract. */ +export function disabledActionsOf( + this: Pick, + type: string, + items: readonly unknown[], +): { disabledActions?: Record } { + 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) }; +} diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 0bf1aa32758..61604f3a09f 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -410,6 +410,7 @@ import { emitMetaItemPublished, notifyMutationListenersLocal, publishMetadataMut import { hierarchyResolverMounted, orgWallEnforced, rlsMembershipResolverKeys, tenancyPostureInForce } from './protocol-authoring-posture-probes.js'; // [#25467 cut 27] The automation service's flow reads: the stored-flow canonicalizer and the engine's registrations. import { flowRegistrationsOf, resolveFlowCanonicalizer, resolveFlowRegistrationReader } from './protocol-automation-flow-reads.js'; +import { disabledActionsOf, resolveDisabledActionsReader } from './protocol-action-activation-reads.js'; // [#25467 cut 28] The code-datasource predicates: declared by an installed package, or registered by the host. import { isDeclaredCodeDatasource, isHostCodeDatasource } from './protocol-code-datasource-predicates.js'; // [#25467 cut 29] The legacy-organization row filters: which rows a not-yet-migrated database still holds. @@ -6735,6 +6736,48 @@ export class ObjectStackProtocolImplementation implements return flowRegistrationsOf.call(host, type, items); } + /** + * [#27842] The data engine's packaged-action activation reader for an + * `object` read — the `disabledActions` beside the item read's envelope + * and beside the list's items (`GetMetaItemResponseSchema` in + * `@objectstack/spec`): given an object body, the names in its + * `actions[]` the activation ledger switches off. + * + * The seam {@link resolveFlowRegistrationReader} uses: the kernel's + * service table, read by name per call, for the member the engine declares + * on `IObjectQLEngine` (`getDisabledActions`). The slot is `objectql`, the + * one the dispatch doors resolve before they refuse a switched-off action + * 409 `ACTION_DISABLED`, so the read and the refusal answer from one + * projection. This package never imports the engine. The engine is asked + * once per call of this method, so one list read is one answer. + * + * `undefined` — so the read serves NO key — for every other type, where + * no reachable engine declares the member, and where the engine answers + * that no ledger is attached. ⛔ Never `[]` in those cases: absence is "no + * ledger answered", and `[]` is the ledger's own "nothing here is off". + */ + private resolveDisabledActionsReader(type: string): ((body: unknown) => string[]) | undefined { + const readGetServicesRegistry = () => this.getServicesRegistry; + const host: ThisParameterType = { + get getServicesRegistry() { return readGetServicesRegistry(); }, + }; + return resolveDisabledActionsReader.call(host, type); + } + + /** + * [#27842] The list's `disabledActions`: the reader's answer per listed + * object, keyed by its `name` — or nothing, where + * {@link resolveDisabledActionsReader} resolves no reader. A pending draft + * (`_draft: true`, the `previewDrafts` overlay) gets no entry, as + * {@link flowRegistrationsOf} gives it none. + */ + private disabledActionsOf(type: string, items: readonly unknown[]): { disabledActions?: Record } { + const host: ThisParameterType = { + resolveDisabledActionsReader: (...a) => this.resolveDisabledActionsReader(...a), + }; + return disabledActionsOf.call(host, type, items); + } + /** * [#20312] The deployment's ADR-0080 SDUI component manifest, when the host * registered one under {@link SDUI_MANIFEST_SERVICE} — `os serve` does, from @@ -10145,6 +10188,10 @@ export class ObjectStackProtocolImplementation implements // see {@link flowRegistrationsOf}. The execution face is the // engine's own read, so it carries none. ...(audience === 'served' ? this.flowRegistrationsOf(request.type, governed) : {}), + // [#27842] Served only, likewise: the packaged actions the + // activation ledger switches off, per listed object — see + // {@link disabledActionsOf}. + ...(audience === 'served' ? this.disabledActionsOf(request.type, governed) : {}), }; } @@ -11272,6 +11319,13 @@ export class ObjectStackProtocolImplementation implements const readRegistration = !execution && decorated !== undefined && decorated !== null ? this.resolveFlowRegistrationReader(request.type) : undefined; + // [#27842] …and, on an `object` read, the names in `item.actions` the + // activation ledger switches off ({@link resolveDisabledActionsReader}), + // beside the item on the same terms: served face only, none on the + // draft returns above, `item` untouched. + const readDisabledActions = !execution && decorated !== undefined && decorated !== null + ? this.resolveDisabledActionsReader(request.type) + : undefined; return { type: request.type, name: request.name, @@ -11301,6 +11355,7 @@ export class ObjectStackProtocolImplementation implements : null, }), ...(readRegistration ? { registration: readRegistration(request.name) } : {}), + ...(readDisabledActions ? { disabledActions: readDisabledActions(decorated) } : {}), }; } @@ -14404,7 +14459,7 @@ export class ObjectStackProtocolImplementation implements * environment → code read order (ADR-0131 D6) — the whole reason this * method delegates rather than re-reading. */ - async getMetaItemCached(request: { type: string, name: string, cacheRequest?: MetadataCacheRequest, locale?: string }): Promise { + async getMetaItemCached(request: { type: string, name: string, cacheRequest?: MetadataCacheRequest, locale?: string }): Promise { // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. The ETag // and the cache entry are keyed by type, so two spellings would cache // the same item twice and invalidate only one of them. @@ -14423,6 +14478,10 @@ export class ObjectStackProtocolImplementation implements // rides beside `data` for the REST layer's default door, and is // never hashed into the ETag below (see the 304 note there). const registration = (result as { registration?: FlowRegistration } | undefined)?.registration; + // [#27842] …and, for an object, the packaged actions the activation + // ledger switches off, on exactly those terms: live, beside `data`, + // never hashed (see the 304 note below). + const disabledActions = (result as { disabledActions?: string[] } | undefined)?.disabledActions; if (!item) { // [#5532] Structured: 404 + the catalog's `RESOURCE_NOT_FOUND`. @@ -14485,7 +14544,11 @@ export class ObjectStackProtocolImplementation implements // flow under an unchanged body. Such a read is always answered in // full; the work is the same either way, since this method always // performs the delegate read. - if (request.cacheRequest?.ifNoneMatch && registration === undefined) { + // + // [#27842] The same holds for `disabledActions`: an administrator's + // switch moves no byte of the object document, so a 304 would keep + // the caller on the switched-off list it was served before. + if (request.cacheRequest?.ifNoneMatch && registration === undefined && disabledActions === undefined) { const clientEtag = request.cacheRequest.ifNoneMatch.replace(/^"(.*)"$/, '$1').replace(/^W\/"(.*)"$/, '$1'); if (clientEtag === hash) { // Return 304 Not Modified @@ -14500,6 +14563,7 @@ export class ObjectStackProtocolImplementation implements return { data: item, ...(registration !== undefined ? { registration } : {}), + ...(disabledActions !== undefined ? { disabledActions } : {}), etag, lastModified: new Date().toISOString(), cacheControl: { diff --git a/packages/objectql/src/action-activation.test.ts b/packages/objectql/src/action-activation.test.ts index ac0642592d1..b2913089fe6 100644 --- a/packages/objectql/src/action-activation.test.ts +++ b/packages/objectql/src/action-activation.test.ts @@ -309,14 +309,20 @@ describe('ActionActivationProjection — absence means ACTIVE', () => { expect(projection.isEnabled('convert_lead')).toBe(true); }); - it('the refusal sentence names the ledger and the remedies — and ⛔ never a clone', () => { + it('[#27842] the refusal sentence is plain words for the person who clicked — no table, no ADR, ⛔ 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'); + // What happened, who can undo it and where, and what to do meanwhile. + expect(message).toBe( + '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.', + ); + // The machine half (the ledger table, the ADR) belongs to the log line + // and the envelope's code, never to what an end user reads. + expect(message).not.toContain('sys_metadata_activation'); + expect(message).not.toMatch(/ADR-\d+/); // 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. @@ -349,7 +355,48 @@ describe('ObjectQL engine — the projection the dispatch doors consult', () => 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'); + expect(engine.describeDisabledAction('convert_lead')).toBe(new ActionActivationProjection().describeDisabled('convert_lead')); + }); + + // [#27842] The member the metadata read serves beside an object as + // `disabledActions`: the doors' own projection, or nothing while no ledger + // is attached — never `[]` for "no ledger", which would tell a client that + // nothing is switched off when no ledger answered at all. + it('getDisabledActions: undefined with no ledger attached, [] for an empty one, the names once one is off', async () => { + const engine = new ObjectQL(); + expect(engine.getDisabledActions()).toBeUndefined(); + // The operability read keeps its own answer for the same state. + expect(engine.listDisabledActions()).toEqual([]); + + const store = new InMemoryActionActivationStore(); + engine.setActionActivationStore(store); + await engine.hydrateActionActivations(); + expect(engine.getDisabledActions()).toEqual([]); + + await engine.setActionActive({ name: 'convert_lead', packageId: 'crm', active: false }); + expect(engine.getDisabledActions()).toEqual(['convert_lead']); + expect(engine.isActionEnabled('convert_lead')).toBe(false); + + await engine.setActionActive({ name: 'convert_lead', packageId: 'crm', active: true }); + expect(engine.getDisabledActions()).toEqual([]); + }); + + it('[#27842] the sentence leaves the ledger and the ADR out; the log line the engine writes keeps them, with the code', () => { + const info = vi.fn(); + const logger: any = { info, warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const engine = new ObjectQL({ logger }); + info.mockClear(); + + const sentence = engine.describeDisabledAction('convert_lead'); + + expect(sentence).not.toContain('sys_metadata_activation'); + expect(info).toHaveBeenCalledTimes(1); + const [line, meta] = info.mock.calls[0]; + expect(line).toContain('ACTION_DISABLED'); + expect(line).toContain("'convert_lead'"); + expect(line).toContain('sys_metadata_activation'); + expect(line).toContain('ADR-0126 §8'); + expect(meta).toEqual({ action: 'convert_lead', code: 'ACTION_DISABLED' }); }); it('setActionActive flips the row and the projection together, in that order', async () => { diff --git a/packages/objectql/src/action-activation.ts b/packages/objectql/src/action-activation.ts index b817cd2a2da..616f3ff3f80 100644 --- a/packages/objectql/src/action-activation.ts +++ b/packages/objectql/src/action-activation.ts @@ -290,16 +290,28 @@ export class ActionActivationProjection { * 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. + * [#27842] It is written for the person who clicked the button, and it is + * what their red toast reads: what happened, who can undo it and where, + * and what they can do meanwhile. It carries no system table, no ADR and + * no tracker number (AGENTS.md: a runtime string carries the lesson, not + * its citation). The machine half stays where a machine reads it: the + * envelope's `ACTION_DISABLED` code and 409, and the log line the engine + * writes when a door asks for this sentence + * (`ObjectQL.describeDisabledAction`), which names the ledger and + * ADR-0126 §8. + * + * ⛔ Never a clone: action-clone is not chartered (ADR-0126 §8 item 2), so + * recommending one would advertise machinery that does not exist. Using + * another action is open exactly as it is today, and that is what it says. + * + * The name is not in the sentence: the caller named the action it asked + * for, and a machine name read in a toast is not plain language. The + * parameter stays so the doors' call is unchanged. */ - describeDisabled(name: string): string { + 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.` + '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.' ); } } diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index fd1d10a8237..76e6439863d 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -5851,7 +5851,7 @@ export class ObjectQL implements IObjectQLEngine { // [ADR-0126 §8] Packaged-action activation // ======================================== // - // Five thin members over {@link ActionActivationProjection}. They are on the + // Seven 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. @@ -5900,11 +5900,36 @@ export class ObjectQL implements IObjectQLEngine { return this.actionActivation.disabledNames(); } + /** + * [#27842] `IObjectQLEngine.getDisabledActions` — what the metadata read + * serves beside an object as `disabledActions`: the names the projection + * the doors consult switches off, or `undefined` while no ledger is + * attached, so the read serves no key rather than claiming that nothing is + * off. {@link listDisabledActions} answers `[]` for both cases, and keeps + * that answer. + */ + getDisabledActions(): string[] | undefined { + return this.actionActivation.durable ? this.actionActivation.disabledNames() : undefined; + } + /** * The `ACTION_DISABLED` refusal SENTENCE, produced by the projection so both * dispatch doors state one refusal rather than two that agree today. + * + * [#27842] The sentence is written for the person who clicked, so it names + * no table and no ADR. Those, with the code and the action's name, go to + * the log line written here: both doors ask for the sentence exactly when + * they refuse (`disabledActionRefusal` in `@objectstack/runtime`), so this + * is the one place every refusal passes that holds the host's logger. It is + * `info`, not `warn`: the refusal is handed to the caller, and nothing is + * degraded (AGENTS.md "Degradation log levels"). */ describeDisabledAction(actionName: string): string { + this.logger.info( + `ACTION_DISABLED: packaged action '${actionName}' refused — it is switched off in the activation ledger ` + + '(sys_metadata_activation, ADR-0126 §8 item 2).', + { action: actionName, code: 'ACTION_DISABLED' }, + ); return this.actionActivation.describeDisabled(actionName); } diff --git a/packages/rest/src/meta-item-read-routes.ts b/packages/rest/src/meta-item-read-routes.ts index 38a5585516e..0655c6e2565 100644 --- a/packages/rest/src/meta-item-read-routes.ts +++ b/packages/rest/src/meta-item-read-routes.ts @@ -765,6 +765,20 @@ export function registerMetaItemReadRoutes(this: MetaItemReadRouteHost, scope: M const cachedRegistration = metaType === 'flow' && result.registration !== undefined ? { registration: result.registration } : {}; + // [#27842] The action twin, for an OBJECT: the + // packaged actions the activation ledger switches + // off, beside the document on the same terms. This + // branch is the default door of + // `GET /meta/object/:name`. An object's validator is + // folded and judged HERE (`audienceFolds`), so the + // 304 below stands down for a read that carries the + // key, as the protocol's does for `registration`: the + // switch moves no byte the validator hashes. + const cachedDisabledActions = metaType === 'object' && result.disabledActions !== undefined + ? { disabledActions: result.disabledActions } + : {}; + const cachedBeside = { ...cachedRegistration, ...cachedDisabledActions }; + const besideJudged = Object.keys(cachedBeside).length > 0; if (result.notModified) { res.status(304).send(); @@ -835,7 +849,7 @@ export function registerMetaItemReadRoutes(this: MetaItemReadRouteHost, scope: M res.header('Vary', 'Accept-Language'); res.json(await this.translateMetaEnvelope( req, req.params.type, environmentId, - { type: metaType, name: req.params.name, ...cachedRegistration }, + { type: metaType, name: req.params.name, ...cachedBeside }, cachedDocument, cacheI18n, runtimeView.document, )); return; @@ -871,7 +885,8 @@ export function registerMetaItemReadRoutes(this: MetaItemReadRouteHost, scope: M ? `W/"${value}"` : `"${value}"`; res.header('ETag', etagValue); - if ((maskApplies || intakeFolds || audienceFolds) && normalizeIfNoneMatch(req.headers['if-none-match']) === value) { + if ((maskApplies || intakeFolds || audienceFolds) && !besideJudged + && normalizeIfNoneMatch(req.headers['if-none-match']) === value) { res.status(304).send(); return; } @@ -921,7 +936,7 @@ export function registerMetaItemReadRoutes(this: MetaItemReadRouteHost, scope: M const cachedEnvelope = { type: metaType, name: req.params.name, - ...cachedRegistration, + ...cachedBeside, }; res.json(await this.translateMetaEnvelope( req, req.params.type, environmentId, cachedEnvelope, cachedDocument, cacheI18n, diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index ddb33cf5a40..332f1be62da 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -939,10 +939,12 @@ export function disabledActionRefusal( // 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. + // engine that answers the boolean but not the prose. [#27842] Both are + // written for the person who clicked: no table, no ADR (the engine logs + // those when it is asked for the sentence; the code rides the envelope). const message = typeof ql.describeDisabledAction === 'function' ? String(ql.describeDisabledAction(name)) - : `Action '${name}' is disabled for this installation (ADR-0126 §8).`; + : 'This action is turned off for this deployment.'; return { code: ACTION_DISABLED_CODE, status: ACTION_DISABLED_STATUS, message }; } diff --git a/packages/runtime/src/domains/action-activation-dispatch.test.ts b/packages/runtime/src/domains/action-activation-dispatch.test.ts index 3f06d74cb4b..7d6327bc05f 100644 --- a/packages/runtime/src/domains/action-activation-dispatch.test.ts +++ b/packages/runtime/src/domains/action-activation-dispatch.test.ts @@ -30,6 +30,9 @@ 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'; +// [#27842] The engine's own sentence producer, so the doubles below serve the +// sentence the engine serves rather than a transcribed copy of it. +import { ActionActivationProjection } from '@objectstack/objectql'; const OBJECT = 'crm_lead'; /** @@ -78,10 +81,8 @@ function boot(opts: { disabled?: string[]; legacyEngine?: boolean; flowResult?: }; 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 projection = new ActionActivationProjection(); + ql.describeDisabledAction = (name: string) => projection.describeDisabled(name); } const metadata: any = { @@ -129,16 +130,23 @@ describe('door 1 — REST `POST /actions/:object/:action`', () => { expect(executeAction).not.toHaveBeenCalled(); }); - it('the refusal names the ledger, the ADR and the remedy — ⛔ and never a clone', async () => { + it('the refusal is a plain sentence for the person who clicked: who can undo it and where — no table, no ADR, ⛔ never a clone', async () => { const { dispatcher } = boot({ disabled: [SCRIPT_ACTION.name] }); - const message = messageOf(await dispatcher.handleActions( - `/${OBJECT}/${SCRIPT_ACTION.name}`, 'POST', {}, CTX(), - )); + const res = await dispatcher.handleActions(`/${OBJECT}/${SCRIPT_ACTION.name}`, 'POST', {}, CTX()); + const message = messageOf(res); - expect(message).toContain('sys_metadata_activation'); - expect(message).toContain('ADR-0126 §8'); - expect(message).toContain('Re-enable the packaged action'); + // [#27842] The machine half stays machine-readable on the envelope. + expect(statusOf(res)).toBe(409); + expect(codeOf(res)).toBe('ACTION_DISABLED'); + // The words are the contract here (they are what the toast reads), so + // the first sentence is pinned, and the remedy beside it. + expect(message.startsWith('This action is turned off for this deployment.')).toBe(true); + expect(message).toContain('An administrator can turn it on again under Setup › Packaged automation'); + // No system table, no ADR citation, no tracker number in what an end user reads. + expect(message).not.toContain('sys_metadata_activation'); + expect(message).not.toMatch(/ADR-\d+/); + expect(message).not.toMatch(/#\d+/); // Action-clone is NOT chartered (§8 item 2) — the sentence must not // advertise machinery that does not exist. expect(message).not.toMatch(/clone/i); @@ -237,7 +245,9 @@ describe('door 2 — the MCP `run_action` bridge', () => { // 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'); + // [#27842] The same plain sentence the REST door serves, from the one producer. + expect(String(thrown.message)).toBe(new ActionActivationProjection().describeDisabled(SCRIPT_ACTION.name)); + expect(String(thrown.message).startsWith('This action is turned off for this deployment.')).toBe(true); expect(h.executeAction).not.toHaveBeenCalled(); }); @@ -273,4 +283,12 @@ describe('the shared guard itself', () => { message: `bespoke sentence for ${SCRIPT_ACTION.name}`, }); }); + + it('[#27842] an engine that answers the switch but not the sentence still refuses in plain words', () => { + const refusal = disabledActionRefusal({} as any, { isActionEnabled: () => false }, SCRIPT_ACTION); + + expect(refusal?.code).toBe('ACTION_DISABLED'); + expect(refusal?.status).toBe(409); + expect(refusal?.message).toBe('This action is turned off for this deployment.'); + }); }); diff --git a/packages/runtime/src/domains/api-action-forward.test.ts b/packages/runtime/src/domains/api-action-forward.test.ts index 8649502e2b1..4ffa920535a 100644 --- a/packages/runtime/src/domains/api-action-forward.test.ts +++ b/packages/runtime/src/domains/api-action-forward.test.ts @@ -37,6 +37,7 @@ import { offOriginApiActivationRefusal, API_FORWARD_MARK_HEADER, } from '../action-execution.js'; +import { ActionActivationProjection } from '@objectstack/objectql'; const OBJECT = 'crm_lead'; const BAN_LEAD = { @@ -89,9 +90,8 @@ function boot(opts: { }), insert: vi.fn(), update: vi.fn(), delete: vi.fn(), isActionEnabled: (name: string) => !disabled.has(name), - describeDisabledAction: (name: string) => - `Action '${name}' is disabled — switched off in the packaged-metadata activation ledger ` + - `(sys_metadata_activation, ADR-0126 §8).`, + // [#27842] The engine's own sentence, never a transcribed copy of it. + describeDisabledAction: (name: string) => new ActionActivationProjection().describeDisabled(name), }; const metadata: any = { load: vi.fn(async () => null), diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 020ad27f9de..80a691c818e 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -319,6 +319,12 @@ export const FlowRegistrationSchema = lazySchema(() => z.discriminatedUnion('sta * ({@link FlowRegistrationSchema}): the items stay bare documents, so the * verdict sits beside them, keyed by each item's `name` — readable without a * per-item read, and never inside an item. + * + * [#27842] `disabledActions` is the same position for the packaged-action + * activation ledger on an `object` list: each listed object's entry is what + * its item read serves as `disabledActions` ({@link GetMetaItemResponseSchema} + * says why it is beside the item, why absence means no ledger answered, and + * why `[]` is stated), keyed by the object's `name`. */ export const GetMetaItemsResponseSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name'), @@ -330,6 +336,15 @@ export const GetMetaItemsResponseSchema = lazySchema(() => z.object({ + '`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: z.record(z.string(), z.array(z.string())).optional().describe( + '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.', + ), })); /** @@ -596,6 +611,33 @@ export type MetadataAuthoringClient = z.input z.object({ + 'the answering process\'s engine. Beside `item`, never inside it: the item\'s `status` stays what ' + 'its author declared.', ), + disabledActions: z.array(z.string()).optional().describe( + '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: z.array(z.object({ path: z.array(z.string().min(1)).describe( 'The changed key path inside `item`: one object key per segment, from the body root down to the ' @@ -2321,6 +2374,13 @@ export const GetMetaItemCachedRequestSchema = lazySchema(() => z.object({ * folded into `etag`, so a read that carries it is never answered * `notModified`: a 304 would pin the caller to the verdict it was served * before, which the validator cannot see change. + * + * [#27842] …and `disabledActions`, on the same terms: `GET /meta/object/:name` + * on a default deployment is answered here, so the packaged actions the + * activation ledger switches off ride beside `data`, read live from the + * delegate read, and a read that carries them is never answered + * `notModified` — an administrator's switch moves no byte of the document, so + * a 304 would keep the caller on the list it was served before the switch. */ export const GetMetaItemCachedResponseSchema = lazySchema(() => MetadataCacheResponseSchema.extend({ registration: FlowRegistrationSchema.optional().describe( @@ -2329,6 +2389,14 @@ export const GetMetaItemCachedResponseSchema = lazySchema(() => MetadataCacheRes + '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: z.array(z.string()).optional().describe( + '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.', + ), })); /** diff --git a/packages/spec/src/contracts/objectql-engine.ts b/packages/spec/src/contracts/objectql-engine.ts index 78adfe03b51..46271b7e201 100644 --- a/packages/spec/src/contracts/objectql-engine.ts +++ b/packages/spec/src/contracts/objectql-engine.ts @@ -325,6 +325,30 @@ export interface IObjectQLEngine extends IDataEngine { removeActionsByPackage(packageName: string): void; /** The dispatcher's action path — one of the two members `getObjectQL` was recorded as needing. */ executeAction(objectName: string, actionName: string, ctx: any): Promise; + /** + * The packaged actions this engine's activation ledger switches off, by + * declarative name (ADR-0126 §8 item 2) — the same projection the dispatch + * doors consult before they refuse a call 409 `ACTION_DISABLED` — or + * `undefined` when no activation ledger is attached to this engine. + * + * [#27842] Declared for the metadata read, which serves it beside an + * object as `disabledActions` (`GetMetaItemResponseSchema`, + * `api/protocol.zod.ts`) so a client can stop offering an action the + * doors will refuse. The metadata protocol reads this member through the + * kernel's service table by name (`objectql`), per call, as it reads + * `IAutomationService.getFlowRegistration`: it never imports the engine. + * + * The two answers are deliberately apart. `undefined` is "no ledger + * answered" (a composition that registers no `sys_metadata_activation`, + * or one whose ledger table failed its probe, so no store was attached): + * the read then serves no key, never an empty list. `[]` is an attached + * ledger that switches nothing off — what the doors then dispatch by. The engine's own operability read, `listDisabledActions()`, + * answers `[]` for both and stays off this contract. + * + * Optional: an engine that keeps no activation projection does not + * declare it, and the read then serves no key rather than a guess. + */ + getDisabledActions?(): string[] | undefined; // ── Hook / middleware seams ────────────────────────────────────────── /** From 9a48fecb6b8b865eec8c9020426497fda6b3ff27 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 03:56:48 +0000 Subject: [PATCH 2/6] wip(spec): regenerate docs and authorable surface; disabledActions shape pins Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- content/docs/references/api/protocol.mdx | 3 + packages/spec/authorable-surface/api.json | 3 + .../src/api/meta-item-response-shapes.test.ts | 59 +++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index c450736f32b..10507c7ca23 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -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` @@ -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` @@ -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` | 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` | 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']` diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 2b92f0f0b39..0bc05d2676d 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -749,6 +749,7 @@ "api/GetMetaItemCachedRequest:type", "api/GetMetaItemCachedResponse:cacheControl", "api/GetMetaItemCachedResponse:data", + "api/GetMetaItemCachedResponse:disabledActions", "api/GetMetaItemCachedResponse:etag", "api/GetMetaItemCachedResponse:lastModified", "api/GetMetaItemCachedResponse:notModified", @@ -782,6 +783,7 @@ "api/GetMetaItemRequest:state", "api/GetMetaItemRequest:type", "api/GetMetaItemResponse:deletable", + "api/GetMetaItemResponse:disabledActions", "api/GetMetaItemResponse:draftChanges", "api/GetMetaItemResponse:editable", "api/GetMetaItemResponse:item", @@ -801,6 +803,7 @@ "api/GetMetaItemsRequest:packageId", "api/GetMetaItemsRequest:previewDrafts", "api/GetMetaItemsRequest:type", + "api/GetMetaItemsResponse:disabledActions", "api/GetMetaItemsResponse:items", "api/GetMetaItemsResponse:registrations", "api/GetMetaItemsResponse:type", diff --git a/packages/spec/src/api/meta-item-response-shapes.test.ts b/packages/spec/src/api/meta-item-response-shapes.test.ts index c2a7d05bf61..636bb43612d 100644 --- a/packages/spec/src/api/meta-item-response-shapes.test.ts +++ b/packages/spec/src/api/meta-item-response-shapes.test.ts @@ -22,6 +22,7 @@ import { describe, it, expect } from 'vitest'; import { FlowRegistrationSchema, + GetMetaItemCachedResponseSchema, GetMetaItemResponseSchema, GetMetaItemsResponseSchema, GetMetaItemLayeredResponseSchema, @@ -29,6 +30,7 @@ import { } from './protocol.zod'; import type { FlowRegistration, + GetMetaItemCachedResponse, GetMetaItemResponse, GetMetaItemsResponse, GetMetaItemLayeredResponse, @@ -377,3 +379,60 @@ describe('`draftChanges` — which surface last changed each key of a pending dr .toMatchObject({ code: 'invalid_format', path: ['draftChanges', 0, 'at'] }); }); }); + +/** + * #27842 — the packaged actions the activation ledger switches off, beside an + * object on all three reads: typed as a name list (the list keyed by object + * name), optional, and never defaulted — absence means no ledger answered. + */ +export type DisabledActionsAreNamesOrAbsent = Assert< Eq< GetMetaItemResponse['disabledActions'], string[] | undefined > >; +export type CachedDisabledActionsAreNamesOrAbsent = Assert< Eq< GetMetaItemCachedResponse['disabledActions'], string[] | undefined > >; +export type ListDisabledActionsAreKeyedByObject = Assert< Eq< GetMetaItemsResponse['disabledActions'], Record | undefined > >; + +describe('`disabledActions` beside an object: the names, `[]`, or absent (#27842)', () => { + const TASK = { + name: 'showcase_task', + actions: [{ name: 'showcase_mark_done', label: 'Mark Done' }, { name: 'showcase_snooze', label: 'Snooze' }], + }; + + it('keeps the names through a parse, beside an item whose `actions` are untouched', () => { + const parsed = GetMetaItemResponseSchema.parse({ + type: 'object', name: 'showcase_task', item: TASK, disabledActions: ['showcase_mark_done'], + }); + expect(parsed.disabledActions).toEqual(['showcase_mark_done']); + expect(parsed.item).toEqual(TASK); + }); + + it('`[]` survives a parse as `[]`, and an absent key stays ABSENT — never defaulted to `[]`', () => { + expect(GetMetaItemResponseSchema.parse({ type: 'object', name: 'showcase_task', item: TASK, disabledActions: [] }) + .disabledActions).toEqual([]); + expect('disabledActions' in GetMetaItemResponseSchema.parse({ type: 'object', name: 'showcase_task', item: TASK })) + .toBe(false); + expect('disabledActions' in GetMetaItemCachedResponseSchema.parse({ data: TASK, notModified: false })).toBe(false); + }); + + it('the cached default door carries it beside `data`', () => { + const parsed = GetMetaItemCachedResponseSchema.parse({ + data: TASK, notModified: false, disabledActions: ['showcase_mark_done'], + }); + expect(parsed.disabledActions).toEqual(['showcase_mark_done']); + }); + + it('the list keeps it keyed by object name, `[]` entries included', () => { + const parsed = GetMetaItemsResponseSchema.parse({ + type: 'object', + items: [TASK, { name: 'showcase_project' }], + disabledActions: { showcase_task: ['showcase_mark_done'], showcase_project: [] }, + }); + expect(parsed.disabledActions).toEqual({ showcase_task: ['showcase_mark_done'], showcase_project: [] }); + expect('disabledActions' in GetMetaItemsResponseSchema.parse({ type: 'object', items: [TASK] })).toBe(false); + }); + + it('refuses a name that is not a string, located at the key', () => { + const verdict = GetMetaItemResponseSchema.safeParse({ + type: 'object', name: 'showcase_task', item: TASK, disabledActions: [{ name: 'showcase_mark_done' }], + }); + expect(verdict.success).toBe(false); + if (!verdict.success) expect(verdict.error.issues[0]?.path).toEqual(['disabledActions', 0]); + }); +}); From d51a274959f3bc1379c6f6b3a23312558304e38f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 04:00:11 +0000 Subject: [PATCH 3/6] wip: tests for disabledActions on all three doors; dogfood and checklist sentence pins; changesets Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .../27842-disabled-actions-beside-item.md | 10 + .changeset/27842-disabled-actions-served.md | 15 ++ .../platform-checklist/areas/api-backend.json | 12 +- .../protocol.object-disabled-actions.test.ts | 244 ++++++++++++++++++ .../protocol-meta-disabled-actions.test.ts | 217 ++++++++++++++++ ...ion-disabled-dispatch-live.dogfood.test.ts | 20 +- ...ed-activation-ledger-reach.dogfood.test.ts | 4 +- ...eta-object-cached-disabled-actions.test.ts | 131 ++++++++++ 8 files changed, 644 insertions(+), 9 deletions(-) create mode 100644 .changeset/27842-disabled-actions-beside-item.md create mode 100644 .changeset/27842-disabled-actions-served.md create mode 100644 packages/metadata-protocol/src/protocol.object-disabled-actions.test.ts create mode 100644 packages/objectql/src/protocol-meta-disabled-actions.test.ts create mode 100644 packages/rest/src/meta-object-cached-disabled-actions.test.ts diff --git a/.changeset/27842-disabled-actions-beside-item.md b/.changeset/27842-disabled-actions-beside-item.md new file mode 100644 index 00000000000..272343f9be1 --- /dev/null +++ b/.changeset/27842-disabled-actions-beside-item.md @@ -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. diff --git a/.changeset/27842-disabled-actions-served.md b/.changeset/27842-disabled-actions-served.md new file mode 100644 index 00000000000..9c05d6e70c6 --- /dev/null +++ b/.changeset/27842-disabled-actions-served.md @@ -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: added + +- **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. diff --git a/docs/qa/platform-checklist/areas/api-backend.json b/docs/qa/platform-checklist/areas/api-backend.json index 12deeacfee6..92e4557550d 100644 --- a/docs/qa/platform-checklist/areas/api-backend.json +++ b/docs/qa/platform-checklist/areas/api-backend.json @@ -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": [ @@ -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" }, { @@ -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" } ] }, diff --git a/packages/metadata-protocol/src/protocol.object-disabled-actions.test.ts b/packages/metadata-protocol/src/protocol.object-disabled-actions.test.ts new file mode 100644 index 00000000000..baf8d2a5eaf --- /dev/null +++ b/packages/metadata-protocol/src/protocol.object-disabled-actions.test.ts @@ -0,0 +1,244 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27842] The `object` meta reads serve, beside the item, the packaged + * actions this deployment's activation ledger switches off: `disabledActions` + * on the item read (both doors) and, keyed by object name, beside the list's + * items. It is the action twin of `registration` (#23316), and it takes that + * key's four choices: + * + * - read through the kernel's service table by name (`objectql`), for the + * optional member the engine declares on `IObjectQLEngine` + * (`getDisabledActions`) — this package imports no engine; + * - absent where no ledger answered (no engine, an engine without the + * member, or one with no ledger attached), never `[]` there; + * - `[]` where a ledger answered and none of the object's actions is off; + * - on the cached default door too, never in the ETag, and a read that + * carries it is never answered 304. + * + * And the one the card fixes: `item.actions` is served byte for byte as + * declared, a switched-off action included. + * + * The engine double answers `getDisabledActions` the way `ObjectQL` does + * (pinned on the real engine, with the real protocol, in + * `@objectstack/objectql`'s `protocol-meta-disabled-actions.test.ts`). + */ +import { describe, expect, it, vi } from 'vitest'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +interface StoredRow { + id: string; + type: string; + name: string; + package_id: string | null; + state: string; + metadata: string; +} + +const storedRow = (type: string, name: string, body: unknown, extra: Partial = {}): StoredRow => ({ + id: `r_${type}_${name}_${extra.state ?? 'active'}`, + type, + name, + package_id: null, + state: 'active', + metadata: JSON.stringify(body), + ...extra, +}); + +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + +const TASK = { + name: 'showcase_task', + label: 'Task', + fields: { title: { name: 'title', type: 'text', label: 'Title' } }, + actions: [ + { name: 'showcase_mark_done', label: 'Mark Done', type: 'script', locations: ['record_header'] }, + { name: 'showcase_snooze', label: 'Snooze', type: 'script', locations: ['record_header'] }, + ], +}; +const PROJECT = { + name: 'showcase_project', + label: 'Project', + fields: { title: { name: 'title', type: 'text', label: 'Title' } }, + actions: [{ name: 'showcase_archive', label: 'Archive', type: 'script' }], +}; +const NOTE = { name: 'showcase_note', label: 'Note', fields: { body: { name: 'body', type: 'text', label: 'Body' } } }; +const ROWS = [storedRow('object', TASK.name, TASK), storedRow('object', PROJECT.name, PROJECT), storedRow('object', NOTE.name, NOTE)]; + +/** + * The store double: `find` / `findOne` over `sys_metadata` rows, and a + * registry that holds nothing of its own. ⛔ No write verb — the reads under + * test issue none. + */ +function harness(rows: StoredRow[], services: Map = new Map()) { + const matching = (where: Record) => { + for (const k of Object.keys(where)) { + if (k.startsWith('$')) throw new Error(`[test double] unsupported WHERE combinator '${k}'`); + } + return rows.filter((r) => + Object.entries(where).every(([k, v]) => v === undefined || whereValueMatches((r as unknown as Record)[k], v)), + ); + }; + const engine: any = { + async find(table: string, opts?: { where?: Record; limit?: number }) { + if (table !== 'sys_metadata') return []; + const matched = matching(opts?.where ?? {}); + // `check:objectql-double-limit` — the caller's bound, applied after the filter. + return opts?.limit === undefined ? matched : matched.slice(0, opts.limit); + }, + async findOne(table: string, opts?: { where?: Record }) { + // `check:engine-double-contract` — refuses what the real engine refuses. + assertEngineFindOnePredicate(table, opts); + if (table !== 'sys_metadata') return null; + return matching(opts?.where ?? {})[0] ?? null; + }, + registry: { + listItems: () => [], + getItem: () => undefined, + getArtifactItem: () => undefined, + getObject: () => undefined, + registerItem: () => undefined, + registerObject: () => undefined, + getPackage: () => undefined, + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }, + }; + return new ObjectStackProtocolImplementation(engine, () => services as Map); +} + +/** The engine's projection: `undefined` with no ledger attached, else the names it switches off. */ +function engineDouble(off: string[] | undefined) { + return { getDisabledActions: vi.fn((): string[] | undefined => (off === undefined ? undefined : [...off])) }; +} + +const withEngine = (off: string[] | undefined) => new Map([['objectql', engineDouble(off)]]); + +describe('[#27842] the item read: `disabledActions` beside `item`, the ledger\'s answer or nothing', () => { + it('names the switched-off actions of THIS object, and serves `item.actions` byte for byte as declared', async () => { + const services = withEngine(['showcase_mark_done', 'some_other_objects_action']); + const plain: any = await harness(ROWS).getMetaItem({ type: 'object', name: TASK.name }); + const judged: any = await harness(ROWS, services).getMetaItem({ type: 'object', name: TASK.name }); + + expect(judged.disabledActions).toEqual(['showcase_mark_done']); + // The card's load-bearing half: the document is untouched — no action + // removed, none rewritten `visible` / `disabled`. + expect(JSON.stringify(judged.item.actions)).toBe(JSON.stringify(plain.item.actions)); + expect(judged.item.actions.map((a: any) => a.name)).toEqual(['showcase_mark_done', 'showcase_snooze']); + expect(judged.item).not.toHaveProperty('disabledActions'); + expect(judged).toHaveProperty('lock'); + }); + + it('`[]` when a ledger answered and none of this object\'s actions is off — and for an object with no actions', async () => { + const services = withEngine(['showcase_mark_done']); + expect((await harness(ROWS, services).getMetaItem({ type: 'object', name: PROJECT.name }) as any).disabledActions) + .toEqual([]); + expect((await harness(ROWS, services).getMetaItem({ type: 'object', name: NOTE.name }) as any).disabledActions) + .toEqual([]); + expect((await harness(ROWS, withEngine([])).getMetaItem({ type: 'object', name: TASK.name }) as any).disabledActions) + .toEqual([]); + }); + + it('control: no key where no ledger answered — no engine, an engine without the member, an engine with no ledger', async () => { + for (const services of [ + new Map(), + new Map([['objectql', { find: async () => [] }]]), + withEngine(undefined), + ]) { + const read: any = await harness(ROWS, services).getMetaItem({ type: 'object', name: TASK.name }); + expect(read.item.actions).toHaveLength(2); + expect(read).not.toHaveProperty('disabledActions'); + } + }); + + it('the plural spelling of the type is folded first and answers the same', async () => { + const read: any = await harness(ROWS, withEngine(['showcase_mark_done'])).getMetaItem({ type: 'objects', name: TASK.name }); + expect(read.disabledActions).toEqual(['showcase_mark_done']); + }); + + it('a type other than `object` reads no key, and the engine is never asked', async () => { + const services = withEngine(['showcase_mark_done']); + const read: any = await harness( + [...ROWS, storedRow('view', 'task_board', { name: 'task_board', label: 'Board' })], services, + ).getMetaItem({ type: 'view', name: 'task_board' }); + + expect(read).not.toHaveProperty('disabledActions'); + expect((services.get('objectql') as ReturnType).getDisabledActions).not.toHaveBeenCalled(); + }); + + it('the execution face carries none: it is the doors\' own read, not a served one', async () => { + const services = withEngine(['showcase_mark_done']); + const read: any = await harness(ROWS, services).getMetaItemForExecution({ type: 'object', name: TASK.name }); + expect(read).not.toHaveProperty('disabledActions'); + }); +}); + +describe('[#27842] the list: `disabledActions` beside the items, keyed by object name', () => { + it('one entry per listed object, `[]` included; the items stay as declared; the engine is asked once', async () => { + const services = withEngine(['showcase_mark_done', 'showcase_archive']); + const list: any = await harness(ROWS, services).getMetaItems({ type: 'object' }); + + expect(list.disabledActions).toEqual({ + [TASK.name]: ['showcase_mark_done'], + [PROJECT.name]: ['showcase_archive'], + [NOTE.name]: [], + }); + for (const item of list.items) expect(item).not.toHaveProperty('disabledActions'); + expect(list.items.find((i: any) => i.name === TASK.name).actions.map((a: any) => a.name)) + .toEqual(['showcase_mark_done', 'showcase_snooze']); + expect((services.get('objectql') as ReturnType).getDisabledActions).toHaveBeenCalledTimes(1); + }); + + it('control: no ledger answered — no key; and the execution face never carries one', async () => { + const bare: any = await harness(ROWS, withEngine(undefined)).getMetaItems({ type: 'object' }); + expect(bare.items.length).toBe(3); + expect(bare).not.toHaveProperty('disabledActions'); + + const execution: any = await harness(ROWS, withEngine(['showcase_mark_done'])).getMetaItemsForExecution({ type: 'object' }); + expect(execution).not.toHaveProperty('disabledActions'); + }); + + it('a pending draft overlaid on the list gets no entry', async () => { + const draftOnly = { name: 'draft_only', label: 'Draft', actions: [{ name: 'showcase_mark_done', label: 'X' }] }; + const list: any = await harness( + [...ROWS, storedRow('object', 'draft_only', draftOnly, { state: 'draft' })], + withEngine(['showcase_mark_done']), + ).getMetaItems({ type: 'object', previewDrafts: true }); + + expect(list.items.map((i: any) => i.name)).toContain('draft_only'); + expect(Object.keys(list.disabledActions)).not.toContain('draft_only'); + expect(list.disabledActions[TASK.name]).toEqual(['showcase_mark_done']); + }); +}); + +describe('[#27842] the cached read — the default door — carries it beside `data`, never in the ETag', () => { + it('serves the ledger\'s answer live beside the document, with an ETag over the document alone', async () => { + const judged: any = await harness(ROWS, withEngine(['showcase_mark_done'])).getMetaItemCached({ type: 'object', name: TASK.name }); + const unjudged: any = await harness(ROWS).getMetaItemCached({ type: 'object', name: TASK.name }); + + expect(judged.disabledActions).toEqual(['showcase_mark_done']); + expect(judged.data).not.toHaveProperty('disabledActions'); + expect(JSON.stringify(judged.data.actions)).toBe(JSON.stringify(unjudged.data.actions)); + // In no validator: the same document, the same ETag. + expect(judged.etag).toEqual(unjudged.etag); + expect(unjudged).not.toHaveProperty('disabledActions'); + }); + + it('a read that carries the key is never answered 304; control: one where no ledger answered still is', async () => { + const services = withEngine(['showcase_mark_done']); + const first: any = await harness(ROWS, services).getMetaItemCached({ type: 'object', name: TASK.name }); + const revalidate = { type: 'object', name: TASK.name, cacheRequest: { ifNoneMatch: `"${first.etag.value}"` } }; + + const judged: any = await harness(ROWS, services).getMetaItemCached(revalidate); + expect(judged.notModified).toBe(false); + expect(judged.disabledActions).toEqual(['showcase_mark_done']); + + const unjudged: any = await harness(ROWS, withEngine(undefined)).getMetaItemCached(revalidate); + expect(unjudged.notModified).toBe(true); + }); +}); diff --git a/packages/objectql/src/protocol-meta-disabled-actions.test.ts b/packages/objectql/src/protocol-meta-disabled-actions.test.ts new file mode 100644 index 00000000000..6d4c060ee0c --- /dev/null +++ b/packages/objectql/src/protocol-meta-disabled-actions.test.ts @@ -0,0 +1,217 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27842] The real engine, the real protocol: `GET /api/v1/meta/object/:name` + * serves beside the object the packaged actions the ENGINE'S OWN activation + * projection switches off — the projection both dispatch doors consult before + * they refuse a call 409 `ACTION_DISABLED` — read through the kernel's service + * table (`objectql`), as a host composes it. + * + * The protocol-side pins (absent / `[]` / the list / the cached door and its + * 304) run over an engine double in `@objectstack/metadata-protocol`'s + * `protocol.object-disabled-actions.test.ts`. What only this package can show + * is that the double is honest: `ObjectQL.getDisabledActions` is the member the + * protocol reaches, and it moves with `setActionActive`, the activation door's + * own write, with no reload in between. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { ObjectQL } from './engine.js'; +import { InMemoryActionActivationStore } from './action-activation.js'; +// [#21516] The rest of the stored-metadata family the repository writes through: the +// engine refuses a name the registry does not resolve, so the harness registers it as a boot does. +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; + +const sysMetadataObject = { + name: 'sys_metadata', + label: 'System Metadata', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + type: { name: 'type', label: 'Type', type: 'text' as const, required: true }, + name: { name: 'name', label: 'Name', type: 'text' as const, required: true }, + organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, + // [#8682] The real `sys_metadata` carries this — it is part of the + // row's uniqueness key `(type, name, organization_id, package_id)` and + // `SysMetadataRepository` writes it — but this minimal stub had omitted + // it. Nothing noticed while an undeclared write key simply travelled to + // the driver; the declared-field door judges the payload against this + // map, so the omission now shows up as the fixture defect it always was. + package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, + metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, + state: { name: 'state', label: 'State', type: 'text' as const }, + version: { name: 'version', label: 'Version', type: 'number' as const }, + created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const }, + updated_at: { name: 'updated_at', label: 'Updated', type: 'datetime' as const }, + }, +}; + +/** + * Minimal stub driver covering only what `SysMetadataRepository` + * exercises. Equality-only WHERE evaluation; one record store per object. + */ +function makeStubDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real + // driver ANDs them. The short-circuiting shape this stub used to carry + // (`if ($or) return $or.some(...)`) discarded every sibling equality key in + // the same object, so a query like + // `{ state:'draft', package_id, $or:[{organization_id:ORG},{organization_id:null}] }` + // was silently answered on the `$or` alone — a different query than the one + // written, with the suite still green. See #7620. + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k.startsWith('$')) continue; + const rowVal = row[k]; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) + ? (v as any).$eq + : v; + const a = rowVal === undefined ? null : rowVal; + const b = expected === undefined ? null : expected; + if (a !== b) return false; + } + return true; + }; + + const driver: any = { + name: 'memory', + version: '0.0.0', + supports: {} as any, + async connect() {}, + async disconnect() {}, + async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { + return storeFor(object).delete(id); + }, + async count(object: string, ast: any) { + return (await this.find(object, ast)).length; + }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, + async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, + async rollback() {}, + }; + return { driver, stores }; +} + +/** A package shipping one object with two record actions — the showcase shape (#27504). */ +const SHOWCASE = { + id: 'com.test.showcase', name: 'showcase', + objects: [{ + name: 'da_task', label: 'Task', + fields: { name: { name: 'name', type: 'text', label: 'Name' } }, + actions: [ + { name: 'da_mark_done', label: 'Mark Done', type: 'url', target: 'https://example.com/done', locations: ['record_header'] }, + { name: 'da_snooze', label: 'Snooze', type: 'url', target: 'https://example.com/snooze', locations: ['record_header'] }, + ], + }], +}; + +async function hostWith(options: { ledger: boolean }) { + const engine = new ObjectQL(); + engine.registerDriver(makeStubDriver().driver, true); + await engine.init(); + engine.registry.registerObject(sysMetadataObject); + for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) engine.registry.registerObject(o as any); + engine.registerApp(structuredClone(SHOWCASE)); + if (options.ledger) { + engine.setActionActivationStore(new InMemoryActionActivationStore()); + await engine.hydrateActionActivations(); + } + // The kernel's service table as `ObjectQLPlugin` fills it: the engine under `objectql`. + const services = new Map([['objectql', engine], ['data', engine]]); + return { engine, protocol: new ObjectStackProtocolImplementation(engine, () => services as Map) }; +} + +const actionNames = (body: any): string[] => (Array.isArray(body?.actions) ? body.actions.map((a: any) => a?.name) : []); + +describe('the object read serves the engine\'s own switched-off actions beside the item (#27842)', () => { + it('a flip through the activation door is on the next read: named beside the item, `item.actions` unchanged', async () => { + const { engine, protocol } = await hostWith({ ledger: true }); + const before: any = await protocol.getMetaItem({ type: 'object', name: 'da_task' } as any); + expect(before.disabledActions).toEqual([]); + + await engine.setActionActive({ name: 'da_mark_done', packageId: 'com.test.showcase', active: false }); + const after: any = await protocol.getMetaItem({ type: 'object', name: 'da_task' } as any); + + expect(after.disabledActions).toEqual(['da_mark_done']); + // The dispatch doors' answer for the same name — one fact, two readers. + expect(engine.isActionEnabled('da_mark_done')).toBe(false); + expect(actionNames(after.item)).toEqual(['da_mark_done', 'da_snooze']); + expect(JSON.stringify(after.item.actions)).toBe(JSON.stringify(before.item.actions)); + + await engine.setActionActive({ name: 'da_mark_done', packageId: 'com.test.showcase', active: true }); + expect(((await protocol.getMetaItem({ type: 'object', name: 'da_task' } as any)) as any).disabledActions).toEqual([]); + }); + + it('the list and the cached default door read the same projection', async () => { + const { engine, protocol } = await hostWith({ ledger: true }); + await engine.setActionActive({ name: 'da_snooze', packageId: 'com.test.showcase', active: false }); + + const list: any = await protocol.getMetaItems({ type: 'object' }); + expect(list.disabledActions.da_task).toEqual(['da_snooze']); + + const cached: any = await protocol.getMetaItemCached({ type: 'object', name: 'da_task' }); + expect(cached.disabledActions).toEqual(['da_snooze']); + expect(actionNames(cached.data)).toEqual(['da_mark_done', 'da_snooze']); + }); + + it('control: an engine with no ledger attached serves no key at all — never `[]`', async () => { + const { protocol } = await hostWith({ ledger: false }); + + const read: any = await protocol.getMetaItem({ type: 'object', name: 'da_task' } as any); + expect(read).not.toHaveProperty('disabledActions'); + expect(actionNames(read.item)).toEqual(['da_mark_done', 'da_snooze']); + expect(await protocol.getMetaItems({ type: 'object' })).not.toHaveProperty('disabledActions'); + }); +}); diff --git a/packages/qa/dogfood/test/packaged-action-disabled-dispatch-live.dogfood.test.ts b/packages/qa/dogfood/test/packaged-action-disabled-dispatch-live.dogfood.test.ts index 73f3eda8f22..1406821c47d 100644 --- a/packages/qa/dogfood/test/packaged-action-disabled-dispatch-live.dogfood.test.ts +++ b/packages/qa/dogfood/test/packaged-action-disabled-dispatch-live.dogfood.test.ts @@ -14,7 +14,7 @@ // - absence means active: on a stock boot with no ledger row the param // gallery's garbage bag answers its param contract's 400 (negative[1]); // - acceptance[0]: a disabled `showcase_mark_done` answers 409 -// ACTION_DISABLED with the engine's sentence naming the ledger, for a +// ACTION_DISABLED with the engine's plain sentence (#27842), for a // bogus id and for a real task, and the real task is not written; // - acceptance[1] / negative[0]: the disabled gallery answers 409 to the bag // its enabled twin answers 400 to, so the consult precedes the param @@ -56,10 +56,20 @@ const BOGUS_ID = 'no-such-record-26572'; const activation = (object: string, action: string) => `/actions/_activation/${object}/${action}`; -/** The engine's `describeDisabledAction` sentence, as the wire carries it. */ -function expectDisabledSentence(message: unknown, action: string) { - expect(String(message)).toMatch(new RegExp(`^Action '${action}' is disabled — `)); - expect(String(message)).toContain('(sys_metadata_activation, ADR-0126 §8)'); +/** + * The engine's `describeDisabledAction` sentence, as the wire carries it. + * + * [#27842] It is the end user's sentence — what the red toast reads — so it + * says what happened and who can undo it where, and names no system table and + * no ADR (the engine's log line carries those; the envelope carries the code). + * The action is the one the caller named, so the sentence does not repeat it; + * `action` stays so each call site still says which refusal it reads. + */ +function expectDisabledSentence(message: unknown, _action: string) { + expect(String(message)).toMatch(/^This action is turned off for this deployment\. /); + expect(String(message)).toContain('An administrator can turn it on again under Setup › Packaged automation'); + expect(String(message)).not.toContain(LEDGER); + expect(String(message)).not.toMatch(/ADR-\d+/); } interface Answer { status: number; text: string; json: any } diff --git a/packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts b/packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts index 759665414a0..18a30249fc0 100644 --- a/packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts +++ b/packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts @@ -207,7 +207,9 @@ describe('#12359 — actions and NO automation service: the ledger is there', () // no param shape and reads no record. A 400 here would mean the param // contract ran first and the switch is not doing its job. expect(res.status, `dispatch of a disabled action answered ${res.status}: ${text}`).toBe(409); - expect(text).toMatch(/ACTION_DISABLED|is disabled/); + // [#27842] The code is the machine handle; the sentence beside it is the + // end user's and names no ledger, so only the code is read here. + expect(text).toContain('ACTION_DISABLED'); }); it('re-enabling re-arms dispatch, and the row records the choice rather than vanishing', async () => { diff --git a/packages/rest/src/meta-object-cached-disabled-actions.test.ts b/packages/rest/src/meta-object-cached-disabled-actions.test.ts new file mode 100644 index 00000000000..e7f058a731c --- /dev/null +++ b/packages/rest/src/meta-object-cached-disabled-actions.test.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #27842 — the DEFAULT `GET /meta/object/:name` carries, beside the object, the +// packaged actions this deployment's activation ledger switches off. +// +// `metadata.enableCache` defaults to `true`, so a default deployment answers +// this read from the CACHED branch, which rebuilds the envelope as +// `{ type, name, item }` — the door every console, SDK and MCP consumer builds +// its action buttons from. The uncached read serves `disabledActions` beside +// `lock`; this branch has to carry it too, or a switched-off action reads as +// live on exactly the door that matters. +// +// An object's validator is folded and judged HERE (`audienceFolds`), not in the +// protocol, so this file also pins the 304 half: a read that carries the key is +// answered in full whatever `If-None-Match` says, because an administrator's +// switch moves no byte the validator hashes; and the unjudged control still is +// answered 304. +// +// The protocol double answers `getMetaItemCached` the way `metadata-protocol`'s +// producer does: the document under `data`, its ETag, and — where a ledger +// answered — the live `disabledActions` its delegate read resolved (pinned on +// the real producer in `protocol.object-disabled-actions.test.ts`). + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; + +const ANON_API = { api: { requireAuth: false } }; + +const TASK = { + name: 'showcase_task', + label: 'Task', + fields: { title: { name: 'title', type: 'text', label: 'Title' } }, + actions: [ + { name: 'showcase_mark_done', label: 'Mark Done', type: 'script' }, + { name: 'showcase_snooze', label: 'Snooze', type: 'script' }, + ], +}; + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeRes() { + const res: any = { statusCode: 200, body: undefined, headers: {} as Record }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.header = vi.fn((k: string, v: string) => { res.headers[k] = v; return res; }); + res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); res.send = vi.fn(); + return res; +} + +/** `disabledActions` as the producer resolved it: the names, `[]`, or `undefined` (no ledger answered). */ +function setup(disabledActions: string[] | undefined) { + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([]), + getMetaItem: vi.fn(), + findData: vi.fn().mockResolvedValue([]), + getMetaItemCached: vi.fn(async () => ({ + data: JSON.parse(JSON.stringify(TASK)), + ...(disabledActions !== undefined ? { disabledActions } : {}), + etag: { value: 'etag_task', weak: false }, + cacheControl: { directives: ['private', 'no-cache'] }, + notModified: false, + })), + }; + const rest = new RestServer(mockServer() as any, protocol as any, ANON_API as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: [] }); + rest.registerRoutes(); + return { rest, protocol }; +} + +async function getItem(rest: any, type: string, name: string, headers: Record = {}) { + const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/meta/:type/:name'); + if (!route) throw new Error('GET /api/v1/meta/:type/:name route not registered'); + const res = makeRes(); + await route.handler({ method: 'GET', params: { type, name }, query: {}, body: {}, headers }, res); + return res; +} + +describe('the default cached `GET /meta/object/:name` carries `disabledActions` beside the object (#27842)', () => { + it('a switched-off action is named beside the item, and `item.actions` is served as declared', async () => { + const { rest, protocol } = setup(['showcase_mark_done']); + + const res = await getItem(rest, 'object', 'showcase_task'); + + // The default branch, measured — not the uncached read. + expect(protocol.getMetaItemCached).toHaveBeenCalledTimes(1); + expect(protocol.getMetaItem).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(200); + expect(res.body.disabledActions).toEqual(['showcase_mark_done']); + expect(res.body.item.actions.map((a: any) => a.name)).toEqual(['showcase_mark_done', 'showcase_snooze']); + expect(res.body.item).not.toHaveProperty('disabledActions'); + }); + + it('`[]` from a ledger that switches none of them off is served as `[]`', async () => { + const res = await getItem(setup([]).rest, 'object', 'showcase_task'); + expect(res.statusCode).toBe(200); + expect(res.body.disabledActions).toEqual([]); + }); + + it('control: where no ledger answered, the envelope carries no key at all', async () => { + const res = await getItem(setup(undefined).rest, 'object', 'showcase_task'); + expect(res.statusCode).toBe(200); + expect(res.body).not.toHaveProperty('disabledActions'); + expect(res.body.item.actions).toHaveLength(2); + }); + + it('a read that carries the key is never answered 304; control: the unjudged read still is', async () => { + // The validator this door issues for the document, read off a first + // answer and sent back as `If-None-Match`. + const first = await getItem(setup(undefined).rest, 'object', 'showcase_task'); + const etag = first.headers.ETag; + expect(typeof etag).toBe('string'); + + const judged = await getItem(setup(['showcase_mark_done']).rest, 'object', 'showcase_task', { 'if-none-match': etag }); + expect(judged.statusCode).toBe(200); + expect(judged.body.disabledActions).toEqual(['showcase_mark_done']); + // The validator itself is unchanged by the key: the same document, the same ETag. + expect(judged.headers.ETag).toBe(etag); + + const unjudged = await getItem(setup(undefined).rest, 'object', 'showcase_task', { 'if-none-match': etag }); + expect(unjudged.statusCode).toBe(304); + }); +}); From 7237921e365096628921e03d89f7bc18d3cb9d85 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 05:20:08 +0000 Subject: [PATCH 4/6] chore(scripts): record the disabled-actions protocol test's findOne double in the engine-double pinned ledger Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- scripts/engine-double-contract.pinned.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index d71f941c971..6cbd2a360ca 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1231,6 +1231,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.object-disabled-actions.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.object-duplicate-action-name-refusal.test.ts", "verb": "delete", From 8216bec475d46fe55888dd1bd07bf1b00bf6459f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 05:34:39 +0000 Subject: [PATCH 5/6] fix(objectql): the disabled-action log line carries the refusal under 'refusal', not 'code' The error-code provenance gate reads an object literal's 'code' property as a wire stamp. The engine's info log line is not a wire answer; the dispatch doors stamp ACTION_DISABLED on the envelope. Naming the log field 'refusal' keeps the code in the log without claiming objectql stamps it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- packages/objectql/src/action-activation.test.ts | 2 +- packages/objectql/src/engine.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/objectql/src/action-activation.test.ts b/packages/objectql/src/action-activation.test.ts index b2913089fe6..e40a4c01ef5 100644 --- a/packages/objectql/src/action-activation.test.ts +++ b/packages/objectql/src/action-activation.test.ts @@ -396,7 +396,7 @@ describe('ObjectQL engine — the projection the dispatch doors consult', () => expect(line).toContain("'convert_lead'"); expect(line).toContain('sys_metadata_activation'); expect(line).toContain('ADR-0126 §8'); - expect(meta).toEqual({ action: 'convert_lead', code: 'ACTION_DISABLED' }); + expect(meta).toEqual({ action: 'convert_lead', refusal: 'ACTION_DISABLED' }); }); it('setActionActive flips the row and the projection together, in that order', async () => { diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 76e6439863d..69fd391273c 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -5928,7 +5928,7 @@ export class ObjectQL implements IObjectQLEngine { this.logger.info( `ACTION_DISABLED: packaged action '${actionName}' refused — it is switched off in the activation ledger ` + '(sys_metadata_activation, ADR-0126 §8 item 2).', - { action: actionName, code: 'ACTION_DISABLED' }, + { action: actionName, refusal: 'ACTION_DISABLED' }, ); return this.actionActivation.describeDisabled(actionName); } From b3d92fee1573d4f20f93de76b5344320e75bac4d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 05:45:56 +0000 Subject: [PATCH 6/6] fix(spec): the engine contract header names both optional members, and the consumer changeset declares Metadata-protocol none The contract review on 7237921e asked for two should-fixes. The IObjectQLEngine header now names getDisabledActions beside judgeFilter as optional by ruling. The four consumer packages add no authorable key of their own, so their patch changeset declares Metadata-protocol: none; the key stays declared `added` on spec's minor changeset. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .changeset/27842-disabled-actions-served.md | 2 +- packages/spec/src/contracts/objectql-engine.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.changeset/27842-disabled-actions-served.md b/.changeset/27842-disabled-actions-served.md index 9c05d6e70c6..0d215c19ffc 100644 --- a/.changeset/27842-disabled-actions-served.md +++ b/.changeset/27842-disabled-actions-served.md @@ -8,7 +8,7 @@ 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: added +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. diff --git a/packages/spec/src/contracts/objectql-engine.ts b/packages/spec/src/contracts/objectql-engine.ts index 46271b7e201..349ea2c322f 100644 --- a/packages/spec/src/contracts/objectql-engine.ts +++ b/packages/spec/src/contracts/objectql-engine.ts @@ -230,9 +230,10 @@ export type EngineFilterJudgement = * guarded call into a `possibly undefined` error and push code back toward the * `any` this contract exists to remove. * - * One member is optional by ruling, not by this default: {@link judgeFilter} - * (#19995 ruling C). Its callers must work with an engine that lacks it, so the - * optional mark states that requirement in the type. + * Two members are optional by ruling, not by this default: {@link judgeFilter} + * (#19995 ruling C) and {@link getDisabledActions} (#27842 ruling letter B, + * the `getFlowRegistration?` shape). Their callers must work with an engine + * that lacks them, so the optional mark states that requirement in the type. */ export interface IObjectQLEngine extends IDataEngine { // ── Schema access ────────────────────────────────────────────────────