Skip to content

Commit 616311c

Browse files
fix(service-settings): a settings change's audit row records the value before and after, with secrets as digests (#30937)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DCcyTeyTsVPTTFaZHQJekZS)_ domain:server#1 · os-elon-musk · session_016WV4cy588UNGpATRLjBi9h Fixes #30923 Claim: 6684421676 (full re-post of 6684227943 with this branch; file surface amended for the dogfood pin, the QA checklist entry and the plugin-audit redaction file) Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:修改设置时写入审计日志的 `config_change` 记录,现在会记下改前值(`old_value`)和改后值(`new_value`),非密钥类设置是可读的原值;密钥类设置(清单标 `encrypted: true`,`password` 类型自动算)两边都只记掩码摘要,从不记明文。审计读取端同时补上一道限制:字段级权限看不到设置值的读者,在这条记录里也看不到值和摘要。 - 为什么改:安全审查要求访问策略(如允许的 IP 范围)的每次修改都能看出改前改后;此前这条记录只有新值的摘要,没有任何入口能读出前后值。ADR-0007 的「审计与历史」一节本来就规定要记前后值、加密值只记占位和校验和,这次是把实现补齐到 ADR。 - 风险与回滚:只往审计行里多写内容,不改列、不改设置写入本身;密钥规则只在设置服务一处判定,测试覆盖密钥改写、重置、标记翻转三种情况都不出明文。读取端收窄只影响被字段级权限屏蔽了设置值的读者。单 PR 可整体回滚。 - 先看:`packages/services/service-settings/src/settings-service.ts` 的 `auditReadable` / `auditPrevious`(密钥规则与改前值);`config-change-audit.ts` 的 `old_value` / `new_value` 写法;`packages/plugins/plugin-audit/src/audit-log-field-redaction.ts` 新增的 `config_change` 收窄。 - 两分钟验证:`pnpm --filter @objectstack/service-settings exec vitest run src/config-change-audit.test.ts`,预期 24 个用例全过,其中 `#30923` 开头的 6 个覆盖 IP 范围前后值、重置、首次写入和密钥。 - 需要你做的:无,席位落地。 ## What changes ADR-0007 § Audit & history decides that every settings write records its before and after on `sys_audit_log`, and that a value declared `encrypted: true` is recorded only as a placeholder plus a checksum. The `config_change` row recorded a digest of the after and nothing of the before. This PR implements the ADR. No ADR records "a digest, never the value" (searched `docs/adr/**`); the module header that said so was the deviation, and it is rewritten. - **`config_change` row (`config-change-audit.ts`).** `new_value` is `{ namespace, key, scope, value, digest }`, and `null` on a reset as before. `old_value` (an existing column, previously never written) has the same shape for the value the same stored settings row held before the write (the row `record_id` names). It is `null` when that row held no value: a first write at that rung, or a row an earlier reset cleared. A reset records its before there. - **The secret rule, in one place (`SettingsService.auditReadable`).** A value is written readable only when the key is not secret-valued and the row it comes from is not stored `encrypted: true`. Secret-valued is the manifest's `encrypted: true`, which `type: 'password'` forces (`registerManifest` reads it into `encryptedKeys`, `settings-service.ts` about line 1259). That is the only sensitivity flag a specifier declares. No `sensitive` flag exists in `packages/spec`, and none is needed, because every credential key in the shipped manifests is `type: 'password'`. A secret's sides carry only `digest`: the encrypted marker, which is the word `encrypted`, a colon and the provider's keyed digest, wrapped in angle brackets (the bare word in angle brackets when no keyed digest is available). That is byte-for-byte the `new_value` these rows carried before, so a reader that parses it keeps working. The before of a secret is the same keyed digest the settings trail's `old_hash` records. `oldHash` is now taken when either ledger is wired, before the reaper destroys the previous ciphertext. A plaintext row of a key declared secret since then, and an encrypted row of a key no longer declared secret, are both recorded masked. `metadata` is unchanged and carries no value. - **What the sink is handed (`settings-service.types.ts`).** `SettingsAuditSink.record`'s entry gains two optional fields: `value`, the after, readable, absent for a secret and on a reset; and `previous`, `{ value?, digest } | null`. A host sink that ignores them works as before. - **Every door.** `sys_setting` declares `apiMethods: ['get', 'list']` and `managedBy: 'engine-owned'`, so the data API cannot write it. Every settings door (Setup, `PUT /api/settings/:namespace`, the built-in `reset` action, `resetNamespace`, in-process `set`/`setMany`) writes through `SettingsService.setMany`, the only caller of the sink. - **Who is served the readable value (`plugin-audit`, `audit-log-field-redaction.ts`).** The ledger's read-side field redaction narrowed only the CRUD mirror's create/update/delete snapshots, and served `config_change` rows as written on the ground that they held only a digest. A `config_change` row about a settings store (`sys_setting`, or `sys_platform_setting` for a global value) is now judged by that store's `value` field. A reader not served `value` (masked or withheld by field-level security) gets neither the value nor its digest on either side, because an unkeyed digest of a short value answers "is it X?". `namespace`, `key` and `scope` stay. The mirror's row about the same write is narrowed the same way. A snapshot that is not a JSON object is dropped (fail closed). A `config_change` row naming no settings store (the deployment notice's hide) is served as written, as before. ## Tests - `config-change-audit.test.ts`, new top-level describe, using the real `SettingsServicePlugin` boot, its own sink, and the real `auth` manifest: - `auth.allowed_ip_ranges`: a first write records `old_value: null`, and a change records both values readable. The before's digest equals the previous after's digest and the settings trail's `old_hash`, and `metadata` carries no value. - The same through `PUT /api/settings/auth`. - A reset records its before and `new_value: null`, and a write after a reset records a null before. - A secret key written, rotated and reset: no plaintext in `old_value`, `new_value` or `metadata`. Every side is the encrypted marker around an `hmac-sha256:` digest of 64 hex digits, each before equals the previous after, and the `new_value` keys stay `namespace, key, scope, digest`. - A key declared secret after a plaintext write: the replaced plaintext is never recorded. - A key no longer declared secret: the encrypted row it replaces is recorded masked. - `audit-log-field-redaction.test.ts`, new top-level describe, on `redactAuditLogRows` over rows in the writer's shape (plugin-audit does not depend on service-settings): - A reader with `value` masked on `sys_setting` or on `sys_platform_setting` gets `{ namespace, key, scope }` on both sides. - A control reader gets the row byte-identical. - A `config_change` row naming no settings store is served as written. - A non-JSON snapshot is dropped. - `settings-config-change-audit.dogfood.test.ts`: the two assertions that pinned the old shape now assert the value. The first case now expects `new_value.value`, and the #30209 case now expects the row to carry the workspace name instead of asserting that it does not. - `docs/qa/platform-checklist/areas/platform-core.json` (`platform-core.settings-audit-secret-fingerprint`): `source[1]` now describes the readable before and after, and the item gets revision 6 with a history row. No clause changed, because every clause there scores the secret `mail.api_key`, whose rows still carry only the masked keyed digest. <details><summary>Measured on main 0e88208 before the fix (in-process settings service, real sink, real auth and mail manifests)</summary> - `auth.allowed_ip_ranges`, set, then changed, then reset: `old_value` absent on all three rows. `new_value` was `{"namespace":"auth","key":"allowed_ip_ranges","scope":"global","digest":"sha256:…"}` on the two sets and `null` on the reset. - `mail.api_key`, set, then rotated, then reset: `old_value` absent. `new_value` carried namespace, key, scope and the encrypted marker around an `hmac-sha256:` digest, then `null` on the reset. No plaintext. </details> <details><summary>Ablations (scripts/ablation-replace.mjs; anchor 1 → 0, mutation marker 0 → 1, restored blob == HEAD blob; both suites import src, so no build between)</summary> - A. `auditReadable` returns `true` (the secret rule dropped): 3 of the 6 `#30923` service cases fail. They are the secret write/rotate/reset case (`new_value` contains the secret), the key declared secret later (`old_value` contains the old plaintext), and the key no longer declared secret (`old_value` has a `value` property). The 3 readable cases stay green. - B. the `config_change` branch removed from the redaction guard: 3 of the 5 new redaction cases fail (masked on `sys_setting`, masked on `sys_platform_setting`, non-JSON snapshot). The control case and the no-store case stay green. </details> ## Acceptance notes - `old_value` / `new_value` describe the stored row at the write's own rung, the row `record_id` names, and that row's previous value is what `old_hash` already fingerprints. A first tenant-scope write records a null before even when a global value or the default was in force. - `Clause-②: yes (widening)`: the served row carries more. The plugin-audit half removes the value and digest only for a reader whose field-level security already withholds the settings store's `value`. That reader was served the unkeyed digest of a non-secret value before, so for them this is a security narrowing. The seat may judge whether that half wants its own arm. - A comment in `plugin-audit/src/audit-plugin.ts` ("a `config_change` row's digest sits next to the mirror's row that carries the value") still reads true for secrets but undersells non-secret rows. That file is outside this PR's surface, so it is noted here and not edited. ## Verification Local runs on the final head are quoted with that head in the report comment on #30923: `@objectstack/service-settings` and `@objectstack/plugin-audit` `test` and `typecheck`; the changed dogfood file; every family `node scripts/pm/dispatch-gates.mjs --commands` derives from this diff, reconciled with `--ran`; `pnpm lint`. CI's required checks are the authority for everything else. --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cy588UNGpATRLjBi9h)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3479c33 commit 616311c

9 files changed

Lines changed: 449 additions & 26 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/service-settings': patch
3+
'@objectstack/plugin-audit': patch
4+
---
5+
6+
fix(service-settings): a settings `config_change` audit row records the value before and after the change, with secrets kept as digests
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
A settings write's `config_change` row on `sys_audit_log` recorded who changed which key and when, plus a digest of the new value. It had no `old_value`, and its `new_value` held no readable value, so the row could not show what a policy setting (for example `auth.allowed_ip_ranges`) was changed from or to. ADR-0007 (Audit & history) asks for both, with an encrypted value recorded only as a placeholder and a checksum.
12+
13+
- **`@objectstack/service-settings`.** `new_value` is `{ namespace, key, scope, value, digest }`. `old_value` has the same shape for the value the same stored settings row held before the write. It is `null` when that row held no value: a first write at that scope, or a row an earlier reset cleared. A reset now records its before value in `old_value`, and its `new_value` stays `null`. A secret-valued key, which is one the manifest declares `encrypted: true` (`type: 'password'` forces it), carries no `value` on either side. Each side carries only its masked digest, `<encrypted:hmac-sha256:…>`, or `<encrypted>` when no keyed digest is available. That is the same `new_value` such rows had before, so readers that parse it keep working. A row stored encrypted is never shown either, even if the key is no longer declared secret. `metadata` is unchanged and carries no value. `SettingsAuditSink.record` gets two optional entry fields, `value` and `previous`, and a host sink that ignores them works as before.
14+
- **`@objectstack/plugin-audit`.** The ledger's read-side field redaction now also applies to a `config_change` row about a settings store (`sys_setting`, or `sys_platform_setting` for a global value). If a reader's field-level security masks that store's `value` field, the reader gets neither the value nor its digest on either side. They still see which setting changed. The CRUD mirror's row about the same write is narrowed the same way. Readers who can see the field get the row exactly as stored.

‎docs/qa/platform-checklist/areas/platform-core.json‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3326,7 +3326,7 @@
33263326
"title": "The settings audit trail keeps no fingerprint of a secret-valued setting that a reader can match offline: both ledgers record the platform's keyed digest or none, never the value or an unkeyed hash, so a caller who can only read the audit rows learns that the secret changed and nothing about its value",
33273327
"since": "v17",
33283328
"status": "active",
3329-
"revision": 5,
3329+
"revision": 6,
33303330
"priority": "P1",
33313331
"surface": "api",
33323332
"personas": [
@@ -3408,7 +3408,7 @@
34083408
},
34093409
"source": [
34103410
"packages/services/service-settings/src/settings-service.ts#secretAuditDigest (one helper supplies a secret write's fingerprint on both write branches, the sys_secret path and the legacy inline-adapter path: the provider's keyedDigest, or null with a once-per-key warn; never the adapter's unkeyed digest)",
3411-
"packages/services/service-settings/src/config-change-audit.ts#CONFIG_CHANGE_ACTION (the sys_audit_log config_change row: object_name sys_setting, new_value carries namespace/key/scope/digest, never the value; null on a reset)",
3411+
"packages/services/service-settings/src/config-change-audit.ts#CONFIG_CHANGE_ACTION (the sys_audit_log config_change row: object_name sys_setting, or sys_platform_setting for a global value; new_value carries namespace/key/scope/digest, null on a reset, and old_value the same shape for the value the stored row held before, null when it held none; a secret-valued key's sides carry the masked digest and never the value, while a non-secret key's also carry the value readable)",
34123412
"packages/spec/src/contracts/crypto-provider.ts#keyedDigest (the contract text: a secret's audit fingerprint comes from keyedDigest and never from digest; with no keyed digest the trail records none)",
34133413
"packages/services/service-settings/src/manifests/mail.manifest.ts (mail.api_key: type password, encrypted: true)",
34143414
"#21792 (the maintainer ruling: secret-valued settings are fingerprinted with the keyed digest, never an unkeyed one; non-secret settings unchanged) · PR #21809 (the fix)",
@@ -3444,6 +3444,12 @@
34443444
"date": "2026-10-27",
34453445
"change": "steps[3] and [6], acceptance[1].clause, [4] (clause, verify), negative[0] and automated.ref re-pointed: a non-secret setting's audit digest is now the SHA-256 the sys_setting_audit / sys_platform_setting_audit columns declare, spelled 'sha256:' + 64 hex (crypto-adapter.ts settingAuditDigest), no longer the adapter's FNV-1a 32 ('fnv32:'). The step-7 control computes the algorithm the service now writes, so it can still prove the offline search would find an unkeyed digest. Not loosened: the secret search keeps the FNV-1a 32 as a second net, so a secret row carrying either unkeyed form still fails",
34463446
"ref": "#26465"
3447+
},
3448+
{
3449+
"revision": 6,
3450+
"date": "2026-11-06",
3451+
"change": "source[1] re-pointed: the config_change row now records the before (old_value) and the after readable for a non-secret key, as ADR-0007 Audit & history decides, so 'never the value' holds for secret-valued keys only. No clause changed: every clause here scores the secret key mail.api_key, whose rows still carry the masked keyed digest and never the value, on both sides",
3452+
"ref": "#30923"
34473453
}
34483454
]
34493455
},

‎packages/plugins/plugin-audit/src/audit-log-field-redaction.test.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,3 +350,83 @@ describe('[#21155] sys_audit_log before/after snapshots are served through the s
350350
for (const v of [V.masked2, V.unserved2, V.unservedDel]) expect(blob).toContain(v);
351351
});
352352
});
353+
354+
// ---------------------------------------------------------------------------
355+
// [#30923] A settings write's `config_change` row
356+
// ---------------------------------------------------------------------------
357+
358+
/**
359+
* [#30923] service-settings' `config_change` row carries the setting's value
360+
* before and after, readable for a key that is not secret-valued: one field of
361+
* the settings store the row names (`sys_setting`, or `sys_platform_setting`
362+
* for a global value), with its digest. A reader whose field-level security
363+
* masks that field is served neither the value nor its fingerprint, as the
364+
* CRUD mirror's row about the same write is narrowed; which setting changed
365+
* stays.
366+
*
367+
* Pinned on the seam's own function over rows in the writer's shape: the writer
368+
* is service-settings, which this package does not depend on. Its read-path
369+
* mount is the one the scene above pins.
370+
*/
371+
describe('[#30923] a config_change row is narrowed by the settings store\'s `value` field', () => {
372+
const S = { before: 'ALRSETTINGBEFORE41', after: 'ALRSETTINGAFTER42' };
373+
const STORES = ['sys_setting', 'sys_platform_setting'];
374+
const STORE_FIELDS = ['id', 'namespace', 'key', 'scope', 'value', 'value_enc', 'encrypted'];
375+
/** The settings store's `value` is served to this reader MASKED. */
376+
const MASKS_VALUE = { userId: 'u_settings_masked', tenantId: 'org_1' };
377+
/** Served every field of the settings stores — the control. */
378+
const SERVED_VALUE = { userId: 'u_settings_control', tenantId: 'org_1' };
379+
380+
const settingsSecurity = {
381+
async getReadableFields(object: string): Promise<string[] | undefined> {
382+
return STORES.includes(object) ? STORE_FIELDS : undefined;
383+
},
384+
async getQueryableFields(object: string, context?: any): Promise<string[] | undefined> {
385+
const readable = (await settingsSecurity.getReadableFields(object)) ?? [];
386+
return context?.userId === MASKS_VALUE.userId ? readable.filter((f) => f !== 'value') : readable;
387+
},
388+
};
389+
const WHICH = { namespace: 'auth', key: 'allowed_ip_ranges', scope: 'global' };
390+
const side = (value: string) => JSON.stringify({ ...WHICH, value, digest: 'sha256:' + 'ab'.repeat(32) });
391+
const configRow = (object_name: string | null): Row => ({
392+
id: 'cc1',
393+
action: 'config_change',
394+
object_name,
395+
record_id: 'set1',
396+
old_value: side(S.before),
397+
new_value: side(S.after),
398+
});
399+
400+
for (const store of STORES) {
401+
it(`masked on ${store}: neither side keeps the value or its digest, and which setting changed stays`, async () => {
402+
const row = configRow(store);
403+
await redactAuditLogRows([row], settingsSecurity, MASKS_VALUE);
404+
expect(snap(row, 'old_value')).toEqual(WHICH);
405+
expect(snap(row, 'new_value')).toEqual(WHICH);
406+
expect(JSON.stringify(row)).not.toContain(S.before);
407+
expect(JSON.stringify(row)).not.toContain(S.after);
408+
});
409+
}
410+
411+
it('control: a reader served the store\'s value reads both sides byte-identical to the row at rest', async () => {
412+
const row = configRow('sys_setting');
413+
const atRest = { ...row };
414+
await redactAuditLogRows([row], settingsSecurity, SERVED_VALUE);
415+
expect(row).toEqual(atRest);
416+
expect(snap(row, 'new_value').value).toBe(S.after);
417+
});
418+
419+
it('a config_change row naming no settings store is served as written', async () => {
420+
const row = configRow(null);
421+
const atRest = { ...row };
422+
await redactAuditLogRows([row], settingsSecurity, MASKS_VALUE);
423+
expect(row).toEqual(atRest);
424+
});
425+
426+
it('fail closed: a settings config_change snapshot that is not a JSON object is dropped', async () => {
427+
const row = { ...configRow('sys_setting'), old_value: `not json ${S.before}` };
428+
await redactAuditLogRows([row], settingsSecurity, SERVED_VALUE);
429+
expect(row).not.toHaveProperty('old_value');
430+
expect(snap(row, 'new_value').value).toBe(S.after);
431+
});
432+
});

‎packages/plugins/plugin-audit/src/audit-log-field-redaction.ts‎

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,21 @@
4747
* the contract publishes WHICH fields are masked, not the masked value.
4848
* - A record-write row's snapshot that is not a JSON object cannot be judged
4949
* key by key, so it is dropped whole (fail closed).
50+
* - [#30923] A `config_change` row about a settings store
51+
* ({@link AUDIT_LOG_SETTINGS_STORE_OBJECTS}) carries, on each side, ONE
52+
* field of that store: the setting's `value` — readable for a key that is
53+
* not secret-valued — with its `digest` (`service-settings`'
54+
* `config-change-audit.ts`). A reader not served the store's `value` field
55+
* has both dropped, the fingerprint with the value it fingerprints (an
56+
* unkeyed digest of a short value answers "is it X?"), as the CRUD
57+
* mirror's row about the same write drops `value`; `namespace`, `key` and
58+
* `scope` stay. A snapshot that is not a JSON object is dropped whole.
5059
* - A row of any other action is served as written. Measured per writer on
5160
* the tree this landed on: `read`, `login`, `logout`, `login_failed`,
52-
* `account_locked`, `import` and `action_invocation` rows write no snapshot; a `config_change` row's snapshot is a DIGEST of the
53-
* setting, never its value (`service-settings`' `config-change-audit.ts`);
54-
* a `platform_admin_standing_change` row's is the deployment's
61+
* `account_locked`, `import` and `action_invocation` rows write no
62+
* snapshot; a `config_change` row naming no settings store (the deployment
63+
* notice's hide) describes no field of one; a
64+
* `platform_admin_standing_change` row's is the deployment's
5565
* administrator roster (`plugin-security`). None is a parent record's field
5666
* map, and narrowing them by a parent's field set would only delete their
5767
* own vocabulary.
@@ -87,8 +97,19 @@ import {
8797
type FieldVisibilitySource,
8898
} from './served-fields.js';
8999
import type { ActivityMiddlewareEngine } from './activity-read-visibility.js';
100+
import { AUDIT_LOG_SETTINGS_STORE_OBJECTS } from './objects/sys-audit-log.object.js';
90101

91102
const LEDGER_OBJECT = 'sys_audit_log';
103+
104+
/**
105+
* [#30923] The settings service's ledger action, and the one field of a
106+
* settings store its snapshot describes: the `value` both stores declare.
107+
* `digest` is that value's fingerprint, so it is judged with it.
108+
*/
109+
const CONFIG_CHANGE_ACTION = 'config_change';
110+
const SETTINGS_STORES = new Set<string>(AUDIT_LOG_SETTINGS_STORE_OBJECTS);
111+
const SETTINGS_VALUE_FIELD = 'value';
112+
const CONFIG_CHANGE_VALUE_KEYS = [SETTINGS_VALUE_FIELD, 'digest'] as const;
92113
const SEAM = 'audit log field redaction';
93114

94115
/**
@@ -111,6 +132,23 @@ const OBJECT_NAME_RE = /^[a-z_][a-z0-9_]*$/;
111132

112133
const RECORD_WRITE = new Set<string>(LEDGER_RECORD_WRITE_ACTIONS);
113134

135+
/**
136+
* [#30923] A `config_change` snapshot for a reader not served the settings
137+
* store's `value`: drop the value and its fingerprint, keep which setting
138+
* changed. Mutates `snapshot`; answers whether it changed.
139+
*/
140+
function dropUnservedSettingValue(snapshot: Record<string, unknown>, served: ReadonlySet<string>): boolean {
141+
if (served.has(SETTINGS_VALUE_FIELD)) return false;
142+
let changed = false;
143+
for (const key of CONFIG_CHANGE_VALUE_KEYS) {
144+
if (key in snapshot) {
145+
delete snapshot[key];
146+
changed = true;
147+
}
148+
}
149+
return changed;
150+
}
151+
114152
/** Drop both snapshot columns — the fail-closed answer for one row. */
115153
function stripSnapshots(row: Record<string, unknown>): void {
116154
for (const col of SNAPSHOT_COLUMNS) delete row[col];
@@ -141,7 +179,10 @@ export async function redactAuditLogRows(
141179
stripSnapshots(row);
142180
continue;
143181
}
144-
if (!RECORD_WRITE.has(action)) continue;
182+
// [#30923] A settings write's row describes one field of its store.
183+
const settingsChange =
184+
action === CONFIG_CHANGE_ACTION && typeof row.object_name === 'string' && SETTINGS_STORES.has(row.object_name);
185+
if (!RECORD_WRITE.has(action) && !settingsChange) continue;
145186

146187
const object = row.object_name;
147188
if (typeof object !== 'string' || !OBJECT_NAME_RE.test(object)) {
@@ -166,7 +207,10 @@ export async function redactAuditLogRows(
166207
continue;
167208
}
168209
// Untouched bytes for a reader served every key.
169-
if (dropUnservedKeys(snapshot, served)) row[col] = JSON.stringify(snapshot);
210+
const changed = settingsChange
211+
? dropUnservedSettingValue(snapshot, served)
212+
: dropUnservedKeys(snapshot, served);
213+
if (changed) row[col] = JSON.stringify(snapshot);
170214
}
171215
}
172216
}

‎packages/qa/dogfood/test/settings-config-change-audit.dogfood.test.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,9 @@ describe('#8145: a settings write reaches sys_audit_log as config_change', () =>
134134
key: 'workspace_name',
135135
encrypted: false,
136136
});
137-
// A digest, never the value — the ledger describes the change, not the data.
137+
// [#30923] The after, readable — `workspace_name` is not secret-valued —
138+
// beside its digest (ADR-0007 § Audit & history).
139+
expect(JSON.parse(String(row.new_value))).toMatchObject({ value: WORKSPACE_NAME });
138140
expect(JSON.parse(String(row.new_value)).digest).toBeTruthy();
139141
// Generous timeout so a REGRESSION reports `waitForRows`'s own message
140142
// ("expected at least one config_change row…") rather than vitest's 5s
@@ -157,15 +159,16 @@ describe('#8145: a settings write reaches sys_audit_log as config_change', () =>
157159
// The pointer pair: object_name names the store, record_id the row in it.
158160
expect(change.record_id).toBe(String(settingRows[0].id));
159161
// The record page's same-record list keys on record_id: beside the
160-
// config_change row it finds the CRUD mirror's row about the same write,
161-
// which holds the non-secret value the digest row does not.
162+
// config_change row it finds the CRUD mirror's row about the same write.
163+
// [#30923] Both now carry the non-secret value: the config_change row
164+
// records it readable, as ADR-0007 § Audit & history decides.
162165
const mirror = (await findRows(ql, 'sys_audit_log', { record_id: change.record_id })).filter((r) =>
163166
['create', 'update'].includes(String(r.action)),
164167
);
165168
expect(mirror.length).toBeGreaterThanOrEqual(1);
166169
expect(mirror.every((r) => r.object_name === change.object_name)).toBe(true);
167170
expect(mirror.some((r) => String(r.new_value ?? '').includes(WORKSPACE_NAME))).toBe(true);
168-
expect(String(change.new_value)).not.toContain(WORKSPACE_NAME);
171+
expect(JSON.parse(String(change.new_value)).value).toBe(WORKSPACE_NAME);
169172
}, 30_000);
170173

171174
it('the shipped `config_changes` list view — its OWN declared filter — now matches', async () => {

0 commit comments

Comments
 (0)