@@ -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 ( ) ;
0 commit comments