From 49c82556a233e16ca6bad2a7576e2ccff8ebf768 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 13 Jul 2026 18:17:15 +0200 Subject: [PATCH] fix(settings): quote numeric-shaped YAML keys so hash-keyed settings survive reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The emitter wrote block-map keys raw while only values went through quoting. A workspace-hash key segment that happens to be all digits with a leading zero (or digits-e-digits) reloaded as an int/float — leading zero dropped, or collapsed to Infinity — orphaning the stored value. Hit ~1 in 1200 repos, deterministically and permanently: the PATH preset (D-106) and the pre-existing Claude account binding for such a repo vanished on every restart with no diagnostics. Same defect class as the T-376 value-corruption fixes in this file; found by the T-511 review pass, confirmed against the live store. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 ++++ lib/kernel/src/settings.dart | 9 ++++++++- test/kernel/src/settings_test.dart | 14 ++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23f967fb..ba713af0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Fixed +- Hash-keyed settings (per-workspace PATH presets, Claude account bindings) + with a number-shaped hash suffix were silently lost on restart — YAML map + keys are now quoted on write. + ## [2.9.0] — 2026-06-30 ### Added diff --git a/lib/kernel/src/settings.dart b/lib/kernel/src/settings.dart index ce7065fd..a5f8435c 100644 --- a/lib/kernel/src/settings.dart +++ b/lib/kernel/src/settings.dart @@ -282,7 +282,14 @@ void _emit(StringBuffer buf, Object? v, int indent) { return; } v.forEach((k, vv) { - buf.write('$pad$k:'); + // Keys go through the same quoting as values: a numeric-shaped key — + // e.g. an all-digit FNV workspace-hash suffix (`app.env.pathPrepend. + // 0123…`, `app.claude.account.`) — would otherwise reload as an + // int/float and silently corrupt the key (leading zero dropped, or + // `1e…` collapsing to Infinity), orphaning the stored value. + buf.write(pad); + _emitScalar(buf, '$k'); + buf.write(':'); if (vv is Map && vv.isNotEmpty) { buf.writeln(); _emit(buf, vv, indent + 1); diff --git a/test/kernel/src/settings_test.dart b/test/kernel/src/settings_test.dart index ed633c58..1e5dede7 100644 --- a/test/kernel/src/settings_test.dart +++ b/test/kernel/src/settings_test.dart @@ -37,6 +37,20 @@ void main() { loaded.dispose(); }); + test('numeric-shaped key segments round-trip unmangled (workspace-hash keys)', () async { + // FNV workspace-hash suffixes (app.env.pathPrepend., the account + // bindings) can be number-shaped; an unquoted YAML key would reload as + // an int (leading zero dropped) or a float ('1e…' → Infinity) and + // silently orphan the stored value. + await store.set>('app.env.pathPrepend.0123456789012345', const ['/opt/go/bin']); + await store.set('app.claude.account.1e23456789012345', 'work'); + final loaded = SettingsStore(appDir: tmp); + await loaded.load(); + expect(loaded.get>('app.env.pathPrepend.0123456789012345'), ['/opt/go/bin']); + expect(loaded.get('app.claude.account.1e23456789012345'), 'work'); + loaded.dispose(); + }); + test('app.* scope supports bool + int + list', () async { await store.set('app.extensions.git.enabled', false); await store.set('app.layout.width', 240);