@@ -52,38 +52,41 @@ use crate::external_intent::{CognitiveEventRow, ExternalIntent};
5252
5353/// The Blood-Brain Barrier enforcement point.
5454///
55+ /// Atomic actor identity snapshot — written once per ingest, read once per project.
56+ /// Single lock eliminates the F-01 identity-tear race where concurrent ingest+project
57+ /// could see role from event N, faculty from N+1, expert from N+2.
58+ #[ derive( Clone , Copy , Debug ) ]
59+ struct ActorState {
60+ role : u8 ,
61+ faculty : u8 ,
62+ expert : ExpertId ,
63+ }
64+
5565/// One `LanceMembrane` per session. All interior state is protected by
5666/// `RwLock` or atomics so it is `Send + Sync`.
5767///
58- /// `current_role`, `current_faculty`, `current_expert` capture the last
59- /// `ingest()` context so that the next `project()` call stamps the correct
60- /// identity columns on the emitted `CognitiveEventRow`.
68+ /// `current_actor` captures the last `ingest()` context atomically so
69+ /// that the next `project()` call stamps a consistent identity triple
70+ /// on the emitted `CognitiveEventRow`.
6171pub struct LanceMembrane {
62- current_role : RwLock < u8 > , // ExternalRole discriminant
63- current_faculty : RwLock < u8 > , // FacultyRole discriminant
64- current_expert : RwLock < ExpertId > ,
72+ current_actor : RwLock < ActorState > ,
6573 current_scent : AtomicU64 ,
66- current_rationale_phase : AtomicBool , // MM-CoT stage: true = Stage 1 rationale
74+ current_rationale_phase : AtomicBool ,
6775 version : AtomicU64 ,
68- /// Server-side fan-out filter (TD-INT-13). Default-empty = pass all.
69- /// Applied INSIDE `project()` before `watcher.bump`, so subscribers
70- /// only see rows matching the filter.
7176 server_filter : RwLock < CommitFilter > ,
72- /// Optional fan-out gate (TD-INT-9). RBAC, multi-tenant scope, etc.
73- /// Default `None` = no gating. When set, `should_emit` is consulted
74- /// before `watcher.bump`.
7577 gate : RwLock < Option < Arc < dyn MembraneGate > > > ,
76- /// Fan-out watcher for projected cognitive events ([realtime] feature only).
7778 #[ cfg( feature = "realtime" ) ]
7879 watcher : LanceVersionWatcher ,
7980}
8081
8182impl LanceMembrane {
8283 pub fn new ( ) -> Self {
8384 Self {
84- current_role : RwLock :: new ( 0 ) ,
85- current_faculty : RwLock :: new ( FacultyRole :: ReadingComprehension as u8 ) ,
86- current_expert : RwLock :: new ( 0 ) ,
85+ current_actor : RwLock :: new ( ActorState {
86+ role : 0 ,
87+ faculty : FacultyRole :: ReadingComprehension as u8 ,
88+ expert : 0 ,
89+ } ) ,
8790 current_scent : AtomicU64 :: new ( 0 ) ,
8891 current_rationale_phase : AtomicBool :: new ( false ) ,
8992 version : AtomicU64 :: new ( 0 ) ,
@@ -127,8 +130,11 @@ impl LanceMembrane {
127130 /// dispatcher derives it from `FacultyDescriptor::is_asymmetric()`
128131 /// plus the current dispatch stage.
129132 pub fn set_faculty_context ( & self , faculty : u8 , expert : ExpertId , rationale_phase : bool ) {
130- * self . current_faculty . write ( ) . expect ( "faculty poisoned" ) = faculty;
131- * self . current_expert . write ( ) . expect ( "expert poisoned" ) = expert;
133+ {
134+ let mut actor = self . current_actor . write ( ) . unwrap_or_else ( |e| e. into_inner ( ) ) ;
135+ actor. faculty = faculty;
136+ actor. expert = expert;
137+ }
132138 self . current_rationale_phase . store ( rationale_phase, Ordering :: Relaxed ) ;
133139 }
134140}
@@ -168,9 +174,12 @@ impl ExternalMembrane for LanceMembrane {
168174 /// Strips all VSA state. Emits only Arrow-scalar-compatible fields.
169175 /// Called on every `CollapseGate` fire with `EmitMode::Persist`.
170176 fn project ( & self , bus : & ShaderBus , meta : MetaWord ) -> CognitiveEventRow {
171- let role = * self . current_role . read ( ) . expect ( "role poisoned" ) ;
172- let faculty = * self . current_faculty . read ( ) . expect ( "faculty poisoned" ) ;
173- let expert = * self . current_expert . read ( ) . expect ( "expert poisoned" ) ;
177+ // Single-lock snapshot: role/faculty/expert are always consistent.
178+ // Poison recovery via into_inner() (F-09: no permanent panic source).
179+ let actor = * self . current_actor . read ( ) . unwrap_or_else ( |e| e. into_inner ( ) ) ;
180+ let role = actor. role ;
181+ let faculty = actor. faculty ;
182+ let expert = actor. expert ;
174183 let scent = self . current_scent . load ( Ordering :: Relaxed ) as u8 ;
175184
176185 let row = CognitiveEventRow {
@@ -227,12 +236,15 @@ impl ExternalMembrane for LanceMembrane {
227236 ///
228237 /// Four-step BBB crossing:
229238 /// 1. Pass membrane — `ExternalIntent` is the safe crossing type.
230- /// 2. Get a role — `intent.role` is stamped into `current_role `.
239+ /// 2. Get a role — `intent.role` is stamped into `current_actor.role `.
231240 /// 3. Get a place — `intent.dn.scent_stub()` becomes `current_scent`.
232241 /// 4. Translate — returns `UnifiedStep` for `OrchestrationBridge::route()`.
233242 fn ingest ( & self , intent : ExternalIntent ) -> UnifiedStep {
234- // 2. Role
235- * self . current_role . write ( ) . expect ( "role poisoned" ) = intent. role as u8 ;
243+ // 2. Role — atomic write to the shared actor state (F-01 fix).
244+ {
245+ let mut actor = self . current_actor . write ( ) . unwrap_or_else ( |e| e. into_inner ( ) ) ;
246+ actor. role = intent. role as u8 ;
247+ }
236248
237249 // 3. Place (Phase A: XOR-fold stub; Phase C: full cascade)
238250 let scent = intent. dn . scent_stub ( ) ;
@@ -303,8 +315,8 @@ mod tests {
303315 assert_eq ! ( step. status, StepStatus :: Pending ) ;
304316 // scent was set
305317 assert_ne ! ( m. current_scent. load( Ordering :: Relaxed ) , 0 ) ;
306- // role was stamped
307- assert_eq ! ( * m . current_role . read( ) . unwrap( ) , ExternalRole :: CrewaiAgent as u8 ) ;
318+ // role was stamped (read from atomic ActorState — F-01 fix)
319+ assert_eq ! ( m . current_actor . read( ) . unwrap( ) . role , ExternalRole :: CrewaiAgent as u8 ) ;
308320 }
309321
310322 #[ test]
0 commit comments