Skip to content

Commit 5128910

Browse files
committed
fix(metadata-protocol): a migrate-stored pass records the user who ran it, or nobody
PutOptions.actor is the acting user's id or null, never a label. The migrate-stored pass and its three doors passed a label instead, and it landed in sys_metadata_history.recorded_by and the rewritten row's updated_by, both lookup('sys_user') columns. - protocol: migrateStoredMetadata forwards request.actor only when one is given; with none, saveMetaItem records null. The pass's name stays on source: 'migrate-stored'. - rest: POST /_migrate-stored takes the actor from resolveMetaWriteActor, the producer every /meta write door shares (user id or nothing). - runtime: POST /metadata/_migrate-stored passes the caller's user id or nothing. - cli: os migrate meta --stored passes no actor; it has no user. - metadata-core: MetadataItemSchema.authoredBy's describe states the same rule as PutOptions.actor. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
1 parent c126984 commit 5128910

7 files changed

Lines changed: 64 additions & 19 deletions

File tree

‎.changeset/23068-migrate-actor.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/metadata-core': patch
4+
'@objectstack/rest': patch
5+
'@objectstack/runtime': patch
6+
'@objectstack/cli': patch
7+
---
8+
9+
fix(metadata-protocol): a stored-metadata migration records the user who ran it, or nobody, never a label
10+
11+
Clause-②: no
12+
13+
- **What was wrong.** The stored-metadata migration pass (`os migrate meta --stored`, `POST /api/v1/meta/_migrate-stored`, the runtime dispatcher's `POST /metadata/_migrate-stored`, and `migrateStoredMetadata()` called in process) recorded a label as the writer of every row it rewrote: `'os migrate meta --stored'`, `'<user id> (POST /api/v1/meta/_migrate-stored)'`, `'POST /api/v1/meta/_migrate-stored'`, or `'migrate-stored'`. The writer lands in `sys_metadata_history.recorded_by` and in the row's `updated_by`. Both columns are references to `sys_user`, so the label pointed at no user. Nothing refused it, because metadata writes run as the system.
14+
- **What it does now.** The pass records the id of the user who ran it, exactly as every other metadata write does. A REST or runtime call by a signed-in user with `manage_metadata` records that user's id. A run with no user records `null`, which means "system-initiated". That covers the CLI, which opens the database directly with no session, and a system caller. The pass still names itself on each history row's `source` (`migrate-stored`), as it did before.
15+
- **What an operator sees.** History rows and rewritten rows written by a migration from this release on name the person who ran it, or nobody. They never name a command or a route. Rows a migration rewrote before this release keep the label they were given.
16+
- **Contract text.** `MetadataItem.authoredBy`'s description now gives the same rule as `PutOptions.actor`: the acting user's id, or `null` for a system-initiated write, never a label such as `"system"` or `"cli"`. The schema accepts the same values as before.

‎packages/cli/src/commands/migrate/meta.stored-flow-resolution.integration.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,11 @@ describe('os migrate meta --stored — the protocol resolves the engine itself (
110110

111111
const protocol: any = stack.kernel.getService('protocol');
112112

113-
// The command's exact call since #4498 — no `canonicalizeFlow`.
113+
// The command's exact call since #4498 — no `canonicalizeFlow` — and,
114+
// since #23068, no `actor` either (the command has no user to name).
114115
const report = await protocol.migrateStoredMetadata({
115116
apply: true,
116117
types: ['flow'],
117-
actor: 'os migrate meta --stored',
118118
});
119119

120120
// Before the resolver this row came back `skipped` with "no automation
@@ -195,7 +195,8 @@ describe('os migrate meta --stored — the protocol resolves the engine itself (
195195
name: 'sfs_purge',
196196
item: { ...item, label: 'Purge Stale Leads (edited)' },
197197
parentVersion: null,
198-
actor: 'studio-roundtrip-probe',
198+
// A user id, as a Studio save passes one — never a label (#23068).
199+
actor: 'usr_studio_probe',
199200
});
200201

201202
const [row] = await ql.find('sys_metadata', {

‎packages/cli/src/commands/migrate/meta.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1555,7 +1555,12 @@ export default class MigrateMeta extends Command {
15551555
: await protocol.migrateStoredMetadata({
15561556
apply,
15571557
...(flags.type && flags.type.length > 0 ? { types: flags.type } : {}),
1558-
actor: 'os migrate meta --stored',
1558+
// [#23068] No `actor`: this command opens the database directly,
1559+
// with no session, so it has no user id to give — and the actor
1560+
// lands in `recorded_by` / `updated_by`, `lookup('sys_user')`
1561+
// columns, where only a user id or `null` belongs. Omitted, the
1562+
// rows record `null` ("system-initiated", #4556); the pass names
1563+
// itself on `source: 'migrate-stored'`.
15591564
});
15601565
const clean = storedMigrationClean(report);
15611566
if (!clean) exitCode = 1;

‎packages/metadata-core/src/types.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export const MetadataItemSchema = z.object({
9999
body: z.record(z.string(), z.unknown()).describe('Canonical Zod-normalised spec'),
100100
hash: z.string().regex(/^sha256:[0-9a-f]{64}$/).describe('sha256(canonicalize(body, ref.type))'),
101101
parentHash: z.string().nullable().describe('Hash this version was derived from; null for first version'),
102-
authoredBy: z.string().nullable().describe('Identity of the writer (user id, "cli", "ai:claude", …); null = system-initiated, no actor'),
102+
authoredBy: z.string().nullable().describe('Identity of the writer: the acting user id, as PutOptions.actor records it; null = system-initiated, no actor — never a label such as "system" or "cli"'),
103103
authoredAt: z.string().describe('ISO-8601 timestamp'),
104104
message: z.string().optional().describe('Optional commit message'),
105105
seq: z.number().int().nonnegative().describe('Sequence number this write produced in the org log'),

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21185,7 +21185,9 @@ export class ObjectStackProtocolImplementation implements
2118521185
* are about to write (the same conversion). It is empty by construction,
2118621186
* and there is no author here for a confirmation prompt to reach.
2118721187
* - `source: 'migrate-stored'` — so a history diff distinguishes a
21188-
* canonicalization pass from an edit someone made.
21188+
* canonicalization pass from an edit someone made. This is the ONLY
21189+
* place the pass names itself: `actor` (below) is the user who ran it
21190+
* or nobody, never a label (#23068).
2118921191
*
2119021192
* ## What it lists and never writes
2119121193
*
@@ -21279,7 +21281,16 @@ export class ObjectStackProtocolImplementation implements
2127921281
apply?: boolean;
2128021282
/** Restrict to these metadata types (singular or plural spelling). */
2128121283
types?: string[];
21282-
/** Recorded as the writer on the history + audit rows. */
21284+
/**
21285+
* The id of the user running the pass — omit it when no user is
21286+
* (a CLI run against the database, an `isSystem` caller). It becomes
21287+
* each rewrite's `PutOptions.actor`, so it lands in
21288+
* `sys_metadata_history.recorded_by` and the row's `updated_by`, both
21289+
* `lookup('sys_user')`: omitted, they record `null`, #4556's
21290+
* "system-initiated". ⛔ Never a label naming the door or the pass —
21291+
* that is a reference to no user, and `source: 'migrate-stored'`
21292+
* already says where the write came from (#23068).
21293+
*/
2128321294
actor?: string;
2128421295
/**
2128521296
* Canonicalize a stored `flow` body (#4454). **Optional override** —
@@ -21611,7 +21622,10 @@ export class ObjectStackProtocolImplementation implements
2161121622
packageId,
2161221623
force: true,
2161321624
source: 'migrate-stored',
21614-
actor: request.actor ?? 'migrate-stored',
21625+
// [#23068] The user who ran the pass, or none — and none
21626+
// reaches `PutOptions.actor` as `null` (`saveMetaItem`'s
21627+
// #4556 default). The pass's name is on `source` above.
21628+
...(request.actor ? { actor: request.actor } : {}),
2161521629
});
2161621630
record({ ...base, notices: flattened, todos: flattenedTodos, outcome: 'rewritten' });
2161721631
} catch (e: any) {

‎packages/rest/src/rest-server.ts‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2772,7 +2772,8 @@ export class RestServer {
27722772
/**
27732773
* [#7749] The acting identity recorded on a metadata WRITE — the single
27742774
* producer for every `/meta` route that stamps an `actor` (save, delete,
2775-
* publish, rollback, compound save).
2775+
* publish, rollback, compound save, and — since #23068, which retired the
2776+
* route-name label it stamped inline — `POST /_migrate-stored`).
27762777
*
27772778
* ## What was wrong
27782779
*
@@ -5925,15 +5926,19 @@ export class RestServer {
59255926
// Preview by default — `apply` must be explicitly true,
59265927
// the same posture the CLI takes. A caller who sends an
59275928
// empty body gets a report and no writes.
5929+
// Attributed to the caller: this writes history rows,
5930+
// and "who ran the migration" is the question they
5931+
// exist to answer. [#23068] By user id alone — the
5932+
// identity this request was authorized as, read by the
5933+
// producer every `/meta` write door shares — or by
5934+
// nobody. It lands in `recorded_by` / `updated_by`,
5935+
// `lookup('sys_user')` columns, so ⛔ never the route's
5936+
// name: the pass records its origin on `source`.
5937+
const actor = await this.resolveMetaWriteActor(environmentId, req);
59285938
const report = await (p as any).migrateStoredMetadata({
59295939
apply: (req.body as any)?.apply === true,
59305940
...(types.length > 0 ? { types } : {}),
5931-
// Attributed to the caller: this writes history +
5932-
// audit rows, and "who ran the migration" is the
5933-
// question those rows exist to answer.
5934-
actor: ctx?.userId
5935-
? `${ctx.userId} (POST ${metadata.prefix}/_migrate-stored)`
5936-
: `POST ${metadata.prefix}/_migrate-stored`,
5941+
...(actor ? { actor } : {}),
59375942
});
59385943
res.json(report);
59395944
} catch (error: any) {

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1832,14 +1832,18 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin
18321832
const types = Array.isArray(body?.types)
18331833
? body.types.filter((t: unknown): t is string => typeof t === 'string' && t.length > 0)
18341834
: undefined;
1835+
// Attributed to the caller, not to the route: this writes history
1836+
// rows, and "who ran the migration" is the question they exist to
1837+
// answer. [#23068] By the caller's user id alone, or by nobody (an
1838+
// `isSystem` caller): it lands in `recorded_by` / `updated_by`,
1839+
// `lookup('sys_user')` columns, so ⛔ never the route's name — the
1840+
// pass records its origin on `source: 'migrate-stored'`.
1841+
const actor = typeof ec?.userId === 'string' && ec.userId ? ec.userId : undefined;
18351842
try {
18361843
const report = await protocol.migrateStoredMetadata({
18371844
apply: body?.apply === true,
18381845
...(types && types.length > 0 ? { types } : {}),
1839-
// Attributed to the caller, not to the route: this writes
1840-
// history + audit rows, and "who ran the migration" is the
1841-
// question those rows exist to answer.
1842-
actor: ec?.userId ? `${ec.userId} (POST /metadata/_migrate-stored)` : 'POST /metadata/_migrate-stored',
1846+
...(actor ? { actor } : {}),
18431847
});
18441848
return { handled: true, response: deps.success(report) };
18451849
} catch (e: any) {

0 commit comments

Comments
 (0)