Skip to content

Commit bc45e2d

Browse files
committed
🩹 fix(policy): cascade pin/unpin re-materialise to descendants
1 parent 655c27f commit bc45e2d

3 files changed

Lines changed: 176 additions & 3 deletions

File tree

β€Žcrates/oversight-server/src/api.rsβ€Ž

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -781,8 +781,10 @@ async fn pin_task_role(
781781
)?;
782782
drop(store);
783783

784-
// Re-materialise so the assignment row reflects the pin.
785-
materialise_task_roles(&state, &task).await;
784+
// Re-materialise so the assignment row reflects the pin AND cascade
785+
// to descendants β€” children that resolve via `parent_role` would
786+
// otherwise keep a stale accountable until someone touched them.
787+
materialise_task_and_descendants(&state, &task).await;
786788

787789
let store = state.store.lock().await;
788790
let resolved = store
@@ -813,7 +815,9 @@ async fn unpin_task_role(
813815
&serde_json::json!({}),
814816
)?;
815817
drop(store);
816-
materialise_task_roles(&state, &task).await;
818+
// Unpin restores the rule chain for this task and (transitively) for
819+
// any descendants that inherited from it via `parent_role`.
820+
materialise_task_and_descendants(&state, &task).await;
817821
Ok(StatusCode::NO_CONTENT)
818822
}
819823

@@ -2276,6 +2280,59 @@ async fn materialise_task_roles(state: &AppState, task: &oversight_models::Task)
22762280
}
22772281
}
22782282

2283+
/// Re-materialise the task and every descendant. Per ADR-004
2284+
/// Β§"Sub-task accountability inheritance", changes to a parent's
2285+
/// accountable (typically via pin / unpin) must cascade to children
2286+
/// that inherit via `parent_role`. The lookup happens once per
2287+
/// descendant under a single store lock to keep the scan cheap.
2288+
///
2289+
/// The traversal is breadth-first with a hard depth ceiling to defend
2290+
/// against pathological data (e.g. an accidentally cyclic parent_id
2291+
/// chain β€” schema doesn't forbid it).
2292+
async fn materialise_task_and_descendants(state: &AppState, root: &oversight_models::Task) {
2293+
materialise_task_roles(state, root).await;
2294+
2295+
const MAX_VISITED: usize = 4096;
2296+
let to_remateriaise: Vec<oversight_models::Task> = {
2297+
let store = state.store.lock().await;
2298+
let mut queue: std::collections::VecDeque<String> =
2299+
std::collections::VecDeque::from([root.id.clone()]);
2300+
let mut seen: std::collections::HashSet<String> =
2301+
std::collections::HashSet::from([root.id.clone()]);
2302+
let mut out: Vec<oversight_models::Task> = Vec::new();
2303+
while let Some(parent_id) = queue.pop_front() {
2304+
if seen.len() >= MAX_VISITED {
2305+
tracing::warn!(
2306+
root = %root.id,
2307+
visited = seen.len(),
2308+
"cascade depth ceiling hit; truncating descendant walk"
2309+
);
2310+
break;
2311+
}
2312+
let children = match store.list_child_task_ids(&parent_id) {
2313+
Ok(c) => c,
2314+
Err(e) => {
2315+
tracing::warn!(parent = %parent_id, error = ?e, "child lookup failed");
2316+
continue;
2317+
}
2318+
};
2319+
for child_id in children {
2320+
if seen.insert(child_id.clone()) {
2321+
queue.push_back(child_id.clone());
2322+
if let Ok(t) = store.get_task(&child_id) {
2323+
out.push(t);
2324+
}
2325+
}
2326+
}
2327+
}
2328+
out
2329+
};
2330+
2331+
for child in to_remateriaise {
2332+
materialise_task_roles(state, &child).await;
2333+
}
2334+
}
2335+
22792336
#[tracing::instrument(
22802337
level = "info",
22812338
name = "create_task",

β€Žcrates/oversight-server/tests/policy_deep.rsβ€Ž

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,110 @@ async fn child_task_inherits_parent_accountable_via_resolver_rule() {
185185
}
186186
}
187187

188+
#[tokio::test(flavor = "multi_thread")]
189+
async fn pin_handler_cascades_to_descendants_without_manual_remateriaise() {
190+
// Drives the *real* HTTP pin handler, which now runs
191+
// `materialise_task_and_descendants` after writing the parent pin.
192+
// No manual child re-materialise β€” if the cascade is wired right,
193+
// the child's accountable updates automatically.
194+
let state = build_sqlite_app_state().await;
195+
let user = "user-deep-cascade-pin";
196+
let (project, wf, parent) = seed_world(&state, user, "cascade-pin");
197+
198+
// Add a second user we can pin to.
199+
let pin_target_id = format!("uuid-{}", uuid::Uuid::new_v4());
200+
{
201+
let store = state.store.try_lock().expect("store");
202+
store
203+
.insert_user(&make_user(&pin_target_id, "Pin Target"))
204+
.unwrap();
205+
store
206+
.add_project_member(&project.id, "user", &pin_target_id, Some("member"))
207+
.unwrap();
208+
}
209+
210+
// Spawn a child + grandchild β€” both inherit accountable via parent_role.
211+
let mut child = Task::new("child", "bug", &wf.id, "backlog");
212+
child.id = format!("task-cas-c-{}", uuid::Uuid::new_v4());
213+
child.project_id = Some(project.id.clone());
214+
child.parent_id = Some(parent.id.clone());
215+
child.assignees = vec![oversight_models::TaskAssignee::executor("agent:claude")];
216+
217+
let mut grandchild = Task::new("grandchild", "bug", &wf.id, "backlog");
218+
grandchild.id = format!("task-cas-gc-{}", uuid::Uuid::new_v4());
219+
grandchild.project_id = Some(project.id.clone());
220+
grandchild.parent_id = Some(child.id.clone());
221+
grandchild.assignees = vec![oversight_models::TaskAssignee::executor("agent:claude")];
222+
223+
{
224+
let store = state.store.try_lock().expect("store");
225+
store.insert_task(&child).unwrap();
226+
materialise(&store, &child);
227+
store.insert_task(&grandchild).unwrap();
228+
materialise(&store, &grandchild);
229+
230+
// Sanity: both descendants currently inherit `user`.
231+
let c_acc = store
232+
.get_role_assignment(&child.id, RoleName::Accountable)
233+
.unwrap()
234+
.unwrap();
235+
assert_eq!(c_acc.actor_id, user, "child starts inheriting from parent");
236+
let gc_acc = store
237+
.get_role_assignment(&grandchild.id, RoleName::Accountable)
238+
.unwrap()
239+
.unwrap();
240+
assert_eq!(
241+
gc_acc.actor_id, user,
242+
"grandchild starts inheriting transitively"
243+
);
244+
}
245+
246+
// Drive the real handler.
247+
grant_admin(&state, user);
248+
reload_authorizer(&state);
249+
let app = build_api_router(state.clone());
250+
let resp = app
251+
.oneshot(
252+
Request::builder()
253+
.method("POST")
254+
.uri(format!("/api/tasks/{}/roles/accountable/pin", parent.id))
255+
.header("content-type", "application/json")
256+
.header("x-actor", format!("user:{user}"))
257+
.body(Body::from(json!({"actor_id": pin_target_id}).to_string()))
258+
.unwrap(),
259+
)
260+
.await
261+
.expect("pin");
262+
assert_eq!(resp.status(), StatusCode::OK);
263+
264+
// No manual materialise calls. Both descendants must already see the
265+
// new pin target.
266+
let store = state.store.try_lock().expect("store");
267+
let parent_acc = store
268+
.get_role_assignment(&parent.id, RoleName::Accountable)
269+
.unwrap()
270+
.unwrap();
271+
assert_eq!(parent_acc.actor_id, pin_target_id);
272+
273+
let child_acc = store
274+
.get_role_assignment(&child.id, RoleName::Accountable)
275+
.unwrap()
276+
.unwrap();
277+
assert_eq!(
278+
child_acc.actor_id, pin_target_id,
279+
"cascade must update direct child without manual re-materialise"
280+
);
281+
282+
let grand_acc = store
283+
.get_role_assignment(&grandchild.id, RoleName::Accountable)
284+
.unwrap()
285+
.unwrap();
286+
assert_eq!(
287+
grand_acc.actor_id, pin_target_id,
288+
"cascade must traverse the full subtree (parent β†’ child β†’ grandchild)"
289+
);
290+
}
291+
188292
#[tokio::test(flavor = "multi_thread")]
189293
async fn parent_pin_propagates_to_child_on_remateriaise() {
190294
let state = build_sqlite_app_state().await;

β€Žcrates/oversight-store/src/repo_task.rsβ€Ž

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,18 @@ impl Store {
448448
Ok(count > 0)
449449
}
450450

451+
/// Direct children of `parent_id` (one level only). Used by the policy
452+
/// engine to cascade re-materialisation when a parent's accountable
453+
/// changes β€” see ADR-004 Β§"Sub-task accountability inheritance".
454+
pub fn list_child_task_ids(&self, parent_id: &str) -> Result<Vec<String>, StoreError> {
455+
let conn = self.connection()?;
456+
let mut stmt = conn.prepare("SELECT id FROM tasks WHERE parent_id = ?1")?;
457+
let ids = stmt
458+
.query_map(params![parent_id], |row| row.get::<_, String>(0))?
459+
.collect::<Result<Vec<_>, _>>()?;
460+
Ok(ids)
461+
}
462+
451463
// ── Proposals ──────────────────────────────────────
452464

453465
/// Reject all proposed tasks in `project_id` whose `proposal_created_at` is before

0 commit comments

Comments
Β (0)