From c4810478b39717d53bf54d00b3d9472e1e6ba921 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 14:21:27 +0000 Subject: [PATCH 1/3] test(metadata-protocol): pin the sys_metadata row's actor columns (red before the fix) Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- ...ys-metadata-repository.recorded-by.test.ts | 70 ++++++ .../src/protocol-metadata-row-actor.test.ts | 206 ++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 packages/objectql/src/protocol-metadata-row-actor.test.ts diff --git a/packages/metadata-protocol/src/sys-metadata-repository.recorded-by.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.recorded-by.test.ts index 10baba6befd..aba0e2e9464 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.recorded-by.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.recorded-by.test.ts @@ -188,3 +188,73 @@ describe('#4556 — recorded_by holds a user id or NULL, never a sentinel', () = expect(headers[0]!.authoredBy).not.toBe('unknown'); }); }); + +/** + * #23033 — the PARENT row records the actor too: `created_by` on the row's + * first write, `updated_by` on every write, from `PutOptions.actor` — the + * header's `actor → updated_by` mapping, which `put()` used to declare and not + * write. A missing actor stores NULL in both, never a placeholder user, for the + * same reason `recorded_by` does above. + * + * This fake stores exactly what it is handed, so these pins prove the + * repository HANDS the engine the pair. That the real engine KEEPS it — the + * `isSystem` write path, the readonly strip and the audit hooks — is pinned on + * the stored row in `packages/objectql/src/protocol-metadata-row-actor.test.ts`. + */ +describe('#23033 — the sys_metadata row records who created it and who last changed it', () => { + let engine: ReturnType; + let repo: SysMetadataRepository; + const ref = { org: 'org_alpha', type: 'view' as const, name: 'case_grid' }; + const onlyRow = () => { + expect(engine.rows.size).toBe(1); + return Array.from(engine.rows.values())[0]!; + }; + + beforeEach(() => { + engine = makeFakeEngine(); + repo = new SysMetadataRepository({ engine, organizationId: 'org_alpha', orgLabel: 'org_alpha' }); + }); + + it('a first write with actor A stamps created_by A and updated_by A', async () => { + await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_alice' }); + const row = onlyRow(); + expect(row.created_by).toBe('usr_alice'); + expect(row.updated_by).toBe('usr_alice'); + }); + + it('a second write with actor B keeps created_by A and moves updated_by to B', async () => { + const first = await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_alice' }); + await repo.put(ref, view('B'), { parentVersion: first.version, actor: 'usr_bob' }); + const row = onlyRow(); + expect(row.created_by).toBe('usr_alice'); + expect(row.updated_by).toBe('usr_bob'); + // History is unchanged: each event names its own write's actor. + expect(engine.historyRows.map((h) => h.recorded_by)).toEqual(['usr_alice', 'usr_bob']); + }); + + it('a write with no actor stamps NULL in both — never a placeholder user', async () => { + await repo.put(ref, view('A'), { parentVersion: null, actor: null }); + const row = onlyRow(); + expect(row.created_by).toBeNull(); + expect(row.updated_by).toBeNull(); + }); + + it('an actor-less write after an authored one clears updated_by and keeps created_by', async () => { + // "Who last changed this?" — nobody named; the previous writer did not make this change. + const first = await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_alice' }); + await repo.put(ref, view('B'), { parentVersion: first.version, actor: null }); + const row = onlyRow(); + expect(row.created_by).toBe('usr_alice'); + expect(row.updated_by).toBeNull(); + }); + + it('the read side follows the row: get().authoredBy and listDrafts().updatedBy name the last writer', async () => { + const first = await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_alice' }); + await repo.put(ref, view('B'), { parentVersion: first.version, actor: 'usr_bob' }); + expect((await repo.get(ref))!.authoredBy).toBe('usr_bob'); + + await repo.put(ref, view('draft'), { parentVersion: null, actor: 'usr_carol', state: 'draft' }); + const drafts = await repo.listDrafts({ type: 'view' }); + expect(drafts.map((d) => [d.name, d.updatedBy])).toEqual([['case_grid', 'usr_carol']]); + }); +}); diff --git a/packages/objectql/src/protocol-metadata-row-actor.test.ts b/packages/objectql/src/protocol-metadata-row-actor.test.ts new file mode 100644 index 00000000000..376fb6a73e1 --- /dev/null +++ b/packages/objectql/src/protocol-metadata-row-actor.test.ts @@ -0,0 +1,206 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #23033 — a metadata write that names its author stores that author on the + * `sys_metadata` row, read back from the row the REAL engine stored. + * + * ## The defect + * + * `SysMetadataRepository`'s header maps `actor → updated_by`, and its read + * side (`rowToItem`'s `authoredBy`, `listDrafts`' `updatedBy`) reads + * `updated_by ?? created_by`. But `put()` built the parent row with neither + * column and wrote it under `isSystem`, whose context carries no user, so the + * engine's own audit stamp (`sys_stamp_audit_insert` / `_update`, which stamp + * the `*_by` pair only from `session.userId`) had nobody to write either. + * Every metadata row read `created_by` / `updated_by` null; the actor reached + * only the history row's `recorded_by`. + * + * ## Why this file is here and not beside the repository + * + * The repository suite (`sys-metadata-repository.recorded-by.test.ts`) can only + * prove the repository HANDS the engine the pair — its engine is a fake that + * stores what it is given. What this card owes on top is that the pair + * SURVIVES the engine: `created_by` is `readonly: true` on `sys_metadata`, the + * static-readonly strip runs inside `engine.insert` / `engine.update` after the + * `before*` hooks, and the audit hooks rewrite the `*_by` pair whenever a + * session user is present. So the write goes through a real kernel — + * `ObjectQLPlugin` binds the real audit hooks and assembles the real protocol + * and `sys_metadata` declaration — and every pin reads the stored row out of + * the driver, never the payload. `metadata-protocol` cannot host this: it must + * not depend on `objectql`, even as a devDependency (the same reason + * `plugin.authoring-channel.test.ts` lives here). + * + * ## The pins (the triage direction's) + * + * - a first write with actor A stores `created_by` A and `updated_by` A; + * - a second write with actor B stores `created_by` A and `updated_by` B; + * - CONTROL: a write with no actor stores both null — no placeholder user; + * - history's `recorded_by` is unchanged: each history row names its own + * write's actor, exactly as before; + * - the door's read-back: `listDrafts` answers `updatedBy` from the row. + */ + +import { randomUUID } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQL } from './engine.js'; +import { ObjectQLPlugin } from './plugin.js'; + +type Stores = Map>>; + +/** In-memory driver, copied from `environment-overlay-email-locale-restart.test.ts`; equality-only WHERE. */ +function makeMemoryDriver(stores: Stores) { + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real + // driver ANDs them. + 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)); + // The caller's bound, after the filter, by presence (`check:objectql-double-limit`). + 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) { + const id = (data.id as string) ?? `r_${randomUUID()}`; + 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; +} + +const ALICE = 'usr_alice'; +const BOB = 'usr_bob'; +const NAME = 'case_grid'; + +// [#21470] `name` is the row's: a body `name` that is not its row's is refused by the write doors. +const viewBody = (label: string) => ({ name: NAME, type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list' }); + +describe('#23033 — the sys_metadata row records who created it and who last changed it', () => { + let kernel: ObjectKernel; + let stores: Stores; + let protocol: any; + + beforeEach(async () => { + // `logger.level` is the real config key, and gracefulShutdown:false keeps + // process signal handlers out of vitest's fork workers (#4250). + kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + stores = new Map(); + const engine = new ObjectQL(); + engine.registerDriver(makeMemoryDriver(stores), true); + await kernel.use(new ObjectQLPlugin({ ql: engine })); + await kernel.bootstrap(); + protocol = kernel.getService('protocol'); + }); + afterEach(async () => { + if (kernel.getState() === 'running') await kernel.shutdown(); + }); + + /** The stored `sys_metadata` rows for the view, as the driver holds them. */ + const parentRows = (state: 'active' | 'draft' = 'active') => + Array.from(stores.get('sys_metadata')?.values() ?? []) + .filter((r) => r.type === 'view' && r.name === NAME && r.state === state); + const parentRow = (state: 'active' | 'draft' = 'active') => { + const rows = parentRows(state); + expect(rows, `exactly one ${state} row for view/${NAME}`).toHaveLength(1); + return rows[0]!; + }; + /** `recorded_by` per history row, in write order. */ + const recordedBy = () => + Array.from(stores.get('sys_metadata_history')?.values() ?? []) + .filter((h) => h.type === 'view' && h.name === NAME) + .sort((a, b) => Number(a.version) - Number(b.version)) + .map((h) => h.recorded_by ?? null); + + it('a first write with actor A stores created_by A and updated_by A', async () => { + await protocol.saveMetaItem({ type: 'view', name: NAME, item: viewBody('A'), actor: ALICE }); + + const row = parentRow(); + expect(row.created_by).toBe(ALICE); + expect(row.updated_by).toBe(ALICE); + expect(recordedBy()).toEqual([ALICE]); + }); + + it('a second write with actor B keeps created_by A and moves updated_by to B', async () => { + await protocol.saveMetaItem({ type: 'view', name: NAME, item: viewBody('A'), actor: ALICE }); + await protocol.saveMetaItem({ type: 'view', name: NAME, item: viewBody('B'), actor: BOB }); + + const row = parentRow(); + expect(row.created_by, 'created_by never changes after the insert').toBe(ALICE); + expect(row.updated_by, 'updated_by names the latest writer').toBe(BOB); + // History is unchanged: each row still names its own write's actor. + expect(recordedBy()).toEqual([ALICE, BOB]); + }); + + it('CONTROL: a write with no actor stores both columns null — no placeholder user', async () => { + await protocol.saveMetaItem({ type: 'view', name: NAME, item: viewBody('A') }); + + const row = parentRow(); + expect(row.created_by ?? null).toBeNull(); + expect(row.updated_by ?? null).toBeNull(); + expect(recordedBy()).toEqual([null]); + }); + + it('the door reads the row back: listDrafts answers updatedBy from the draft row', async () => { + await protocol.saveMetaItem({ type: 'view', name: NAME, item: viewBody('A'), actor: ALICE, mode: 'draft' }); + await protocol.saveMetaItem({ type: 'view', name: NAME, item: viewBody('B'), actor: BOB, mode: 'draft' }); + + const row = parentRow('draft'); + expect(row.created_by).toBe(ALICE); + expect(row.updated_by).toBe(BOB); + const { drafts } = await protocol.listDrafts({ type: 'view' }); + expect(drafts.map((d: any) => [d.name, d.updatedBy])).toEqual([[NAME, BOB]]); + }); +}); From 232fe6136483bc78bf41c3e4880ba83e74cf01c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 14:38:37 +0000 Subject: [PATCH 2/3] fix(metadata-protocol): the sys_metadata row records who created it and who last changed it SysMetadataRepository.put stamps created_by on insert and updated_by on every write from PutOptions.actor, as its header's actor -> updated_by mapping declares. A missing actor stores NULL in both, never a placeholder user. History's recorded_by is unchanged. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .changeset/23033-metadata-row-actor.md | 11 +++++++++++ .../src/sys-metadata-repository.ts | 14 +++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 .changeset/23033-metadata-row-actor.md diff --git a/.changeset/23033-metadata-row-actor.md b/.changeset/23033-metadata-row-actor.md new file mode 100644 index 00000000000..9cedf181038 --- /dev/null +++ b/.changeset/23033-metadata-row-actor.md @@ -0,0 +1,11 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): a metadata row records who created it and who last changed it + +Clause-②: no + +- **What was wrong.** A metadata write that named its author stored no author on the `sys_metadata` row. `created_by` and `updated_by` read null on every row, though the repository's own schema mapping declared `actor → updated_by` and its read side reads that column. Only the history row's `recorded_by` named the writer, so "who changed this object?" could be answered from history alone. +- **What it does now.** Every metadata write stamps the row from the write's actor: `created_by` when the row is first written, and `updated_by` on every write. Studio saves, `PUT /api/v1/meta/:type/:name`, publishing a draft, restoring a version and an in-process `saveMetaItem({ actor })` all write through this one path. A write with no actor (a boot sync, a migration with no user, an unauthenticated internal call) stores null in both columns, never a placeholder user. A save that changes nothing writes nothing, so it leaves both columns as they were. +- **What an operator or a consumer sees.** A row written from now on names its creator and its last writer, so a consumer (such as cloud's AI build agent, whose staged drafts name their author) can read the author from the row itself. The pending-changes list (`listDrafts`' `updatedBy`) names the draft's last writer. Rows written before this release keep their null columns until their next write. History's `recorded_by` is unchanged. diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index da58b694f38..92251cd1c42 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -46,7 +46,8 @@ * hash (sha256) → checksum (text(64)) * monotonic version int → version (number) * org isolation → organization_id (lookup) - * actor → updated_by (lookup, optional) + * actor → updated_by (lookup, optional; every write) + * first write's actor → created_by (lookup, optional; insert only) * * Composition: PR-10c will compose * `LayeredRepository([FileSystemRepository, SysMetadataRepository])` @@ -908,6 +909,14 @@ export class SysMetadataRepository implements MetadataRepository { state, version, updated_at: now, + // [#23033] The header's `actor → updated_by`, on every write. Written + // HERE because nothing below stamps it: the row goes in under + // `isSystem` with no user on the context, and the engine's audit hooks + // stamp the `*_by` pair only from `session.userId`. No actor → NULL, + // never a placeholder user — the `recorded_by` rule (#4556), since + // both are `lookup('sys_user')`. History's `recorded_by` below stays + // as it was: the same actor, on the event row. + updated_by: opts.actor ?? null, }; // [#22877] Only for a type whose identity is a pair: every other type's // row payload stays byte-identical to what it was before the column. @@ -935,6 +944,9 @@ export class SysMetadataRepository implements MetadataRepository { }); } else { parentRowData.created_at = now; + // [#23033] Who created the row: the first write's actor, set on the + // insert only — an update never names `created_by`, so it keeps it. + parentRowData.created_by = opts.actor ?? null; await this.engine.insert('sys_metadata', parentRowData, { context: { ...ctx, isSystem: true } }); } From 1299d185f5a132e550011dcef43878cb65782b55 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 14:40:06 +0000 Subject: [PATCH 3/3] test(metadata-protocol): read the row-actor pins through the environment-wide repository listDrafts reads Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .../src/sys-metadata-repository.recorded-by.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/metadata-protocol/src/sys-metadata-repository.recorded-by.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.recorded-by.test.ts index aba0e2e9464..8760e1d7ccb 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.recorded-by.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.recorded-by.test.ts @@ -204,7 +204,9 @@ describe('#4556 — recorded_by holds a user id or NULL, never a sentinel', () = describe('#23033 — the sys_metadata row records who created it and who last changed it', () => { let engine: ReturnType; let repo: SysMetadataRepository; - const ref = { org: 'org_alpha', type: 'view' as const, name: 'case_grid' }; + // Environment-wide, the topology the protocol's own overlay repository has + // (ADR-0131 D6) and the scope `listDrafts` reads. + const ref = { org: 'system', type: 'view' as const, name: 'case_grid' }; const onlyRow = () => { expect(engine.rows.size).toBe(1); return Array.from(engine.rows.values())[0]!; @@ -212,7 +214,7 @@ describe('#23033 — the sys_metadata row records who created it and who last ch beforeEach(() => { engine = makeFakeEngine(); - repo = new SysMetadataRepository({ engine, organizationId: 'org_alpha', orgLabel: 'org_alpha' }); + repo = new SysMetadataRepository({ engine }); }); it('a first write with actor A stamps created_by A and updated_by A', async () => {