Skip to content

Commit ca5f8f0

Browse files
committed
Support explicit branch access for subagent concurrency
1 parent 3207091 commit ca5f8f0

18 files changed

Lines changed: 723 additions & 130 deletions

File tree

memoria/crates/memoria-api/src/auth.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ pub async fn group_main_write_guard(
142142
req: axum::http::Request<axum::body::Body>,
143143
next: axum::middleware::Next,
144144
) -> axum::response::Response {
145+
use axum::body::{to_bytes, Body};
145146
use axum::response::IntoResponse;
146147

147148
// Extract bearer token from Authorization header
@@ -176,6 +177,75 @@ pub async fn group_main_write_guard(
176177
if group_main_write_allowed_for_solo_owner(&state, gid, &p.user_id).await {
177178
return next.run(req).await;
178179
}
180+
let path = req.uri().path();
181+
let explicit_query_branch = req
182+
.uri()
183+
.query()
184+
.and_then(|query| {
185+
serde_urlencoded::from_str::<
186+
std::collections::HashMap<String, String>,
187+
>(query)
188+
.ok()
189+
})
190+
.and_then(|query| query.get("branch").cloned())
191+
.map(|branch| {
192+
let branch = branch.trim();
193+
!branch.is_empty() && branch != "main"
194+
})
195+
.unwrap_or(false);
196+
if req.method() == axum::http::Method::DELETE
197+
&& path.starts_with("/v1/memories/")
198+
&& !path.ends_with("/correct")
199+
&& explicit_query_branch
200+
{
201+
return next.run(req).await;
202+
}
203+
let branch_aware_body_route = matches!(
204+
path,
205+
"/v1/memories"
206+
| "/v1/memories/batch"
207+
| "/v1/memories/correct"
208+
| "/v1/memories/purge"
209+
| "/v1/observe"
210+
) || (path.starts_with("/v1/memories/")
211+
&& path.ends_with("/correct"));
212+
if !branch_aware_body_route {
213+
return (
214+
StatusCode::FORBIDDEN,
215+
"main is read-only in group mode; \
216+
create or checkout a branch, then use selective apply"
217+
.to_string(),
218+
)
219+
.into_response();
220+
}
221+
let (parts, body) = req.into_parts();
222+
let bytes = match to_bytes(body, 8 * 1024 * 1024).await {
223+
Ok(bytes) => bytes,
224+
Err(e) => {
225+
return (
226+
StatusCode::PAYLOAD_TOO_LARGE,
227+
format!("request body could not be buffered: {e}"),
228+
)
229+
.into_response();
230+
}
231+
};
232+
let explicit_non_main_branch =
233+
serde_json::from_slice::<serde_json::Value>(&bytes)
234+
.ok()
235+
.and_then(|body| {
236+
body.get("branch")
237+
.and_then(|branch| branch.as_str())
238+
.map(str::to_string)
239+
})
240+
.map(|branch| {
241+
let branch = branch.trim();
242+
!branch.is_empty() && branch != "main"
243+
})
244+
.unwrap_or(false);
245+
if explicit_non_main_branch {
246+
let req = axum::http::Request::from_parts(parts, Body::from(bytes));
247+
return next.run(req).await;
248+
}
179249
return (
180250
StatusCode::FORBIDDEN,
181251
"main is read-only in group mode; \

memoria/crates/memoria-api/src/models.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pub struct StoreRequest {
1717
pub initial_confidence: Option<f64>,
1818
pub observed_at: Option<String>,
1919
pub source: Option<String>,
20+
pub branch: Option<String>,
2021
}
2122
fn default_memory_type() -> String {
2223
"semantic".to_string()
@@ -25,6 +26,7 @@ fn default_memory_type() -> String {
2526
#[derive(Deserialize)]
2627
pub struct BatchStoreRequest {
2728
pub memories: Vec<StoreRequest>,
29+
pub branch: Option<String>,
2830
}
2931

3032
#[derive(Deserialize)]
@@ -34,6 +36,7 @@ pub struct RetrieveRequest {
3436
pub top_k: i64,
3537
pub session_id: Option<String>,
3638
pub session_scope: Option<String>,
39+
pub branch: Option<String>,
3740
/// Explain level: false/"none" = off, true/"basic" = basic, "verbose" = per-candidate scores, "analyze" = full
3841
#[serde(default, deserialize_with = "deserialize_explain")]
3942
pub explain: String,
@@ -79,6 +82,7 @@ pub struct SearchRequest {
7982
pub top_k: i64,
8083
pub session_id: Option<String>,
8184
pub session_scope: Option<String>,
85+
pub branch: Option<String>,
8286
#[serde(default, deserialize_with = "deserialize_explain")]
8387
pub explain: String,
8488
}
@@ -122,6 +126,7 @@ fn deserialize_explain<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String,
122126
pub struct CorrectRequest {
123127
pub new_content: String,
124128
pub reason: Option<String>,
129+
pub branch: Option<String>,
125130
}
126131

127132
#[derive(Deserialize)]
@@ -131,6 +136,7 @@ pub struct CorrectByQueryRequest {
131136
pub session_id: Option<String>,
132137
pub session_scope: Option<String>,
133138
pub reason: Option<String>,
139+
pub branch: Option<String>,
134140
}
135141

136142
impl CorrectByQueryRequest {
@@ -156,6 +162,7 @@ pub struct PurgeRequest {
156162
pub session_id: Option<String>,
157163
pub memory_types: Option<Vec<String>>,
158164
pub reason: Option<String>,
165+
pub branch: Option<String>,
159166
}
160167

161168
pub enum PurgeSelector {

memoria/crates/memoria-api/src/routes/admin.rs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -856,7 +856,10 @@ pub async fn user_branch_stats(
856856
if name == "main" || raw_table_name.is_empty() {
857857
continue;
858858
}
859-
if !raw_table_name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
859+
if !raw_table_name
860+
.chars()
861+
.all(|c| c.is_ascii_alphanumeric() || c == '_')
862+
{
860863
tracing::warn!(
861864
user_id = %user_id,
862865
branch = %name,
@@ -867,11 +870,10 @@ pub async fn user_branch_stats(
867870
continue;
868871
}
869872
let bt = user_store.t(raw_table_name);
870-
let count_result = sqlx::query_scalar::<_, i64>(&format!(
871-
"SELECT COUNT(*) FROM {bt} WHERE is_active > 0"
872-
))
873-
.fetch_one(user_store.pool())
874-
.await;
873+
let count_result =
874+
sqlx::query_scalar::<_, i64>(&format!("SELECT COUNT(*) FROM {bt} WHERE is_active > 0"))
875+
.fetch_one(user_store.pool())
876+
.await;
875877
let count = match count_result {
876878
Ok(n) => n,
877879
Err(e) => {

memoria/crates/memoria-api/src/routes/governance.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,28 @@ use memoria_service::{
55
use serde_json::json;
66
use tracing::warn;
77

8-
use crate::{auth::AuthUser, models::*, routes::memory::api_err, state::AppState};
8+
use crate::{
9+
auth::{group_main_write_allowed_for_solo_owner, AuthUser},
10+
models::*,
11+
routes::memory::api_err,
12+
state::AppState,
13+
};
914

1015
pub async fn governance(
1116
State(state): State<AppState>,
1217
auth: AuthUser,
1318
Json(req): Json<GovernanceRequest>,
1419
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
20+
if let Some(group_id) = auth.group_id.as_deref() {
21+
if !group_main_write_allowed_for_solo_owner(&state, group_id, &auth.user_id).await {
22+
return Err((
23+
StatusCode::FORBIDDEN,
24+
"governance is disabled in group mode because it mutates main memory state"
25+
.to_string(),
26+
));
27+
}
28+
}
29+
1530
let sql = state
1631
.service
1732
.user_sql_store(auth.scope_id())

memoria/crates/memoria-api/src/routes/mcp.rs

Lines changed: 55 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,26 @@ const GROUP_MAIN_WRITE_BLOCKED: &[&str] = &[
9595
"memory_rollback",
9696
];
9797

98+
fn mcp_explicit_non_main_branch(params: Option<&serde_json::Value>) -> bool {
99+
params
100+
.and_then(|params| params.get("arguments"))
101+
.and_then(|args| args.get("branch"))
102+
.and_then(|branch| branch.as_str())
103+
.map(str::trim)
104+
.is_some_and(|branch| !branch.is_empty() && branch != "main")
105+
}
106+
107+
fn mcp_tool_supports_explicit_branch(tool: &str) -> bool {
108+
matches!(
109+
tool,
110+
"memory_store" | "memory_correct" | "memory_purge" | "memory_observe"
111+
)
112+
}
113+
114+
fn mcp_tool_mutates_main_regardless_checkout(tool: &str) -> bool {
115+
matches!(tool, "memory_governance")
116+
}
117+
98118
fn mcp_tool_dirty_mask(tool: &str) -> Option<crate::metrics_summary::DirtyMask> {
99119
use crate::metrics_summary::DirtyMask;
100120
match tool {
@@ -254,38 +274,50 @@ pub async fn mcp_handler(
254274
let blocked_tool: Option<String> = if let Some(gid) = &auth.group_id {
255275
if let Some(tool) = tracked_tool.as_deref() {
256276
if GROUP_MAIN_WRITE_BLOCKED.contains(&tool) {
257-
// ACTOR_USER_ID is already set by the global actor_scope_layer.
258-
let maybe_blocked = state
259-
.service
260-
.user_sql_store(gid)
277+
if mcp_tool_mutates_main_regardless_checkout(tool) {
278+
if crate::auth::group_main_write_allowed_for_solo_owner(
279+
&state,
280+
gid,
281+
&auth.user_id,
282+
)
261283
.await
262-
.ok()
263-
.map(|sql| {
284+
{
285+
None
286+
} else {
287+
Some(tool.to_string())
288+
}
289+
} else if mcp_tool_supports_explicit_branch(tool)
290+
&& mcp_explicit_non_main_branch(params.as_ref())
291+
{
292+
None
293+
} else {
294+
// ACTOR_USER_ID is already set by the global actor_scope_layer.
295+
let maybe_blocked = state.service.user_sql_store(gid).await.ok().map(|sql| {
264296
let gid_owned = gid.clone();
265-
memoria_storage::ACTOR_USER_ID
266-
.scope(auth.user_id.clone(), async move {
267-
sql.active_branch_name(&gid_owned).await
268-
})
297+
memoria_storage::ACTOR_USER_ID.scope(auth.user_id.clone(), async move {
298+
sql.active_branch_name(&gid_owned).await
299+
})
269300
});
270-
if let Some(fut) = maybe_blocked {
271-
if let Ok(branch) = fut.await {
272-
let is_solo_owner =
273-
crate::auth::group_main_write_allowed_for_solo_owner(
274-
&state,
275-
gid,
276-
&auth.user_id,
277-
)
278-
.await;
279-
if branch == "main" && !is_solo_owner {
280-
Some(tool.to_string())
301+
if let Some(fut) = maybe_blocked {
302+
if let Ok(branch) = fut.await {
303+
let is_solo_owner =
304+
crate::auth::group_main_write_allowed_for_solo_owner(
305+
&state,
306+
gid,
307+
&auth.user_id,
308+
)
309+
.await;
310+
if branch == "main" && !is_solo_owner {
311+
Some(tool.to_string())
312+
} else {
313+
None
314+
}
281315
} else {
282316
None
283317
}
284318
} else {
285319
None
286320
}
287-
} else {
288-
None
289321
}
290322
} else {
291323
None

0 commit comments

Comments
 (0)