From c19c2e167c3ecc6ff03a4a32788fe1b0228c516a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:04:28 +0000 Subject: [PATCH 1/2] fix(metadata-protocol): seed replay never compares a credential the read path masks A secret field, and a password field on an object not managed by better-auth, reads back as SECRET_MASK through engine.find, so the replay compared the seed's plaintext with the mask and rewrote every seeded row carrying one on every boot. Such a field no longer blocks the skip while a value is set; setting or clearing one is still a change, and a load that skipped a row without comparing one says so in one info line naming the fields and the remedy. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD --- .../src/seed-loader-replay.test.ts | 243 ++++++++++++++++++ packages/metadata-protocol/src/seed-loader.ts | 210 +++++++++++++-- 2 files changed, 431 insertions(+), 22 deletions(-) diff --git a/packages/metadata-protocol/src/seed-loader-replay.test.ts b/packages/metadata-protocol/src/seed-loader-replay.test.ts index 74908e16186..807ea04e258 100644 --- a/packages/metadata-protocol/src/seed-loader-replay.test.ts +++ b/packages/metadata-protocol/src/seed-loader-replay.test.ts @@ -5,6 +5,7 @@ import { SeedLoaderService } from './seed-loader.js'; import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; import { temporalComparandKind, temporalStorageForm } from '@objectstack/core'; +import { SECRET_MASK, isMaskedOnReadFieldType } from '@objectstack/spec/data'; /** * Replay regression: seeds with lookup natural keys must survive a dev-server @@ -491,3 +492,245 @@ describe('seed replay — a temporal value spelled short is the value the driver expect(replay.result.summary.totalSkipped).toBe(3); }); }); + +/** + * [#24624] A `password` or `secret` value replayed over the row the engine + * stored for it. + * + * The engine never reads a credential back: `ObjectQL.maskSecretFields` serves + * every `secret` field, and every `password` field on an object that is not + * `managedBy: 'better-auth'`, as `SECRET_MASK` on `find` / `findOne` — in every + * context, the seed's system read included (ADR-0100) — and an unset one as + * `null`. So the replay holds the seed's plaintext on one side and the mask on + * the other, and no comparison of the two can say whether the value changed. + * Ruled (letter B): such a field never blocks the skip, and a load that skipped + * a row without comparing one says so in one line. + * + * The engine below stores what a real one stores — a `password` verbatim, a + * `secret` as an opaque ref — stamps `updated_at` on every write, and masks on + * read by the spec's declaration (`isMaskedOnReadFieldType`, the predicate the + * engine's own collector asks), returning copies so the mask never reaches the + * store. + */ +describe('seed replay — a credential the read path masks never blocks the skip', () => { + const VAULT_FIELDS = { + name: { type: 'text' }, + note: { type: 'text' }, + pin: { type: 'password' }, + api_key: { type: 'secret' }, + }; + const VAULT = { name: 'cred_replay_vault', fields: VAULT_FIELDS }; + const TREE_VAULT = { + name: 'cred_replay_tree_vault', + fields: { ...VAULT_FIELDS, parent: { type: 'lookup', reference: 'cred_replay_tree_vault' } }, + }; + type VaultObject = { name: string; managedBy?: string; fields: Record }; + const PATHS: Array<{ path: string; object: VaultObject; sequential: boolean }> = [ + { path: 'batched decideWriteAction', object: VAULT, sequential: false }, + { path: 'sequential writeRecord', object: TREE_VAULT, sequential: true }, + ]; + + const SEED_ROWS: Array> = [ + { name: 'with-password', note: 'n1', pin: 'hunter2' }, + { name: 'with-secret', note: 'n2', api_key: 'sk-live-1' }, + { name: 'with-both', note: 'n3', pin: 'swordfish', api_key: 'sk-live-2' }, + { name: 'no-credential', note: 'n4' }, + ]; + + function createMaskingEngine(object: VaultObject) { + const { engine, store } = createFaithfulEngine(); + let clock = 0; + let handle = 0; + const stored = (row: Record) => { + const out: Record = { ...row, updated_at: `stamp-${++clock}` }; + for (const [key, value] of Object.entries(row)) { + if (object.fields[key]?.type === 'secret' && value != null) out[key] = `secret:h${++handle}`; + } + return out; + }; + const masked = (row: Record) => { + const out = { ...row }; + for (const [key, def] of Object.entries(object.fields)) { + if (key in out && isMaskedOnReadFieldType(def.type, object.managedBy)) { + out[key] = out[key] == null ? null : SECRET_MASK; + } + } + return out; + }; + const insert = (engine.insert as any).getMockImplementation(); + (engine.insert as any).mockImplementation(async (o: string, data: any, opts?: any) => + insert(o, Array.isArray(data) ? data.map(stored) : stored(data), opts)); + const update = (engine.update as any).getMockImplementation(); + (engine.update as any).mockImplementation(async (o: string, data: any, opts?: any) => update(o, stored(data), opts)); + const find = (engine.find as any).getMockImplementation(); + (engine.find as any).mockImplementation(async (o: string, query?: any) => + ((await find(o, query)) as Array>).map(masked)); + return { engine, store }; + } + + function metadataOf(object: VaultObject): IMetadataService { + return { + getObject: vi.fn(async (name: string) => (name === object.name ? object : undefined)), + listObjects: vi.fn(async () => [object]), + register: vi.fn(async () => {}), + get: vi.fn(async (_t: string, name: string) => (name === object.name ? object : undefined)), + list: vi.fn(async () => []), + unregister: vi.fn(async () => {}), + exists: vi.fn(async () => false), + listNames: vi.fn(async () => []), + } as unknown as IMetadataService; + } + + async function load( + engine: IDataEngine, + object: VaultObject, + mode: string, + records: Array>, + ) { + const logger = createLogger(); + const writeRecord = vi.spyOn(SeedLoaderService.prototype as any, 'writeRecord'); + try { + const result = await new SeedLoaderService(engine, metadataOf(object), logger).load({ + seeds: [{ object: object.name, externalId: 'name', mode, env: ['prod', 'dev', 'test'], records }] as any, + config: CONFIG, + }); + return { result, logger, writeRecordCalls: writeRecord.mock.calls.length }; + } finally { + writeRecord.mockRestore(); + } + } + + const rowOf = (store: Record, object: VaultObject, name: string) => + store[object.name].find((r) => r.name === name)!; + const updatedNames = (engine: IDataEngine, store: Record, object: VaultObject) => + (engine.update as any).mock.calls + .map(([, data]: [string, any]) => store[object.name].find((r) => r.id === data.id)?.name) + .sort(); + /** The load's notice lines: the ones naming the uncompared credential rule. */ + const credentialNotices = (logger: ReturnType) => + [...logger.info.mock.calls, ...logger.warn.mock.calls] + .map(([message]) => String(message)) + .filter((message) => message.includes('masked on read')); + + it.each(PATHS.flatMap((p) => ['upsert', 'update'].map((mode) => ({ ...p, mode }))))( + '$path, mode $mode: replaying the unchanged seed issues no update and moves no updated_at', + async ({ object, sequential, mode }) => { + const { engine, store } = createMaskingEngine(object); + const first = await load(engine, object, 'upsert', SEED_ROWS); + expect(first.result.summary.totalInserted).toBe(4); + // The premise: the store holds the credential, the read hands back the mask. + expect(rowOf(store, object, 'with-password').pin).toBe('hunter2'); + expect(String(rowOf(store, object, 'with-secret').api_key)).toMatch(/^secret:h\d+$/); + expect((await engine.find(object.name, {}))[0].pin).toBe(SECRET_MASK); + (engine.update as any).mockClear(); + const before = structuredClone(store); + + const replay = await load(engine, object, mode, SEED_ROWS); + + expect(replay.writeRecordCalls > 0).toBe(sequential); + expect(engine.update).not.toHaveBeenCalled(); + expect(replay.result.summary.totalUpdated).toBe(0); + expect(replay.result.summary.totalSkipped).toBe(4); + expect(replay.result.summary.totalErrored).toBe(0); + expect(store).toEqual(before); + }, + ); + + it.each(PATHS)('$path: a credential-only seed change is not rewritten, and the load says so in one line', async ({ object, sequential }) => { + const { engine, store } = createMaskingEngine(object); + await load(engine, object, 'upsert', SEED_ROWS); + (engine.update as any).mockClear(); + const before = structuredClone(store); + const credentialOnly = SEED_ROWS.map((r) => + r.name === 'with-password' ? { ...r, pin: 'hunter3' } + : r.name === 'with-secret' ? { ...r, api_key: 'sk-live-9' } + : r); + + const replay = await load(engine, object, 'upsert', credentialOnly); + + expect(replay.writeRecordCalls > 0).toBe(sequential); + expect(engine.update).not.toHaveBeenCalled(); + expect(replay.result.summary.totalSkipped).toBe(4); + expect(store).toEqual(before); + // ONE line for the whole load, naming every field it left uncompared, + // the rule, and the two ways a credential-only change takes effect. + const notices = credentialNotices(replay.logger); + expect(notices).toHaveLength(1); + expect(replay.logger.info.mock.calls.map(([m]) => String(m))).toContain(notices[0]); + expect(notices[0]).toContain(`${object.name}.pin`); + expect(notices[0]).toContain(`${object.name}.api_key`); + expect(notices[0]).toMatch(/another field/); + expect(notices[0]).toMatch(/rebuild/); + }); + + it.each(PATHS)('$path: a change to another field rewrites the row, and the credential lands as the seed declares it', async ({ object, sequential }) => { + const { engine, store } = createMaskingEngine(object); + await load(engine, object, 'upsert', SEED_ROWS); + (engine.update as any).mockClear(); + const secretRefBefore = rowOf(store, object, 'with-secret').api_key; + const changed = SEED_ROWS.map((r) => + r.name === 'with-password' ? { ...r, note: 'n1-edited', pin: 'hunter3' } + : r.name === 'with-secret' ? { ...r, note: 'n2-edited', api_key: 'sk-live-9' } + : r); + + const replay = await load(engine, object, 'upsert', changed); + + expect(replay.writeRecordCalls > 0).toBe(sequential); + expect(updatedNames(engine, store, object)).toEqual(['with-password', 'with-secret']); + expect(replay.result.summary.totalUpdated).toBe(2); + expect(rowOf(store, object, 'with-password').pin).toBe('hunter3'); + expect(rowOf(store, object, 'with-password').note).toBe('n1-edited'); + // The update carried the seed's plaintext, so the engine wrote a new ref. + expect((engine.update as any).mock.calls.find(([, d]: [string, any]) => d.note === 'n2-edited')[1].api_key).toBe('sk-live-9'); + expect(rowOf(store, object, 'with-secret').api_key).not.toBe(secretRefBefore); + }); + + it.each(PATHS)('$path: the read still tells set from unset, so setting or clearing a credential is written', async ({ object, sequential }) => { + const { engine, store } = createMaskingEngine(object); + await load(engine, object, 'upsert', SEED_ROWS); + (engine.update as any).mockClear(); + const presenceChanged = SEED_ROWS.map((r) => + r.name === 'no-credential' ? { ...r, pin: 'first-pin' } + : r.name === 'with-password' ? { ...r, pin: null } + : r); + + const replay = await load(engine, object, 'upsert', presenceChanged); + + expect(replay.writeRecordCalls > 0).toBe(sequential); + expect(updatedNames(engine, store, object)).toEqual(['no-credential', 'with-password']); + expect(rowOf(store, object, 'no-credential').pin).toBe('first-pin'); + expect(rowOf(store, object, 'with-password').pin).toBeNull(); + }); + + it('a field the declaration does not mask keeps comparing, even when it holds the mask\'s text', async () => { + const object = VAULT; + const { engine, store } = createMaskingEngine(object); + await load(engine, object, 'upsert', [{ name: 'literal-bullets', note: SECRET_MASK }]); + (engine.update as any).mockClear(); + + const replay = await load(engine, object, 'upsert', [{ name: 'literal-bullets', note: 'plain note' }]); + + expect(updatedNames(engine, store, object)).toEqual(['literal-bullets']); + expect(rowOf(store, object, 'literal-bullets').note).toBe('plain note'); + expect(credentialNotices(replay.logger)).toEqual([]); + }); + + it('a load that skips no row with an uncompared credential says nothing about the rule', async () => { + const object = VAULT; + const { engine } = createMaskingEngine(object); + await load(engine, object, 'upsert', SEED_ROWS); + + const changedOnly = await load( + engine, + object, + 'upsert', + SEED_ROWS.filter((r) => 'pin' in r || 'api_key' in r).map((r) => ({ ...r, note: `${r.note}-edited` })), + ); + const noCredential = await load(engine, object, 'upsert', SEED_ROWS.filter((r) => r.name === 'no-credential')); + + expect(changedOnly.result.summary.totalUpdated).toBe(3); + expect(credentialNotices(changedOnly.logger)).toEqual([]); + expect(noCredential.result.summary.totalSkipped).toBe(1); + expect(credentialNotices(noCredential.logger)).toEqual([]); + }); +}); diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index ee3eb38f87d..3963a4b46a2 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -20,6 +20,10 @@ import { isTenancyDisabled, referenceTargetOf, resolveInjectedSystemColumns, + // [#24624] The ONE declaration of which field types the engine masks on read + // (and which `managedBy` buckets exempt them), and the mask itself (ADR-0100). + isMaskedOnReadFieldType, + SECRET_MASK, } from '@objectstack/spec/data'; import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; import { resolveSeedRecord } from '@objectstack/formula'; @@ -494,6 +498,44 @@ function temporalFieldKinds(definition: unknown): ReadonlyMap { + const masked = new Set(); + const object = definition as + | { fields?: Record; managedBy?: unknown } + | null + | undefined; + const fields = object?.fields; + if (!fields || typeof fields !== 'object') return masked; + for (const [name, field] of Object.entries(fields)) { + if (isMaskedOnReadFieldType(field?.type, object.managedBy)) masked.add(name); + } + return masked; +} + +/** + * How a replay compares the fields of ONE object, read once per dataset from + * its definition (see {@link SeedLoaderService.isNoOpReplay}). + */ +interface ReplayComparands { + /** [#24562] Fields compared in their STORED temporal form. */ + readonly temporal: ReadonlyMap; + /** [#24624] Fields the read path masks: a set value is never compared. */ + readonly masked: ReadonlySet; +} + export class SeedLoaderService implements ISeedLoaderService { private engine: IDataEngine; private metadata: IMetadataService; @@ -614,6 +656,19 @@ export class SeedLoaderService implements ISeedLoaderService { * Reset per `load`. */ private replayIdByAuthoredId = new Map>(); + /** + * [#24624] Per seeded object, the credential fields the CURRENT {@link load} + * skipped a row without comparing (see {@link SeedLoaderService.isNoOpReplay}), + * and how many rows it skipped that way. Reported once, at the end of the + * load, by {@link reportUncomparedCredentials}. + * + * An instance field for the same reason {@link summariesStale} is one: the + * skip is counted several parameters below the point the load ends. Filled + * only where a skip is COUNTED, never by the comparison itself, which + * {@link decideWriteAction} runs up to three times per record. Reset per + * `load`. + */ + private uncomparedCredentials = new Map; rows: number }>(); constructor(engine: IDataEngine, metadata: IMetadataService, logger: Logger) { this.engine = engine; @@ -686,6 +741,54 @@ export class SeedLoaderService implements ISeedLoaderService { } } + /** + * [#24624] Record a skipped row whose credential fields the replay did not + * compare. Called only where the skip is counted. + */ + private noteUncomparedCredentials(objectName: string, fields: readonly string[]): void { + if (fields.length === 0) return; + let entry = this.uncomparedCredentials.get(objectName); + if (!entry) { + entry = { fields: new Set(), rows: 0 }; + this.uncomparedCredentials.set(objectName, entry); + } + entry.rows++; + for (const field of fields) entry.fields.add(field); + } + + /** + * [#24624] ONE line per load when a replay skipped a row without comparing a + * credential field: the rule, the fields it applied to, and the two ways a + * credential-only seed change takes effect (ruled letter B: a masked field + * never blocks the skip). + * + * Logged at `info`, like {@link filterByEnv}'s line: the skip is the + * declared, intended outcome, and it happens on every healthy replay of a + * seed that carries a credential (the stock showcase, every boot), where a + * `warn` would cry wolf — and the official examples boot with zero warnings + * (`examples/app-showcase/test/no-startup-warnings.test.ts`). Nothing is + * lost that the system claims to hold: the row keeps the credential it had, + * so this is not the durability class AGENTS.md reserves `error` for. + */ + private reportUncomparedCredentials(): void { + if (this.uncomparedCredentials.size === 0) return; + const fields: string[] = []; + let rows = 0; + for (const [objectName, entry] of this.uncomparedCredentials) { + rows += entry.rows; + for (const field of entry.fields) fields.push(`${objectName}.${field}`); + } + this.logger.info( + `[SeedLoader] ${rows} seeded row(s) were left unchanged without comparing their credential field(s) ` + + `${fields.join(', ')}: a secret field, and a password field on an object not managed by better-auth, ` + + `is masked on read, so a replay cannot see whether the seed changed a value that is already set and ` + + `never rewrites a row for that alone. A seed change to only such a field is not applied on replay: ` + + `change another field of the same row too (the rewrite then writes the seed's value), or rebuild the ` + + `database.`, + { rows, fields }, + ); + } + private async loadDatasets(request: SeedLoaderRequestParsed): Promise { const startTime = Date.now(); // Pin the environment `Seed.env` is gated on BEFORE anything reads config. @@ -714,6 +817,8 @@ export class SeedLoaderService implements ISeedLoaderService { // [#21665] One replay's ids belong to one organization: a reused service // instance must never re-point the next load's references with them. this.replayIdByAuthoredId.clear(); + // [#24624] One notice per load, about this load's skips only. + this.uncomparedCredentials.clear(); // When the caller pinned no target org (an in-process publish has no active // user session — the AI build agent's publish path; the `single`-posture @@ -843,6 +948,9 @@ export class SeedLoaderService implements ISeedLoaderService { await this.resolveDeferredUpdates(deferredUpdates, insertedRecords, allResults, allErrors, config.organizationId); } + // [#24624] The rule the replay applied to credential fields, said once. + this.reportUncomparedCredentials(); + // 7. Build final result const durationMs = Date.now() - startTime; return this.buildResult(config, graph, allResults, allErrors, durationMs); @@ -1240,8 +1348,13 @@ export class SeedLoaderService implements ISeedLoaderService { // judge, and nothing here refuses a row for an owner it cannot prove. const objectDefinition = await this.resolveObjectDefinition(objectName); const rowsCarryOrganization = objectDefinition == null || seedRowNeedsOrganization(objectDefinition); - // [#24562] What a replay compares in the stored form (`isNoOpReplay`). - const temporalKinds = temporalFieldKinds(objectDefinition); + // How a replay compares each field (`isNoOpReplay`): [#24562] a temporal + // field in its stored form, [#24624] a field the read path masks never by + // its set value. + const comparands: ReplayComparands = { + temporal: temporalFieldKinds(objectDefinition), + masked: maskedOnReadFields(objectDefinition), + }; // [ADR-0131 D9] With no organization pinned and none derivable on an install // that registers the organization object, a row of an organization-owned // object has no owner: it is REFUSED below, never written NULL. @@ -1717,7 +1830,7 @@ export class SeedLoaderService implements ISeedLoaderService { // it, and which organization an unowned row belongs to is the // organization-ownership ceremony's attribution, never the loader's. if (unownedHolders && unownedHolders.size > 0) { - const decision = this.decideWriteAction(record, mode, externalId, existingRecords, temporalKinds, unownedHolders); + const decision = this.decideWriteAction(record, mode, externalId, existingRecords, comparands, unownedHolders); if (decision.action === 'refuse') { errored++; const label = this.externalIdLabel(externalId); @@ -1771,7 +1884,7 @@ export class SeedLoaderService implements ISeedLoaderService { // verbatim — this line only moves WHEN the author learns, from after the // engine's rejection to before the write that provokes it. if (!config.dryRun && requiredDeferrals.length > 0) { - if (this.decideWriteAction(record, mode, externalId, existingRecords, temporalKinds).action === 'insert') { + if (this.decideWriteAction(record, mode, externalId, existingRecords, comparands).action === 'insert') { for (const deferral of requiredDeferrals) { if (earlySignalledFields.has(deferral.field)) continue; earlySignalledFields.add(deferral.field); @@ -1802,12 +1915,15 @@ export class SeedLoaderService implements ISeedLoaderService { // against an earlier one via `insertedRecords` — see `hasSelfRef`. try { const result = await this.writeRecord( - objectName, record, mode, externalId, existingRecords, temporalKinds + objectName, record, mode, externalId, existingRecords, comparands ); if (result.action === 'inserted') inserted++; else if (result.action === 'updated') updated++; - else if (result.action === 'skipped') skipped++; + else if (result.action === 'skipped') { + skipped++; + if (result.uncompared) this.noteUncomparedCredentials(objectName, result.uncompared); // [#24624] + } const externalIdValue = this.externalIdKey(record, externalId); const internalId = result.id; @@ -1840,11 +1956,12 @@ export class SeedLoaderService implements ISeedLoaderService { ); } } else { - const decision = this.decideWriteAction(record, mode, externalId, existingRecords, temporalKinds); + const decision = this.decideWriteAction(record, mode, externalId, existingRecords, comparands); const externalIdValue = this.externalIdKey(record, externalId); if (decision.action === 'skip') { skipped++; + if (decision.uncompared) this.noteUncomparedCredentials(objectName, decision.uncompared); // [#24624] if (decision.id) noteLanded(i, decision.id); // [commit 9a884c6e4] if (decision.id && externalIdValue) { insertedRecords.get(objectName)!.set(externalIdValue, decision.id); @@ -2641,8 +2758,8 @@ export class SeedLoaderService implements ISeedLoaderService { mode: string, externalId: string | string[], existingRecords: Map | undefined, - temporalKinds: ReadonlyMap, - ): Promise<{ action: 'inserted' | 'updated' | 'skipped'; id?: string }> { + comparands: ReplayComparands, + ): Promise<{ action: 'inserted' | 'updated' | 'skipped'; id?: string; uncompared?: readonly string[] }> { const existing = existingRecords?.get(this.externalIdKey(record, externalId)); const opts = SeedLoaderService.SEED_OPTIONS as any; @@ -2657,8 +2774,8 @@ export class SeedLoaderService implements ISeedLoaderService { return { action: 'skipped' }; } const id = this.extractId(existing); - if (this.isNoOpReplay(record, existing, temporalKinds)) { - return { action: 'skipped', id }; + if (this.isNoOpReplay(record, existing, comparands)) { + return { action: 'skipped', id, uncompared: this.uncomparedCredentialFields(record, existing, comparands) }; } await this.writeRecoveringSummary(objectName, () => withTransientRetry(() => this.engine.update(objectName, { ...record, id }, opts))); return { action: 'updated', id }; @@ -2667,8 +2784,8 @@ export class SeedLoaderService implements ISeedLoaderService { case 'upsert': { if (existing) { const id = this.extractId(existing); - if (this.isNoOpReplay(record, existing, temporalKinds)) { - return { action: 'skipped', id }; + if (this.isNoOpReplay(record, existing, comparands)) { + return { action: 'skipped', id, uncompared: this.uncomparedCredentialFields(record, existing, comparands) }; } await this.writeRecoveringSummary(objectName, () => withTransientRetry(() => this.engine.update(objectName, { ...record, id }, opts))); return { action: 'updated', id }; @@ -2719,12 +2836,12 @@ export class SeedLoaderService implements ISeedLoaderService { mode: string, externalId: string | string[], existingRecords: Map | undefined, - temporalKinds: ReadonlyMap, + comparands: ReplayComparands, unownedHolders?: Map, ): | { action: 'insert' } | { action: 'update'; id: string } - | { action: 'skip'; id?: string } + | { action: 'skip'; id?: string; uncompared?: readonly string[] } | { action: 'refuse'; holderId?: string } { const key = this.externalIdKey(record, externalId); const existing = existingRecords?.get(key); @@ -2734,13 +2851,13 @@ export class SeedLoaderService implements ISeedLoaderService { switch (mode) { case 'update': if (!existing) return { action: 'skip' }; - return this.isNoOpReplay(record, existing, temporalKinds) - ? { action: 'skip', id: this.extractId(existing) } + return this.isNoOpReplay(record, existing, comparands) + ? { action: 'skip', id: this.extractId(existing), uncompared: this.uncomparedCredentialFields(record, existing, comparands) } : { action: 'update', id: this.extractId(existing)! }; case 'upsert': if (!existing) return { action: 'insert' }; - return this.isNoOpReplay(record, existing, temporalKinds) - ? { action: 'skip', id: this.extractId(existing) } + return this.isNoOpReplay(record, existing, comparands) + ? { action: 'skip', id: this.extractId(existing), uncompared: this.uncomparedCredentialFields(record, existing, comparands) } : { action: 'update', id: this.extractId(existing)! }; case 'ignore': return existing ? { action: 'skip', id: this.extractId(existing) } : { action: 'insert' }; @@ -2765,7 +2882,7 @@ export class SeedLoaderService implements ISeedLoaderService { * the historical update behavior. * * [#24562] A field the object declares `datetime`, `date` or `time` - * (`temporalKinds`, from {@link temporalFieldKinds}) is compared in its + * (`comparands.temporal`, from {@link temporalFieldKinds}) is compared in its * STORED form: both sides go through `temporalStorageForm`, the one rule * every driver applies on write (ADR-0053). The question a replay asks is * whether writing the seed value would change what the column holds, and a @@ -2773,19 +2890,68 @@ export class SeedLoaderService implements ISeedLoaderService { * `'…T14:30:00.000Z'` — so two spellings of one instant, one calendar day or * one wall clock are the same stored value and the row is skipped. Read * literally, they rewrote the row on every boot. + * + * [#24624] A field the read path masks (`comparands.masked`, from + * {@link maskedOnReadFields}) never blocks the skip by its value: the + * existing row was read through `engine.find`, which serves a set `secret`, + * and a set `password` on an object not managed by better-auth, as + * `SECRET_MASK` in every context (ADR-0100), so the seed's plaintext has + * nothing to be compared with. Ruled letter B: such a field is not compared, + * a seed change to it alone is not applied on replay, and the load says so + * once ({@link reportUncomparedCredentials}); a change to any other field + * still rewrites the whole seeded record, the credential included. Only a + * SET value is uncomparable — the mask is presence-preserving, so an unset + * credential reads back `null` and setting or clearing one is still a + * change. No unmasked read is taken for this; the loader never sees a + * stored credential. + * + * Pure: {@link decideWriteAction} asks it up to three times per record. */ private isNoOpReplay( record: Record, existing: Record, - temporalKinds: ReadonlyMap, + comparands: ReplayComparands, ): boolean { for (const [key, value] of Object.entries(record)) { if (key === 'id') continue; - if (!this.seedValueEquals(value, existing[key], temporalKinds.get(key))) return false; + if (SeedLoaderService.isUncomparableCredential(key, value, existing[key], comparands)) continue; + if (!this.seedValueEquals(value, existing[key], comparands.temporal.get(key))) return false; } return true; } + /** + * [#24624] The credential fields of `record` a no-op replay skips without + * comparing — what the skip site records for the load's one notice line. + */ + private uncomparedCredentialFields( + record: Record, + existing: Record, + comparands: ReplayComparands, + ): readonly string[] { + if (comparands.masked.size === 0) return []; + return Object.entries(record) + .filter(([key, value]) => key !== 'id' && SeedLoaderService.isUncomparableCredential(key, value, existing[key], comparands)) + .map(([key]) => key); + } + + /** + * [#24624] Whether the replay must not compare this field: the definition + * declares it masked on read, the existing row read it back as the mask (a + * value IS set), and the seed sets one too. Both declaration and mask are + * required: a field the definition does not mask is compared even when it + * holds the mask's text, and a field the engine did not mask (an exempt + * object) never reads back as the mask. + */ + private static isUncomparableCredential( + key: string, + seedValue: unknown, + stored: unknown, + comparands: ReplayComparands, + ): boolean { + return comparands.masked.has(key) && stored === SECRET_MASK && seedValue != null; + } + /** * Loose equality across driver round-trip representations (see isNoOpReplay). * From 20abbaf71d913256c6ec2c01e50ffd16683be148 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:18:18 +0000 Subject: [PATCH 2/2] chore(changeset): metadata-protocol patch for the masked-credential replay skip Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD --- .changeset/24624-replay-masked-field.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/24624-replay-masked-field.md diff --git a/.changeset/24624-replay-masked-field.md b/.changeset/24624-replay-masked-field.md new file mode 100644 index 00000000000..acf78973676 --- /dev/null +++ b/.changeset/24624-replay-masked-field.md @@ -0,0 +1,10 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): a seed replay no longer rewrites every seeded row that carries a password or secret field on every boot + +Clause-②: no (a replay comparison: no accept set, served body or masking verdict moves; the loader takes no new read and never sees a stored credential) + +- **What changed.** A replay reads the existing rows through `engine.find`, which serves a set `secret` field, and a set `password` field on an object not managed by better-auth, as `SECRET_MASK` in every context (ADR-0100). The replay compared the seed's plaintext with that mask, read the row as changed and rewrote it on every boot: `updated_at` moved, lifecycle validation ran again and history could record an edit nobody made. A field the object declares masked on read (asked of `isMaskedOnReadFieldType`, the same declaration the engine's own mask reads) now never blocks the skip while the existing row reads back the mask. +- **What a seed author sees.** A seed change to ONLY such a field is not applied on replay. Change another field of the same row as well (the rewrite then writes the whole seeded record, the new credential included), or rebuild the database. Setting a credential on a row that has none, or clearing one, is still written: an unset credential reads back `null`, not the mask. A load that skipped a row without comparing a credential says so in one `info` line naming the fields and both remedies.