Skip to content

Commit a98693d

Browse files
fix(runtime): the action and security activation doors record which admin switched an item (#26551)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XTXjaSjCiT9wP1UtogZiA8r)_ Part of #26474. This PR covers the action door and the security catalog door. The flow toggle half stays open on the card and waits on spec card #26506, which declares a caller channel on `IAutomationService.toggleFlow` (seat ruling `6434876664`, letter A). Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么:** 管理员关掉一个打包的 action,或关掉安全目录里的职位、权限集、共享规则、webhook 时,激活台账行的 `created_by` / `updated_by` 和对应审计行的 `user_id` / `actor` 现在记的是这位管理员,不再是空。 - **为什么改:** 以前台账一律用不带用户的系统身份写入,审计记录说不出是谁停用了出厂功能。 - **风险与回滚:** 写入仍是提权写(权限不变),只是多带了调用者身份;没有调用者的写入(启动期等)照旧用系统身份。回滚 = revert 本 PR。flow 开关还没改,等 spec 卡 #26506。 - **先看:** `packages/core/src/utils/metadata-activation-store.ts`(`setActive` 的 context 与 `MetadataActivationWriteContext` 类型)、`packages/runtime/src/domains/activation-gate.ts`(`activationWriteContext`)、`packages/runtime/src/domains/catalog-activation.ts` 写入处。 - **两分钟验证:** `pnpm --filter @objectstack/dogfood exec vitest run test/activation-ledger-actor.dogfood.test.ts`(先按 CI 建好 dogfood 依赖闭包),预期 5/5 通过,台账行与审计行都写着登录管理员的 id。 - **需要你做的:** 无,席位落地。 ## What changes - **`@objectstack/core`:** `ObjectStoreMetadataActivationStore.setActive(row, options?)` takes `{ context }`, which is the caller the write is made for. The keyed read and the insert or update all run under that context. The new type `MetadataActivationWriteContext` requires `isSystem: true`, so a door cannot pass the caller's context down without the elevation. There is deliberately no runtime read of `isSystem` in the store: that would be a new elevation read site for `check:system-context-census`, and the type plus the one shared runtime helper already carry it. Without a context the store keeps its system context, so a write that no caller requested (boot time, in-process) still names nobody. `MetadataActivationWriteOptions` is exported, and the `MetadataActivationStore` interface carries the optional parameter. - **`@objectstack/objectql`:** `ObjectQL.setActionActive(row, options?)` and `ActionActivationProjection.setActive(row, options?)` pass the options to the store unchanged. `ActionActivationWriteOptions` is exported. - **`@objectstack/runtime`:** a new `activationWriteContext(context)` in `domains/activation-gate.ts` returns `{ ...callerContext, isSystem: true }`, the caller's own execution context, elevated. Both doors call it after their two authority gates admit the caller: - `POST /api/v1/actions/_activation/:object/:action` (`domains/actions.ts`) - `POST /api/v1/security/_activation/:type/:name` (`domains/catalog-activation.ts`), which covers positions, permission sets, sharing rules and webhooks. **Mechanism.** The elevation is kept, because the ledger object is engine-owned and only an elevated write reaches it. The caller is carried as the acting principal in the same context. The engine stamps `created_by` / `updated_by` from its `userId`, and the audit plugin reads `user_id` / `actor` from the same session. No actor is stamped by hand on any row. This is the `sudo()`-shaped elevation that `ObjectRepository.execute` documents. It is deliberately not `buildActionExecutionContext`, which also marks the context as an app-automation elevation. ## Why Card #26474 (QA source #26468): on a stock showcase, a signed-in admin switched off a packaged flow and a packaged action, and all four actor fields came out null. The cause was that every door ended in `ObjectStoreMetadataActivationStore.setActive`, which wrote with a constant `{ isSystem: true, positions: [], permissions: [] }` carrying no user. ## Tests Red first on `main` (`98c02577`): the test commits `6eed608a` and `2e0d5df6` precede the fix commit `773a999a`. The red was measured with the test commits on top of unchanged `main` source and a build of that source. | Suite | On `main` | After the fix | |:--|:--|:--| | core `metadata-activation-store.test.ts`, new describe "the write names the caller who asked for it" (3 cases) | the insert and update cases failed (`expected { isSystem: true, positions: [], … } to deeply equal { userId: 'usr_admin', … }`); the no-caller case is a control and passes on both | pass | | objectql `action-activation.test.ts`, "setActionActive hands the caller's write context through to the durable store" | failed | pass | | runtime `action-activation-posture-gate.test.ts`, "the activation write names the caller who asked for it" (tenant admin under `single`, operator under `isolated`) | 2 failed (`expected undefined to deeply equal { userId: ... }`) | pass | | runtime `catalog-activation-door.test.ts`, "the write names the caller who asked for it" (`position`, `permission`: insert then update) | 2 failed (both writes ran under the bare system context) | pass | | dogfood `activation-ledger-actor.dogfood.test.ts` (showcase boot plus `AuditPlugin`, signed-in admin; action and position, off then on; ledger row and audit row) | 4 failed (`expected null to be` the admin id, on `created_by` and `updated_by`) | 5/5 pass | Existing pins that read `setActionActive`'s single argument now read the second one with `expect.anything()` (`action-activation-posture-gate.test.ts`, `tenancy-posture-outage-gates.test.ts`, `scoped-service-miss-attribution.test.ts`). The `action-activation-unpackaged.test.ts` double now forwards the options to the real projection and store. <details><summary>Ablation (one behaviour, one mutation): the doors hand down a context with no caller</summary> `scripts/ablation-replace.mjs` on `packages/runtime/src/domains/activation-gate.ts`. The anchor was `return { ...caller, isSystem: true };`, replaced by a marker-carrying `{ isSystem: true }`. Then `pnpm --filter @objectstack/runtime build`, and `ablation-dist-preflight.mjs @objectstack/runtime ABLATE_26474` (exit 0, marker in dist). - Ablated: runtime door suites, 5 failed (the 4 new caller pins, plus the legacy empty-body pin that this run exposed as still reading one argument; that pin is fixed in a later commit). Dogfood: 4 failed, `expected null to be` the admin id. - Restored: the blob equals HEAD (`651aa0c0`), `git diff HEAD` is empty, rebuilt, `--absent` preflight exit 0. Dogfood 5/5 pass. </details> Package suites, run under the verify lock. The final head is `c5672b2f`; its last commit changes only `@objectstack/core`: - At `c5672b2f`: `pnpm --filter @objectstack/core test` passed 122 files and 2791 tests. `typecheck` for core, objectql and runtime exited 0. Runtime `src/domains` passed 148 files and 4156 tests, and objectql `action-activation.test.ts` passed 19 tests. Dogfood passed 3 files and 24 tests: `activation-ledger-actor`, `catalog-activation-door` and `packaged-activation-ledger-reach`, on a rebuilt core. - At `57a1b5e9` (the same runtime and objectql source): `pnpm --filter @objectstack/objectql test` passed 472 files and 8790 tests. `pnpm --filter @objectstack/runtime test` passed 457 files and 7426 tests, with 19 skipped. The dogfood regression gate does not run on PRs or in the merge queue. The dogfood numbers above are the local run. ## Acceptance notes - **Flow door: not in this PR.** `POST /api/v1/automation/:name/toggle` reaches the ledger only through `IAutomationService.toggleFlow(name, enabled)` in `packages/spec`, which has no caller channel, and the server lane does not edit spec. It waits on #26506 and stays the open half of #26474. Its ledger row and audit row are still null today. - **Audit row organization.** This is read from code and not asserted. The audit writer stamps `tenant_id` from the session's `organizationId`, so an activation audit row written for a caller who has an active organization now carries that organization instead of null. Under `group` / `isolated` only the platform operator can write this ledger, and the operator's context has no organization. - **Side note from the card (clone audited as `system`):** a different code path. The clone arm saves through `registerAndSaveFlow` (metadata protocol save, the `PutOptions.actor` family), not through the activation ledger. It is not carried here; see seat ruling `6434876664` item 3. ## Verification Gates are derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this diff (71 commands), plus `eslint --no-inline-config` scoped to the touched files. All were run at `c5672b2f`, and every exit code was captured before any pipe. `--ran` reconciliation: **71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN**. - The first sweep reddened `pnpm check:system-context-census` (`[file-without-a-row]`): a runtime `isSystem` read in the store counted as a new elevation read site. Commit `c5672b2f` drops that read, because the type carries the elevation, and the re-run is green. - `check-engine-split-ratio --days 90` and `check-plugin-teardown-shape --self-test` first refused on the shallow clone (exit 2 and 3: a prerequisite, so nothing was measured). After `git fetch --shallow-since=2026-07-22 origin main` and a fetch of the self-test's control commit `621a4876`, both exit 0. - Not local: the CI-only type-check lanes and the Build Core job, which are read from this PR's check runs. The dogfood regression gate does not run on PRs. <details><summary>Every gate, with its exit code</summary> - `node scripts/check-adr-0087-registration.mjs --base origin/main` :: exit 0 - `node scripts/check-adr-0087-registration.mjs --self-test` :: exit 0 - `node scripts/check-changeset-no-major.mjs --base origin/main` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs --self-test` :: exit 0 - `node scripts/check-comment-mask-adoption.mjs` :: exit 0 - `node scripts/check-comment-mask-adoption.mjs --self-test` :: exit 0 - `node scripts/check-comment-mask-corpus.mjs` :: exit 0 - `node scripts/check-dev-prereqs.mjs --self-test` :: exit 0 - `node scripts/check-dts-emitted.mjs --self-test` :: exit 0 - `node scripts/check-empty-changeset.mjs --base origin/main` :: exit 0 - `node scripts/check-empty-changeset.mjs --self-test` :: exit 0 - `node scripts/check-engine-split-ratio.mjs --days 90` :: exit 0 - `node scripts/check-engine-split-ratio.mjs --self-test` :: exit 0 - `node scripts/check-issue-citations.mjs` :: exit 0 - `node scripts/check-plugin-teardown-shape.mjs` :: exit 0 - `node scripts/check-plugin-teardown-shape.mjs --self-test` :: exit 0 - `node scripts/ci/assert-turbo-remote-cache.mjs --self-test` :: exit 0 - `node scripts/ci/plan-test-core-shards.mjs --self-test` :: exit 0 - `node scripts/ci/prune-turbo-cache.mjs --self-test` :: exit 0 - `node scripts/docs-audit/check-affected-docs.mjs` :: exit 0 - `node scripts/docs-audit/check-drift-comment.mjs` :: exit 0 - `node scripts/pm/release-rehearsal-clone.mjs --self-test` :: exit 0 - `node scripts/release-pending-publish.mjs --self-test` :: exit 0 - `pnpm --filter @objectstack/spec run check:duration-unit-keys` :: exit 0 - `pnpm --filter @objectstack/spec run check:empty-state` :: exit 0 - `pnpm --filter @objectstack/spec run check:liveness` :: exit 0 - `pnpm --filter @objectstack/spec run check:strictness-ledger` :: exit 0 - `pnpm --filter @objectstack/spec run check:variant-docs` :: exit 0 - `pnpm check:changeset-gate-self-tests` :: exit 0 - `pnpm check:ci-filter-parity` :: exit 0 - `pnpm check:closed-ceremony-text` :: exit 0 - `pnpm check:cross-package-test-inputs` :: exit 0 - `pnpm check:dispatcher-error-vocabulary` :: exit 0 - `pnpm check:doc-authoring` :: exit 0 - `pnpm check:driver-memory-census` :: exit 0 - `pnpm check:dts-closure` :: exit 0 - `pnpm check:dual-build-cjs-loads` :: exit 0 - `pnpm check:durability-log-level` :: exit 0 - `pnpm check:engine-double-contract` :: exit 0 - `pnpm check:error-status-conformance` :: exit 0 - `pnpm check:gitlink-declared` :: exit 0 - `pnpm check:issue-citations` :: exit 0 - `pnpm check:kernel-hook-pairs` :: exit 0 - `pnpm check:keyed-text-bounds` :: exit 0 - `pnpm check:lean-entry-closure` :: exit 0 - `pnpm check:logger-receiver-detach` :: exit 0 - `pnpm check:nul-bytes` :: exit 0 - `pnpm check:objectql-double-limit` :: exit 0 - `pnpm check:org-identifier` :: exit 0 - `pnpm check:page-declaration-shape` :: exit 0 - `pnpm check:platform-object-tenancy-census` :: exit 0 - `pnpm check:pm-changeset-deadline-census` :: exit 0 - `pnpm check:published-files` :: exit 0 - `pnpm check:query-options-erasure` :: exit 0 - `pnpm check:refd-timer-probe` :: exit 0 - `pnpm check:registry-log-declared` :: exit 0 - `pnpm check:rest-log-spy-declared` :: exit 0 - `pnpm check:route-envelope` :: exit 0 - `pnpm check:slot-lookup` :: exit 0 - `pnpm check:sourcemap-no-sources-content` :: exit 0 - `pnpm check:stack-collection-maps` :: exit 0 - `pnpm check:swallow-census-controls` :: exit 0 - `pnpm check:system-context-census` :: exit 0 - `pnpm check:test-source-alias` :: exit 0 - `pnpm check:tier-file-adoption` :: exit 0 - `pnpm check:type-check-coverage` :: exit 0 - `pnpm check:type-check-debt` :: exit 0 - `pnpm check:undeclared-dep-imports` :: exit 0 - `pnpm check:watch-hint-literal` :: exit 0 - `pnpm check:where-matcher` :: exit 0 - `pnpm exec eslint --no-inline-config packages/core/src/utils packages/objectql/src/action-activation.ts packages/objectql/src/action-activation.test.ts packages/objectql/src/engine.ts packages/objectql/src/index.ts packages/runtime/src/domains packages/qa/dogfood/test/activation-ledger-actor.dogfood.test.ts` :: exit 0 </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (Scoped to the touched files; the repo-wide run is CI's.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (The 维护者速读 block is the maintainer-facing Chinese channel.) --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5pwL1eE2BziiUM28wm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b53d9f3 commit a98693d

16 files changed

Lines changed: 394 additions & 40 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/core': patch
3+
'@objectstack/objectql': patch
4+
'@objectstack/runtime': patch
5+
---
6+
7+
fix(runtime): switching a packaged action or a security catalog item off now records which administrator did it
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
The activation ledger (`sys_metadata_activation`) is written by the ADR-0126 activation doors. Until now, its row and that row's audit entry named nobody: `created_by` / `updated_by` on the ledger row and `user_id` / `actor` on the `sys_audit_log` row were null, even when a signed-in administrator made the request. The store wrote every row under a fixed system context that carries no user.
13+
14+
The action door (`POST /api/v1/actions/_activation/:object/:action`) and the security catalog door (`POST /api/v1/security/_activation/:type/:name`, for positions, permission sets, sharing rules and webhooks) now write the row for the caller. They pass down the caller's own execution context, elevated (`{ ...callerContext, isSystem: true }`). The elevation keeps the engine-owned ledger writable, and the engine and the audit plugin record the caller's user id on both rows.
15+
16+
- `@objectstack/core`: `MetadataActivationStore.setActive(row, options?)` takes an optional `{ context }`. The new `MetadataActivationWriteContext` type requires `isSystem: true`, so a door cannot pass the caller's context down without the elevation. Without a context, the store keeps writing under its system context, so a write that no caller requested still names nobody. `MetadataActivationWriteOptions` is exported too.
17+
- `@objectstack/objectql`: `ObjectQL.setActionActive(row, options?)` and `ActionActivationProjection.setActive(row, options?)` pass the options to the store unchanged. `ActionActivationWriteOptions` is exported.
18+
- `@objectstack/runtime`: both doors pass the caller's elevated context.
19+
20+
The flow toggle (`POST /api/v1/automation/:name/toggle`) is not changed yet. It reaches the ledger through `IAutomationService.toggleFlow(name, enabled)`, and that contract has no way to carry the caller.

‎packages/core/src/utils/metadata-activation-store.test.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,49 @@ describe('ObjectStoreMetadataActivationStore — an item no package ships', () =
346346
});
347347
});
348348

349+
// [#26474] A door writes the row ON BEHALF of the administrator who asked, so
350+
// the write runs under that caller's own envelope, elevated — the engine stamps
351+
// `created_by` / `updated_by` from its `userId`, and the audit plugin reads
352+
// `user_id` / `actor` from the same session. The store's constant system
353+
// context names nobody, which is what left both rows' actor null.
354+
describe('ObjectStoreMetadataActivationStore — the write names the caller who asked for it', () => {
355+
const CALLER = { userId: 'usr_admin', organizationId: 'org_acme', positions: ['org_admin'], isSystem: true as const };
356+
357+
it('INSERTS under the context it is handed, and reads the existing row under the same one', async () => {
358+
const { engine, calls } = makeStoreEngine([]);
359+
360+
await new ObjectStoreMetadataActivationStore(engine, 'action')
361+
.setActive({ name: 'convert_lead', packageId: 'crm', active: false }, { context: CALLER });
362+
363+
expect(calls.map((c) => [c.op, c.options?.context])).toEqual([
364+
['find', CALLER],
365+
['insert', CALLER],
366+
]);
367+
});
368+
369+
it('UPDATES under the context it is handed', async () => {
370+
const { engine, calls } = makeStoreEngine([
371+
{ id: 'r1', metadata_type: 'action', name: 'convert_lead', package_id: 'crm', active: false },
372+
]);
373+
374+
await new ObjectStoreMetadataActivationStore(engine, 'action')
375+
.setActive({ name: 'convert_lead', packageId: 'crm', active: true }, { context: CALLER });
376+
377+
expect(calls.find((c) => c.op === 'update')?.options?.context).toEqual(CALLER);
378+
});
379+
380+
it('with no caller, writes under the system context it always has — a boot-time write names nobody', async () => {
381+
const { engine, calls } = makeStoreEngine([]);
382+
383+
await new ObjectStoreMetadataActivationStore(engine, 'flow').setActive({ name: 'nightly_sync', active: false });
384+
385+
expect(calls.map((c) => c.options?.context)).toEqual([
386+
{ isSystem: true, positions: [], permissions: [] },
387+
{ isSystem: true, positions: [], permissions: [] },
388+
]);
389+
});
390+
});
391+
349392
describe('InMemoryMetadataActivationStore', () => {
350393
it('round-trips a row and reflects the latest flip', async () => {
351394
const store = new InMemoryMetadataActivationStore();

‎packages/core/src/utils/metadata-activation-store.ts‎

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,46 @@
8282
*/
8383
export const METADATA_ACTIVATION_TABLE = 'sys_metadata_activation';
8484

85-
/** Infrastructure rows, not tenant data — the `sys_metadata_activation` posture. */
85+
/**
86+
* Infrastructure rows, not tenant data — the `sys_metadata_activation` posture.
87+
* The context of every read, and of a write no caller is named for (a boot-time
88+
* or in-process flip): such a row names nobody, which is true of it.
89+
*/
8690
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
8791

92+
/**
93+
* The context one ledger WRITE runs under when a caller asked for it: that
94+
* caller's own execution context, ELEVATED — `{ ...callerContext, isSystem: true }`,
95+
* the `sudo()`-shaped elevation the engine's own trusted paths use.
96+
*
97+
* Both halves are load-bearing:
98+
*
99+
* - **the elevation** — the ledger object is `managedBy: 'engine-owned'` and
100+
* declares `apiMethods: ['get', 'list']`, so nothing but an elevated write
101+
* reaches it. The door has already judged the caller's authority (ADR-0126
102+
* §5); the write itself is the platform's;
103+
* - **the caller** — the engine stamps `created_by` / `updated_by` from its
104+
* `userId`, and the audit plugin records `user_id` / `actor` from the same
105+
* session, so the record of an install-wide switch names the administrator
106+
* who threw it. A bare system context names nobody.
107+
*
108+
* `isSystem: true` is in the type, so a door cannot hand the caller's bare
109+
* context down by accident. Runtime doors build it with one shared helper
110+
* (`activationWriteContext` in `@objectstack/runtime`), so they cannot disagree
111+
* about who a row names.
112+
*/
113+
export type MetadataActivationWriteContext = { readonly isSystem: true; readonly userId?: string } & Record<string, unknown>;
114+
115+
/** Options for one {@link MetadataActivationStore.setActive} call. */
116+
export interface MetadataActivationWriteOptions {
117+
/**
118+
* The caller this write is made on behalf of, already elevated
119+
* ({@link MetadataActivationWriteContext}). Absent ⇒ the store's own system
120+
* context, and the row names nobody.
121+
*/
122+
context?: MetadataActivationWriteContext;
123+
}
124+
88125
/**
89126
* [ADR-0126 §4] One artifact's install-level activation row.
90127
*
@@ -120,8 +157,11 @@ export interface MetadataActivationRow {
120157
export interface MetadataActivationStore {
121158
/** Every activation row for this type — the ledger is deployment-wide. */
122159
list(): Promise<MetadataActivationRow[]>;
123-
/** Insert or update the row for one packaged artifact. */
124-
setActive(row: MetadataActivationRow): Promise<void>;
160+
/**
161+
* Insert or update the row for one packaged artifact — written on behalf of
162+
* `options.context` when a caller asked for it.
163+
*/
164+
setActive(row: MetadataActivationRow, options?: MetadataActivationWriteOptions): Promise<void>;
125165
}
126166

127167
/**
@@ -176,7 +216,8 @@ export class InMemoryMetadataActivationStore implements MetadataActivationStore
176216
return [...this.rows.values()];
177217
}
178218

179-
async setActive(row: MetadataActivationRow): Promise<void> {
219+
/** The caller is not recorded: a map has no `created_by` to stamp. */
220+
async setActive(row: MetadataActivationRow, _options?: MetadataActivationWriteOptions): Promise<void> {
180221
refuseEmptyPackageId(row, `'${row.name}'`);
181222
this.rows.set(row.name, { ...row });
182223
}
@@ -187,10 +228,13 @@ export class InMemoryMetadataActivationStore implements MetadataActivationStore
187228
* `sys_metadata_activation` object (ADR-0126 §4), scoped to one
188229
* `metadata_type`.
189230
*
190-
* All access uses a system context: the object is `managedBy: 'engine-owned'`
191-
* and declares `apiMethods: ['get', 'list']`, i.e. the generic data API cannot
231+
* All access is elevated: the object is `managedBy: 'engine-owned'` and
232+
* declares `apiMethods: ['get', 'list']`, i.e. the generic data API cannot
192233
* write it at all — these rows are written by the ADR-0126 enable/disable
193-
* doors and by nothing else.
234+
* doors and by nothing else. Reads use the store's own system context; a write
235+
* a door makes for a caller runs under that caller's context, elevated, so the
236+
* row and its audit row name who switched the item
237+
* ({@link MetadataActivationWriteContext}).
194238
*/
195239
export class ObjectStoreMetadataActivationStore implements MetadataActivationStore {
196240
constructor(
@@ -256,21 +300,27 @@ export class ObjectStoreMetadataActivationStore implements MetadataActivationSto
256300
* `packageId` absent writes NO package (`null`), the ledger's spelling for
257301
* an item no package ships. An empty string is refused before anything is
258302
* read or written ({@link refuseEmptyPackageId}).
303+
*
304+
* `options.context` is the caller the write is made for, elevated
305+
* ({@link MetadataActivationWriteContext}). The keyed lookup runs under it
306+
* too, so the read-then-write is one principal's act; absent, both run
307+
* under the store's system context and the row names nobody.
259308
*/
260-
async setActive(row: MetadataActivationRow): Promise<void> {
309+
async setActive(row: MetadataActivationRow, options?: MetadataActivationWriteOptions): Promise<void> {
261310
refuseEmptyPackageId(row, `${this.metadataType} '${row.name}'`);
311+
const context = options?.context ?? SYSTEM_CTX;
262312
const packageId = row.packageId ?? null;
263313
const existing = await this.engine.find(METADATA_ACTIVATION_TABLE, {
264314
where: { metadata_type: this.metadataType, name: row.name },
265-
context: SYSTEM_CTX,
315+
context,
266316
});
267317
const current = Array.isArray(existing) ? existing[0] : undefined;
268318

269319
if (current && (current as { id?: unknown }).id != null) {
270320
await this.engine.update(
271321
METADATA_ACTIVATION_TABLE,
272322
{ id: (current as { id: unknown }).id, active: row.active, package_id: packageId },
273-
{ context: SYSTEM_CTX },
323+
{ context },
274324
);
275325
return;
276326
}
@@ -283,7 +333,7 @@ export class ObjectStoreMetadataActivationStore implements MetadataActivationSto
283333
package_id: packageId,
284334
active: row.active,
285335
},
286-
{ context: SYSTEM_CTX },
336+
{ context },
287337
);
288338
}
289339

‎packages/objectql/src/action-activation.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,23 @@ describe('ObjectQL engine — the projection the dispatch doors consult', () =>
366366
expect(await store.list()).toEqual([{ name: 'convert_lead', packageId: 'crm', active: true }]);
367367
});
368368

369+
// [#26474] The door hands the engine the caller it writes on behalf of; the
370+
// engine and the projection hand it on to the durable store unchanged, so
371+
// the row's `created_by` / `updated_by` and its audit row name that caller.
372+
it('setActionActive hands the caller\'s write context through to the durable store', async () => {
373+
const { engine: ledger, calls } = makeStoreEngine([]);
374+
const engine = new ObjectQL();
375+
engine.setActionActivationStore(new ObjectStoreActionActivationStore(ledger));
376+
const context = { userId: 'usr_admin', positions: ['org_admin'], isSystem: true as const };
377+
378+
await engine.setActionActive({ name: 'convert_lead', packageId: 'crm', active: false }, { context });
379+
380+
expect(calls.map((c) => [c.op, c.options?.context])).toEqual([
381+
['find', context],
382+
['insert', context],
383+
]);
384+
});
385+
369386
it('⛔ executeAction does NOT consult the ledger — the key is not the identity (ADR-0110 D2)', async () => {
370387
// A target-bound script action registers under its `target`, so a check
371388
// inside `executeAction` would be asking a question the arguments

‎packages/objectql/src/action-activation.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ import {
9494
type MetadataActivationRow,
9595
type MetadataActivationStore,
9696
type MetadataActivationStoreEngine,
97+
type MetadataActivationWriteOptions,
9798
} from '@objectstack/core';
9899

99100
/**
@@ -142,6 +143,13 @@ export type ActionActivationStore = MetadataActivationStore;
142143
*/
143144
export type ActionActivationStoreEngine = MetadataActivationStoreEngine;
144145

146+
/**
147+
* Options for one flip: the caller it is made on behalf of, elevated — the
148+
* shared ledger's write options, so the row and its audit row name who switched
149+
* the action (`@objectstack/core`'s `MetadataActivationWriteContext`).
150+
*/
151+
export type ActionActivationWriteOptions = MetadataActivationWriteOptions;
152+
145153
/**
146154
* In-memory {@link ActionActivationStore} — process-lifetime only, for tests
147155
* and for hosts with no durable plane. It is NOT a sanctioned production
@@ -253,8 +261,11 @@ export class ActionActivationProjection {
253261
* it is a deployment whose ledger table was never registered or synced.
254262
* Reporting a durable install-wide switch that did not persist is the exact
255263
* failure ADR-0126 §6 wall 3 exists to close, so this throws instead.
264+
*
265+
* `options` reaches the store unchanged: the door that asked names its
266+
* caller there, and the store writes on that caller's behalf.
256267
*/
257-
async setActive(row: ActionActivationRow): Promise<void> {
268+
async setActive(row: ActionActivationRow, options?: ActionActivationWriteOptions): Promise<void> {
258269
if (!this.store) {
259270
throw Object.assign(
260271
new Error(
@@ -269,7 +280,7 @@ export class ActionActivationProjection {
269280
{ code: 'SERVICE_UNAVAILABLE', status: 503 },
270281
);
271282
}
272-
await this.store.setActive(row);
283+
await this.store.setActive(row, options);
273284
if (row.active) this.disabled.delete(row.name);
274285
else this.disabled.add(row.name);
275286
}

‎packages/objectql/src/engine.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,12 @@ import { lowerFilterCondition, type FilterCondition, type FilterLoweringOptions
4949
import { MAX_BULK_PER_ROW_HOOK_ROWS } from '@objectstack/spec/data';
5050
// [ADR-0126 §8] The packaged-action activation ledger: its row contract, its
5151
// stores and the engine-held projection the dispatch doors consult.
52-
import { ActionActivationProjection, type ActionActivationRow, type ActionActivationStore } from './action-activation.js';
52+
import {
53+
ActionActivationProjection,
54+
type ActionActivationRow,
55+
type ActionActivationStore,
56+
type ActionActivationWriteOptions,
57+
} from './action-activation.js';
5358
import {
5459
assertListComparandShapes,
5560
assertFilterIsMaterializable,
@@ -5755,10 +5760,12 @@ export class ObjectQL implements IObjectQLEngine {
57555760
* the projection only after that write returns. Throws (503
57565761
* `SERVICE_UNAVAILABLE`) when no ledger is attached rather than reporting a
57575762
* durable switch that did not persist; the write AUTHORITY (§5) is enforced
5758-
* one tier up, at the route, because it needs the caller.
5763+
* one tier up, at the route, because it needs the caller. That route also
5764+
* names the caller in `options.context` (elevated), so the ledger row and
5765+
* its audit row record who switched the action.
57595766
*/
5760-
async setActionActive(row: ActionActivationRow): Promise<void> {
5761-
await this.actionActivation.setActive(row);
5767+
async setActionActive(row: ActionActivationRow, options?: ActionActivationWriteOptions): Promise<void> {
5768+
await this.actionActivation.setActive(row, options);
57625769
}
57635770

57645771
/**

‎packages/objectql/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export type {
3030
ActionActivationRow,
3131
ActionActivationStore,
3232
ActionActivationStoreEngine,
33+
ActionActivationWriteOptions,
3334
} from './action-activation.js';
3435

3536
// Export Registry

0 commit comments

Comments
 (0)