Skip to content

Commit d5fd199

Browse files
committed
fix: unfold_source visibility + RecallContext for /unfold and MCP
Made-with: Cursor
1 parent 7138951 commit d5fd199

3 files changed

Lines changed: 64 additions & 38 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,14 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
2323
- Table name allowlists prevent SQL injection in dynamic admin queries
2424

2525
### Fixes
26+
- `unfold_source` / GET `/unfold` now take `RecallContext` and filter by `owner_id` / `visibility` (fixes MCP `unfold_source` 3-arg mismatch and release CI build)
2627
- Decision search in retry loop used hardcoded limit instead of `fts_limit`
2728
- Fallback recall paths returned NULL `owner_id` causing visibility issues
2829
- MCP handlers bypassed visibility by hardcoding solo context
2930
- `handle_user_add` used re-query instead of `last_insert_rowid()`
3031
- Removed `tasks` from archive/visibility allowlists (uses `task_id` TEXT, not `id`)
3132

3233
### Known Issues
33-
- `/unfold` endpoint has no visibility filtering (root cause fix pending)
3434
- MCP JSON-RPC lacks per-caller identity (uses default owner for all callers)
3535
- `is_visible` treats NULL `owner_id` as visible in team mode (should fail closed after migration)
3636
- Team-mode test environment needed to validate end-to-end

CHANGELOG_v0.3.0_section.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@
1212
- Table name allowlists prevent SQL injection in dynamic admin queries
1313

1414
### Fixes
15+
- `unfold_source` / GET `/unfold` now take `RecallContext` and filter by `owner_id` / `visibility` (fixes MCP `unfold_source` 3-arg mismatch and release CI build)
1516
- Decision search in retry loop used hardcoded limit instead of `fts_limit`
1617
- Fallback recall paths returned NULL `owner_id` causing visibility issues
1718
- MCP handlers bypassed visibility by hardcoding solo context
1819
- `handle_user_add` used re-query instead of `last_insert_rowid()`
1920
- Removed `tasks` from archive/visibility allowlists (uses `task_id` TEXT, not `id`)
2021

2122
### Known Issues
22-
- `/unfold` endpoint has no visibility filtering (root cause fix pending)
2323
- MCP JSON-RPC lacks per-caller identity (uses default owner for all callers)
2424
- `is_visible` treats NULL `owner_id` as visible in team mode (should fail closed after migration)
2525
- Team-mode test environment needed to validate end-to-end

daemon-rs/src/handlers/recall.rs

Lines changed: 62 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1588,9 +1588,11 @@ pub async fn handle_unfold(
15881588
Query(query): Query<UnfoldQuery>,
15891589
headers: HeaderMap,
15901590
) -> Response {
1591-
if let Err(resp) = ensure_auth(&headers, &state) {
1592-
return resp;
1593-
}
1591+
let caller_id = match ensure_auth_with_caller(&headers, &state) {
1592+
Ok(id) => id,
1593+
Err(resp) => return resp,
1594+
};
1595+
let ctx = RecallContext::from_caller(caller_id, &state);
15941596
let sources_str = match &query.sources {
15951597
Some(s) if !s.trim().is_empty() => s.trim().to_string(),
15961598
_ => {
@@ -1618,7 +1620,7 @@ pub async fn handle_unfold(
16181620
let mut total_tokens = 0usize;
16191621

16201622
for source in &requested {
1621-
if let Some(item) = unfold_source(&conn, source) {
1623+
if let Some(item) = unfold_source(&conn, source, &ctx) {
16221624
let tokens = estimate_tokens(item["text"].as_str().unwrap_or(""));
16231625
total_tokens += tokens;
16241626
results.push(json!({
@@ -1647,63 +1649,87 @@ pub async fn handle_unfold(
16471649
)
16481650
}
16491651

1650-
/// Look up the full text of a single source string.
1651-
pub fn unfold_source(conn: &Connection, source: &str) -> Option<Value> {
1652-
// Try memory by source field
1653-
if let Ok(text) = conn.query_row(
1654-
"SELECT text, type FROM memories WHERE source = ?1 AND status = 'active' ORDER BY score DESC LIMIT 1",
1652+
/// Look up the full text of a single source string (team visibility applied when `ctx.team_mode`).
1653+
pub fn unfold_source(conn: &Connection, source: &str, ctx: &RecallContext) -> Option<Value> {
1654+
if let Ok((text, ty, owner_id, visibility)) = conn.query_row(
1655+
"SELECT text, type, owner_id, visibility FROM memories WHERE source = ?1 AND status = 'active' ORDER BY score DESC LIMIT 1",
16551656
params![source],
1656-
|row| Ok(json!({"text": row.get::<_, String>(0)?, "type": row.get::<_, String>(1)?})),
1657+
|row| {
1658+
Ok((
1659+
row.get::<_, String>(0)?,
1660+
row.get::<_, String>(1)?,
1661+
row.get::<_, Option<i64>>(2)?,
1662+
row.get::<_, Option<String>>(3)?,
1663+
))
1664+
},
16571665
) {
1658-
return Some(text);
1666+
if is_visible(owner_id, visibility.as_deref(), ctx) {
1667+
return Some(json!({"text": text, "type": ty}));
1668+
}
16591669
}
16601670

1661-
// Try decision by ID (source format: "decision::123" or just the context string)
16621671
if let Some(id_str) = source.strip_prefix("decision::") {
16631672
if let Ok(id) = id_str.parse::<i64>() {
1664-
if let Ok(text) = conn.query_row(
1665-
"SELECT decision, context FROM decisions WHERE id = ?1 AND status = 'active'",
1673+
if let Ok((decision, context, owner_id, visibility)) = conn.query_row(
1674+
"SELECT decision, context, owner_id, visibility FROM decisions WHERE id = ?1 AND status = 'active'",
16661675
params![id],
16671676
|row| {
1668-
let decision: String = row.get(0)?;
1669-
let context: Option<String> = row.get(1)?;
1677+
Ok((
1678+
row.get::<_, String>(0)?,
1679+
row.get::<_, Option<String>>(1)?,
1680+
row.get::<_, Option<i64>>(2)?,
1681+
row.get::<_, Option<String>>(3)?,
1682+
))
1683+
},
1684+
) {
1685+
if is_visible(owner_id, visibility.as_deref(), ctx) {
16701686
let full = match context {
1671-
Some(ctx) => format!("{decision}\n\nContext: {ctx}"),
1687+
Some(c) => format!("{decision}\n\nContext: {c}"),
16721688
None => decision,
16731689
};
1674-
Ok(json!({"text": full, "type": "decision"}))
1675-
},
1676-
) {
1677-
return Some(text);
1690+
return Some(json!({"text": full, "type": "decision"}));
1691+
}
16781692
}
16791693
}
16801694
}
16811695

1682-
// Try decision by context field (some sources use context as the source string)
1683-
if let Ok(text) = conn.query_row(
1684-
"SELECT decision, context FROM decisions WHERE context = ?1 AND status = 'active' ORDER BY score DESC LIMIT 1",
1696+
if let Ok((decision, context, owner_id, visibility)) = conn.query_row(
1697+
"SELECT decision, context, owner_id, visibility FROM decisions WHERE context = ?1 AND status = 'active' ORDER BY score DESC LIMIT 1",
16851698
params![source],
16861699
|row| {
1687-
let decision: String = row.get(0)?;
1688-
let context: Option<String> = row.get(1)?;
1700+
Ok((
1701+
row.get::<_, String>(0)?,
1702+
row.get::<_, Option<String>>(1)?,
1703+
row.get::<_, Option<i64>>(2)?,
1704+
row.get::<_, Option<String>>(3)?,
1705+
))
1706+
},
1707+
) {
1708+
if is_visible(owner_id, visibility.as_deref(), ctx) {
16891709
let full = match context {
1690-
Some(ctx) => format!("{decision}\n\nContext: {ctx}"),
1710+
Some(c) => format!("{decision}\n\nContext: {c}"),
16911711
None => decision,
16921712
};
1693-
Ok(json!({"text": full, "type": "decision"}))
1694-
},
1695-
) {
1696-
return Some(text);
1713+
return Some(json!({"text": full, "type": "decision"}));
1714+
}
16971715
}
16981716

1699-
// Try memory by partial source match (e.g., "memory::project_cortex_plan.md")
17001717
let stripped = source.strip_prefix("memory::").unwrap_or(source);
1701-
if let Ok(text) = conn.query_row(
1702-
"SELECT text, type FROM memories WHERE source LIKE ?1 AND status = 'active' ORDER BY score DESC LIMIT 1",
1718+
if let Ok((text, ty, owner_id, visibility)) = conn.query_row(
1719+
"SELECT text, type, owner_id, visibility FROM memories WHERE source LIKE ?1 AND status = 'active' ORDER BY score DESC LIMIT 1",
17031720
params![format!("%{stripped}%")],
1704-
|row| Ok(json!({"text": row.get::<_, String>(0)?, "type": row.get::<_, String>(1)?})),
1721+
|row| {
1722+
Ok((
1723+
row.get::<_, String>(0)?,
1724+
row.get::<_, String>(1)?,
1725+
row.get::<_, Option<i64>>(2)?,
1726+
row.get::<_, Option<String>>(3)?,
1727+
))
1728+
},
17051729
) {
1706-
return Some(text);
1730+
if is_visible(owner_id, visibility.as_deref(), ctx) {
1731+
return Some(json!({"text": text, "type": ty}));
1732+
}
17071733
}
17081734

17091735
None

0 commit comments

Comments
 (0)