diff --git a/.changeset/22817-action-activation-unpackaged.md b/.changeset/22817-action-activation-unpackaged.md new file mode 100644 index 00000000000..0faf2b3f707 --- /dev/null +++ b/.changeset/22817-action-activation-unpackaged.md @@ -0,0 +1,13 @@ +--- +"@objectstack/runtime": minor +"@objectstack/core": minor +--- + +The action activation door switches an action no package ships, its row names no package, and a ledger write it refuses answers with the status its code means + +Clause-②: yes (widening) + +- **The door accepts an action the environment authored** (`@objectstack/runtime`). `POST /api/v1/actions/_activation/:object/:action` declares that it does not require the action to be packaged. FROM: an object-less action saved through the metadata door (`PUT /api/v1/meta/action/:name`), or one bound to an object no package ships, was refused `503` with `{ code: 'SERVICE_UNAVAILABLE' }` and the store's sentence about an empty package id, and nothing was written. TO: it is switched like a packaged one, `200`, its row carries no package (`package_id` is `null`), and dispatch then refuses it `409 ACTION_DISABLED` like any switched-off action. Who may call the door is unchanged (`manage_metadata`; the platform operator under a `group` or `isolated` posture), and so are its other refusals (an undeclared action `404`, an ambiguous name `409`, an engine with no ledger `501`). +- **An action is credited only to the package that ships it** (`@objectstack/runtime`). FROM: an action with no package of its own was credited to its route object's package, so an action the environment saved against a packaged object wrote that object's package into its row. TO: the row names the action's own package; for an action declared inside its object's `actions`, the object's package; otherwise no package. A `sys_metadata` placeholder or a tenant-authored action's package binding is not a shipping package. Rows of shipped actions are unchanged, including the platform's own actions declared inside their objects. No reader decides anything from `package_id`, so nothing that is switched on or off changes. +- **A refused ledger write keeps its code and its status together** (`@objectstack/runtime`, ADR-0112). FROM: the door kept a thrown `code` but answered `503` whenever the error declared no status, so a record validation refusal was served as `503 VALIDATION_FAILED`. TO: the door answers through the dispatcher's one thrown-error rule: a record validation refusal is `400 VALIDATION_FAILED`, an error that declares its own pair keeps it (no ledger attached: `503 SERVICE_UNAVAILABLE`), and an error that declares no status is `503 SERVICE_UNAVAILABLE`. +- **The in-memory activation store refuses what the durable store refuses** (`@objectstack/core`). FROM: `InMemoryMetadataActivationStore.setActive` (the base of `InMemoryActionActivationStore` and `InMemoryFlowActivationStore`) accepted `packageId: ''`, which `ObjectStoreMetadataActivationStore.setActive` refuses. TO: it refuses `packageId: ''` with the same `TypeError` and the same sentence, before it keeps anything. If a test passes `''`, omit the key instead. diff --git a/packages/core/src/utils/metadata-activation-store.test.ts b/packages/core/src/utils/metadata-activation-store.test.ts index 450dc3008df..f65b44283f2 100644 --- a/packages/core/src/utils/metadata-activation-store.test.ts +++ b/packages/core/src/utils/metadata-activation-store.test.ts @@ -362,4 +362,33 @@ describe('InMemoryMetadataActivationStore', () => { await store.setActive({ name: 'nightly_sync', packageId: 'crm', active: true }); expect(await store.list()).toEqual([{ name: 'nightly_sync', packageId: 'crm', active: true }]); }); + + // The flow and action stores' test double refuses exactly the row the + // durable store refuses: a double more lenient than what it stands for lets + // a test pass a write the real ledger refuses. + it('REFUSES an empty-string package as the durable store does, and keeps nothing', async () => { + const store = new InMemoryMetadataActivationStore(); + const { engine } = makeStoreEngine([]); + + const fromDouble = await store.setActive({ name: 'env_action', packageId: '', active: false }).catch((e) => e); + const fromStore = await new ObjectStoreMetadataActivationStore(engine, 'action') + .setActive({ name: 'env_action', packageId: '', active: false }).catch((e) => e); + + expect(fromDouble).toBeInstanceOf(TypeError); + expect(fromStore).toBeInstanceOf(TypeError); + // One refusal, two stores: the same text, except that the durable store + // names its row's type in the subject and the double has no type. + expect(fromDouble.message).toBe(fromStore.message.replace(`action 'env_action'`, `'env_action'`)); + expect(await store.list()).toEqual([]); + }); + + it('keeps an item no package ships with packageId ABSENT', async () => { + const store = new InMemoryMetadataActivationStore(); + + await store.setActive({ name: 'env_action', active: false }); + + const rows = await store.list(); + expect(rows).toEqual([{ name: 'env_action', active: false }]); + expect('packageId' in rows[0]).toBe(false); + }); }); diff --git a/packages/core/src/utils/metadata-activation-store.ts b/packages/core/src/utils/metadata-activation-store.ts index 3a309ee1d08..b8fe921e559 100644 --- a/packages/core/src/utils/metadata-activation-store.ts +++ b/packages/core/src/utils/metadata-activation-store.ts @@ -97,11 +97,13 @@ export interface MetadataActivationRow { name: string; /** * The package that ships the artifact — ABSENT when no package does: a - * position or permission set the environment authored, which the security - * catalog's activation door switches as well as a packaged one. Absent is - * the only package-less spelling: the store writes no value for it and - * refuses `''` ({@link ObjectStoreMetadataActivationStore.setActive}), so - * no reader can take an environment item for a package named `''`. + * position, permission set or action the environment authored, which the + * security catalog's and the action's activation doors switch as well as a + * packaged one. Absent is the only package-less spelling: the store writes + * no value for it and refuses `''` + * ({@link ObjectStoreMetadataActivationStore.setActive}), and so does the + * in-memory double, so no reader can take an environment item for a + * package named `''`. */ packageId?: string; /** Is the artifact armed for this installation. */ @@ -134,12 +136,36 @@ export interface MetadataActivationStoreEngine { update(object: string, data: any, options?: any): Promise; } +/** + * Refuse the empty string as a row's package, before anything is read or + * written — the ONE refusal both stores apply, so the in-memory double can + * never accept a row the durable store refuses. + * + * `''` is the placeholder a caller reaches for with `?? ''`, and a stored `''` + * would be one more spelling of "no package" beside the absent one — exactly + * the ambiguity {@link MetadataActivationRow.packageId} rules out. + * + * @param subject how the refusal names the row's artifact (`flow 'x'`, `'x'`). + */ +function refuseEmptyPackageId(row: MetadataActivationRow, subject: string): void { + if (row.packageId !== '') return; + throw new TypeError( + `Activation row for ${subject} names the empty string as its package. ` + + `Omit packageId for an item no package ships; an empty package id is not a package.`, + ); +} + /** * In-memory {@link MetadataActivationStore} — process-lifetime only, for tests * and for hosts with no durable plane. What it lacks versus the ObjectStore * implementation is DURABILITY, which is exactly the property ADR-0126 §6 * wall 3 asks for; it is not a sanctioned production off-switch. * + * It refuses exactly the rows the durable store refuses (an empty package id, + * {@link refuseEmptyPackageId}): it is the flow and action stores' test + * double, and a double more lenient than what it stands for lets a test pass + * a write the real ledger refuses. + * * No discriminator: an in-memory map is per-instance, so there is no shared * table for a neighbouring type's rows to be in. */ @@ -151,6 +177,7 @@ export class InMemoryMetadataActivationStore implements MetadataActivationStore } async setActive(row: MetadataActivationRow): Promise { + refuseEmptyPackageId(row, `'${row.name}'`); this.rows.set(row.name, { ...row }); } } @@ -228,18 +255,10 @@ export class ObjectStoreMetadataActivationStore implements MetadataActivationSto * * `packageId` absent writes NO package (`null`), the ledger's spelling for * an item no package ships. An empty string is refused before anything is - * read or written: it is the placeholder a caller reaches for with - * `?? ''`, and a stored `''` would be one more spelling of "no package" - * beside the absent one — exactly the ambiguity the field's contract - * rules out. + * read or written ({@link refuseEmptyPackageId}). */ async setActive(row: MetadataActivationRow): Promise { - if (row.packageId === '') { - throw new TypeError( - `Activation row for ${this.metadataType} '${row.name}' names the empty string as its package. ` + - `Omit packageId for an item no package ships; an empty package id is not a package.`, - ); - } + refuseEmptyPackageId(row, `${this.metadataType} '${row.name}'`); const packageId = row.packageId ?? null; const existing = await this.engine.find(METADATA_ACTIVATION_TABLE, { where: { metadata_type: this.metadataType, name: row.name }, diff --git a/packages/runtime/src/domains/action-activation-unpackaged.test.ts b/packages/runtime/src/domains/action-activation-unpackaged.test.ts new file mode 100644 index 00000000000..ea6a8957085 --- /dev/null +++ b/packages/runtime/src/domains/action-activation-unpackaged.test.ts @@ -0,0 +1,295 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#22817] ADR-0126 §4 — the action activation door switches an action NO +// package ships, as its docblock declares, and its row names no package. +// +// ## What was measured before this file existed (showcase boot, `6364375b5`) +// +// Two actions saved through `PUT /api/v1/meta/action/:name`, then switched off +// through `POST /api/v1/actions/_activation/:object/:action`: +// +// - object-less (`global`) → 503 SERVICE_UNAVAILABLE carrying the store's +// TypeScript-caller sentence, and no row: the door wrote `?? ''`, the store +// refuses `''`, and the door's `catch` defaulted every undeclared status to +// 503; +// - bound to the packaged `showcase_task` → 200, and the row named +// `com.example.showcase`: the door fell back to the ROUTE OBJECT's package, +// crediting the environment's action to a package that does not ship it. +// +// Over the same boot's 146 resolvable declarations, the door's new reading +// (`shippingPackageOf`) changes exactly those two and leaves every shipped one +// byte-identical — including the 73 platform actions declared unstamped inside +// a code object's `actions` (`sys_user`'s `invite_user`, …), which keep naming +// the object's package. The controls below are those two shapes. +// +// ## Why the REAL projection and store, not a `setActionActive` spy +// +// The acceptance fact is "the row lands with `package_id` null", which is a +// property of three layers at once: this door, objectql's projection (which +// hands the row to the store unchanged) and core's store (which writes `null` +// for an absent package and refuses `''`). A spy on `setActionActive` would pin +// the first layer's argument and none of the rest — the posture-gate file +// already does that for the packaged case. + +import { describe, it, expect } from 'vitest'; +import { ActionActivationProjection, ObjectStoreActionActivationStore } from '@objectstack/objectql'; + +import { HttpDispatcher } from '../http-dispatcher.js'; +import type { HttpProtocolContext } from '../http-dispatcher.js'; + +const LEDGER = 'sys_metadata_activation'; +const OBJECT = 'crm_lead'; +const PACKAGE = 'crm'; + +/** Shipped by `crm`, stamped on itself — the ordinary packaged action. */ +const PACKAGED = { + name: 'convert_lead', label: 'Convert Lead', objectName: OBJECT, type: 'script', + _packageId: PACKAGE, _provenance: 'package', +}; +/** Declared inside its code object's `actions`, unstamped — the platform objects' shape. */ +const EMBEDDED_UNSTAMPED = { name: 'invite_lead', label: 'Invite Lead', objectName: OBJECT, type: 'script' }; +/** Saved through the metadata door and bound to the packaged object — the measured mis-attribution. */ +const ENV_ON_PACKAGED_OBJECT = { + name: 'env_ping_lead', label: 'Env Ping Lead', objectName: OBJECT, type: 'script', _provenance: 'org', +}; +/** Saved through the metadata door with no object — the measured 503. */ +const ENV_GLOBAL = { name: 'env_ping_global', label: 'Env Ping Global', type: 'script', _provenance: 'org' }; +/** Stamps that are not a shipping package (ADR-0029 D9.6). */ +const SENTINEL_STAMPED = { name: 'rehydrated_ping', objectName: OBJECT, type: 'script', _packageId: 'sys_metadata' }; +const ORG_BOUND = { name: 'org_bound_ping', objectName: OBJECT, type: 'script', _packageId: 'app.crm', _provenance: 'org' }; + +const STANDALONE = [ENV_ON_PACKAGED_OBJECT, ENV_GLOBAL, SENTINEL_STAMPED, ORG_BOUND]; + +type Row = Record; + +/** + * The double's WHERE predicate, at module scope so `pnpm check:where-matcher` + * can lift it. Equality only, and a loud refusal for anything else: the store's + * reads are `{ metadata_type }` and `{ metadata_type, name }`. + */ +function matches(row: Row, where: Row | undefined): boolean { + return Object.entries(where ?? {}).every(([k, v]) => { + if (k.startsWith('$') || (v !== null && typeof v === 'object')) { + throw new Error(`fake engine: unsupported WHERE combinator '${k}' — this double implements equality only`); + } + return row[k] === v; + }); +} + +/** + * The in-memory `sys_metadata_activation` the durable store writes into: the + * store's keyed `find` and its `insert`, and nothing else. At module scope, on + * its own, so `pnpm check:objectql-double-limit` can lift and drive the `find`. + * + * `writeError` is thrown from every write — a store or record-validation + * failure arriving through the door's own `catch`. + * + * ⛔ No `update`: every case here writes a FIRST row, which the store inserts. + * The store's update leg for a package-less row is core's to pin + * (`metadata-activation-store.test.ts`, "UPDATES its row with no package on the + * way back on"); a second update double here would pin nothing that one does not. + */ +function makeLedgerTable(writeError?: Error) { + const rows: Row[] = []; + let nextId = 1; + const engine = { + async find(object: string, options?: any) { + const found = object === 'sys_metadata_activation' ? rows.filter((r) => matches(r, options?.where)) : []; + // The caller's bound, AFTER the filter, by presence: the store's + // table probe reads `limit: 1`. + return typeof options?.limit === 'number' ? found.slice(0, options.limit) : found; + }, + async insert(object: string, data: Row) { + if (writeError) throw writeError; + const row = { id: `act_${nextId++}`, ...data }; + if (object === LEDGER) rows.push(row); + return row; + }, + }; + return { rows, engine }; +} + +interface Harness { + dispatcher: HttpDispatcher; + rows: Row[]; + executed: () => number; +} + +/** + * One deployment: the route object is a code artifact shipped by `crm`, the + * engine carries the REAL action activation projection over the REAL durable + * store, and that store writes into {@link makeLedgerTable}. + */ +function boot(opts: { writeError?: Error } = {}): Harness { + const { rows, engine: ledger } = makeLedgerTable(opts.writeError); + let executed = 0; + const objectDef = { + name: OBJECT, _packageId: PACKAGE, _provenance: 'package', actions: [PACKAGED, EMBEDDED_UNSTAMPED], + }; + const projection = new ActionActivationProjection(); + const ql: any = { + ...ledger, + executeAction: async () => { + executed++; + return { ran: 'script' }; + }, + getSchema: (name: string) => (name === OBJECT ? objectDef : undefined), + registry: { + getObject: (name: string) => (name === OBJECT ? objectDef : undefined), + getItem: (type: string, name: string) => + (type === 'action' ? STANDALONE.find((a) => a.name === name) : undefined), + }, + isActionEnabled: (name: string) => projection.isEnabled(name), + describeDisabledAction: (name: string) => projection.describeDisabled(name), + setActionActive: (row: any) => projection.setActive(row), + }; + projection.attach(new ObjectStoreActionActivationStore(ql)); + + const metadata: any = { + load: async () => null, + loadDiagnosed: async () => ({ data: null, degraded: false, errors: [] }), + loadMany: async (type: string) => (type === 'action' ? STANDALONE : []), + listObjects: async () => [objectDef], + getObject: async () => objectDef, + }; + const services: Record = { objectql: ql, data: ql, metadata }; + const resolve = (name: string): unknown => services[name] ?? null; + const kernel = { + getService: resolve, + getServiceAsync: async (name: string) => resolve(name), + context: { getService: resolve }, + }; + return { dispatcher: new HttpDispatcher(kernel as never), rows, executed: () => executed }; +} + +/** A single-tenant administrator holding `manage_metadata`: every authority gate passes. */ +const ADMIN = (): HttpProtocolContext => ({ + request: {}, + environmentId: 'platform', + executionContext: { + userId: 'u_admin', + positions: ['org_owner', 'org_admin'], + permissions: ['organization_admin'], + systemPermissions: ['manage_metadata'], + organizationId: 'org_acme', + }, +} as unknown as HttpProtocolContext); + +const statusOf = (r: any): unknown => r?.response?.status; +const codeOf = (r: any): unknown => r?.response?.body?.error?.code; +const bodyOf = (r: any): string => JSON.stringify(r?.response?.body); + +const flip = (h: Harness, objectName: string, actionName: string, enabled: boolean) => + h.dispatcher.handleActions(`/_activation/${objectName}/${actionName}`, 'POST', { enabled }, ADMIN()); + +describe('#22817 — an action no package ships is switched, and its row names no package', () => { + it('an OBJECT-LESS environment action switches off: 200, and the row lands with package_id null', async () => { + const h = boot(); + + const res = await flip(h, 'global', ENV_GLOBAL.name, false); + + expect(statusOf(res), bodyOf(res)).toBe(200); + expect(h.rows).toEqual([ + { id: 'act_1', metadata_type: 'action', name: ENV_GLOBAL.name, package_id: null, active: false }, + ]); + }); + + it('…and switching it ON first writes the same package-less row, armed', async () => { + const h = boot(); + + const res = await flip(h, 'global', ENV_GLOBAL.name, true); + + expect(statusOf(res), bodyOf(res)).toBe(200); + expect(h.rows).toEqual([ + { id: 'act_1', metadata_type: 'action', name: ENV_GLOBAL.name, package_id: null, active: true }, + ]); + }); + + it('an environment action BOUND TO A PACKAGED OBJECT is not credited to the object\'s package', async () => { + const h = boot(); + + const res = await flip(h, OBJECT, ENV_ON_PACKAGED_OBJECT.name, false); + + expect(statusOf(res), bodyOf(res)).toBe(200); + // It used to name `crm` — the route object's package, which ships the + // object and not this action. + expect(h.rows).toEqual([ + { id: 'act_1', metadata_type: 'action', name: ENV_ON_PACKAGED_OBJECT.name, package_id: null, active: false }, + ]); + }); + + it.each([ + ['the `sys_metadata` rehydration sentinel', SENTINEL_STAMPED], + ['a tenant-authored action bound to an app package', ORG_BOUND], + ])('a stamp that is not a shipping package names none: %s', async (_label, action) => { + const h = boot(); + + const res = await flip(h, OBJECT, action.name, false); + + expect(statusOf(res), bodyOf(res)).toBe(200); + expect(h.rows.map((r) => r.package_id)).toEqual([null]); + }); + + it('the switch it records is honoured: the environment action is then refused at dispatch', async () => { + const h = boot(); + + await flip(h, 'global', ENV_GLOBAL.name, false); + const res = await h.dispatcher.handleActions(`/global/${ENV_GLOBAL.name}`, 'POST', {}, ADMIN()); + + expect(statusOf(res), bodyOf(res)).toBe(409); + expect(codeOf(res)).toBe('ACTION_DISABLED'); + expect(h.executed()).toBe(0); + }); +}); + +describe('#22817 CONTROL — a shipped action\'s row still names the package that ships it', () => { + it('an action stamped by its package', async () => { + const h = boot(); + + const res = await flip(h, OBJECT, PACKAGED.name, false); + + expect(statusOf(res), bodyOf(res)).toBe(200); + expect(h.rows).toEqual([ + { id: 'act_1', metadata_type: 'action', name: PACKAGED.name, package_id: PACKAGE, active: false }, + ]); + }); + + it('an action declared unstamped INSIDE its code object\'s `actions` names the object\'s package', async () => { + const h = boot(); + + const res = await flip(h, OBJECT, EMBEDDED_UNSTAMPED.name, false); + + expect(statusOf(res), bodyOf(res)).toBe(200); + expect(h.rows).toEqual([ + { id: 'act_1', metadata_type: 'action', name: EMBEDDED_UNSTAMPED.name, package_id: PACKAGE, active: false }, + ]); + }); +}); + +describe('#22817 — a refused ledger write keeps ADR-0112\'s code and status together', () => { + it('a record validation refusal is 400 VALIDATION_FAILED, and nothing lands', async () => { + const refusal = Object.assign(new Error('Package is required'), { + name: 'ValidationError', code: 'VALIDATION_FAILED', + fields: [{ field: 'package_id', code: 'required', message: 'Package is required' }], + }); + const h = boot({ writeError: refusal }); + + const res = await flip(h, OBJECT, PACKAGED.name, false); + + // It used to be served as 503 VALIDATION_FAILED: a retryable status on + // a refusal no retry clears. + expect(statusOf(res)).toBe(400); + expect(codeOf(res)).toBe('VALIDATION_FAILED'); + expect(h.rows).toEqual([]); + }); + + it('a store failure that declares no status is 503 SERVICE_UNAVAILABLE, never a 200', async () => { + const h = boot({ writeError: new Error('connection reset') }); + + const res = await flip(h, 'global', ENV_GLOBAL.name, false); + + expect(statusOf(res)).toBe(503); + expect(codeOf(res)).toBe('SERVICE_UNAVAILABLE'); + expect(h.rows).toEqual([]); + }); +}); diff --git a/packages/runtime/src/domains/actions.ts b/packages/runtime/src/domains/actions.ts index 4df8754ee03..c989d1e62c4 100644 --- a/packages/runtime/src/domains/actions.ts +++ b/packages/runtime/src/domains/actions.ts @@ -66,6 +66,9 @@ import { // `isScriptFaultMessage` / `UNCLASSIFIED_FAULT`, so this door's crash reading // cannot drift from the `/data` door's. ⛔ Never re-inline either. import { isNativeErrorName, INTERNAL_ERROR_MESSAGE, sandboxBusinessMessage } from '@objectstack/types'; +// [ADR-0029 D9.6] The ONE "does a code package ship this?" reading, which the +// activation write below asks to name the ledger row's package. +import { isCodeArtifactBody, isTenantAuthored } from '@objectstack/metadata-core'; import * as actionExec from '../action-execution.js'; import { actorUserFromExecutionContext, resolveActorDisplayName } from '../security/actor-user.js'; import { validationFailureDetails } from '../validation-failure.js'; @@ -141,13 +144,19 @@ function isActionActivationWrite(parts: string[], method: string): boolean { * (§4). Two objects declaring the same action name therefore address ONE * row, so this refuses instead of silently switching both off. * 5. **The durable write**, through the engine, which writes the row before - * touching its projection. + * touching its projection. The row names the package that ships the + * action, and no package when none does ({@link shippingPackageOf}). * - * ⛔ It does not require the action to be PACKAGED. Neither does the flow - * toggle: a row for a runtime-authored artifact is harmless (absence means - * active, and the row records a real administrator choice), while a - * provenance test here would refuse the flip for anything the registry cannot - * classify — failing closed on the wrong axis. + * ⛔ It does not require the action to be PACKAGED. An action the environment + * authored through the metadata door is switched exactly like a shipped one, + * and its row names no package. The flow toggle draws the other line — it + * refuses a flow no package ships — because such a flow has a server-side + * switch of its own, its definition's `status`. An action has none: `visible` + * and `disabled` are rendering predicates that no dispatch door reads, so the + * ledger row is the only switch an environment-authored action has. The row + * is harmless (absence means active, and the row records a real administrator + * choice), while a provenance test here would refuse the flip for anything the + * registry cannot classify — failing closed on the wrong axis. */ async function handleActionActivationWrite( deps: DomainHandlerDeps, @@ -220,11 +229,7 @@ async function handleActionActivationWrite( if (ambiguity) return ambiguity; // ── 5. the durable write ──────────────────────────────────────────────── - const packageId = String( - (declaration.action as { _packageId?: unknown })?._packageId ?? - (declaration.obj as { _packageId?: unknown } | undefined)?._packageId ?? - '', - ); + const packageId = shippingPackageOf(declaration); if (typeof ql.setActionActive !== 'function') { // An engine too old to carry the projection cannot make this durable, // and a 200 here would report a switch that never existed. @@ -238,21 +243,58 @@ async function handleActionActivationWrite( }; } try { - await ql.setActionActive({ name: actionName, packageId, active: enabled }); - } catch (err: any) { - // The engine declares its own class for the one refusal it raises (no - // ledger attached → 503 SERVICE_UNAVAILABLE); anything else is a store - // failure, and a failed write must never read as a successful flip. - const status = typeof err?.status === 'number' ? err.status : 503; - const code = typeof err?.code === 'string' ? err.code : 'SERVICE_UNAVAILABLE'; - return { - handled: true, - response: deps.error(err?.message ?? String(err), status, { code }), - }; + // An action no package ships carries NO package key: absent is the + // ledger's one spelling for it, and the store writes `null`. + await ql.setActionActive({ + name: actionName, + ...(packageId !== undefined ? { packageId } : {}), + active: enabled, + }); + } catch (err: unknown) { + // A failed write must never read as a successful flip, and its code and + // status must say the same thing (ADR-0112). The one shared rule + // decides both (`resolveThrownHttpError`): the engine's own refusal + // keeps its pair (no ledger attached: 503 SERVICE_UNAVAILABLE), a + // record validation refusal is 400 VALIDATION_FAILED, and anything that + // declares no status is a store failure, 503 SERVICE_UNAVAILABLE. + return { handled: true, response: deps.errorFromThrown(err, 503) }; } return { handled: true, response: deps.success({ name: actionName, objectName, enabled }) }; } +/** + * [ADR-0126 §4] The package that SHIPS a resolved action declaration — the + * ledger row's `package_id` — or `undefined` when no package does. + * + * Asked through the one "does a code package ship this?" reading, + * `isCodeArtifactBody` (ADR-0029 D9.6: a real `_packageId`, not the + * `'sys_metadata'` sentinel a package-less row carries, and not tenant + * provenance), never through a bare `_packageId` read: + * + * - the action's own package, when the action is a code artifact; + * - for an action declared INSIDE its object's `actions`, the object's + * package when the object is a code artifact. The platform's own objects + * ship their actions that way, unstamped (`sys_user`'s `invite_user`, + * `sys_approval_request`'s `approval_approve`, …), so their rows keep + * naming the package that ships the object; + * - otherwise none. ⛔ A STANDALONE action is never credited to its route + * object's package: an action the environment saved through the metadata + * door and bound to a packaged object is not shipped by that package, and + * neither is a tenant-authored action wherever it is declared. + * + * "Declared inside" is reference identity with the object's `actions` entry, + * which is what `resolveRouteActionDeclaration` returns for an embedded action. + */ +function shippingPackageOf(declaration: { action: unknown; obj: unknown }): string | undefined { + const action = declaration.action as { _packageId?: unknown } | undefined; + const obj = declaration.obj as { _packageId?: unknown; actions?: unknown } | undefined; + if (isTenantAuthored(action)) return undefined; + if (isCodeArtifactBody(action)) return String(action!._packageId); + const embedded = Array.isArray(obj?.actions) && obj.actions.includes(action); + if (embedded && isCodeArtifactBody(obj)) return String(obj!._packageId); + return undefined; +} + /** * [ADR-0126 §4] Refuse a flip whose NAME does not identify one action. *