Skip to main content

max / balanced_breakfast

config: give BB user_config a sync posture, off unconditional sync BalancedBreakfast synced every user_config key unconditionally: the triggers enqueued a changelog row for any key, and the initial snapshot swept the whole table, with no import filter on the way back in. That is the shape audiofiles was hardened away from (fuzz-2026-07-06 #2, -07-20 #1, -07-21 #3): a key naming device state must never cross the sync boundary. This is step 5 of the portable-config migration and the regression it was meant to fix. BB is a sqlx app, so it takes only the pure-data half of synckit-config: the ConfigSpec and Posture vocabulary. Storage stays BB's sqlx ConfigRepository (no rusqlite ConfigStore). config_key::CONFIG declares the posture of each known key; only theme is Synced. bb-welcomed (a per-device first-run flag) and the legacy bb-theme alias are Local, and every undeclared key the generic set_config command might write is Local by the spec's fail-closed default. The spec drives three gates, all the config_key_policy allowlist audiofiles and synckit use (EXISTS ... replicated = 1): - export triggers (migration 013, recreated from unconditional), - the initial snapshot query, and - a new import filter in apply_changes_inner_tx, so a remote peer cannot set a device-local or unknown key. The policy is seeded from CONFIG.policy_rows() at every open (folded into Database::migrate, the one path app and tests share), so the spec stays the source of truth. Behavior change: bb-welcomed stops syncing (per-device welcome), by choice. Pre-existing push_changes_*/pull_changes_* tests fail identically on a clean tree (rustls 'No provider set' in the test env); unrelated to this change.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 22:48 UTC
Commit: 600108e686d0d19b0f3884a110452b1f7f4de877
Parent: e275578
9 files changed, +268 insertions, -10 deletions
M Cargo.lock +10
@@ -530,6 +530,7 @@ dependencies = [
530 530 "serde",
531 531 "serde_json",
532 532 "sqlx",
533 + "synckit-config",
533 534 "thiserror 2.0.18",
534 535 "tokio",
535 536 "tracing",
@@ -5887,6 +5888,7 @@ dependencies = [
5887 5888 "serde",
5888 5889 "serde_json",
5889 5890 "sha2 0.11.0",
5891 + "synckit-config",
5890 5892 "thiserror 2.0.18",
5891 5893 "tokio",
5892 5894 "tokio-stream",
@@ -5899,6 +5901,14 @@ dependencies = [
5899 5901 ]
5900 5902
5901 5903 [[package]]
5904 + name = "synckit-config"
5905 + version = "0.1.2"
5906 + dependencies = [
5907 + "rusqlite",
5908 + "thiserror 2.0.18",
5909 + ]
5910 +
5911 + [[package]]
5902 5912 name = "synstructure"
5903 5913 version = "0.13.2"
5904 5914 source = "registry+https://github.com/rust-lang/crates.io-index"
M Cargo.toml +1
@@ -67,6 +67,7 @@ bb-pdf = { path = "crates/bb-pdf" }
67 67 # External sibling crates (shared under ~/Code/Libraries)
68 68 supernote-push = { path = "../../Libraries/supernote-push" }
69 69 synckit-client = { path = "../../synckit/synckit-client" }
70 + synckit-config = { path = "../../synckit/synckit-config" }
70 71 tauri = "2.10.2"
71 72 tauri-plugin-updater = "2"
72 73 tagtree = { path = "../../MNW/shared/tagtree" }
@@ -7,6 +7,7 @@ description = "Database layer for BalancedBreakfast"
7 7 [dependencies]
8 8 bb-interface.workspace = true
9 9 sqlx.workspace = true
10 + synckit-config.workspace = true
10 11 tokio.workspace = true
11 12 thiserror.workspace = true
12 13 tracing.workspace = true
@@ -0,0 +1,95 @@
1 + //! Single source of truth for `user_config` keys and their sync posture.
2 + //!
3 + //! BalancedBreakfast used to sync every `user_config` key unconditionally: the
4 + //! table's triggers enqueued a changelog row for any key, and the initial
5 + //! snapshot swept the whole table. That is the same shape audiofiles was
6 + //! hardened away from (fuzz-2026-07-06 #2, -07-20 #1, -07-21 #3): a config key
7 + //! that names device state must never cross the sync boundary, and "sync
8 + //! everything" cannot hold one back.
9 + //!
10 + //! [`CONFIG`] is the family's shared [`ConfigSpec`] for BB's `user_config`
11 + //! table, declaring each known key's [`Posture`]. It drives two filters:
12 + //!
13 + //! - the SQL export filter (the `config_key_policy` table, seeded from this spec
14 + //! by [`Database::seed_config_key_policy`](crate::Database::seed_config_key_policy)
15 + //! and joined by the `user_config` triggers and the initial snapshot), and
16 + //! - the Rust import filter ([`key_excluded_from_sync`]), applied when a remote
17 + //! pull batch is written back.
18 + //!
19 + //! Storage stays sqlx (BB's `ConfigRepository`), not `synckit_config::ConfigStore`
20 + //! (rusqlite): this crate takes only the pure-data half of synckit-config, the
21 + //! spec and posture vocabulary, so a sqlx app shares the declaration without the
22 + //! rusqlite store. Posture is fail-closed: an undeclared key is
23 + //! [`Local`](Posture::Local) and never syncs.
24 + //!
25 + //! <!-- wiki: bb-config -->
26 +
27 + use synckit_config::{ConfigSpec, Posture};
28 +
29 + /// BB's `user_config` posture declaration.
30 + ///
31 + /// Only `theme` crosses the sync boundary. `bb-welcomed` is a per-device
32 + /// first-run flag (each device shows the welcome once). `bb-theme` is the legacy
33 + /// theme key read once to migrate the old value into `theme`; it must not sync
34 + /// on its own. Every other key the generic `set_config` command might write is
35 + /// undeclared and therefore `Local` (fail-closed).
36 + pub const CONFIG: ConfigSpec = ConfigSpec::new(
37 + "user_config",
38 + &[
39 + // Synced: user preferences carried across the user's devices.
40 + ("theme", Posture::Synced),
41 + // Local: per-device state and the legacy alias, never synced.
42 + ("bb-welcomed", Posture::Local),
43 + ("bb-theme", Posture::Local),
44 + ],
45 + );
46 +
47 + /// Import-side filter: true when a raw remote `user_config` key must be dropped
48 + /// rather than written back. A key the spec does not clear to sync is refused,
49 + /// which by the spec's fail-closed default also refuses any unknown key: a
50 + /// hostile server cannot set a device-local or unrecognized key by sending it.
51 + #[must_use]
52 + pub fn key_excluded_from_sync(key: &str) -> bool {
53 + !CONFIG.is_synced(key)
54 + }
55 +
56 + #[cfg(test)]
57 + mod tests {
58 + use super::*;
59 +
60 + #[test]
61 + fn only_theme_crosses_the_boundary() {
62 + assert!(CONFIG.is_synced("theme"), "theme is a shared preference");
63 + assert!(!CONFIG.is_synced("bb-welcomed"), "welcome is per-device");
64 + assert!(
65 + !CONFIG.is_synced("bb-theme"),
66 + "the legacy alias never syncs"
67 + );
68 + }
69 +
70 + #[test]
71 + fn the_import_filter_refuses_local_and_unknown_keys() {
72 + assert!(!key_excluded_from_sync("theme"), "theme is admitted");
73 + assert!(
74 + key_excluded_from_sync("bb-welcomed"),
75 + "per-device stays home"
76 + );
77 + assert!(
78 + key_excluded_from_sync("totally_unknown"),
79 + "an unknown key fails closed",
80 + );
81 + }
82 +
83 + // The policy rows the export filter is seeded from must carry every declared
84 + // key, marking only `theme` replicated. If this drifted, the SQL export and
85 + // the Rust import would disagree.
86 + #[test]
87 + fn policy_rows_replicate_only_theme() {
88 + let replicated: Vec<&str> = CONFIG
89 + .policy_rows()
90 + .filter(|row| row.replicated)
91 + .map(|row| row.key)
92 + .collect();
93 + assert_eq!(replicated, ["theme"]);
94 + }
95 + }
@@ -1,9 +1,11 @@
1 1 //! BalancedBreakfast Database Layer
2 2
3 + pub mod config_key;
3 4 pub mod id_types;
4 5 pub mod models;
5 6 pub mod repository;
6 7
8 + pub use config_key::{CONFIG, key_excluded_from_sync};
7 9 pub use id_types::*;
8 10 pub use models::*;
9 11 pub use repository::*;
@@ -40,12 +42,21 @@ impl Database {
40 42 Ok(Self { pool })
41 43 }
42 44
43 - /// Run migrations
45 + /// Run migrations, then re-seed the config sync policy.
46 + ///
47 + /// The seed runs at every open (not baked into a migration) so [`CONFIG`]
48 + /// stays the single source of truth for which `user_config` keys sync:
49 + /// changing a key's posture in Rust takes effect on the next start, no
50 + /// migration needed. `migrate` is the one path every caller (app and tests)
51 + /// takes, so folding the seed in here means the export triggers always have
52 + /// a policy table to join.
44 53 #[tracing::instrument(skip_all)]
45 54 pub async fn migrate(&self) -> Result<(), sqlx::migrate::MigrateError> {
46 55 sqlx::migrate!("../../migrations/sqlite")
47 56 .run(&self.pool)
48 - .await
57 + .await?;
58 + seed_config_key_policy(&self.pool).await?;
59 + Ok(())
49 60 }
50 61
51 62 /// Get the connection pool
@@ -83,7 +94,9 @@ impl Database {
83 94 }
84 95
85 96 /// Get a repository for user_config key-value pairs (theme, welcome flag,
86 - /// etc.). Synced via sync_changelog triggers (migration 007).
97 + /// etc.). Which keys sync is governed by posture: the triggers (migration
98 + /// 013) enqueue a changelog row only for keys the [`CONFIG`] spec marks
99 + /// `Synced`. See [`config_key`].
87 100 #[tracing::instrument(skip_all)]
88 101 pub fn config(&self) -> ConfigRepository {
89 102 ConfigRepository::new(self.pool.clone())
@@ -104,3 +117,29 @@ impl Database {
104 117 BookmarksRepository::new(self.pool.clone())
105 118 }
106 119 }
120 +
121 + /// Seed `config_key_policy` from the [`CONFIG`] spec: one row per declared key,
122 + /// marking which may replicate. Idempotent (clear and reinsert the closed set),
123 + /// so a posture changed in code is reflected on the next open, and a key dropped
124 + /// from the spec loses its row and stops syncing. An undeclared key has no row,
125 + /// so the export filter cannot admit it.
126 + ///
127 + /// Public so test harnesses that stand up the schema directly can seed the same
128 + /// policy the app does, rather than leaving the export triggers with nothing to
129 + /// join.
130 + #[tracing::instrument(skip_all)]
131 + pub async fn seed_config_key_policy(pool: &SqlitePool) -> Result<(), sqlx::Error> {
132 + let mut tx = pool.begin().await?;
133 + sqlx::query("DELETE FROM config_key_policy")
134 + .execute(&mut *tx)
135 + .await?;
136 + for row in CONFIG.policy_rows() {
137 + sqlx::query("INSERT INTO config_key_policy (key, replicated) VALUES (?1, ?2)")
138 + .bind(row.key)
139 + .bind(i64::from(row.replicated))
140 + .execute(&mut *tx)
141 + .await?;
142 + }
143 + tx.commit().await?;
144 + Ok(())
145 + }
@@ -0,0 +1,49 @@
1 + -- Give user_config a sync posture.
2 + --
3 + -- Until now every user_config key synced unconditionally: the triggers below
4 + -- enqueued a changelog row for any key, and the initial snapshot swept the whole
5 + -- table. That is the shape audiofiles was hardened away from (fuzz-2026-07-06 #2,
6 + -- -07-20 #1, -07-21 #3): a key naming device state must never cross the sync
7 + -- boundary. The policy table is an allowlist a key only matches when its row
8 + -- says replicated, seeded from the ConfigKey spec at every open
9 + -- (bb_db::config_key::CONFIG, via Database::seed_config_key_policy). An
10 + -- undeclared key has no row, so the filter fails closed.
11 +
12 + CREATE TABLE IF NOT EXISTS config_key_policy (
13 + key TEXT PRIMARY KEY,
14 + replicated INTEGER NOT NULL
15 + );
16 +
17 + DROP TRIGGER IF EXISTS sync_user_config_insert;
18 + DROP TRIGGER IF EXISTS sync_user_config_update;
19 + DROP TRIGGER IF EXISTS sync_user_config_delete;
20 +
21 + CREATE TRIGGER sync_user_config_insert AFTER INSERT ON user_config
22 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
23 + AND EXISTS (SELECT 1 FROM config_key_policy p WHERE p.key = NEW.key AND p.replicated = 1)
24 + BEGIN
25 + INSERT INTO sync_changelog (table_name, op, row_id, data)
26 + VALUES ('user_config', 'INSERT', NEW.key, json_object(
27 + 'key', NEW.key,
28 + 'value', NEW.value
29 + ));
30 + END;
31 +
32 + CREATE TRIGGER sync_user_config_update AFTER UPDATE ON user_config
33 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
34 + AND EXISTS (SELECT 1 FROM config_key_policy p WHERE p.key = NEW.key AND p.replicated = 1)
35 + BEGIN
36 + INSERT INTO sync_changelog (table_name, op, row_id, data)
37 + VALUES ('user_config', 'UPDATE', NEW.key, json_object(
38 + 'key', NEW.key,
39 + 'value', NEW.value
40 + ));
41 + END;
42 +
43 + CREATE TRIGGER sync_user_config_delete AFTER DELETE ON user_config
44 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
45 + AND EXISTS (SELECT 1 FROM config_key_policy p WHERE p.key = OLD.key AND p.replicated = 1)
46 + BEGIN
47 + INSERT INTO sync_changelog (table_name, op, row_id)
48 + VALUES ('user_config', 'DELETE', OLD.key);
49 + END;
@@ -93,6 +93,14 @@ async fn apply_changes_inner_tx(
93 93 let mut deletes: Vec<&ChangeEntry> = Vec::new();
94 94
95 95 for change in &changes {
96 + // Import-side posture filter. A remote peer must not set a user_config
97 + // key that is not cleared to sync: the same allowlist the export triggers
98 + // apply, enforced on the way in so a hostile server cannot smuggle a
99 + // device-local (or unknown) key past us. row_id is the key for
100 + // user_config; an excluded key is dropped rather than applied.
101 + if change.table == "user_config" && bb_db::key_excluded_from_sync(&change.row_id) {
102 + continue;
103 + }
96 104 match change.op {
97 105 ChangeOp::Insert | ChangeOp::Update => upserts.push(change),
98 106 ChangeOp::Delete => deletes.push(change),
@@ -530,6 +538,40 @@ mod tests {
530 538 );
531 539 }
532 540
541 + // The import boundary: a hostile or careless peer sends a device-local config
542 + // key (bb-welcomed) alongside a synced one (theme). The synced key applies;
543 + // the device-local key is dropped rather than written to user_config.
544 + #[tokio::test]
545 + async fn apply_remote_changes_drops_device_local_config_key() {
546 + let pool = setup_test_db().await;
547 +
548 + let change = |key: &str, value: &str| ChangeEntry {
549 + table: "user_config".to_string(),
550 + op: ChangeOp::Insert,
551 + row_id: key.to_string(),
552 + timestamp: chrono::Utc::now(),
553 + hlc: Hlc::zero(DeviceId::nil()),
554 + extra: serde_json::Map::default(),
555 + data: Some(json!({ "key": key, "value": value })),
556 + };
557 + apply_remote_changes(
558 + &pool,
559 + vec![change("theme", "dark"), change("bb-welcomed", "1")],
560 + )
561 + .await
562 + .unwrap();
563 +
564 + let applied: Vec<String> = sqlx::query_scalar("SELECT key FROM user_config ORDER BY key")
565 + .fetch_all(&pool)
566 + .await
567 + .unwrap();
568 + assert_eq!(
569 + applied,
570 + ["theme"],
571 + "a synced key is applied; a device-local key from a remote is refused",
572 + );
573 + }
574 +
533 575 #[tokio::test]
534 576 async fn apply_remote_changes_mixed_ops() {
535 577 let pool = setup_test_db().await;
@@ -170,6 +170,9 @@ mod tests {
170 170 .run(&pool)
171 171 .await
172 172 .unwrap();
173 + // Seed the config sync policy the same way `Database::migrate` does, so
174 + // the user_config export triggers have the allowlist to join.
175 + bb_db::seed_config_key_policy(&pool).await.unwrap();
173 176 pool
174 177 }
175 178
@@ -79,11 +79,15 @@ pub async fn create_initial_snapshot(pool: &SqlitePool) -> Result<i64, ApiError>
79 79 .map_err(super::db_err)?;
80 80 total += result.rows_affected() as i64;
81 81
82 - // user_config
82 + // user_config (only keys the posture policy clears to sync; same allowlist
83 + // the triggers join, so the snapshot cannot export a device-local key the
84 + // live path would hold back)
83 85 let result = sqlx::query(
84 86 "INSERT INTO sync_changelog (table_name, op, row_id, data) \
85 87 SELECT 'user_config', 'INSERT', key, \
86 - json_object('key', key, 'value', value) FROM user_config",
88 + json_object('key', key, 'value', value) FROM user_config \
89 + WHERE EXISTS (SELECT 1 FROM config_key_policy p \
90 + WHERE p.key = user_config.key AND p.replicated = 1)",
87 91 )
88 92 .execute(pool)
89 93 .await
@@ -194,10 +198,15 @@ mod tests {
194 198 .await
195 199 .unwrap();
196 200
197 - sqlx::query("INSERT INTO user_config (key, value) VALUES ('pref', 'yes')")
198 - .execute(&pool)
199 - .await
200 - .unwrap();
201 + // A synced key (theme) is snapshotted; a device-local one (bb-welcomed)
202 + // is held back by the same posture policy the triggers use, so only one
203 + // user_config row is captured.
204 + sqlx::query(
205 + "INSERT INTO user_config (key, value) VALUES ('theme', 'dark'), ('bb-welcomed', '1')",
206 + )
207 + .execute(&pool)
208 + .await
209 + .unwrap();
201 210
202 211 // Create a starred item (only starred/read items are snapshotted for feed_items)
203 212 let item_id = create_test_item(&pool, &feed_id, "ext-star").await;
@@ -214,7 +223,16 @@ mod tests {
214 223 .unwrap();
215 224
216 225 let total = create_initial_snapshot(&pool).await.unwrap();
217 - assert_eq!(total, 4); // 1 feed + 1 feed_tag + 1 user_config + 1 starred item
226 + assert_eq!(total, 4); // 1 feed + 1 feed_tag + 1 user_config (theme, not bb-welcomed) + 1 starred item
227 +
228 + // The captured user_config row is the synced key, not the device-local one.
229 + let config_rows: Vec<String> = sqlx::query_scalar(
230 + "SELECT row_id FROM sync_changelog WHERE table_name = 'user_config'",
231 + )
232 + .fetch_all(&pool)
233 + .await
234 + .unwrap();
235 + assert_eq!(config_rows, ["theme"], "only the synced key is snapshotted");
218 236 }
219 237
220 238 // ── Changelog retention cap ──