Skip to content

Commit 33ab543

Browse files
fix(runtime): the /automation definition doors record which user saved a flow (#27538)
Part of #27414. This PR delivers the `sys_metadata` half: the row's `created_by` / `updated_by` and its history's `recorded_by`. The `sys_audit_log` half is not in this PR. Its null `user_id` is produced in `packages/metadata-protocol`, outside this card's claimed file surface, and the same null appears on the `/meta` door (measured below). The report on the card says how to finish it. Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么:** 管理员通过 `POST /api/v1/automation`(新建)、`PUT /api/v1/automation/:name`(更新)、`POST /api/v1/automation/:name/clone`(克隆)保存 flow 时,`sys_metadata` 行的 `created_by` / `updated_by` 和历史行的 `recorded_by` 现在记的是这位管理员,不再是空。 - **为什么改:** 三个门共用 `registerAndSaveFlow`,它调 `saveMetaItem` 时没带 `actor`。`/meta` 门一直带着,所以同一个 flow 从 `/meta` 存能记人,从 `/automation` 存就记不到。ADR-0073 D3 要求归属必须具体。 - **风险与回滚:** 只多传一个用户 id,取自已鉴权的执行上下文,不读请求体;无用户的系统调用照旧不带 actor。回滚 = revert 本 PR。未解决:同一写入的 `sys_audit_log` 行 `user_id` 仍为空,`/meta` 门也一样,根因在 `packages/metadata-protocol`,不在本卡文件面内。 - **先看:** `packages/runtime/src/domains/automation.ts` 的 `registerAndSaveFlow`(`saveMetaItem` 那一行);`packages/runtime/src/domains/automation-flow-save-actor.test.ts`。 - **两分钟验证:** `pnpm --filter @objectstack/runtime exec vitest run src/domains/automation-flow-save-actor.test.ts`,预期 3/3 通过:新建、更新、克隆的行都写着调用者 id,无用户的对照写 null。 - **需要你做的:** 无,席位落地。 ## What changes - `registerAndSaveFlow` in `packages/runtime/src/domains/automation.ts` is the one persistence path for `POST /automation`, `PUT /automation/:name` and `POST /automation/:name/clone`. It now passes the caller's user id as `saveMetaItem`'s `actor`. The id is read through `activationCaller(context)`, the helper the toggle door uses since PR #26605: the signed-in user's id from the execution context and nothing else, never from the body. A context with no user passes no `actor`, so a system write stays unattributed as before. - The three callers already passed `context` into the function, so no caller changed. - `automation-authoring-doors-durable.test.ts`: two exact-argument pins on `saveMetaItem` now include `actor: 'u_admin'`, the id of the context those tests already send. ## Tests <details><summary>Measured before the change, on the stock showcase (fresh file DB, signed in as admin@objectos.ai), at base ded67bf</summary> The three `/automation` doors and, as a control, `PUT /api/v1/meta/flow/:name`. Then each new `sys_metadata` row and the `sys_audit_log` rows on `sys_metadata`: ```text row qa_clone_before created_by None updated_by None row qa_create_before created_by None updated_by None (created by POST, then updated by PUT) row qa_meta_before created_by O9Nq...BM updated_by O9Nq...BM (control: the /meta door) audit create sys_metadata (meta row) user_id None actor None audit update sys_metadata (create row) user_id None actor None audit create sys_metadata (create row) user_id None actor None audit create sys_metadata (clone row) user_id None actor None ``` Create and update show the same null actor as clone, so all three are in scope (same function, same fix). The audit rows are null on all four doors, including the `/meta` door that already passes `actor`. </details> <details><summary>Measured after the change, same rig, runtime rebuilt (head d325afa)</summary> ```text row qa_clone_after created_by LzwM...1y updated_by LzwM...1y row qa_create_after created_by LzwM...1y updated_by LzwM...1y row qa_meta_after created_by LzwM...1y updated_by LzwM...1y (control, unchanged) audit create/update sys_metadata, all four rows: user_id None actor None (not changed by this PR) ``` </details> - New `packages/runtime/src/domains/automation-flow-save-actor.test.ts` (3 cases). It uses a real store, the same rig as `meta-save-actor.test.ts`: better-sqlite3 `:memory:`, the real `sys_metadata*` objects and a real `ObjectStackProtocolImplementation`. The door is driven through the real `HttpDispatcher`, and the rows are read back. Create records the creator, update by another user records that user as updater, clone records the cloning admin. The control is a caller with no user: its rows record null on all three doors. <details><summary>Ablation (one mutation, through scripts/ablation-replace.mjs)</summary> - The fix was committed first. Mutation: `saveMetaItem`'s argument lost the `actor` spread. Anchor 1 to 0, blob 14a061f to 540e6524. - Result: 4 failed, 10 passed. The failures were the 2 durable pins and the create/update and clone row pins. The no-user control stayed green. - Restore: blob back to HEAD (14a061f), `git diff HEAD` empty. - The runtime suite imports `automation.ts` from `src` (relative import), so no rebuild was needed to observe the mutation. </details> - Package suites at head 8551475, under the verify lock: `pnpm --filter @objectstack/runtime typecheck` exit 0, including its test-layer check. `pnpm --filter @objectstack/runtime test`: 472 files passed, 7671 tests passed, 19 skipped. - After merging `origin/main` (head f1f7ce3; incoming commits touch `objectql`, so its closure was rebuilt): 6 related files re-run (the new pin, durable, tenant-authored-write, flow-clone, meta-save-actor, toggle-caller), 47/47 passed. ## Acceptance notes - **Not delivered: the `sys_audit_log` half of #27414.** `SysMetadataRepository.put` in `packages/metadata-protocol/src/sys-metadata-repository.ts` writes the `sys_metadata` row under `{ ...ctx, isSystem: true }`, with no `session.userId` and no `provenance.attributedUserId`. The audit writer in `plugin-audit` takes `user_id` from exactly those two fields, so the audit row names nobody on every metadata save door. The `/meta` door is included, and the live control above shows it. The `actor` reaches `updated_by` / `created_by` / `recorded_by` but never the engine write's context. That file is outside this card's claimed surface, so it is left to the seat. - The toggle doors record the user on their audit rows because their writes run with the caller's id on the context. That is the activation ledger, a different write path. ## Verification - Gate list derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this diff: 61 commands. Each exit code was captured before any pipe. `--ran` reconciliation: **61 derived, 59 run, 2 NOT-MEASURED, 0 UNRUN**. - NOT MEASURED, prerequisite not met (exit 3), left to CI: `node scripts/check-plugin-teardown-shape.mjs --self-test` (shallow clone, its pinned control commit is unreachable; the scan itself exited 0) and `pnpm check:dual-build-cjs-loads` (needs a full workspace build). - `eslint --no-inline-config` on the three touched `.ts` files: exit 0. The repo-wide lint is CI's. - Gates were run at 8551475. The later merge of `origin/main` brought no change to `packages/runtime` or to anything these gates derive from. --- _Generated by [Claude Code](https://claude.ai/code/session_017xRBcScm27Q3R5mkYLPWdB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 710c2da commit 33ab543

4 files changed

Lines changed: 166 additions & 3 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): creating, updating or cloning a flow over `/automation` now records which user saved it
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`POST /api/v1/automation`, `PUT /api/v1/automation/:name` and `POST /api/v1/automation/:name/clone` store the flow as a `sys_metadata` row. Until now that row named nobody: `created_by` and `updated_by`, and `recorded_by` on its `sys_metadata_history` entry, were null even when a signed-in administrator made the request. The `/meta` door already recorded the user. All three `/automation` doors now pass the signed-in user's id as the save's actor, so the row names that user, the same way `PUT /api/v1/meta/flow/:name` does. A request with no user, such as the engine's own system call, still saves with no actor, as before.
11+
12+
The `sys_audit_log` row for the same write is not changed by this fix. It still has a null `user_id` on every metadata door, `/meta` included.

‎packages/runtime/src/domains/automation-authoring-doors-durable.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ describe('[#20862] the create and update doors save what they register', () => {
108108
expect(dataOf(response)?.name).toBe(NEW);
109109
expect(h.registerFlow).toHaveBeenCalledWith(NEW, body);
110110
expect(h.saveMetaItem).toHaveBeenCalledTimes(1);
111-
expect(h.saveMetaItem).toHaveBeenCalledWith({ type: 'flow', name: NEW, item: body });
111+
expect(h.saveMetaItem).toHaveBeenCalledWith({ type: 'flow', name: NEW, item: body, actor: 'u_admin' });
112112
expect(h.registerFlow.mock.invocationCallOrder[0]).toBeLessThan(h.saveMetaItem.mock.invocationCallOrder[0]!);
113113
});
114114

@@ -119,7 +119,7 @@ describe('[#20862] the create and update doors save what they register', () => {
119119

120120
expect(statusOf(response)).toBe(200);
121121
expect(h.registerFlow).toHaveBeenCalledWith(CUSTOMER, definition);
122-
expect(h.saveMetaItem).toHaveBeenCalledWith({ type: 'flow', name: CUSTOMER, item: definition });
122+
expect(h.saveMetaItem).toHaveBeenCalledWith({ type: 'flow', name: CUSTOMER, item: definition, actor: 'u_admin' });
123123
expect(h.held(CUSTOMER)).toEqual(definition);
124124
});
125125

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27414, ADR-0073 D3] The `/automation` definition doors record who saved.
5+
*
6+
* `POST /`, `PUT /:name` and `POST /:name/clone` persist through one path,
7+
* `registerAndSaveFlow`, which called `protocol.saveMetaItem` with no `actor`
8+
* although the execution context it had just admitted carried the caller's
9+
* `userId`. So the `sys_metadata` row a signed-in admin created, updated or
10+
* cloned recorded `null` in `created_by` / `updated_by` and in
11+
* `sys_metadata_history.recorded_by`. The save now passes the caller's id the
12+
* way the toggle door does (`activationCaller`), and nothing when the context
13+
* has no user.
14+
*
15+
* ## The rig
16+
*
17+
* One REAL store, as in `meta-save-actor.test.ts`: better-sqlite3 `:memory:`,
18+
* the real `sys_metadata*` objects and a real
19+
* `ObjectStackProtocolImplementation`, so the claim is about the rows, read
20+
* back after every save. The automation service is a fake that holds the
21+
* flows it is given; the door is driven through the real `HttpDispatcher`.
22+
*
23+
* The CONTROL is a caller with no user (`isSystem`, the engine's own
24+
* self-invocation shape): its save records null, as before.
25+
*/
26+
27+
import { describe, it, expect, afterEach } from 'vitest';
28+
import { ObjectQL } from '@objectstack/objectql';
29+
import { SqlDriver } from '@objectstack/driver-sql';
30+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
31+
import { SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject } from '@objectstack/platform-objects/metadata';
32+
33+
import { HttpDispatcher } from '../http-dispatcher.js';
34+
import type { HttpProtocolContext } from '../http-dispatcher.js';
35+
36+
/** `registry.registerObject` requires a package id (see the sibling real-stack tests). */
37+
const TEST_PACKAGE_ID = 'objectstack-test';
38+
const SOURCE = 'customer_source_flow';
39+
40+
const definitionOf = (name: string, label: string) => ({
41+
name,
42+
label,
43+
type: 'autolaunched',
44+
nodes: [
45+
{ id: 'start', type: 'start', label: 'Start' },
46+
{ id: 'end', type: 'end', label: 'End' },
47+
],
48+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
49+
});
50+
51+
/** An author holds `manage_metadata`, so every door below admits the write. */
52+
const author = (userId: string): HttpProtocolContext => ({
53+
request: {},
54+
executionContext: { userId, systemPermissions: ['manage_metadata'] },
55+
} as HttpProtocolContext);
56+
/** CONTROL: engine self-invocation, admitted by `isSystem`, with no user to record. */
57+
const SYSTEM = (): HttpProtocolContext => ({ request: {}, executionContext: { isSystem: true } } as HttpProtocolContext);
58+
59+
interface Recorded { created_by: unknown; updated_by: unknown; recorded_by: unknown[] }
60+
61+
const liveEngines: ObjectQL[] = [];
62+
afterEach(async () => {
63+
while (liveEngines.length) {
64+
try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
65+
}
66+
});
67+
68+
async function boot() {
69+
const engine = new ObjectQL();
70+
liveEngines.push(engine);
71+
engine.registerDriver(new SqlDriver({
72+
client: 'better-sqlite3',
73+
connection: { filename: ':memory:' },
74+
useNullAsDefault: true,
75+
}), true);
76+
await engine.init();
77+
engine.registry.registerObject(SysMetadata as any, TEST_PACKAGE_ID);
78+
engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID);
79+
engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID);
80+
await engine.syncSchemas();
81+
const protocol = new ObjectStackProtocolImplementation(engine as any);
82+
83+
const flows = new Map<string, unknown>([[SOURCE, definitionOf(SOURCE, 'Source')]]);
84+
const automation = {
85+
handlerReady: true,
86+
registerFlow: (name: string, definition: unknown) => { flows.set(name, definition); return definition; },
87+
unregisterFlow: (name: string) => { flows.delete(name); },
88+
getFlow: async (name: string) => flows.get(name) ?? null,
89+
listFlows: async () => [...flows.keys()],
90+
};
91+
const services: Record<string, unknown> = { protocol, automation };
92+
const resolve = (name: string): unknown => services[name];
93+
const kernel: any = { getService: resolve, getServiceAsync: async (name: string) => resolve(name), context: { getService: resolve } };
94+
95+
const recorded = async (name: string): Promise<Recorded> => {
96+
const rows = await engine.find('sys_metadata', { where: { type: 'flow', name } });
97+
expect(rows ?? [], `one sys_metadata row for ${name}`).toHaveLength(1);
98+
const history = await engine.find('sys_metadata_history', { where: { type: 'flow', name } });
99+
return {
100+
created_by: (rows as any[])[0].created_by ?? null,
101+
updated_by: (rows as any[])[0].updated_by ?? null,
102+
recorded_by: [...(history ?? [])]
103+
.sort((a: any, b: any) => Number(a.version) - Number(b.version))
104+
.map((h: any) => h.recorded_by ?? null),
105+
};
106+
};
107+
return { dispatcher: new HttpDispatcher(kernel), recorded };
108+
}
109+
110+
const statusOf = (r: { response?: { status?: number } }) => r.response?.status;
111+
112+
describe('[#27414] the /automation definition doors record the user who saved', () => {
113+
it('POST / records the creator; PUT /:name by another user records that user as the updater', async () => {
114+
const h = await boot();
115+
expect(statusOf(await h.dispatcher.handleAutomation('', 'POST', definitionOf('new_flow', 'v1'), author('u_author'), undefined))).toBe(200);
116+
expect(await h.recorded('new_flow')).toEqual({ created_by: 'u_author', updated_by: 'u_author', recorded_by: ['u_author'] });
117+
118+
const definition = definitionOf('new_flow', 'v2');
119+
expect(statusOf(await h.dispatcher.handleAutomation('/new_flow', 'PUT', { definition }, author('u_editor'), undefined))).toBe(200);
120+
expect(await h.recorded('new_flow')).toEqual({ created_by: 'u_author', updated_by: 'u_editor', recorded_by: ['u_author', 'u_editor'] });
121+
}, 60_000);
122+
123+
it('POST /:name/clone records the admin who cloned on the copy', async () => {
124+
const h = await boot();
125+
const result = await h.dispatcher.handleAutomation(
126+
`/${SOURCE}/clone`, 'POST', { name: 'my_copy', label: 'My Copy' }, author('u_admin'), undefined,
127+
);
128+
expect(statusOf(result)).toBe(200);
129+
expect(await h.recorded('my_copy')).toEqual({ created_by: 'u_admin', updated_by: 'u_admin', recorded_by: ['u_admin'] });
130+
}, 60_000);
131+
132+
it('CONTROL: a caller with no user records null on all three doors, never a label', async () => {
133+
const h = await boot();
134+
expect(statusOf(await h.dispatcher.handleAutomation('', 'POST', definitionOf('sys_flow', 'v1'), SYSTEM(), undefined))).toBe(200);
135+
expect(statusOf(await h.dispatcher.handleAutomation('/sys_flow', 'PUT', { definition: definitionOf('sys_flow', 'v2') }, SYSTEM(), undefined))).toBe(200);
136+
expect(await h.recorded('sys_flow')).toEqual({ created_by: null, updated_by: null, recorded_by: [null, null] });
137+
138+
expect(statusOf(await h.dispatcher.handleAutomation(`/${SOURCE}/clone`, 'POST', { name: 'sys_copy', label: 'System Copy' }, SYSTEM(), undefined))).toBe(200);
139+
expect(await h.recorded('sys_copy')).toEqual({ created_by: null, updated_by: null, recorded_by: [null] });
140+
}, 60_000);
141+
});

‎packages/runtime/src/domains/automation.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2107,6 +2107,8 @@ async function flowWriteRefusal(
21072107
* What is saved is the definition as the caller's door built it — never the
21082108
* engine's parsed copy, whose materialized schema defaults would freeze on
21092109
* disk (the save canonicalizes it the way it does every `/meta` flow write).
2110+
* It is saved under the caller's user id as `actor` [#27414], as the `/meta`
2111+
* door's save is.
21102112
*/
21112113
async function registerAndSaveFlow(
21122114
deps: DomainHandlerDeps,
@@ -2129,8 +2131,16 @@ async function registerAndSaveFlow(
21292131
};
21302132
}
21312133
if (typeof store?.saveMetaItem === 'function') {
2134+
// [#27414, ADR-0073 D3] The save names who asked: the caller's user id
2135+
// as the save's `actor`, which the store records in
2136+
// `sys_metadata.created_by` / `updated_by` and the row's history. Read
2137+
// through the toggle door's `activationCaller`: the signed-in user's
2138+
// id off the execution context and nothing else, never off the body,
2139+
// so the flow doors cannot disagree about who a write names. A context
2140+
// with no user passes no actor, an unattributed write as before.
2141+
const { userId: actor } = activationCaller(context);
21322142
try {
2133-
await store.saveMetaItem({ type: FLOW_METADATA_TYPE, name, item: definition });
2143+
await store.saveMetaItem({ type: FLOW_METADATA_TYPE, name, item: definition, ...(actor ? { actor } : {}) });
21342144
} catch (e) {
21352145
restoreFlowRegistration(automationService, name, held);
21362146
return {

0 commit comments

Comments
 (0)