@@ -4440,11 +4440,19 @@ impl Database {
44404440 /// actually changed behavior. Feeds into decay_tick's composite scoring
44414441 /// and flips efficacy_status to 'useful' or 'dead' once enough attempts
44424442 /// accrue, so dead rules decay out of recall and useful ones resist decay.
4443+ ///
4444+ /// `workspace_hash` (#396, the #338 pattern): when set, the row is
4445+ /// resolved with STRICT workspace equality — the same semantics as a
4446+ /// workspace-scoped recall — so an agent stamps efficacy onto the row it
4447+ /// actually saw, never the global `''` row or another workspace's row.
4448+ /// When absent, the deterministic pick (#391: global `''` first, then
4449+ /// lexicographically-first workspace) is kept unchanged.
44434450 pub fn follow(
44444451 &self,
44454452 category: &str,
44464453 key: &str,
44474454 followed: bool,
4455+ workspace_hash: Option<&str>,
44484456 ) -> Result<crate::models::FollowReport, Box<dyn std::error::Error>> {
44494457 let conn = self.conn()?;
44504458 // #385: read-decide-write — the counts must be read under the writer
@@ -4458,17 +4466,36 @@ impl Database {
44584466 // identity since #339 — the same key can exist per workspace — and the
44594467 // old key-addressed UPDATE stamped one workspace's counts and
44604468 // efficacy_status onto every workspace's row (and archived rows).
4461- // Same deterministic pick as get_entity/resolve_entity_id, restricted
4462- // to live rows.
4463- let existing: Option<(String, i64, i64)> = tx
4464- .query_row(
4469+ // Without a workspace: same deterministic pick as
4470+ // get_entity/resolve_entity_id, restricted to live rows. With one
4471+ // (#396): strict equality, no global fallback — a workspace-scoped
4472+ // recall never surfaced the `''` row, so falling back would stamp a
4473+ // row the agent never saw.
4474+ let row_result = match workspace_hash {
4475+ Some(ws) => tx.query_row(
4476+ "SELECT id, follow_count, miss_count FROM entities \
4477+ WHERE category = ?1 AND key = ?2 AND workspace_hash = ?3 \
4478+ AND archived = 0 \
4479+ ORDER BY id ASC LIMIT 1",
4480+ params![category, key, ws],
4481+ |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
4482+ ),
4483+ None => tx.query_row(
44654484 "SELECT id, follow_count, miss_count FROM entities \
44664485 WHERE category = ?1 AND key = ?2 AND archived = 0 \
44674486 ORDER BY workspace_hash ASC, id ASC LIMIT 1",
44684487 params![category, key],
44694488 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
4470- )
4471- .ok();
4489+ ),
4490+ };
4491+ // #394: only "no rows" maps to the not-found report — a real rusqlite
4492+ // error (locked DB, corruption) must propagate, not masquerade as
4493+ // "not found".
4494+ let existing: Option<(String, i64, i64)> = match row_result {
4495+ Ok(row) => Some(row),
4496+ Err(rusqlite::Error::QueryReturnedNoRows) => None,
4497+ Err(e) => return Err(Box::new(e)),
4498+ };
44724499
44734500 let (target_id, follow_count, miss_count) = match existing {
44744501 Some((id, f, m)) => {
@@ -13364,20 +13391,20 @@ mod tests {
1336413391
1336513392 // Below the 5-attempt floor: status stays 'unverified' even at 100%.
1336613393 for _ in 0..3 {
13367- let r = db.follow("convention", "test-rule", true).unwrap();
13394+ let r = db.follow("convention", "test-rule", true, None ).unwrap();
1336813395 assert_eq!(r.efficacy_status, "unverified");
1336913396 }
1337013397
1337113398 // 4th followed, 5th missed -> 4/5 = 0.8 >= USEFUL_THRESHOLD (0.75).
13372- let r4 = db.follow("convention", "test-rule", true).unwrap();
13399+ let r4 = db.follow("convention", "test-rule", true, None ).unwrap();
1337313400 assert_eq!(r4.follow_count, 4);
13374- let r5 = db.follow("convention", "test-rule", false).unwrap();
13401+ let r5 = db.follow("convention", "test-rule", false, None ).unwrap();
1337513402 assert_eq!(r5.miss_count, 1);
1337613403 assert!((r5.follow_rate - 0.8).abs() < 1e-9, "got {}", r5.follow_rate);
1337713404 assert_eq!(r5.efficacy_status, "useful");
1337813405
1337913406 // Unknown entity -> found: false, no panic.
13380- let missing = db.follow("convention", "does-not-exist", true).unwrap();
13407+ let missing = db.follow("convention", "does-not-exist", true, None ).unwrap();
1338113408 assert!(!missing.found);
1338213409
1338313410 let _ = fs::remove_file(&path);
@@ -13409,7 +13436,7 @@ mod tests {
1340913436 let db = Arc::clone(&db);
1341013437 thread::spawn(move || {
1341113438 for _ in 0..CALLS {
13412- db.follow("convention", "hot-rule", true).expect("follow");
13439+ db.follow("convention", "hot-rule", true, None ).expect("follow");
1341313440 }
1341413441 })
1341513442 })
@@ -13450,7 +13477,7 @@ mod tests {
1345013477 b.workspace_hash = "bbbb".to_string();
1345113478 db.remember_skip_dedup(&b).unwrap();
1345213479
13453- let r = db.follow("convention", "shared-rule", true).unwrap();
13480+ let r = db.follow("convention", "shared-rule", true, None ).unwrap();
1345413481 assert!(r.found);
1345513482 assert_eq!(r.follow_count, 1);
1345613483
@@ -13476,6 +13503,111 @@ mod tests {
1347613503 let _ = fs::remove_file(&path);
1347713504 }
1347813505
13506+ #[test]
13507+ fn follow_with_workspace_stamps_only_the_matching_workspace_row() {
13508+ // #396: a workspace-scoped agent recalls with strict equality
13509+ // (workspace_hash = ?) and never sees the global '' row, so its
13510+ // follow() must land on ITS row — not the deterministic global-first
13511+ // pick, which would give another row phantom counts while the row the
13512+ // agent actually saw accrues no signal.
13513+ let (db, path) = temp_db();
13514+ let g = make_entity("f-396-g", "convention", "ws-rule", r#"{"rule":"global"}"#);
13515+ db.remember_skip_dedup(&g).unwrap(); // workspace_hash = '' (global)
13516+ let mut a = make_entity("f-396-a", "convention", "ws-rule", r#"{"rule":"a"}"#);
13517+ a.workspace_hash = "aaaa".to_string();
13518+ db.remember_skip_dedup(&a).unwrap();
13519+ let mut b = make_entity("f-396-b", "convention", "ws-rule", r#"{"rule":"b"}"#);
13520+ b.workspace_hash = "bbbb".to_string();
13521+ db.remember_skip_dedup(&b).unwrap();
13522+
13523+ let r = db.follow("convention", "ws-rule", true, Some("bbbb")).unwrap();
13524+ assert!(r.found, "the bbbb row exists and must be found");
13525+ assert_eq!(r.follow_count, 1);
13526+
13527+ let conn = db.conn().unwrap();
13528+ let count_of = |id: &str| -> (i64, i64) {
13529+ conn.query_row(
13530+ "SELECT follow_count, miss_count FROM entities WHERE id = ?1",
13531+ params![id],
13532+ |r| Ok((r.get(0)?, r.get(1)?)),
13533+ )
13534+ .unwrap()
13535+ };
13536+ assert_eq!(
13537+ count_of("f-396-b"),
13538+ (1, 0),
13539+ "the workspace-matching row takes the follow"
13540+ );
13541+ assert_eq!(
13542+ count_of("f-396-g"),
13543+ (0, 0),
13544+ "the global '' row must NOT take a workspace-scoped follow"
13545+ );
13546+ assert_eq!(
13547+ count_of("f-396-a"),
13548+ (0, 0),
13549+ "another workspace's row must be untouched"
13550+ );
13551+ let _ = fs::remove_file(&path);
13552+ }
13553+
13554+ #[test]
13555+ fn follow_without_workspace_keeps_deterministic_pick() {
13556+ // #396 back-compat pin: with no workspace_hash the resolution is
13557+ // unchanged — global '' row first, then lexicographically-first
13558+ // workspace (the #391 get_entity pick).
13559+ let (db, path) = temp_db();
13560+ let g = make_entity("f-396-d0", "convention", "det-rule", r#"{"rule":"global"}"#);
13561+ db.remember_skip_dedup(&g).unwrap(); // workspace_hash = '' (global)
13562+ let mut a = make_entity("f-396-d1", "convention", "det-rule", r#"{"rule":"a"}"#);
13563+ a.workspace_hash = "aaaa".to_string();
13564+ db.remember_skip_dedup(&a).unwrap();
13565+
13566+ let r = db.follow("convention", "det-rule", true, None).unwrap();
13567+ assert!(r.found);
13568+
13569+ let conn = db.conn().unwrap();
13570+ let count_of = |id: &str| -> i64 {
13571+ conn.query_row(
13572+ "SELECT follow_count FROM entities WHERE id = ?1",
13573+ params![id],
13574+ |r| r.get(0),
13575+ )
13576+ .unwrap()
13577+ };
13578+ assert_eq!(count_of("f-396-d0"), 1, "global '' row is the unscoped pick");
13579+ assert_eq!(count_of("f-396-d1"), 0, "workspace row untouched by unscoped follow");
13580+ let _ = fs::remove_file(&path);
13581+ }
13582+
13583+ #[test]
13584+ fn follow_with_unknown_workspace_is_clean_not_found() {
13585+ // #396: workspace given but no row in that workspace — clean
13586+ // found:false, no global fallback, nothing stamped anywhere.
13587+ let (db, path) = temp_db();
13588+ let g = make_entity("f-396-n0", "convention", "nf-rule", r#"{"rule":"global"}"#);
13589+ db.remember_skip_dedup(&g).unwrap(); // workspace_hash = '' (global)
13590+ let mut a = make_entity("f-396-n1", "convention", "nf-rule", r#"{"rule":"a"}"#);
13591+ a.workspace_hash = "aaaa".to_string();
13592+ db.remember_skip_dedup(&a).unwrap();
13593+
13594+ let r = db.follow("convention", "nf-rule", true, Some("zzzz")).unwrap();
13595+ assert!(!r.found, "no row in workspace zzzz — must be not-found");
13596+ assert_eq!(r.follow_count, 0);
13597+
13598+ let conn = db.conn().unwrap();
13599+ let stamped: i64 = conn
13600+ .query_row(
13601+ "SELECT COALESCE(SUM(follow_count + miss_count), 0) FROM entities \
13602+ WHERE category = 'convention' AND key = 'nf-rule'",
13603+ [],
13604+ |r| r.get(0),
13605+ )
13606+ .unwrap();
13607+ assert_eq!(stamped, 0, "nothing may be stamped on any row");
13608+ let _ = fs::remove_file(&path);
13609+ }
13610+
1347913611 #[test]
1348013612 fn remember_union_stored_link_wins_on_same_target() {
1348113613 // #382 review advisory: pin the union's conflict rule — when the
@@ -13517,11 +13649,11 @@ mod tests {
1351713649
1351813650 // 1 followed, 4 missed -> 1/5 = 0.2, right at DEAD_THRESHOLD (not below),
1351913651 // so push one more miss to go clearly under it.
13520- db.follow("convention", "ignored-rule", true).unwrap();
13652+ db.follow("convention", "ignored-rule", true, None ).unwrap();
1352113653 for _ in 0..5 {
13522- db.follow("convention", "ignored-rule", false).unwrap();
13654+ db.follow("convention", "ignored-rule", false, None ).unwrap();
1352313655 }
13524- let r = db.follow("convention", "ignored-rule", false).unwrap();
13656+ let r = db.follow("convention", "ignored-rule", false, None ).unwrap();
1352513657 assert!(r.follow_rate < 0.20, "got {}", r.follow_rate);
1352613658 assert_eq!(r.efficacy_status, "dead");
1352713659
0 commit comments