Skip to content

Commit d70a8c0

Browse files
tcconnallytcconnallyclaude
authored
fix(follow): optional workspace_hash arg — strict workspace scoping for efficacy stamps (closes #396) (#407)
A workspace-scoped agent recalls with strict equality (workspace_hash = ?) and never sees the global '' row, but follow() only knew the deterministic pick (global first, then lexicographically-first workspace) — so the agent's efficacy stamp landed on a row it never saw: its own row accrued no signal while another workspace's row collected phantom counts that could flip its efficacy_status. Residual from PR #395's adversarial review (finding 1). - mimir_follow (+ mneme_/perseus_vault_ aliases) accepts optional workspace_hash (the #338 pattern): present = strict equality, no global fallback; absent = the existing deterministic pick, unchanged. - The row-resolve .ok() is tightened to explicit QueryReturnedNoRows handling (the #394 principle): no rows -> clean found:false, real DB errors propagate. - MCP inputSchema for follow documents the new arg (schema declared once; aliases are synthesized from it). Closes #396 Co-authored-by: tcconnally <hermes@perseus.observer> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a56e9fd commit d70a8c0

4 files changed

Lines changed: 188 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,23 @@
33
All notable changes to Perseus Vault (formerly Mimir/Mneme) are documented here. This project adheres to
44
[Semantic Versioning](https://semver.org/).
55

6+
## [Unreleased]
7+
8+
### Added
9+
- `mimir_follow` accepts an optional `workspace_hash` (#396, the #338
10+
pattern): when set, the efficacy stamp resolves its target row with strict
11+
workspace equality — the same semantics as a workspace-scoped recall — so a
12+
workspace-scoped agent's follow/miss signal lands on the row it actually
13+
saw, instead of the deterministic global-first pick giving another
14+
workspace's row (or the global `''` row) phantom counts. Omitted = the
15+
existing deterministic pick, unchanged.
16+
17+
### Fixed
18+
- `follow()`'s row resolution no longer collapses real DB errors into
19+
"not found" (#396, the #394 principle): only `QueryReturnedNoRows` maps to
20+
the clean `found: false` report; a locked file or corruption error now
21+
propagates.
22+
623
## [2.14.0] - 2026-07-02
724

825
### Added

src/db.rs

Lines changed: 147 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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

src/mcp.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2125,6 +2125,10 @@ fn list_tools(id: Option<Value>) -> JsonRpcResponse {
21252125
"context": {
21262126
"type": "string",
21272127
"description": "Optional description of the action/context this observation relates to"
2128+
},
2129+
"workspace_hash": {
2130+
"type": "string",
2131+
"description": "Workspace scope filter. When set, the stamped row is resolved with strict workspace equality — the same semantics as a workspace-scoped recall — so the signal lands on the row the agent actually saw (no global fallback). Omit to keep the unscoped deterministic pick (global '' row first, then lexicographically-first workspace)."
21282132
}
21292133
},
21302134
"required": [

src/tools.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1599,6 +1599,11 @@ pub struct FollowArgs {
15991599
#[serde(default)]
16001600
#[allow(dead_code)]
16011601
pub context: Option<String>,
1602+
/// #396 (the #338 pattern): when set, the target row is resolved with
1603+
/// strict workspace equality — matching workspace-scoped recall — instead
1604+
/// of the deterministic global-first pick.
1605+
#[serde(default)]
1606+
pub workspace_hash: Option<String>,
16021607
}
16031608

16041609
/// Record whether an entity (convention/insight/lesson) was actually FOLLOWED
@@ -1610,7 +1615,7 @@ pub fn handle_follow(db: &Database, args: Value) -> Result<String, String> {
16101615
serde_json::from_value(args).map_err(|e| format!("Invalid follow arguments: {}", e))?;
16111616

16121617
let report = db
1613-
.follow(&a.category, &a.key, a.followed)
1618+
.follow(&a.category, &a.key, a.followed, a.workspace_hash.as_deref())
16141619
.map_err(|e| format!("Follow failed: {}", e))?;
16151620

16161621
serde_json::to_string(&report).map_err(|e| format!("Serialization failed: {}", e))
@@ -4225,6 +4230,20 @@ mod tests {
42254230
assert_eq!(a.limit, 10);
42264231
}
42274232

4233+
#[test]
4234+
fn follow_args_accept_null_workspace_hash() {
4235+
// #396's new optional arg must follow the explicit-null tolerance
4236+
// rule (#330) like the rest of the tool surface.
4237+
let v = json!({
4238+
"category": "convention",
4239+
"key": "k",
4240+
"followed": true,
4241+
"workspace_hash": null
4242+
});
4243+
let a: FollowArgs = serde_json::from_value(v).expect("null workspace_hash must deserialize");
4244+
assert!(a.workspace_hash.is_none());
4245+
}
4246+
42284247
#[test]
42294248
fn context_args_accept_null_for_new_optional_fields() {
42304249
// #356/#366 args must follow the same explicit-null tolerance rule

0 commit comments

Comments
 (0)