Skip to content

Commit 1d0dc37

Browse files
committed
fix(PN-88): shed quarantine entries created during an isolation window (SEC-004)
1 parent 209876c commit 1d0dc37

4 files changed

Lines changed: 120 additions & 18 deletions

File tree

src/context/compact.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -927,6 +927,7 @@ mod tests {
927927
health: crate::session_store::HealthCounters::default(),
928928
compaction: crate::session_store::CompactionMetrics::default(),
929929
isolation_ephemeral_from: None,
930+
isolation_quarantine_from: None,
930931
quarantine: Vec::new(),
931932
}
932933
}

src/server/handlers/chat.rs

Lines changed: 110 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -224,21 +224,35 @@ pub async fn chat(
224224
if session.data.isolation_ephemeral_from.is_none() {
225225
session.data.isolation_ephemeral_from = Some(session.data.messages.len());
226226
}
227-
} else if let Some(watermark) = session.data.isolation_ephemeral_from.take() {
228-
let dropped = session.data.messages.len().saturating_sub(watermark);
229-
session.data.messages.truncate(watermark);
230-
session.data.compaction.estimated_tokens =
231-
crate::context::estimate_conversation_tokens(&session.data.messages);
232-
tracing::info!(
233-
"[chat] dropped {dropped} ephemeral isolation message(s) on return to normal"
234-
);
227+
// SEC-004: track the quarantine lane the same way, so a fallback tangent
228+
// created during this isolation window is shed on return to normal.
229+
if session.data.isolation_quarantine_from.is_none() {
230+
session.data.isolation_quarantine_from = Some(session.data.quarantine.len());
231+
}
232+
} else {
233+
if let Some(watermark) = session.data.isolation_ephemeral_from.take() {
234+
let dropped = session.data.messages.len().saturating_sub(watermark);
235+
session.data.messages.truncate(watermark);
236+
session.data.compaction.estimated_tokens =
237+
crate::context::estimate_conversation_tokens(&session.data.messages);
238+
tracing::info!(
239+
"[chat] dropped {dropped} ephemeral isolation message(s) on return to normal"
240+
);
241+
}
242+
let dropped_q = shed_isolation_quarantine(&mut session.data);
243+
if dropped_q > 0 {
244+
tracing::info!(
245+
"[chat] dropped {dropped_q} ephemeral isolation quarantine entry(ies) on return to normal"
246+
);
247+
}
235248
}
236249

237250
// === Session limit check ===
238251
// Check if the session has exceeded its channel-specific limits (message cap,
239252
// time cap, or hallucination threshold). If so, archive the current conversation
240253
// with a structured handoff and start fresh before processing this message.
241254
let pre_turn_len = session.data.messages.len();
255+
let pre_turn_quarantine_len = session.data.quarantine.len();
242256
let limits = state.config.sessions.get_identity_limits(&resolved_key);
243257
// Session resets archive to disk — shed in isolation (the in-memory
244258
// session simply keeps growing for the duration of the diagnosis).
@@ -504,8 +518,25 @@ pub async fn chat(
504518
// Re-sample: a turn admitted just before the marker appeared must not
505519
// write after it. OR of the two samples drives every gate below.
506520
let isolated = isolated || crate::server::isolation::is_active(&state.root_dir);
507-
if isolated && session.data.isolation_ephemeral_from.is_none() {
508-
session.data.isolation_ephemeral_from = Some(pre_turn_len);
521+
if isolated {
522+
if session.data.isolation_ephemeral_from.is_none() {
523+
session.data.isolation_ephemeral_from = Some(pre_turn_len);
524+
}
525+
// SEC-004: a turn that flipped to isolated mid-flight must not commit its
526+
// fallback tangent to the quarantine lane. Anchor the watermark at this
527+
// turn's pre-turn quarantine length (if not already anchored earlier in
528+
// the isolation window) and drop anything the fallback just pushed —
529+
// don't wait for the next normal turn to shed it.
530+
if session.data.isolation_quarantine_from.is_none() {
531+
session.data.isolation_quarantine_from = Some(pre_turn_quarantine_len);
532+
}
533+
if committed_to_quarantine {
534+
let from = session
535+
.data
536+
.isolation_quarantine_from
537+
.unwrap_or(pre_turn_quarantine_len);
538+
session.data.quarantine.truncate(from);
539+
}
509540
}
510541

511542
let text = result.text.clone();
@@ -832,6 +863,21 @@ fn gated_fallback<F>(resolved_key: &str, isolated: bool, under_cap: bool, build:
832863
(!isolated && resolved_key == "owner" && under_cap).then_some(build)
833864
}
834865

866+
/// Shed the quarantine entries added during an isolation window, truncating the
867+
/// lane back to its entry-time watermark and clearing the watermark (SEC-004).
868+
/// Mirrors the message-lane ephemeral shed. Returns the number of entries
869+
/// dropped (0 when no isolation window was open).
870+
fn shed_isolation_quarantine(data: &mut crate::session_store::SessionData) -> usize {
871+
match data.isolation_quarantine_from.take() {
872+
Some(watermark) => {
873+
let dropped = data.quarantine.len().saturating_sub(watermark);
874+
data.quarantine.truncate(watermark);
875+
dropped
876+
}
877+
None => 0,
878+
}
879+
}
880+
835881
/// Run one interactive turn with reactive refusal fallback (PN-88).
836882
///
837883
/// The default (fable) model runs first, appending to the trunk (`data.messages`).
@@ -865,11 +911,7 @@ where
865911
// integer underflow panic / out-of-bounds index (SEC-005).
866912
let user_index = match data.messages.len().checked_sub(1) {
867913
Some(index) => index,
868-
None => {
869-
return Err(
870-
"invoke_turn_with_refusal_fallback called with an empty trunk".into(),
871-
)
872-
}
914+
None => return Err("invoke_turn_with_refusal_fallback called with an empty trunk".into()),
873915
};
874916

875917
let default_outcome = crate::task_context::scope(
@@ -1526,6 +1568,38 @@ mod tests {
15261568
assert!(data.quarantine.is_empty());
15271569
}
15281570

1571+
/// SEC-004: the quarantine-lane shed drops exactly the entries added during
1572+
/// an isolation window (back to the entry-time watermark) and clears the
1573+
/// watermark, leaving anything present before isolation intact.
1574+
#[test]
1575+
fn shed_isolation_quarantine_drops_only_in_window_entries() {
1576+
let mut data = session_with(&[], "current turn");
1577+
// A quarantine tangent that predates the isolation window.
1578+
data.quarantine.push(user("pre-isolation q"));
1579+
// Isolation begins here.
1580+
data.isolation_quarantine_from = Some(data.quarantine.len());
1581+
// Two entries pushed while isolated (e.g. a mid-flight fallback commit).
1582+
data.quarantine.push(user("in-isolation spicy"));
1583+
data.quarantine.push(user("in-isolation opus reply"));
1584+
1585+
let dropped = shed_isolation_quarantine(&mut data);
1586+
1587+
assert_eq!(dropped, 2);
1588+
assert_eq!(data.quarantine.len(), 1);
1589+
assert!(
1590+
data.isolation_quarantine_from.is_none(),
1591+
"watermark not cleared after shedding"
1592+
);
1593+
match &data.quarantine[0].content {
1594+
MessageContent::Text(t) => assert_eq!(t, "pre-isolation q"),
1595+
_ => panic!("unexpected content"),
1596+
}
1597+
1598+
// A second shed with no open window is a no-op.
1599+
assert_eq!(shed_isolation_quarantine(&mut data), 0);
1600+
assert_eq!(data.quarantine.len(), 1);
1601+
}
1602+
15291603
/// A convenience builder for gate tests — an owner+under_cap combination
15301604
/// should arm, everything else should not.
15311605
fn dummy_build() -> Result<Box<dyn LmProvider>, ProviderError> {
@@ -1568,7 +1642,13 @@ mod tests {
15681642
let gated = gated_fallback("owner", false, false, build);
15691643

15701644
let err = invoke_turn_with_refusal_fallback(
1571-
&mut data, &RefusingMock, gated, &tools, "sys", 1024, "corr",
1645+
&mut data,
1646+
&RefusingMock,
1647+
gated,
1648+
&tools,
1649+
"sys",
1650+
1024,
1651+
"corr",
15721652
)
15731653
.await
15741654
.unwrap_err();
@@ -1604,7 +1684,13 @@ mod tests {
16041684
let gated = gated_fallback("guest:stranger", false, true, build);
16051685

16061686
let err = invoke_turn_with_refusal_fallback(
1607-
&mut data, &RefusingMock, gated, &tools, "sys", 1024, "corr",
1687+
&mut data,
1688+
&RefusingMock,
1689+
gated,
1690+
&tools,
1691+
"sys",
1692+
1024,
1693+
"corr",
16081694
)
16091695
.await
16101696
.unwrap_err();
@@ -1637,7 +1723,13 @@ mod tests {
16371723
let gated = gated_fallback("peer:Nova", false, true, build);
16381724

16391725
let err = invoke_turn_with_refusal_fallback(
1640-
&mut data, &RefusingMock, gated, &tools, "sys", 1024, "corr",
1726+
&mut data,
1727+
&RefusingMock,
1728+
gated,
1729+
&tools,
1730+
"sys",
1731+
1024,
1732+
"corr",
16411733
)
16421734
.await
16431735
.unwrap_err();

src/session_health.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ mod tests {
239239
health: HealthCounters::default(),
240240
compaction: CompactionMetrics::default(),
241241
isolation_ephemeral_from: None,
242+
isolation_quarantine_from: None,
242243
quarantine: Vec::new(),
243244
}
244245
}

src/session_store.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,13 @@ pub struct SessionData {
158158
/// the first normal turn truncates back to this watermark.
159159
#[serde(skip)]
160160
pub isolation_ephemeral_from: Option<usize>,
161+
/// Index into `quarantine` of the first entry added while isolated (PN-88,
162+
/// SEC-004). Parallels `isolation_ephemeral_from` for the quarantine lane:
163+
/// never serialized, and the first normal turn truncates the lane back to
164+
/// this watermark so a fallback tangent created during an isolation window
165+
/// never survives to disk.
166+
#[serde(skip)]
167+
pub isolation_quarantine_from: Option<usize>,
161168
/// Quarantine lane (PN-88): refused turns re-run on the fallback model.
162169
/// This is a genuine record of what was said, but it is excluded from the
163170
/// default model's context so its safety classifier does not re-trip on
@@ -511,6 +518,7 @@ impl Session {
511518
health: HealthCounters::default(),
512519
compaction: CompactionMetrics::default(),
513520
isolation_ephemeral_from: None,
521+
isolation_quarantine_from: None,
514522
quarantine: Vec::new(),
515523
},
516524
dirty: false,

0 commit comments

Comments
 (0)