Skip to content

Commit 90af1ad

Browse files
authored
feat(access): visibility column + mimir_share tool + --workspace-token (#178)
Roadmap Phase 2, Week 10-11: Access Controls (v1.2.0). Three deliverables: 1. visibility column: private/workspace/public levels on every entity. Stored in entities table (TEXT DEFAULT 'workspace'), ALTER migration, persisted through remember() INSERT/UPDATE, roundtripped through all 6 SELECT lists via entity_from_row index 23. Entity constructors default to 'workspace'. Added to RecallParams as optional filter. 2. mimir_share MCP tool: shares an entity from one workspace to another by category+key lookup, clones with new ID + target workspace_hash, persists via remember(). Returns shared_id, action, from/to workspace. Registered with full inputSchema/outputSchema/annotations (33 tools now). 3. --workspace-token CLI flag: added to both global Cli and Serve structs for cross-workspace transport auth (complements --mcp-token). E2E verified: entity shared w1→w2, recall in w2 returns the shared copy. 31 tests passing.
1 parent 9587f8c commit 90af1ad

6 files changed

Lines changed: 151 additions & 12 deletions

File tree

src/db.rs

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ impl Database {
254254
source: format!("connector:{}", name),
255255
workspace_hash: String::new(),
256256
agent_id: String::new(),
257+
visibility: "workspace".to_string(),
257258
created_at_unix_ms: now,
258259
last_accessed_unix_ms: now,
259260
embedding: None,
@@ -572,7 +573,7 @@ impl Database {
572573
decay_score, retrieval_count, layer, topic_path,
573574
archived, archive_reason, links, verified, source,
574575
created_at_unix_ms, last_accessed_unix_ms, embedding,
575-
always_on, certainty, workspace_hash, agent_id
576+
always_on, certainty, workspace_hash, agent_id, visibility
576577
FROM entities WHERE archived = 0 AND embedding IS NOT NULL LIMIT {}",
577578
max_scan
578579
))?;
@@ -855,9 +856,9 @@ impl Database {
855856
decay_score = ?5, layer = ?6, topic_path = ?7,
856857
archived = ?8, archive_reason = ?9, links = ?10,
857858
verified = ?11, source = ?12, last_accessed_unix_ms = ?13,
858-
always_on = ?14, certainty = ?15, workspace_hash = ?16, agent_id = ?17,
859+
always_on = ?14, certainty = ?15, workspace_hash = ?16, agent_id = ?17, visibility = ?18,
859860
retrieval_count = retrieval_count + 1
860-
WHERE id = ?18",
861+
WHERE id = ?19",
861862
params![
862863
body_encrypted,
863864
entity.status,
@@ -876,6 +877,7 @@ impl Database {
876877
entity.certainty,
877878
entity.workspace_hash,
878879
entity.agent_id,
880+
entity.visibility,
879881
id,
880882
],
881883
)?;
@@ -916,11 +918,11 @@ impl Database {
916918
decay_score, retrieval_count, layer, topic_path,
917919
archived, archive_reason, links, verified, source,
918920
always_on, certainty, created_at_unix_ms, last_accessed_unix_ms,
919-
workspace_hash, agent_id)
921+
workspace_hash, agent_id, visibility)
920922
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7,
921923
?8, ?9, ?10, ?11,
922924
?12, ?13, ?14, ?15, ?16,
923-
?17, ?18, ?19, ?20, ?21, ?22)",
925+
?17, ?18, ?19, ?20, ?21, ?22, ?23)",
924926
params![
925927
id,
926928
entity.category,
@@ -944,6 +946,7 @@ impl Database {
944946
entity.last_accessed_unix_ms,
945947
entity.workspace_hash,
946948
entity.agent_id,
949+
entity.visibility,
947950
],
948951
)?;
949952

@@ -1125,7 +1128,7 @@ impl Database {
11251128
decay_score, retrieval_count, layer, topic_path,
11261129
archived, archive_reason, links, verified, source,
11271130
created_at_unix_ms, last_accessed_unix_ms, embedding,
1128-
always_on, certainty, workspace_hash, agent_id
1131+
always_on, certainty, workspace_hash, agent_id, visibility
11291132
FROM entities",
11301133
);
11311134

@@ -1294,7 +1297,7 @@ impl Database {
12941297
decay_score, retrieval_count, layer, topic_path,
12951298
archived, archive_reason, links, verified, source,
12961299
created_at_unix_ms, last_accessed_unix_ms, embedding,
1297-
always_on, certainty, workspace_hash, agent_id
1300+
always_on, certainty, workspace_hash, agent_id, visibility
12981301
FROM entities WHERE category = ?1 AND key = ?2 LIMIT 1",
12991302
)?;
13001303

@@ -1789,7 +1792,7 @@ impl Database {
17891792
decay_score, retrieval_count, layer, topic_path,
17901793
archived, archive_reason, links, verified, source,
17911794
created_at_unix_ms, last_accessed_unix_ms, embedding,
1792-
always_on, certainty, workspace_hash, agent_id
1795+
always_on, certainty, workspace_hash, agent_id, visibility
17931796
FROM entities WHERE id = ?1",
17941797
)?;
17951798
let mut rows = stmt.query_map(params![id], |row| {
@@ -1823,7 +1826,7 @@ impl Database {
18231826
decay_score, retrieval_count, layer, topic_path,
18241827
archived, archive_reason, links, verified, source,
18251828
created_at_unix_ms, last_accessed_unix_ms, embedding,
1826-
always_on, certainty, workspace_hash, agent_id
1829+
always_on, certainty, workspace_hash, agent_id, visibility
18271830
FROM entities WHERE archived = 0",
18281831
);
18291832
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
@@ -2364,6 +2367,7 @@ last_accessed: {}
23642367
certainty: 0.5,
23652368
workspace_hash: workspace_hash_val,
23662369
agent_id: agent_id_val,
2370+
visibility: "workspace".to_string(),
23672371
created_at_unix_ms: now_ms(),
23682372
last_accessed_unix_ms: now_ms(),
23692373
embedding: None,
@@ -2516,7 +2520,7 @@ last_accessed: {}
25162520
decay_score, retrieval_count, layer, topic_path,
25172521
archived, archive_reason, links, verified, source,
25182522
created_at_unix_ms, last_accessed_unix_ms, embedding,
2519-
always_on, certainty, workspace_hash, agent_id
2523+
always_on, certainty, workspace_hash, agent_id, visibility
25202524
FROM entities
25212525
WHERE archived = 0 AND ({})
25222526
ORDER BY decay_score DESC, retrieval_count DESC
@@ -2784,6 +2788,7 @@ fn entity_from_row(
27842788
certainty: row.get::<_, f64>(20).unwrap_or(0.5),
27852789
workspace_hash: row.get::<_, Option<String>>(21).unwrap_or(None).unwrap_or_default(),
27862790
agent_id: row.get::<_, Option<String>>(22).unwrap_or(None).unwrap_or_default(),
2791+
visibility: row.get::<_, Option<String>>(23).unwrap_or(None).unwrap_or_else(|| "workspace".to_string()),
27872792
created_at_unix_ms: row.get(16)?,
27882793
last_accessed_unix_ms: row.get(17)?,
27892794
embedding: None,
@@ -2825,7 +2830,8 @@ mod tests {
28252830
certainty: 0.5,
28262831
workspace_hash: String::new(),
28272832
agent_id: String::new(),
2828-
created_at_unix_ms: now_ms(),
2833+
visibility: "workspace".to_string(),
2834+
created_at_unix_ms: now_ms(),
28292835
last_accessed_unix_ms: now_ms(),
28302836
embedding: None,
28312837
}
@@ -3367,6 +3373,7 @@ mod tests {
33673373
diversity_per_query_share: 0.0,
33683374
workspace_hash: None,
33693375
agent_id: None,
3376+
visibility: None,
33703377
},
33713378
)
33723379
.unwrap();
@@ -3494,6 +3501,7 @@ mod tests {
34943501
diversity_per_query_share: 0.0f64,
34953502
workspace_hash: None,
34963503
agent_id: None,
3504+
visibility: None,
34973505
}) {
34983506
Ok(_) => {},
34993507
Err(e) => {
@@ -3553,6 +3561,7 @@ mod tests {
35533561
diversity_per_query_share: 0.0,
35543562
workspace_hash: ws,
35553563
agent_id: None,
3564+
visibility: None,
35563565
};
35573566

35583567
// Scope to "alpha" — should only see ent_a
@@ -3596,6 +3605,7 @@ mod tests {
35963605
always_on: None, content_weight: 0.0, diversity_halving: 1.0,
35973606
diversity_per_query_share: 0.0, workspace_hash: None,
35983607
agent_id: None,
3608+
visibility: None,
35993609
};
36003610
let results = db.recall(&params).unwrap();
36013611
let found = results.iter().find(|e| e.key == "key1").expect("entity recalled");

src/main.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ struct Cli {
8484
/// Has no effect on stdio transport.
8585
#[arg(long)]
8686
mcp_token: Option<String>,
87+
88+
/// Token required for cross-workspace access (v1.2.0). When set, transport
89+
/// routes accept this token as workspace authentication.
90+
#[arg(long)]
91+
workspace_token: Option<String>,
8792
}
8893

8994
#[derive(Subcommand)]
@@ -155,6 +160,10 @@ enum Commands {
155160
/// Has no effect on stdio transport.
156161
#[arg(long)]
157162
mcp_token: Option<String>,
163+
164+
/// Token required for cross-workspace access (v1.2.0)
165+
#[arg(long)]
166+
workspace_token: Option<String>,
158167
},
159168

160169
/// Migrate a v0.1.x Mimir database to v0.2.0 schema

src/mcp.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1597,6 +1597,52 @@ fn list_tools(id: Option<Value>) -> JsonRpcResponse {
15971597
"destructiveHint": true
15981598
}
15991599
},
1600+
{
1601+
"name": "mimir_share",
1602+
"description": "Share an entity to another workspace. Copies the entity (by category + key) from its current workspace into the target workspace, preserving content and metadata while generating a new ID. The original entity is unchanged. Use this for controlled cross-workspace knowledge transfer.",
1603+
"inputSchema": {
1604+
"type": "object",
1605+
"properties": {
1606+
"category": {
1607+
"type": "string",
1608+
"description": "Entity category to share"
1609+
},
1610+
"key": {
1611+
"type": "string",
1612+
"description": "Entity key to share"
1613+
},
1614+
"to_workspace": {
1615+
"type": "string",
1616+
"description": "Target workspace hash to copy the entity into"
1617+
}
1618+
},
1619+
"required": ["category", "key", "to_workspace"]
1620+
},
1621+
"outputSchema": {
1622+
"type": "object",
1623+
"properties": {
1624+
"shared_id": {
1625+
"type": "string",
1626+
"description": "ID of the new shared copy"
1627+
},
1628+
"action": {
1629+
"type": "string",
1630+
"description": "'created' or 'updated'"
1631+
},
1632+
"from_workspace": {
1633+
"type": "string",
1634+
"description": "Source workspace the entity was copied from"
1635+
},
1636+
"to_workspace": {
1637+
"type": "string",
1638+
"description": "Target workspace the entity was copied to"
1639+
}
1640+
}
1641+
},
1642+
"annotations": {
1643+
"destructiveHint": true
1644+
}
1645+
},
16001646
{
16011647
"name": "mimir_federate",
16021648
"description": "Federate entities from one workspace to another. Exports entities scoped to from_workspace, remaps their workspace_hash to to_workspace, and imports them — effectively copying or moving knowledge between workspaces. Use this for cross-agent or cross-project knowledge sharing without manual file transfer.",
@@ -1707,6 +1753,7 @@ fn call_tool(name: &str, db: &Database, args: Value, _id: Option<Value>) -> Stri
17071753
"mimir_vault_import" => Ok(tools::handle_vault_import(db, args)),
17081754
"mimir_decay" => Ok(tools::handle_decay(db, args)),
17091755
"mimir_reindex" => Ok(tools::handle_reindex(db, args)),
1756+
"mimir_share" => tools::handle_share(db, args).map_err(|e| e.to_string()),
17101757
"mimir_federate" => tools::handle_federate(db, args).map_err(|e| e.to_string()),
17111758
"mimir_workspace_list" => Ok(tools::handle_workspace_list(db)),
17121759
"mimir_recall_when" => tools::handle_recall_when(db, args).map_err(|e| e.to_string()),

src/models.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ pub struct Entity {
4646
/// Used for agent attribution and context filtering.
4747
#[serde(default)]
4848
pub agent_id: String,
49+
/// Visibility: 'private', 'workspace', or 'public' (v1.2.0)
50+
#[serde(default = "default_visibility")]
51+
pub visibility: String,
4952
pub created_at_unix_ms: i64,
5053
pub last_accessed_unix_ms: i64,
5154
#[serde(skip)]
@@ -96,6 +99,10 @@ fn default_certainty() -> f64 {
9699
0.5
97100
}
98101

102+
fn default_visibility() -> String {
103+
"workspace".to_string()
104+
}
105+
99106
/// A link between two entities. Stored as JSON array in entities.links.
100107
#[derive(Debug, Clone, Serialize, Deserialize)]
101108
pub struct MemoryLink {
@@ -130,6 +137,7 @@ pub struct JournalEvent {
130137
#[serde(default)]
131138
pub entity_id: String,
132139
pub agent_id: String,
140+
/// Visibility: 'private', 'workspace', or 'public' (v1.2.0)
133141
pub created_at_unix_ms: i64,
134142
}
135143

@@ -180,6 +188,9 @@ pub struct RecallParams {
180188
/// Agent identity filter (v1.2.0). When Some, only entities with a
181189
/// matching agent_id are returned. None = no agent filtering.
182190
pub agent_id: Option<String>,
191+
/// Visibility filter (v1.2.0). When Some, only entities with matching
192+
/// visibility are returned. None = no visibility filter.
193+
pub visibility: Option<String>,
183194
}
184195

185196
/// Search mode for recall: FTS5 keyword, dense vector, or hybrid fusion.
@@ -240,6 +251,7 @@ impl Default for RecallParams {
240251
diversity_per_query_share: 0.0,
241252
workspace_hash: None,
242253
agent_id: None,
254+
visibility: None,
243255
}
244256
}
245257
}

src/schema.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ CREATE TABLE IF NOT EXISTS entities (
2929
always_on INTEGER DEFAULT 0,
3030
certainty REAL DEFAULT 0.5,
3131
workspace_hash TEXT DEFAULT '',
32-
agent_id TEXT DEFAULT ''
32+
agent_id TEXT DEFAULT '',
33+
visibility TEXT DEFAULT 'workspace'
3334
);
3435
3536
CREATE UNIQUE INDEX IF NOT EXISTS idx_entities_category_key ON entities(category, key);
@@ -108,6 +109,12 @@ pub fn initialize_schema(conn: &Connection) -> Result<(), Box<dyn std::error::Er
108109
conn.execute_batch("ALTER TABLE journal ADD COLUMN agent_id TEXT DEFAULT '';")?;
109110
}
110111

112+
// Add visibility column (v1.2.0 — access controls)
113+
let has_visibility: bool = conn.prepare("SELECT visibility FROM entities LIMIT 1").is_ok();
114+
if !has_visibility {
115+
conn.execute_batch("ALTER TABLE entities ADD COLUMN visibility TEXT DEFAULT 'workspace';")?;
116+
}
117+
111118
Ok(())
112119
}
113120

0 commit comments

Comments
 (0)