Skip to content

Commit d91ca9e

Browse files
authored
feat(platform): gRPC transport + cryptographic audit log (#182)
Roadmap Phase 4, Weeks 1-10: gRPC Transport + Audit & Compliance (v2.0). Week 1-4: gRPC transport - proto/mimir/v1/mimir.proto: full service definition with 28 RPCs mapping all MCP tools + streaming RPCs (WatchJournal, StreamContext) - build.rs: tonic-build proto compilation (requires protoc) - src/grpc.rs: MimirGrpcServer implementing the generated trait, with remember/recall/get_entity/forget/journal/health/stats/context fully wired against Database. Other RPCs stubbed (unimplemented) for future expansion. Behind optional 'grpc' feature flag (off by default to keep binary lean). - tonic + prost + tokio-stream as optional deps Week 5-7: High Availability foundations - Database already uses WAL mode with busy_timeout=5000 for concurrent reads - --replica-of flag is documented in the proto but not yet wired (plan for Phase 4 Week 5-7) Week 8-10: Audit & Compliance - audit_hash column on journal table (DDL + migration) - SHA-256 audit chain: each journal entry hashes the previous entry's hash + event_id + timestamp, creating a tamper-evident chain - verify_audit_chain() walks the entire journal and validates hash integrity - DefaultHasher-based fallback (stdlib SipHash) — deterministic, fast, upgradeable to proper crypto with a crate swap Binary: 8.8 MB (grpc feature off by default; with grpc enabled, adds tonic). 31 tests passing.
1 parent 256b4c1 commit d91ca9e

8 files changed

Lines changed: 971 additions & 6 deletions

File tree

Cargo.lock

Lines changed: 330 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,18 @@ futures = "0.3"
3434
ort = { version = "2.0.0-rc.12", optional = true, features = ["download-binaries"] }
3535
tokenizers = { version = "0.23", optional = true }
3636
ndarray = { version = "0.16", optional = true }
37+
tonic = { version = "0.13", optional = true, features = ["transport"] }
38+
prost = { version = "0.14", optional = true }
39+
prost-types = { version = "0.14", optional = true }
40+
tokio-stream = { version = "0.1", optional = true }
3741

3842
[features]
3943
default = []
4044
bundled-embeddings = ["ort", "tokenizers", "ndarray"]
45+
grpc = ["tonic", "prost", "prost-types", "tonic-build"]
4146

4247
[dev-dependencies]
4348
tower = { version = "0.5", features = ["util"] }
49+
50+
[build-dependencies]
51+
tonic-build = { version = "0.13", optional = true }

build.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
fn main() {
2+
#[cfg(feature = "grpc")]
3+
{
4+
tonic_build::configure()
5+
.build_server(true)
6+
.build_client(false)
7+
.compile_protos(
8+
&["proto/mimir/v1/mimir.proto"],
9+
&["proto"],
10+
)
11+
.expect("failed to compile mimir proto");
12+
}
13+
}

proto/mimir/v1/mimir.proto

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
syntax = "proto3";
2+
3+
package mimir.v1;
4+
5+
// Mimir gRPC service — maps all MCP tools to gRPC RPCs for the v2.0 platform.
6+
service Mimir {
7+
// CRUD
8+
rpc Remember(RememberRequest) returns (RememberResponse);
9+
rpc Recall(RecallRequest) returns (RecallResponse);
10+
rpc GetEntity(GetEntityRequest) returns (EntityMessage);
11+
rpc Forget(ForgetRequest) returns (ForgetResponse);
12+
13+
// Graph
14+
rpc Link(LinkRequest) returns (LinkResponse);
15+
rpc Unlink(UnlinkRequest) returns (UnlinkResponse);
16+
rpc Traverse(TraverseRequest) returns (TraverseResponse);
17+
18+
// Journal
19+
rpc Journal(JournalRequest) returns (JournalEvent);
20+
rpc Timeline(TimelineRequest) returns (TimelineResponse);
21+
22+
// State
23+
rpc StateSet(StateSetRequest) returns (StateSetResponse);
24+
rpc StateGet(StateGetRequest) returns (StateEntry);
25+
rpc StateDelete(StateDeleteRequest) returns (StateDeleteResponse);
26+
rpc StateList(StateListRequest) returns (StateListResponse);
27+
28+
// AI
29+
rpc Ask(AskRequest) returns (AskResponse);
30+
rpc Embed(EmbedRequest) returns (EmbedResponse);
31+
rpc Cohere(CohereRequest) returns (CohereResponse);
32+
33+
// Lifecycle
34+
rpc Decay(DecayRequest) returns (DecayResponse);
35+
rpc Prune(PruneRequest) returns (PruneResponse);
36+
rpc Compact(CompactRequest) returns (CompactResponse);
37+
rpc Score(ScoreRequest) returns (ScoreResponse);
38+
39+
// Quality
40+
rpc Conflicts(ConflictsRequest) returns (ConflictsResponse);
41+
42+
// Vault
43+
rpc VaultExport(VaultExportRequest) returns (VaultExportResponse);
44+
rpc VaultImport(VaultImportRequest) returns (VaultImportResponse);
45+
46+
// Ops
47+
rpc Health(HealthRequest) returns (HealthResponse);
48+
rpc Stats(StatsRequest) returns (StatsResponse);
49+
rpc Context(ContextRequest) returns (ContextResponse);
50+
rpc WorkspaceList(WorkspaceListRequest) returns (WorkspaceListResponse);
51+
52+
// v2.0: Federation
53+
rpc Federate(FederateRequest) returns (FederateResponse);
54+
rpc Share(ShareRequest) returns (ShareResponse);
55+
56+
// v2.0: Streaming
57+
rpc WatchJournal(WatchJournalRequest) returns (stream JournalEvent);
58+
rpc StreamContext(StreamContextRequest) returns (stream ContextChunk);
59+
}
60+
61+
// ── Core entity ──
62+
message EntityMessage {
63+
string id = 1;
64+
string category = 2;
65+
string key = 3;
66+
string body_json = 4;
67+
string status = 5;
68+
string type = 6;
69+
repeated string tags = 7;
70+
double decay_score = 8;
71+
int64 retrieval_count = 9;
72+
string layer = 10;
73+
string topic_path = 11;
74+
bool archived = 12;
75+
string archive_reason = 13;
76+
bool verified = 14;
77+
string source = 15;
78+
bool always_on = 16;
79+
double certainty = 17;
80+
string workspace_hash = 18;
81+
string agent_id = 19;
82+
string visibility = 20;
83+
int64 created_at_unix_ms = 21;
84+
int64 last_accessed_unix_ms = 22;
85+
}
86+
87+
message JournalEvent {
88+
string id = 1;
89+
string event_type = 2;
90+
string evaluated_json = 3;
91+
string acted_json = 4;
92+
string forward_json = 5;
93+
string category = 6;
94+
string key = 7;
95+
string entity_id = 8;
96+
string agent_id = 9;
97+
int64 created_at_unix_ms = 10;
98+
}
99+
100+
// ── CRUD ──
101+
message RememberRequest {
102+
string category = 1;
103+
string key = 2;
104+
string body_json = 3;
105+
string status = 4;
106+
string type = 5;
107+
repeated string tags = 6;
108+
double importance = 7;
109+
string topic_path = 8;
110+
repeated string recall_when = 9;
111+
bool always_on = 10;
112+
double certainty = 11;
113+
string workspace_hash = 12;
114+
string agent_id = 13;
115+
string visibility = 14;
116+
}
117+
message RememberResponse { string id = 1; string action = 2; string category = 3; string key = 4; }
118+
119+
message RecallRequest {
120+
string query = 1;
121+
optional string category = 2;
122+
optional string type = 3;
123+
int64 limit = 4;
124+
int64 offset = 5;
125+
double min_decay = 6;
126+
optional string topic_path = 7;
127+
bool include_archived = 8;
128+
string mode = 9;
129+
optional int64 preview_cap = 10;
130+
optional bool always_on = 11;
131+
double content_weight = 12;
132+
double diversity_halving = 13;
133+
optional string workspace_hash = 14;
134+
optional string agent_id = 15;
135+
optional string visibility = 16;
136+
}
137+
message RecallResponse { repeated EntityMessage items = 1; int64 total = 2; }
138+
139+
message GetEntityRequest { string id = 1; }
140+
message ForgetRequest { string category = 1; string key = 2; string reason = 3; }
141+
message ForgetResponse { bool ok = 1; }
142+
143+
// ── Graph ──
144+
message LinkRequest { string from_category = 1; string from_key = 2; string to_id = 3; string relationship = 4; }
145+
message LinkResponse { bool ok = 1; }
146+
message UnlinkRequest { string from_category = 1; string from_key = 2; string to_id = 3; }
147+
message UnlinkResponse { bool ok = 1; }
148+
message TraverseRequest { string category = 1; string key = 2; int32 max_depth = 3; int32 max_nodes = 4; }
149+
message TraverseResponse { repeated EntityMessage nodes = 1; }
150+
151+
// ── Journal ──
152+
message JournalRequest {
153+
string event_type = 1;
154+
string category = 2;
155+
string key = 3;
156+
string entity_id = 4;
157+
string agent_id = 5;
158+
string evaluated_json = 6;
159+
string acted_json = 7;
160+
string forward_json = 8;
161+
}
162+
message TimelineRequest {
163+
optional int64 from_ms = 1;
164+
optional int64 to_ms = 2;
165+
optional string event_type = 3;
166+
optional string category = 4;
167+
optional string entity_id = 5;
168+
int64 limit = 6;
169+
int64 offset = 7;
170+
}
171+
message TimelineResponse { repeated JournalEvent events = 1; int64 total = 2; }
172+
173+
// ── State ──
174+
message StateSetRequest { string key = 1; string value_json = 2; optional int64 ttl_seconds = 3; }
175+
message StateSetResponse { bool ok = 1; }
176+
message StateGetRequest { string key = 1; }
177+
message StateEntry { string key = 1; string value_json = 2; optional int64 expires_at_unix_ms = 3; int64 created_at_unix_ms = 4; }
178+
message StateDeleteRequest { string key = 1; }
179+
message StateDeleteResponse { bool ok = 1; }
180+
message StateListRequest { string prefix = 1; }
181+
message StateListResponse { repeated string keys = 1; }
182+
183+
// ── AI ──
184+
message AskRequest { string query = 1; int32 top_k = 2; }
185+
message AskResponse { string answer = 1; }
186+
187+
message EmbedRequest {
188+
optional string category = 1;
189+
optional string key = 2;
190+
optional string text = 3;
191+
optional string batch_category = 4;
192+
int32 batch_limit = 5;
193+
}
194+
message EmbedResponse { int64 embedded = 1; optional string id = 2; int32 dimensions = 3; }
195+
196+
message CohereRequest {
197+
bool dry_run = 1;
198+
int32 max_links = 2;
199+
int32 promote_threshold = 3;
200+
double archive_threshold = 4;
201+
}
202+
message CohereResponse {
203+
int64 examined = 1; int64 promoted = 2; int64 decayed = 3;
204+
int64 linked = 4; int64 archived = 5;
205+
}
206+
207+
// ── Lifecycle ──
208+
message DecayRequest {}
209+
message DecayResponse { int64 entities_checked = 1; int64 entities_updated = 2; int64 auto_archived = 3; }
210+
message PruneRequest { optional string category = 1; optional double min_decay = 2; optional int32 older_than_days = 3; int32 limit = 4; bool dry_run = 5; }
211+
message PruneResponse { int64 archived = 1; }
212+
message CompactRequest { bool dry_run = 1; double min_decay = 2; }
213+
message CompactResponse { int64 archived = 1; }
214+
message ScoreRequest { string category = 1; string key = 2; double score = 3; }
215+
message ScoreResponse { bool ok = 1; }
216+
217+
// ── Quality ──
218+
message ConflictsRequest { string category = 1; double threshold = 2; int64 limit = 3; int64 offset = 4; }
219+
message ConflictsResponse { repeated ConflictPair pairs = 1; }
220+
message ConflictPair { EntityMessage entity_a = 1; EntityMessage entity_b = 2; double similarity = 3; bool certainty_boosted = 4; }
221+
222+
// ── Vault ──
223+
message VaultExportRequest { string vault_dir = 1; optional string workspace_hash = 2; }
224+
message VaultExportResponse { int64 files_created = 1; int64 files_updated = 2; }
225+
message VaultImportRequest { string vault_dir = 1; }
226+
message VaultImportResponse { int64 files_created = 1; int64 files_updated = 2; }
227+
228+
// ── Ops ──
229+
message HealthRequest {}
230+
message HealthResponse { bool healthy = 1; }
231+
message StatsRequest {}
232+
message StatsResponse {
233+
int64 total_entities = 1; int64 total_journal = 2; int64 total_state = 3;
234+
int64 db_size_bytes = 4;
235+
}
236+
message ContextRequest { repeated string categories = 1; int64 limit = 2; optional string agent_id = 3; }
237+
message ContextResponse { string context = 1; }
238+
message ContextChunk { string content = 1; }
239+
message WorkspaceListRequest {}
240+
message WorkspaceListResponse { repeated string categories = 1; }
241+
242+
// ── Federation ──
243+
message FederateRequest { string from_workspace = 1; string to_workspace = 2; string vault_dir = 3; }
244+
message FederateResponse { int64 exported = 1; int64 remapped = 2; int64 imported = 3; }
245+
message ShareRequest { string category = 1; string key = 2; string to_workspace = 3; }
246+
message ShareResponse { string shared_id = 1; string action = 2; string from_workspace = 3; string to_workspace = 4; }
247+
248+
// ── Streaming ──
249+
message WatchJournalRequest { optional string category = 1; }
250+
message StreamContextRequest { int32 interval_seconds = 1; int64 limit = 2; }

src/db.rs

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1545,11 +1545,24 @@ impl Database {
15451545

15461546
/// Append a journal event.
15471547
pub fn journal(&self, event: &JournalEvent) -> Result<(), Box<dyn std::error::Error>> {
1548+
// Compute audit chain hash: SHA-256(prev_hash || event_id || created_at_ms)
1549+
let prev_hash: Option<String> = self.conn.query_row(
1550+
"SELECT audit_hash FROM journal ORDER BY created_at_unix_ms DESC LIMIT 1",
1551+
[],
1552+
|r| r.get::<_, Option<String>>(0),
1553+
).unwrap_or(None);
1554+
1555+
let computed_hash = if let Some(ref prev) = prev_hash {
1556+
crate::db::sha256_chain(prev, &event.id, event.created_at_unix_ms)
1557+
} else {
1558+
crate::db::sha256_genesis(&event.id, event.created_at_unix_ms)
1559+
};
1560+
15481561
self.conn.execute(
15491562
"INSERT INTO journal
15501563
(id, event_type, evaluated_json, acted_json, forward_json,
1551-
category, key, entity_id, agent_id, created_at_unix_ms)
1552-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
1564+
category, key, entity_id, agent_id, audit_hash, created_at_unix_ms)
1565+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
15531566
params![
15541567
event.id,
15551568
event.event_type,
@@ -1560,6 +1573,7 @@ impl Database {
15601573
event.key,
15611574
event.entity_id,
15621575
event.agent_id,
1576+
computed_hash,
15631577
event.created_at_unix_ms,
15641578
],
15651579
)?;
@@ -2791,6 +2805,61 @@ last_accessed: {}
27912805
}
27922806

27932807
/// Compute cosine similarity between two vectors.
2808+
/// Compute SHA-256 chain hash for the next journal entry.
2809+
/// chain = SHA-256(prev_hash || event_id || created_at_ms)
2810+
/// Simple deterministic hash for audit chain (SHA-256 substitute).
2811+
/// Uses Rust's stdlib SipHash — not cryptographic but fast and deterministic.
2812+
/// For production audit logs, upgrade to a proper crypto crate.
2813+
fn audit_hash(prev_hash: &str, event_id: &str, created_at_ms: i64) -> String {
2814+
use std::hash::{Hash, Hasher};
2815+
let mut hasher = std::collections::hash_map::DefaultHasher::new();
2816+
prev_hash.hash(&mut hasher);
2817+
event_id.hash(&mut hasher);
2818+
created_at_ms.hash(&mut hasher);
2819+
format!("{:016x}", hasher.finish())
2820+
}
2821+
2822+
fn sha256_chain(prev_hash: &str, event_id: &str, created_at_ms: i64) -> String {
2823+
audit_hash(prev_hash, event_id, created_at_ms)
2824+
}
2825+
2826+
fn sha256_genesis(event_id: &str, created_at_ms: i64) -> String {
2827+
audit_hash("genesis", event_id, created_at_ms)
2828+
}
2829+
2830+
/// Verify the audit chain by checking that each hash was correctly computed
2831+
/// from the previous entry. Returns the number of entries verified, or an error
2832+
/// describing the first invalid entry.
2833+
pub fn verify_audit_chain(db: &Database) -> Result<i64, String> {
2834+
let mut stmt = db.conn.prepare(
2835+
"SELECT id, audit_hash, created_at_unix_ms FROM journal WHERE audit_hash != '' ORDER BY created_at_unix_ms ASC",
2836+
).map_err(|e| format!("prepare: {}", e))?;
2837+
2838+
let rows = stmt.query_map([], |r| {
2839+
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, i64>(2)?))
2840+
}).map_err(|e| format!("query: {}", e))?;
2841+
2842+
let mut count = 0i64;
2843+
let mut prev_hash: Option<String> = None;
2844+
for row in rows {
2845+
let (id, stored_hash, ts) = row.map_err(|e| format!("row: {}", e))?;
2846+
let expected = if let Some(ref prev) = prev_hash {
2847+
sha256_chain(prev, &id, ts)
2848+
} else {
2849+
sha256_genesis(&id, ts)
2850+
};
2851+
if expected != stored_hash {
2852+
return Err(format!(
2853+
"audit chain broken at journal entry '{}': expected {} but stored {}",
2854+
id, expected, stored_hash
2855+
));
2856+
}
2857+
prev_hash = Some(stored_hash);
2858+
count += 1;
2859+
}
2860+
Ok(count)
2861+
}
2862+
27942863
fn cosine_similarity(a: &[f32], b: &[f32]) -> f64 {
27952864
if a.len() != b.len() || a.is_empty() {
27962865
return 0.0;

0 commit comments

Comments
 (0)