Skip to content

Commit abc7a94

Browse files
fix(metadata): history snapshots no longer store the keys a connector save drains (#31242)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XJGqAZH1VXaPVicsfiY7fkh)_ Refs #29484 (step (c) of ruling 6677709817 only; the card stays open for step (b), the read-only count and the scrub.) Clause-②: yes (narrowing) — Ruling-ref 6677709817: what a history row stores narrows; the new published subpath `@objectstack/metadata/history-snapshot` widens the package surface (contract review 6712278931). Nothing a door accepts or serves changes. Metadata-protocol: none Skills-docs: none — nothing an author or agent writes changes: no key, door, refusal or served answer moves, and the new subpath is an internal seam between two server packages that no skill or docs page teaches. ## 维护者速读 - 改了什么:`sys_metadata_history` 新写入的快照,不再保存连接器上「保存时会被清掉」的那些键(未声明的 `auth` 键,以及对象型 `retryConfig` 里的未声明键)。两个历史写入点(协议层 `SysMetadataRepository.put`,以及旧的 `DatabaseLoader`)都改走同一个函数 `historySnapshotBody`。 - 为什么改:#28640 ②B 让「读出再保存」把这些键从活行里清掉,但历史快照是同一份内容的第二份落盘副本。实测回滚到旧版本时,新写的 `revert` 历史行会把该键原样带回来;不经过保存门的写入也一样。裁决 6677709817 选 (c):从现在起新历史行不再带它。 - 风险与回滚:只改历史行的 `metadata` 列;其它类型(以及没有此类键的连接器)快照与原来逐字节相同,有 CONTROL 用例钉住。历史行的 `checksum` 仍是活行的指纹,读历史的出口不变。已有历史行不动,留给 (b)。回滚即 revert。 - 先看:`packages/metadata/src/utils/history-snapshot-body.ts`(删哪些位置完全由类型 redactor 的 `notCarriedForwardKeys` 决定,没有第二份名单);`packages/metadata-protocol/src/sys-metadata-repository.ts` 的历史 insert 一处;`packages/metadata/src/loaders/database-loader.ts` 的 `createHistoryRecord`。 - 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run src/sys-metadata-repository.history-snapshot-drained-keys.test.ts`,预期 5/5 通过,其中「回滚到旧快照」一条证明新 revert 行不含该键。 - 需要你做的:无,席位落地。 ## What changes - **One helper decides what a history snapshot stores**: `historySnapshotBody(type, body)` in `@objectstack/metadata`, published as the leaf subpath `@objectstack/metadata/history-snapshot` (its own tsup entry, for the reason `@objectstack/metadata/errors` has one: the protocol needs the function, not the manager and loaders). - It asks the type's read-path redactor (`getMetadataTypeRedactor`, `@objectstack/spec/kernel`) for `notCarriedForwardKeys` on this body and removes exactly those positions from a copy. That is the same answer the save door's carry-forward drains by (`planCarryForward` in `metadata-redaction.ts`), so there is one predicate, not a second list. The helper names no key and no type. - A body with no such position comes back **by reference**, so every other type's snapshot is the body written, byte for byte. - A dotted key name (`auth['legacy.token']`) is resolved the way the carry-forward resolves it: every dot a separator first, then longer trailing keys. - After removing, it asks the redactor again. If a named position is still there, it throws instead of writing that snapshot (a shape it does not understand must not pass silently). - **Both `sys_metadata_history` writers call it**: - `SysMetadataRepository.put` (`@objectstack/metadata-protocol`): the history writer behind the `/meta` save door, publish (`promoteDraft`) and revert (`restoreVersion`, used by `rollbackMetaItem` and commit revert). A throw fails the write, inside the same transaction. - `DatabaseLoader.createHistoryRecord` (`@objectstack/metadata`): its posture for every history failure is kept: the snapshot is skipped and logged, and the metadata write itself succeeds. - **Unchanged**: the live `sys_metadata` row each writer stores, the history row's `checksum` (still the live row's stamp, its documented meaning), and the history read exit `redactStoredMetadataRow(s)`. ### Mechanism measured (the order's hypotheses) - Hypothesis "the history writer lives in `packages/metadata`": **partly falsified**. There are two writers. The one every runtime save, publish and revert goes through is `SysMetadataRepository.put` in `packages/metadata-protocol`; `DatabaseLoader` (MetadataManager's platform-global loader) is the second. The seat amended the claim surface for the first (amendment 6710598621). - Hypothesis "the snapshot is written before the drain runs": **falsified for the save door**. The door drains the body before `repo.put`, and `put` writes the same body to the live row and to history, so a save of the served form already wrote a drained snapshot (code reading of `protocol-save.ts` → `repo.put`). The new rows that still carried the key came from bodies no door re-judged: **a revert to a snapshot written before the drain** (measured: the new `revert` row held it; pinned below), a publish of an older draft, a direct `put`, and `DatabaseLoader` writes. - The **whole** not-carried-forward set is withheld, per the seat's answer: the connector's undeclared `auth` keys (ruling 6566912252, #28640 ②B) and the undeclared keys of an object `retryConfig` (ruling 6593804783). The rule is "a snapshot never carries a key the save drains". The contract review judges this reading. ## Tests New, all three files: - `packages/metadata/src/utils/history-snapshot-body.test.ts` (9): drops exactly what the connector redactor names (the test first pins that the redactor names `auth.token`, so the drop is the shared answer); plural spelling; dotted key name; cleared value; `retryConfig` key; input not mutated. CONTROLs: a declared credential map (carried forward, not drained) comes back by reference; `object` and `datasource` come back by reference; non-object bodies pass through. - `packages/metadata/src/loaders/database-loader-history-snapshot-withholds-drained-keys.test.ts` (5, real SQLite through `driver-sqlite-wasm`): `create`, `update` and `revert` snapshots hold no undeclared `auth` key. CONTROLs: every declared position kept exactly; a non-connector snapshot and a connector with nothing undeclared are byte-identical to `JSON.stringify(body)`. - `packages/metadata-protocol/src/sys-metadata-repository.history-snapshot-drained-keys.test.ts` (5): a `put` writes a snapshot without the key; **a revert to a version-1 snapshot that still holds the key appends a `revert` snapshot without it**. Same three CONTROLs. - `packages/metadata/src/serializers/typescript-serializer-annotation.test.ts`: the export-entry count pin moves 7 → 8 for the new `./history-snapshot` entry (the pin exists so that a new entry moves it). - `scripts/engine-double-contract.pinned.json`: the new repository fake is recorded by `node scripts/check-engine-double-contract.mjs --write` (the gate's own remedy; 0 seams lost). ## Verification All readings below are on head `23391a14` unless a line says otherwise; the rework round's readings (head `2c0a794a`) are in the last block. <details><summary>Ablation (the first pin of each writer): red with the call removed, green restored</summary> Both through `node scripts/ablation-replace.mjs` (anchor hit once, blob moved, restore proved `blob == HEAD` and `git diff HEAD` empty). The suites import the writer from `src/`, so no rebuild sits between mutation and run. - `DatabaseLoader.createHistoryRecord`: `historySnapshotBody(type, metadata)` → `metadata`. The loader file went **3 failed | 2 passed (5)**: the `create`, CONTROL-declared and `update`/`revert` pins red with `expected '{"name":"vendor_api",…' not to contain 'TEST_PLACEHOLDER_LEGACY_AUTH_VALUE'`; the two byte-identical CONTROLs stayed green. - `SysMetadataRepository.put`: `historySnapshotBody(ref.type, body)` → `body`. The repository file went **3 failed | 2 passed (5)**: the `put`, CONTROL-declared and revert pins red the same way; the two byte-identical CONTROLs stayed green. </details> <details><summary>Package suites and typecheck</summary> - `pnpm --filter @objectstack/metadata test`: Test Files 70 passed (70), Tests 971 passed (971) (head `23391a14`); `pnpm --filter @objectstack/metadata run typecheck`: exit 0. - `pnpm --filter @objectstack/metadata-protocol test`: Test Files 322 passed | 4 skipped (326), Tests 22530 passed | 26 skipped (22556) (after the merge of `origin/main`; later commits touch only this card's test file and a ledger); `pnpm --filter @objectstack/metadata-protocol run typecheck`: exit 0 on `818912eb`. - Full dogfood battery (owed by the `(narrowing)` arm), `turbo run build --filter='@objectstack/dogfood^...'` then `pnpm --filter @objectstack/dogfood test` under the lock, on `818912eb`: `VERDICT command-exit 0`, Test Files 435 passed | 3 skipped (438), Tests 3609 passed | 41 skipped (3650). The one later commit edits a `@objectstack/metadata` unit test only. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 83 families on this diff, every one run and exit 0; `--ran` reconciliation: 83 derived, 83 run, 0 NOT-MEASURED, 0 UNRUN. Run on `23391a14` after the last commit (the record is `pm-orders/server/29484-gates-ran.txt` in the project files). Plus `eslint --no-inline-config packages/metadata packages/metadata-protocol`: exit 0. The first loop found two reds in this card's own repository fake (`check:engine-double-contract`: ledger did not record it; `check:objectql-double-limit`: its `find` ignored `limit`); both fixed in `818912eb` and green since. </details> <details><summary>Rework round (contract review 6712278931), head 2c0a794</summary> - `Clause-②:` becomes `yes (narrowing)` here and in the changeset; `Skills-docs:` line added. - The fail-closed throw in `historySnapshotBody` now names its fix (the redactor must spell the position as a dotted path through plain objects, or the helper must learn the shape). New pin in `history-snapshot-body.test.ts`: a test-only redactor naming an array-hop position makes it throw and leaves the body untouched. - `origin/main` merged once (`cd5ece01`). `pnpm --filter @objectstack/metadata test`: Test Files 70 passed (70), Tests 972 passed (972); typecheck exit 0; the repository pins 5/5. - Derived gates on `2c0a794a`: 83 families, all exit 0 (`check:lean-entry-closure` and `check:dual-build-cjs-loads` read after a packages build); `--ran`: 83 run, 0 NOT-MEASURED (record: `pm-orders/server/29484-rework-gates-ran.txt` in the project files). `eslint --no-inline-config packages/metadata packages/metadata-protocol`: exit 0. - Dogfood not re-run: the delta since `818912eb` is an error sentence, a unit test and the `origin/main` merge. </details> ## Acceptance notes - One out-of-scope finding about the live row (not history) went to the seat privately, per the card's rule on stored-secret specifics; nothing of it is fixed here. - Existing history rows are not touched: that is step (b), a read-only count first, then a scrub after the maintainer confirms the count. - No `packages/spec/**` file changes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5nFAEGcShB48YZExSP --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5nFAEGcShB48YZExSP)_ <!-- signed: domain:server#5 · os-project-manager · session_01FEkQ5nFAEGcShB48YZExSP --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8f4e8fb commit abc7a94

11 files changed

Lines changed: 576 additions & 6 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/metadata': patch
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
fix(metadata): a `sys_metadata_history` snapshot no longer stores what a connector save drains from the live row
7+
8+
Clause-②: yes (narrowing)
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no authoring door accepts or refuses anything it did not before. Only the body a history row stores for a new write changes; existing history rows are left as they are, so there is nothing for a migration to rewrite and no tombstone. -->
12+
13+
**BREAKING** for a reader of raw `sys_metadata_history` rows that expected a connector snapshot to hold an undeclared `auth` key. No door serves one: the history read exit already withheld it.
14+
15+
A connector row stored before the instance-auth shapes went strict can hold a value under an `auth` key its kind does not declare. A save of the served form drains that key from the live row, but a history snapshot is a second copy of the body at rest, and nothing stopped a new snapshot from keeping it: a revert to an older version wrote it into a fresh `revert` snapshot, and a write that no door re-judged did too. Now:
16+
17+
- **Both history writers store the snapshot through one helper**, `historySnapshotBody`, published as `@objectstack/metadata/history-snapshot`: `SysMetadataRepository.put` in `@objectstack/metadata-protocol` (behind the `/meta` save door, publish and revert) and `DatabaseLoader` in `@objectstack/metadata`.
18+
- **What it leaves out is the type's redactor's own answer**: every position named in `notCarriedForwardKeys`, the same answer the save's carry-forward drains by. For a connector those are its undeclared `auth` keys and the undeclared keys inside an object `retryConfig`. Nothing in the helper names a key or a type.
19+
- **Every other body is stored as written, byte for byte.** That covers every type whose redactor marks no such position, and every type with no redactor. The history row's `checksum` keeps naming the live row's stamp, and the history read exit (`redactStoredMetadataRow(s)`) is unchanged.
20+
21+
History rows written before this change are not rewritten here. A one-time scrub follows separately, after a read-only count.
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Ruling 6677709817, step (c): a `sys_metadata_history` snapshot written by
5+
* `SysMetadataRepository.put` withholds every position the type's redactor
6+
* marks withheld AND not carried forward — the connector's undeclared `auth`
7+
* keys the save door drains from the live row — so no new history row carries
8+
* such a value, whichever write produced the body.
9+
*
10+
* `put` is the one history writer behind the `/meta` save door, publish
11+
* (`promoteDraft`) and revert (`restoreVersion`). The save door already drains
12+
* the body it hands `put`, so the bodies that still reach `put` holding such a
13+
* key are the ones no door re-judged: a direct `put`, and a revert to a
14+
* snapshot written before this step. Both are pinned below. The live row is
15+
* NOT asserted on: the ruling binds what history stores.
16+
*
17+
* The placeholder below is a fixture string, not a credential.
18+
*/
19+
20+
import { describe, it, expect, beforeEach } from 'vitest';
21+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core';
22+
import { SysMetadataRepository } from './sys-metadata-repository.js';
23+
24+
interface Row { [k: string]: unknown }
25+
26+
const LEGACY_AUTH_VALUE = 'TEST_PLACEHOLDER_LEGACY_AUTH_VALUE';
27+
28+
/** Minimal engine fake that stores exactly what it is handed (the recorded-by suite's shape). */
29+
function makeFakeEngine() {
30+
const rows = new Map<string, Row>();
31+
const historyRows: Row[] = [];
32+
33+
const keyOf = (w: Record<string, unknown>) =>
34+
`${String(w.type)}|${String(w.name)}|${String(w.organization_id ?? 'null')}|${String(w.state ?? 'active')}`;
35+
36+
const findRow = (where: Record<string, unknown>) => {
37+
if (where.id !== undefined) {
38+
for (const [k, r] of rows) if (r.id === where.id) return { key: k, row: r };
39+
return null;
40+
}
41+
const k = keyOf(where);
42+
const r = rows.get(k);
43+
return r ? { key: k, row: r } : null;
44+
};
45+
46+
const matchesHistory = (h: Row, where: Record<string, unknown>): boolean =>
47+
Object.entries(where).every(([k, v]) => {
48+
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
49+
return v === undefined || h[k] === v;
50+
});
51+
52+
return {
53+
rows,
54+
historyRows,
55+
async find(table: string, opts: { where: Record<string, unknown>; limit?: number }) {
56+
const found = table === 'sys_metadata_history'
57+
? historyRows.filter((h) => matchesHistory(h, opts.where))
58+
: Array.from(rows.values()).filter((r) => {
59+
if (opts.where.type && r.type !== opts.where.type) return false;
60+
if (opts.where.organization_id !== undefined && r.organization_id !== opts.where.organization_id) return false;
61+
if (opts.where.state && r.state !== opts.where.state) return false;
62+
return true;
63+
});
64+
// The caller's bound, applied after the filter (check:objectql-double-limit).
65+
return typeof opts?.limit === 'number' ? found.slice(0, opts.limit) : found;
66+
},
67+
async findOne(table: string, opts: { where: Record<string, unknown> }) {
68+
assertEngineFindOnePredicate(table, opts);
69+
if (table === 'sys_metadata_history') return historyRows.find((h) => matchesHistory(h, opts.where)) ?? null;
70+
return findRow(opts.where)?.row ?? null;
71+
},
72+
async insert(table: string, data: Record<string, unknown>) {
73+
if (table === 'sys_metadata_history') {
74+
const h: Row = { ...data };
75+
if (!h.id) h.id = `h_${historyRows.length + 1}`;
76+
historyRows.push(h);
77+
return { id: h.id as string };
78+
}
79+
const k = keyOf(data);
80+
const row: Row = { id: `r_${rows.size + 1}`, ...data };
81+
rows.set(k, row);
82+
return { id: row.id as string };
83+
},
84+
async update(_t: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
85+
assertEngineUpdateDispatch(data, opts);
86+
const found = findRow(opts.where);
87+
if (!found) throw new Error('not found');
88+
rows.set(found.key, { ...found.row, ...data });
89+
return { id: found.row.id as string };
90+
},
91+
async delete(_t: string, opts: { where: Record<string, unknown> }) {
92+
assertEngineDeleteDispatch(opts);
93+
const found = findRow(opts.where);
94+
if (!found) return { deleted: 0 };
95+
rows.delete(found.key);
96+
return { deleted: 1 };
97+
},
98+
async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> { return cb(undefined, { owned: true }); },
99+
};
100+
}
101+
102+
/** A connector whose `auth` holds a key its `bearer` kind does not declare. */
103+
function legacyConnector(label = 'Vendor API'): Row {
104+
return {
105+
name: 'vendor_api',
106+
label,
107+
type: 'api',
108+
provider: 'rest',
109+
// A DECLARED credential position (withheld on read, carried forward on save): kept in history.
110+
providerConfig: { baseUrl: 'https://api.vendor.example/v1', headers: { Accept: 'application/json' } },
111+
auth: { type: 'bearer', credentialRef: 'VENDOR_API_TOKEN', token: LEGACY_AUTH_VALUE },
112+
};
113+
}
114+
115+
const DECLARED_AUTH = { type: 'bearer', credentialRef: 'VENDOR_API_TOKEN' };
116+
117+
describe('[ruling 6677709817 (c)] SysMetadataRepository history snapshots withhold the drained connector keys', () => {
118+
let engine: ReturnType<typeof makeFakeEngine>;
119+
let repo: SysMetadataRepository;
120+
// `connector` is not in the repository's typed `MetaRef` union; the protocol
121+
// builds its refs the same way (`as Parameters<typeof repo.put>[0]`).
122+
const ref = { org: 'org_alpha', type: 'connector', name: 'vendor_api' } as unknown as Parameters<SysMetadataRepository['put']>[0];
123+
124+
beforeEach(() => {
125+
engine = makeFakeEngine();
126+
repo = new SysMetadataRepository({ engine, orgLabel: 'org_alpha' });
127+
});
128+
129+
// `runtime-only`: a connector is a runtime-created item here, not an override
130+
// of one a managed package ships (which the repository seals).
131+
const snapshot = (h: Row): Row => JSON.parse(h.metadata as string) as Row;
132+
133+
it('a put of a connector holding an undeclared `auth` key writes a snapshot without it', async () => {
134+
await repo.put(ref, legacyConnector(), { parentVersion: null, actor: null, intent: 'runtime-only' });
135+
136+
expect(engine.historyRows).toHaveLength(1);
137+
expect(engine.historyRows[0]!.metadata as string).not.toContain(LEGACY_AUTH_VALUE);
138+
expect(snapshot(engine.historyRows[0]!).auth).toEqual(DECLARED_AUTH);
139+
});
140+
141+
it('CONTROL: every declared position is kept in the snapshot exactly as written', async () => {
142+
await repo.put(ref, legacyConnector(), { parentVersion: null, actor: null, intent: 'runtime-only' });
143+
144+
const { auth: _auth, ...rest } = legacyConnector();
145+
const { auth, ...snapshotRest } = snapshot(engine.historyRows[0]!);
146+
expect(auth).toEqual(DECLARED_AUTH);
147+
expect(snapshotRest).toEqual(rest);
148+
});
149+
150+
it('a revert to a snapshot written before this step appends a revert snapshot without it', async () => {
151+
const first = await repo.put(ref, legacyConnector(), { parentVersion: null, actor: null, intent: 'runtime-only' });
152+
await repo.put(ref, legacyConnector('Vendor API (edited)'), { parentVersion: first.version, actor: null, intent: 'runtime-only' });
153+
// The version-1 snapshot as a write before this step stored it: the key held.
154+
const v1 = engine.historyRows.find((h) => h.version === 1)!;
155+
v1.metadata = JSON.stringify(legacyConnector());
156+
157+
await repo.restoreVersion(ref, 1, { actor: null, intent: 'runtime-only' });
158+
159+
const revert = engine.historyRows.find((h) => h.operation_type === 'revert')!;
160+
expect(revert).toBeDefined();
161+
expect(revert.metadata as string).not.toContain(LEGACY_AUTH_VALUE);
162+
expect(snapshot(revert).auth).toEqual(DECLARED_AUTH);
163+
});
164+
165+
it('CONTROL: a non-connector type\'s snapshot is byte-identical to the body written', async () => {
166+
const viewRef = { org: 'org_alpha', type: 'view' as const, name: 'case_grid' };
167+
const body = { name: 'case_grid', label: 'Cases', object: 'case', columns: [{ field: 'name' }] };
168+
await repo.put(viewRef, body, { parentVersion: null, actor: null, intent: 'runtime-only' });
169+
170+
expect(engine.historyRows).toHaveLength(1);
171+
expect(engine.historyRows[0]!.metadata).toBe(JSON.stringify(body));
172+
});
173+
174+
it('CONTROL: a connector with nothing undeclared is byte-identical to the body written', async () => {
175+
const { auth: _auth, ...rest } = legacyConnector();
176+
const body = { ...rest, auth: { ...DECLARED_AUTH } };
177+
await repo.put(ref, body, { parentVersion: null, actor: null, intent: 'runtime-only' });
178+
179+
expect(engine.historyRows[0]!.metadata).toBe(JSON.stringify(body));
180+
});
181+
});

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ import { hashSpec, orderedMapKeys, ConflictError, readLegacyOrganizationRows } f
6363
// #4825 deliberately retired. See `@objectstack/metadata/errors` for why that
6464
// leaf subpath exists.
6565
import { isMissingTableError } from '@objectstack/metadata/errors';
66+
// [ruling 6677709817 (c)] The snapshot body both history writers store: the
67+
// written body without what the type's redactor marks not carried forward.
68+
import { historySnapshotBody } from '@objectstack/metadata/history-snapshot';
6669
import { readEnvWithDeprecation } from '@objectstack/types';
6770
import type {
6871
MetadataRepository,
@@ -1233,6 +1236,13 @@ export class SysMetadataRepository implements MetadataRepository {
12331236

12341237
// Durable history append — same transaction, so the parent write
12351238
// and the audit row commit together or roll back together.
1239+
//
1240+
// [ruling 6677709817 (c)] The snapshot withholds every position the
1241+
// type's redactor marks withheld AND not carried forward (a connector's
1242+
// undeclared `auth` keys), whatever produced this body — a save, a
1243+
// publish, a revert to an older snapshot — so no new history row keeps
1244+
// what the save drains from the live row. Every other body is stored as
1245+
// written; `checksum` keeps naming the live row's stamp.
12361246
await this.engine.insert(
12371247
this.historyTable,
12381248
{
@@ -1242,7 +1252,7 @@ export class SysMetadataRepository implements MetadataRepository {
12421252
name: ref.name,
12431253
version,
12441254
operation_type: op,
1245-
metadata: JSON.stringify(body),
1255+
metadata: JSON.stringify(historySnapshotBody(ref.type, body)),
12461256
checksum: hash,
12471257
previous_checksum: existingHash,
12481258
change_note: opts.message,

‎packages/metadata/package.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,16 @@
7777
"types": "./dist/picklist-references.d.cts",
7878
"default": "./dist/picklist-references.cjs"
7979
}
80+
},
81+
"./history-snapshot": {
82+
"import": {
83+
"types": "./dist/utils/history-snapshot-body.d.ts",
84+
"default": "./dist/utils/history-snapshot-body.js"
85+
},
86+
"require": {
87+
"types": "./dist/utils/history-snapshot-body.d.cts",
88+
"default": "./dist/utils/history-snapshot-body.cjs"
89+
}
8090
}
8191
},
8292
"files": [
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Ruling 6677709817, step (c): a `sys_metadata_history` snapshot written by
5+
* `DatabaseLoader` withholds every position the type's redactor marks withheld
6+
* AND not carried forward — the connector's undeclared `auth` keys the save
7+
* door drains from the live row — so no new history row carries such a value.
8+
*
9+
* Driven onto real SQLite (`driver-sqlite-wasm`) through the loader's two
10+
* history-writing paths, `save` and `registerRollback`, and read back from the
11+
* history table's rows, never from a cache. The live row the loader writes is
12+
* NOT asserted on: the ruling binds what history stores.
13+
*
14+
* The placeholder below is a fixture string, not a credential.
15+
*/
16+
17+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
18+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
19+
import { DatabaseLoader } from './database-loader.js';
20+
21+
type Row = Record<string, unknown>;
22+
23+
const LEGACY_AUTH_VALUE = 'TEST_PLACEHOLDER_LEGACY_AUTH_VALUE';
24+
25+
/** A connector whose `auth` holds a key its `bearer` kind does not declare. */
26+
function legacyConnector(): Row {
27+
return {
28+
name: 'vendor_api',
29+
label: 'Vendor API',
30+
type: 'api',
31+
provider: 'rest',
32+
// A DECLARED credential position (withheld on read, carried forward on save): kept in history.
33+
providerConfig: { baseUrl: 'https://api.vendor.example/v1', headers: { Accept: 'application/json' } },
34+
auth: { type: 'bearer', credentialRef: 'VENDOR_API_TOKEN', token: LEGACY_AUTH_VALUE },
35+
};
36+
}
37+
38+
describe('[ruling 6677709817 (c)] DatabaseLoader history snapshots withhold the drained connector keys', () => {
39+
let driver: SqliteWasmDriver;
40+
let loader: DatabaseLoader;
41+
42+
beforeEach(async () => {
43+
driver = new SqliteWasmDriver({ filename: ':memory:' });
44+
await driver.connect();
45+
loader = new DatabaseLoader({ driver, trackHistory: true, cache: { enabled: false } });
46+
});
47+
48+
afterEach(async () => {
49+
await driver.disconnect();
50+
});
51+
52+
async function history(type: string, name: string): Promise<Row[]> {
53+
const rows = (await driver.find('sys_metadata_history', { where: { type, name } })) as Row[];
54+
return rows.sort((a, b) => Number(a.version) - Number(b.version));
55+
}
56+
57+
const snapshot = (r: Row): Row => JSON.parse(r.metadata as string) as Row;
58+
59+
it('a create snapshot of a connector holding an undeclared `auth` key stores it without that key', async () => {
60+
await loader.save('connector', 'vendor_api', legacyConnector());
61+
62+
const rows = await history('connector', 'vendor_api');
63+
expect(rows).toHaveLength(1);
64+
expect(rows[0].operation_type).toBe('create');
65+
expect(rows[0].metadata as string).not.toContain(LEGACY_AUTH_VALUE);
66+
expect(snapshot(rows[0]).auth).toEqual({ type: 'bearer', credentialRef: 'VENDOR_API_TOKEN' });
67+
});
68+
69+
it('CONTROL: every declared position is kept in the snapshot exactly as written', async () => {
70+
await loader.save('connector', 'vendor_api', legacyConnector());
71+
72+
const { auth: _auth, ...rest } = legacyConnector();
73+
const { auth, ...snapshotRest } = snapshot((await history('connector', 'vendor_api'))[0]);
74+
// The declared `auth` keys (the kind and its reference) and the declared
75+
// credential map beside them: the snapshot drops the undeclared key only.
76+
expect(auth).toEqual({ type: 'bearer', credentialRef: 'VENDOR_API_TOKEN' });
77+
expect(snapshotRest).toEqual(rest);
78+
});
79+
80+
it('an update snapshot and a revert snapshot withhold it too', async () => {
81+
await loader.save('connector', 'vendor_api', legacyConnector());
82+
await loader.save('connector', 'vendor_api', { ...legacyConnector(), label: 'Vendor API (edited)' });
83+
await loader.registerRollback('connector', 'vendor_api', legacyConnector(), 1);
84+
85+
const rows = await history('connector', 'vendor_api');
86+
expect(rows.map((r) => r.operation_type)).toEqual(['create', 'update', 'revert']);
87+
for (const r of rows) {
88+
expect(r.metadata as string).not.toContain(LEGACY_AUTH_VALUE);
89+
expect(snapshot(r).auth).toEqual({ type: 'bearer', credentialRef: 'VENDOR_API_TOKEN' });
90+
}
91+
});
92+
93+
it('CONTROL: a non-connector type\'s snapshot is byte-identical to the body written', async () => {
94+
const body = { name: 'invoice', label: 'Invoice', fields: { token: { type: 'text', label: 'Token' } } };
95+
await loader.save('object', 'invoice', body);
96+
97+
const rows = await history('object', 'invoice');
98+
expect(rows).toHaveLength(1);
99+
expect(rows[0].metadata).toBe(JSON.stringify(body));
100+
});
101+
102+
it('CONTROL: a connector with nothing undeclared is byte-identical to the body written', async () => {
103+
const { auth: _auth, ...rest } = legacyConnector();
104+
const body = { ...rest, auth: { type: 'bearer', credentialRef: 'VENDOR_API_TOKEN' } };
105+
await loader.save('connector', 'vendor_api', body);
106+
107+
expect((await history('connector', 'vendor_api'))[0].metadata).toBe(JSON.stringify(body));
108+
});
109+
});

‎packages/metadata/src/loaders/database-loader.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { SysMetadataObject, SysMetadataHistoryObject, hashSpec, readLegacyOrgani
2323
import { applyConversionsToStoredItem } from '@objectstack/spec';
2424
import { lowerFilterCondition, type FilterLoweringOptions } from '@objectstack/spec/data';
2525
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
26+
import { historySnapshotBody } from '../utils/history-snapshot-body.js';
2627
import type { IDataDriver, IDataEngine, DriverQuery } from '@objectstack/spec/contracts';
2728
import type { MetadataLoader, MetadataKeyedItem } from './loader-interface.js';
2829
import { LRUCache } from '../utils/lru-cache.js';
@@ -774,7 +775,18 @@ export class DatabaseLoader implements MetadataLoader {
774775
const now = new Date().toISOString();
775776

776777
const historyId = generateId();
777-
const metadataJson = JSON.stringify(metadata);
778+
// [ruling 6677709817 (c)] The snapshot withholds every position the type's
779+
// redactor marks not carried forward (a connector's undeclared `auth`
780+
// keys), so no history row keeps what the save drains from the live row.
781+
// Before the sequence read: a snapshot that cannot be made is skipped, the
782+
// write itself having succeeded — this method's posture for every failure.
783+
let metadataJson: string;
784+
try {
785+
metadataJson = JSON.stringify(historySnapshotBody(type, metadata));
786+
} catch (error) {
787+
console.error(`Failed to create history record for ${type}/${name}:`, error);
788+
return;
789+
}
778790

779791
// Compute per-org monotonic event_seq. Legacy path: not inside a
780792
// transaction, so concurrent writers can collide. The SysMetadataRepository

0 commit comments

Comments
 (0)