Skip to main content

max / audiofiles

Sync .afcl classifier layers across own devices (classifier Phase 7c) Migration 026 recreates classifier_exemplars with a TEXT primary key (<layer_id>#<n>): the M025 INTEGER autoincrement id collides across devices, which row-level last-write-wins can't reconcile. Adds sync triggers for classifier_layers / classifier_exemplars / classifier_layer_rules and registers all three in audiofiles-sync (UPSERT/DELETE order, table_columns, pk_columns, initial snapshot). afcl::remove_layer now deletes children explicitly (membership, rules, exemplars, layer) instead of via FK cascade — recursive_triggers is off, so cascade deletes wouldn't fire the sync triggers and removals wouldn't propagate. New cross-crate test proves an imported layer (and its later removal) syncs to a fresh device. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-15 01:41 UTC
Commit: a264ff1979588cd2492d42f9b73c4a03613c81c5
Parent: 456b1ac
4 files changed, +194 insertions, -14 deletions
@@ -301,10 +301,12 @@ pub fn import(db: &Database, afcl: &Afcl, source: Option<&str>) -> Result<Import
301 301 .map_err(|e| CoreError::Serialization(e.to_string()))?;
302 302 let tags = serde_json::to_string(&ex.tags)
303 303 .map_err(|e| CoreError::Serialization(e.to_string()))?;
304 + // Globally-unique TEXT id (layer id is unique) so it survives cross-device sync.
305 + let ex_id = format!("{layer_id}#{exemplars}");
304 306 db.conn().execute(
305 - "INSERT INTO classifier_exemplars (layer_id, feat_version, vector, tags)
306 - VALUES (?1, ?2, ?3, ?4)",
307 - rusqlite::params![layer_id, FEATURE_VERSION, vector, tags],
307 + "INSERT INTO classifier_exemplars (id, layer_id, feat_version, vector, tags)
308 + VALUES (?1, ?2, ?3, ?4, ?5)",
309 + rusqlite::params![ex_id, layer_id, FEATURE_VERSION, vector, tags],
308 310 )?;
309 311 exemplars += 1;
310 312 }
@@ -389,11 +391,17 @@ pub fn remove_layer(db: &Database, layer_id: &str) -> Result<()> {
389 391 let rows = stmt.query_map([layer_id], |row| row.get::<_, String>(0))?;
390 392 rows.collect::<std::result::Result<Vec<_>, _>>()?
391 393 };
392 - // delete_rule reconciles each rule's tags away (manual/local tags stay sticky).
394 + // Delete children explicitly (not via FK cascade): recursive_triggers is off, so cascade
395 + // deletes wouldn't fire the sync triggers and the removal wouldn't propagate to other
396 + // devices. Membership first, then the rules (delete_rule reconciles their tags away and
397 + // is sync-logged), then the exemplars, then the layer.
398 + db.conn()
399 + .execute("DELETE FROM classifier_layer_rules WHERE layer_id = ?1", [layer_id])?;
393 400 for id in &rule_ids {
394 401 rules::delete_rule(db, id)?;
395 402 }
396 - // Cascades classifier_exemplars + classifier_layer_rules.
403 + db.conn()
404 + .execute("DELETE FROM classifier_exemplars WHERE layer_id = ?1", [layer_id])?;
397 405 db.conn().execute("DELETE FROM classifier_layers WHERE id = ?1", [layer_id])?;
398 406 Ok(())
399 407 }
@@ -438,7 +446,7 @@ pub fn enabled_imported_exemplars(db: &Database) -> Result<Vec<ImportedExemplar>
438 446 )?;
439 447 let rows = stmt.query_map([FEATURE_VERSION], |row| {
440 448 Ok((
441 - row.get::<_, i64>(0)?,
449 + row.get::<_, String>(0)?,
442 450 row.get::<_, String>(1)?,
443 451 row.get::<_, String>(2)?,
444 452 row.get::<_, f64>(3)?,
@@ -446,13 +454,13 @@ pub fn enabled_imported_exemplars(db: &Database) -> Result<Vec<ImportedExemplar>
446 454 })?;
447 455 let mut out = Vec::new();
448 456 for r in rows {
449 - let (rowid, vec_json, tags_json, weight) = r?;
457 + let (id, vec_json, tags_json, weight) = r?;
450 458 let vector: Vec<f64> = serde_json::from_str(&vec_json)
451 459 .map_err(|e| CoreError::Serialization(e.to_string()))?;
452 460 let tags: Vec<String> = serde_json::from_str(&tags_json)
453 461 .map_err(|e| CoreError::Serialization(e.to_string()))?;
454 462 if vector.len() == NUM_FEATURES {
455 - out.push(ImportedExemplar { id: format!("layer#{rowid}"), vector, tags, weight });
463 + out.push(ImportedExemplar { id, vector, tags, weight });
456 464 }
457 465 }
458 466 Ok(out)
@@ -1345,6 +1345,78 @@ CREATE TABLE IF NOT EXISTS classifier_layer_rules (
1345 1345 );
1346 1346 "#;
1347 1347
1348 + const MIGRATION_026: &str = r#"
1349 + -- Phase 7c: sync the classifier-layer tables across the user's own devices.
1350 + -- classifier_exemplars is recreated with a TEXT primary key: the M025 INTEGER autoincrement
1351 + -- id collides across devices (each device numbers from 1), which row-level sync can't
1352 + -- reconcile. Imported exemplars are re-importable, so dropping any M025 rows is acceptable.
1353 + DROP TABLE IF EXISTS classifier_exemplars;
1354 + CREATE TABLE classifier_exemplars (
1355 + id TEXT PRIMARY KEY, -- globally unique ('<layer_id>#<n>')
1356 + layer_id TEXT NOT NULL REFERENCES classifier_layers(id) ON DELETE CASCADE,
1357 + feat_version INTEGER NOT NULL,
1358 + vector TEXT NOT NULL,
1359 + tags TEXT NOT NULL
1360 + );
1361 + CREATE INDEX IF NOT EXISTS idx_classifier_exemplars_layer ON classifier_exemplars(layer_id);
1362 +
1363 + -- Sync triggers (opaque non-sensitive ids => cleartext row_id, like tag_rules/collections;
1364 + -- the JSON payload, which carries tag strings, is encrypted on the wire). recursive_triggers
1365 + -- is off, so FK cascades don't fire these — afcl::remove_layer deletes children explicitly.
1366 + CREATE TRIGGER IF NOT EXISTS sync_classifier_layers_insert AFTER INSERT ON classifier_layers
1367 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
1368 + BEGIN
1369 + INSERT INTO sync_changelog (table_name, op, row_id, data)
1370 + VALUES ('classifier_layers', 'INSERT', NEW.id,
1371 + json_object('id', NEW.id, 'name', NEW.name, 'kind', NEW.kind, 'weight', NEW.weight,
1372 + 'enabled', NEW.enabled, 'source', NEW.source, 'imported_at', NEW.imported_at));
1373 + END;
1374 + CREATE TRIGGER IF NOT EXISTS sync_classifier_layers_update AFTER UPDATE ON classifier_layers
1375 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
1376 + BEGIN
1377 + INSERT INTO sync_changelog (table_name, op, row_id, data)
1378 + VALUES ('classifier_layers', 'UPDATE', NEW.id,
1379 + json_object('id', NEW.id, 'name', NEW.name, 'kind', NEW.kind, 'weight', NEW.weight,
1380 + 'enabled', NEW.enabled, 'source', NEW.source, 'imported_at', NEW.imported_at));
1381 + END;
1382 + CREATE TRIGGER IF NOT EXISTS sync_classifier_layers_delete AFTER DELETE ON classifier_layers
1383 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
1384 + BEGIN
1385 + INSERT INTO sync_changelog (table_name, op, row_id, data)
1386 + VALUES ('classifier_layers', 'DELETE', OLD.id, json_object('id', OLD.id));
1387 + END;
1388 +
1389 + CREATE TRIGGER IF NOT EXISTS sync_classifier_exemplars_insert AFTER INSERT ON classifier_exemplars
1390 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
1391 + BEGIN
1392 + INSERT INTO sync_changelog (table_name, op, row_id, data)
1393 + VALUES ('classifier_exemplars', 'INSERT', NEW.id,
1394 + json_object('id', NEW.id, 'layer_id', NEW.layer_id, 'feat_version', NEW.feat_version,
1395 + 'vector', NEW.vector, 'tags', NEW.tags));
1396 + END;
1397 + CREATE TRIGGER IF NOT EXISTS sync_classifier_exemplars_delete AFTER DELETE ON classifier_exemplars
1398 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
1399 + BEGIN
1400 + INSERT INTO sync_changelog (table_name, op, row_id, data)
1401 + VALUES ('classifier_exemplars', 'DELETE', OLD.id, json_object('id', OLD.id));
1402 + END;
1403 +
1404 + CREATE TRIGGER IF NOT EXISTS sync_classifier_layer_rules_insert AFTER INSERT ON classifier_layer_rules
1405 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
1406 + BEGIN
1407 + INSERT INTO sync_changelog (table_name, op, row_id, data)
1408 + VALUES ('classifier_layer_rules', 'INSERT', NEW.layer_id || ':' || NEW.rule_id,
1409 + json_object('layer_id', NEW.layer_id, 'rule_id', NEW.rule_id));
1410 + END;
1411 + CREATE TRIGGER IF NOT EXISTS sync_classifier_layer_rules_delete AFTER DELETE ON classifier_layer_rules
1412 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
1413 + BEGIN
1414 + INSERT INTO sync_changelog (table_name, op, row_id, data)
1415 + VALUES ('classifier_layer_rules', 'DELETE', OLD.layer_id || ':' || OLD.rule_id,
1416 + json_object('layer_id', OLD.layer_id, 'rule_id', OLD.rule_id));
1417 + END;
1418 + "#;
1419 +
1348 1420 /// Register `hash_row_id(salt, key) -> TEXT` as a deterministic SQLite
1349 1421 /// function on the given connection. Used by the M018 sync triggers so the
1350 1422 /// `sync_changelog.row_id` field never carries cleartext content (tag strings,
@@ -1450,6 +1522,7 @@ impl Database {
1450 1522 MIGRATION_023,
1451 1523 MIGRATION_024,
1452 1524 MIGRATION_025,
1525 + MIGRATION_026,
1453 1526 ];
1454 1527
1455 1528 for (i, sql) in MIGRATIONS.iter().enumerate() {
@@ -1634,7 +1707,7 @@ mod tests {
1634 1707 .conn()
1635 1708 .query_row("PRAGMA user_version", [], |row| row.get(0))
1636 1709 .unwrap();
1637 - assert_eq!(version, 25);
1710 + assert_eq!(version, 26);
1638 1711 }
1639 1712
1640 1713 #[test]
@@ -1645,7 +1718,7 @@ mod tests {
1645 1718 .conn()
1646 1719 .query_row("PRAGMA user_version", [], |row| row.get(0))
1647 1720 .unwrap();
1648 - assert_eq!(version, 25);
1721 + assert_eq!(version, 26);
1649 1722 }
1650 1723
1651 1724 /// Open a fresh file-backed DB, close, reopen. The second open re-enters
@@ -1664,7 +1737,7 @@ mod tests {
1664 1737 .conn()
1665 1738 .query_row("PRAGMA user_version", [], |row| row.get(0))
1666 1739 .unwrap();
1667 - assert_eq!(version, 25);
1740 + assert_eq!(version, 26);
1668 1741 }
1669 1742
1670 1743 /// Simulates the worst-case recovery path: a prior partial migration left
@@ -1708,7 +1781,7 @@ mod tests {
1708 1781 .conn()
1709 1782 .query_row("PRAGMA user_version", [], |row| row.get(0))
1710 1783 .unwrap();
1711 - assert_eq!(version, 25);
1784 + assert_eq!(version, 26);
1712 1785 }
1713 1786
1714 1787 /// M018 contract: the `sync_changelog.row_id` for sensitive tables must
@@ -1930,7 +2003,7 @@ mod tests {
1930 2003 let initial_version: i32 = conn
1931 2004 .query_row("PRAGMA user_version", [], |row| row.get(0))
1932 2005 .unwrap();
1933 - assert_eq!(initial_version, 25);
2006 + assert_eq!(initial_version, 26);
1934 2007
1935 2008 let batch = format!(
1936 2009 "BEGIN;\n{}\nPRAGMA user_version = 999;\nCOMMIT;",
@@ -1993,7 +2066,7 @@ mod tests {
1993 2066 .conn()
1994 2067 .query_row("PRAGMA user_version", [], |row| row.get(0))
1995 2068 .unwrap();
1996 - assert_eq!(version, 25);
2069 + assert_eq!(version, 26);
1997 2070 }
1998 2071
1999 2072 #[test]
@@ -29,6 +29,9 @@ pub(crate) const UPSERT_ORDER: &[&str] = &[
29 29 "tag_provenance",
30 30 "tag_rules",
31 31 "tag_policy",
32 + "classifier_layers",
33 + "classifier_exemplars",
34 + "classifier_layer_rules",
32 35 "collection_members",
33 36 "user_config",
34 37 "edit_history",
@@ -39,6 +42,9 @@ pub(crate) const DELETE_ORDER: &[&str] = &[
39 42 "edit_history",
40 43 "user_config",
41 44 "collection_members",
45 + "classifier_layer_rules",
46 + "classifier_exemplars",
47 + "classifier_layers",
42 48 "tag_policy",
43 49 "tag_rules",
44 50 "tag_provenance",
@@ -85,6 +91,11 @@ pub(crate) fn table_columns(table: &str) -> Option<&'static [&'static str]> {
85 91 "actions", "created_at",
86 92 ]),
87 93 "tag_policy" => Some(&["tag", "review_threshold", "auto_threshold"]),
94 + "classifier_layers" => {
95 + Some(&["id", "name", "kind", "weight", "enabled", "source", "imported_at"])
96 + }
97 + "classifier_exemplars" => Some(&["id", "layer_id", "feat_version", "vector", "tags"]),
98 + "classifier_layer_rules" => Some(&["layer_id", "rule_id"]),
88 99 "collections" => Some(&["id", "name", "description", "created_at", "filter_json"]),
89 100 "collection_members" => Some(&["collection_id", "sample_hash", "added_at"]),
90 101 "user_config" => Some(&["key", "value"]),
@@ -108,6 +119,9 @@ pub(crate) fn pk_columns(table: &str) -> &'static [&'static str] {
108 119 "tag_provenance" => &["sample_hash", "tag"],
109 120 "tag_rules" => &["id"],
110 121 "tag_policy" => &["tag"],
122 + "classifier_layers" => &["id"],
123 + "classifier_exemplars" => &["id"],
124 + "classifier_layer_rules" => &["layer_id", "rule_id"],
111 125 "collections" => &["id"],
112 126 "collection_members" => &["collection_id", "sample_hash"],
113 127 "user_config" => &["key"],
@@ -32,6 +32,9 @@ pub fn create_initial_snapshot(conn: &Connection) -> Result<i64> {
32 32 ("tag_provenance", "SELECT sample_hash || ':' || tag, json_object('sample_hash', sample_hash, 'tag', tag, 'source', source, 'rule_id', rule_id) FROM tag_provenance"),
33 33 ("tag_rules", "SELECT id, json_object('id', id, 'name', name, 'enabled', enabled, 'priority', priority, 'match_mode', match_mode, 'conditions', conditions, 'actions', actions, 'created_at', created_at) FROM tag_rules"),
34 34 ("tag_policy", "SELECT tag, json_object('tag', tag, 'review_threshold', review_threshold, 'auto_threshold', auto_threshold) FROM tag_policy"),
35 + ("classifier_layers", "SELECT id, json_object('id', id, 'name', name, 'kind', kind, 'weight', weight, 'enabled', enabled, 'source', source, 'imported_at', imported_at) FROM classifier_layers"),
36 + ("classifier_exemplars", "SELECT id, json_object('id', id, 'layer_id', layer_id, 'feat_version', feat_version, 'vector', vector, 'tags', tags) FROM classifier_exemplars"),
37 + ("classifier_layer_rules", "SELECT layer_id || ':' || rule_id, json_object('layer_id', layer_id, 'rule_id', rule_id) FROM classifier_layer_rules"),
35 38 ("collections", "SELECT CAST(id AS TEXT), json_object('id', id, 'name', name, 'description', description, 'created_at', created_at, 'filter_json', filter_json) FROM collections"),
36 39 ("collection_members", "SELECT CAST(collection_id AS TEXT) || ':' || sample_hash, json_object('collection_id', collection_id, 'sample_hash', sample_hash, 'added_at', added_at) FROM collection_members"),
37 40 ("user_config", "SELECT key, json_object('key', key, 'value', value) FROM user_config WHERE key NOT LIKE 'sync_%' AND key != 'loose_files'"),
@@ -1540,4 +1543,86 @@ mod tests {
1540 1543 let changed = rules::apply_all_rules(&b).unwrap();
1541 1544 assert_eq!(changed, 0, "synced state already reconciled; re-apply is a no-op");
1542 1545 }
1546 +
1547 + /// Import an `.afcl` layer on device A, sync to fresh device B, and confirm the layer,
1548 + /// its exemplars (in the k-NN index), and its disabled rule all rebuild — then a layer
1549 + /// removal on A propagates the deletes to B. (Phase 7c.)
1550 + #[test]
1551 + fn imported_classifier_layer_syncs_to_fresh_device() {
1552 + use audiofiles_core::analysis::afcl::{self, Afcl, AfclExemplar, AfclManifest};
1553 + use audiofiles_core::analysis::{classify::FEATURE_VERSION, exemplar};
1554 + use audiofiles_core::rules::{MatchMode, Rule, RuleAction, RuleCondition, RuleField, RuleOp};
1555 +
1556 + let make_afcl = || Afcl {
1557 + manifest: AfclManifest {
1558 + afcl_version: afcl::AFCL_VERSION,
1559 + feat_version: FEATURE_VERSION,
1560 + name: "shared kicks".into(),
1561 + description: String::new(),
1562 + kind: "imported".into(),
1563 + created_at: 0,
1564 + license_note: String::new(),
1565 + exemplar_count: 2,
1566 + rule_count: 1,
1567 + policy_count: 0,
1568 + },
1569 + exemplars: vec![
1570 + AfclExemplar { vector: vec![0.0; 35], tags: vec!["instrument.drum.kick".into()] },
1571 + AfclExemplar { vector: vec![0.05; 35], tags: vec!["instrument.drum.kick".into()] },
1572 + ],
1573 + rules: vec![Rule {
1574 + id: String::new(),
1575 + name: "shared kick rule".into(),
1576 + enabled: true, // import forces disabled
1577 + priority: 0,
1578 + match_mode: MatchMode::All,
1579 + conditions: vec![RuleCondition {
1580 + field: RuleField::Name,
1581 + op: RuleOp::Contains,
1582 + value: "kick".into(),
1583 + }],
1584 + actions: vec![RuleAction::AddTag("instrument.drum.kick".into())],
1585 + created_at: 0,
1586 + }],
1587 + policy: vec![],
1588 + };
1589 +
1590 + // --- Device A: import the layer, then drain its changelog. ---
1591 + let a = setup_test_db();
1592 + clear_changelog(a.conn());
1593 + let summary = afcl::import(&a, &make_afcl(), Some("shared.afcl")).unwrap();
1594 + let changes = drain_changelog(a.conn());
1595 +
1596 + // --- Device B: fresh DB, apply the synced changes. ---
1597 + let b = setup_test_db();
1598 + clear_changelog(b.conn());
1599 + apply_remote_changes(b.conn(), &changes).unwrap();
1600 +
1601 + // Layer, exemplars, and membership rebuilt.
1602 + let layers = afcl::list_layers(&b).unwrap();
1603 + assert_eq!(layers.len(), 1);
1604 + assert_eq!(layers[0].id, summary.layer_id);
1605 + assert_eq!(layers[0].exemplar_count, 2);
1606 + assert_eq!(layers[0].rule_count, 1);
1607 + // The imported exemplars join B's k-NN index.
1608 + assert_eq!(afcl::enabled_imported_exemplars(&b).unwrap().len(), 2);
1609 + assert_eq!(exemplar::build_index(&b).unwrap().len(), 2);
1610 + // The imported rule synced and is disabled.
1611 + let synced = audiofiles_core::rules::list_rules(&b).unwrap();
1612 + assert_eq!(synced.len(), 1);
1613 + assert!(!synced[0].enabled, "imported rule stays disabled across sync");
1614 +
1615 + // --- Removal on A propagates to B (explicit child deletes are sync-logged). ---
1616 + clear_changelog(a.conn());
1617 + afcl::remove_layer(&a, &summary.layer_id).unwrap();
1618 + let del_changes = drain_changelog(a.conn());
1619 + apply_remote_changes(b.conn(), &del_changes).unwrap();
1620 +
1621 + assert!(afcl::list_layers(&b).unwrap().is_empty(), "layer removed on B");
1622 + assert!(afcl::enabled_imported_exemplars(&b).unwrap().is_empty());
1623 + assert!(
1624 + audiofiles_core::rules::list_rules(&b).unwrap().is_empty(),
1625 + "the layer's imported rule was removed on B too"
1626 + );
1627 + }
1543 1628 }