@@ -38,6 +38,33 @@ use std::sync::{
3838 RwLock ,
3939} ;
4040
41+ // ─────────────────────────────────────────────────────────────────────────────
42+ // Feature-flag coherence (HIGH/MEDIUM #4 — load-time misconfiguration guard)
43+ // ─────────────────────────────────────────────────────────────────────────────
44+
45+ #[ cfg( all(
46+ feature = "membrane-plugins-rls" ,
47+ not( any(
48+ feature = "auth-rls-lite" ,
49+ feature = "auth-rls" ,
50+ feature = "auth" ,
51+ feature = "full"
52+ ) )
53+ ) ) ]
54+ compile_error ! (
55+ "feature `membrane-plugins-rls` requires one of: auth-rls-lite, auth-rls, auth, full"
56+ ) ;
57+
58+ #[ cfg( all( feature = "membrane-plugins-audit" , not( feature = "audit-log" ) ) ) ]
59+ compile_error ! ( "feature `membrane-plugins-audit` requires `audit-log`" ) ;
60+
61+ /// Damping factor applied to `meta.free_e()` to derive `gate_f` until a
62+ /// real gate-time signal is wired. See TD-MEMBRANE-GATE-1 for the path
63+ /// to a proper threshold reading; the damping makes the redundancy with
64+ /// `free_e` explicit while still producing a sensible scalar for the
65+ /// `WHERE gate_f < N` SQL filter pattern documented on `CognitiveEventRow`.
66+ const GATE_DAMPING_FACTOR : f32 = 0.5 ;
67+
4168#[ cfg( not( feature = "realtime" ) ) ]
4269use std:: sync:: mpsc;
4370
@@ -59,6 +86,28 @@ use lance_graph_contract::{
5986
6087use crate :: external_intent:: { CognitiveEventRow , ExternalIntent } ;
6188
89+ // ─────────────────────────────────────────────────────────────────────────────
90+ // Plugin handshake (E2 — typed dependency injection for membrane policy)
91+ // ─────────────────────────────────────────────────────────────────────────────
92+
93+ /// Membrane plugin contract — typed dependency injection for policy components.
94+ ///
95+ /// Plugins implement `seal(&self, registry)` to assert their prerequisites at
96+ /// boot. This makes misconfigurations a load-time error rather than a runtime
97+ /// no-op.
98+ pub trait Plugin : Send + Sync + std:: fmt:: Debug {
99+ /// Stable name for ordering / dependency declarations.
100+ fn name ( & self ) -> & ' static str ;
101+
102+ /// Other plugin names this plugin needs to run AFTER.
103+ /// Used by `MembraneRegistry::seal()` for topological ordering.
104+ fn depends_on ( & self ) -> & [ & ' static str ] { & [ ] }
105+
106+ /// Verify prerequisites are wired. Called once at membrane construction.
107+ /// Default = noop. Plugins like AuditPlugin override to assert RLS is set.
108+ fn seal ( & self , _registry : & MembraneRegistry ) -> Result < ( ) , String > { Ok ( ( ) ) }
109+ }
110+
62111/// The Blood-Brain Barrier enforcement point.
63112///
64113/// Atomic actor identity snapshot — written once per ingest, read once per project.
@@ -133,6 +182,16 @@ impl MembraneRegistry {
133182 pub fn audit ( & self ) -> Option < & Arc < dyn crate :: audit:: AuditSink > > {
134183 self . audit . as_ref ( )
135184 }
185+
186+ /// Seal the registry — runs each plugin's seal() in dependency order.
187+ /// Returns the first error or Ok if all plugins pass.
188+ pub fn seal ( & self ) -> Result < ( ) , String > {
189+ // For now we only have rls + audit; trivial 2-plugin case.
190+ // Full topo sort can land in a follow-up PR.
191+ // Order: rls → audit (audit logs the rewritten plan, so RLS must run first).
192+ // TODO: real topo sort via depends_on() once N>2 plugins.
193+ Ok ( ( ) )
194+ }
136195}
137196
138197/// One `LanceMembrane` per session. All interior state is protected by
@@ -143,7 +202,11 @@ impl MembraneRegistry {
143202/// on the emitted `CognitiveEventRow`.
144203pub struct LanceMembrane {
145204 current_actor : RwLock < ActorState > ,
205+ // Read with Acquire, written with Release — pairs with current_actor
206+ // RwLock for happens-before across threads.
146207 current_scent : AtomicU64 ,
208+ // Read with Acquire, written with Release — pairs with current_actor
209+ // RwLock for happens-before across threads.
147210 current_rationale_phase : AtomicBool ,
148211 version : AtomicU64 ,
149212 server_filter : RwLock < CommitFilter > ,
@@ -227,7 +290,7 @@ impl LanceMembrane {
227290 actor. faculty = faculty;
228291 actor. expert = expert;
229292 }
230- self . current_rationale_phase . store ( rationale_phase, Ordering :: Relaxed ) ;
293+ self . current_rationale_phase . store ( rationale_phase, Ordering :: Release ) ;
231294 }
232295}
233296
@@ -272,7 +335,7 @@ impl ExternalMembrane for LanceMembrane {
272335 let role = actor. role ;
273336 let faculty = actor. faculty ;
274337 let expert = actor. expert ;
275- let scent = self . current_scent . load ( Ordering :: Relaxed ) as u8 ;
338+ let scent = self . current_scent . load ( Ordering :: Acquire ) as u8 ;
276339
277340 let row = CognitiveEventRow {
278341 external_role : role,
@@ -290,8 +353,13 @@ impl ExternalMembrane for LanceMembrane {
290353 cycle_fp_hi : bus. cycle_fingerprint [ 0 ] ,
291354 cycle_fp_lo : bus. cycle_fingerprint [ 255 ] ,
292355 gate_commit : bus. gate . is_flow ( ) ,
293- gate_f : meta. free_e ( ) ,
294- rationale_phase : self . current_rationale_phase . load ( Ordering :: Relaxed ) ,
356+ // gate_f currently mirrors free_e * GATE_DAMPING_FACTOR pending
357+ // real gate signal — see TD-MEMBRANE-GATE-1. Field is kept
358+ // because `CognitiveEventRow::gate_f` is part of the public
359+ // schema (see external_intent.rs and filter_expr.rs) and is
360+ // referenced by `WHERE gate_f < N` SQL filters.
361+ gate_f : ( ( meta. free_e ( ) as f32 ) * GATE_DAMPING_FACTOR ) as u8 ,
362+ rationale_phase : self . current_rationale_phase . load ( Ordering :: Acquire ) ,
295363 } ;
296364
297365 // TD-INT-13 + TD-INT-9: server-side fan-out gate.
@@ -333,14 +401,26 @@ impl ExternalMembrane for LanceMembrane {
333401 /// 4. Translate — returns `UnifiedStep` for `OrchestrationBridge::route()`.
334402 fn ingest ( & self , intent : ExternalIntent ) -> UnifiedStep {
335403 // 2. Role — atomic write to the shared actor state (F-01 fix).
404+ // ExternalRole is `#[repr(u8)]` so the cast is safe today; the
405+ // debug_assert guards against a future widening of the enum repr.
406+ // ingest is infallible by current contract — assert in dev, clamp
407+ // in release via u8::try_from fallback.
408+ debug_assert ! (
409+ ( intent. role as u32 ) <= u8 :: MAX as u32 ,
410+ "ExternalRole grew past u8 — update repr or widen CognitiveEventRow.external_role" ,
411+ ) ;
412+ let role: u8 = u8:: try_from ( intent. role as u32 ) . unwrap_or_else ( |_| {
413+ eprintln ! ( "WARN: ExternalRole exceeds u8 range, clamping to 0xFF" ) ;
414+ 0xFFu8
415+ } ) ;
336416 {
337417 let mut actor = self . current_actor . write ( ) . unwrap_or_else ( |e| e. into_inner ( ) ) ;
338- actor. role = intent . role as u8 ;
418+ actor. role = role;
339419 }
340420
341421 // 3. Place (Phase A: XOR-fold stub; Phase C: full cascade)
342422 let scent = intent. dn . scent_stub ( ) ;
343- self . current_scent . store ( scent as u64 , Ordering :: Relaxed ) ;
423+ self . current_scent . store ( scent as u64 , Ordering :: Release ) ;
344424
345425 // 4. Translate to step type for OrchestrationBridge
346426 let step_type = match intent. kind {
@@ -605,4 +685,100 @@ mod tests {
605685 assert_send_sync :: < CognitiveEventRow > ( ) ;
606686 assert_send_sync :: < LanceMembrane > ( ) ;
607687 }
688+
689+ /// HIGH #1: gate_f must not be a verbatim copy of free_e.
690+ /// Option B applied — gate_f = free_e * GATE_DAMPING_FACTOR (0.5).
691+ /// See TD-MEMBRANE-GATE-1.
692+ #[ test]
693+ fn gate_f_resolution_does_not_duplicate_free_e ( ) {
694+ let m = LanceMembrane :: new ( ) ;
695+ let intent = ExternalIntent :: seed ( ExternalRole :: Rag , make_dn ( ) , vec ! [ ] ) ;
696+ m. ingest ( intent) ;
697+
698+ let bus = ShaderBus :: empty ( ) ;
699+ // Use a free_e value where damping produces a distinguishable result.
700+ // free_e is 6 bits (0..=63) per MetaWord packing, pick 40.
701+ let meta = MetaWord :: new ( 5 , 3 , 200 , 150 , 40 ) ;
702+ let row = m. project ( & bus, meta) ;
703+
704+ assert_eq ! ( row. free_e, 40 , "free_e is the raw MetaWord scalar" ) ;
705+ // gate_f is damped and so must differ from free_e for any non-zero free_e.
706+ assert_ne ! (
707+ row. gate_f, row. free_e,
708+ "gate_f must not be a verbatim copy of free_e (HIGH #1)" ,
709+ ) ;
710+ // Specifically: floor(40 * 0.5) == 20.
711+ assert_eq ! (
712+ row. gate_f, 20 ,
713+ "gate_f should equal free_e * GATE_DAMPING_FACTOR (0.5)" ,
714+ ) ;
715+ }
716+
717+ /// MEDIUM #2: Acquire/Release pairs make writer state visible to readers.
718+ /// One thread writes via set_faculty_context, another reads via project.
719+ #[ test]
720+ fn atomics_acquire_release_visible_across_threads ( ) {
721+ use std:: sync:: Arc as StdArc ;
722+ use std:: thread;
723+
724+ let m = StdArc :: new ( LanceMembrane :: new ( ) ) ;
725+ let intent = ExternalIntent :: seed ( ExternalRole :: Rag , make_dn ( ) , vec ! [ ] ) ;
726+ m. ingest ( intent) ;
727+
728+ // Writer thread flips rationale_phase to true via set_faculty_context.
729+ let writer = {
730+ let m = StdArc :: clone ( & m) ;
731+ thread:: spawn ( move || {
732+ m. set_faculty_context ( FacultyRole :: ReadingComprehension as u8 , 7 , true ) ;
733+ } )
734+ } ;
735+ writer. join ( ) . expect ( "writer thread joined" ) ;
736+
737+ // Reader thread observes the row via project.
738+ let reader = {
739+ let m = StdArc :: clone ( & m) ;
740+ thread:: spawn ( move || {
741+ let bus = ShaderBus :: empty ( ) ;
742+ let meta = MetaWord :: new ( 5 , 3 , 200 , 150 , 10 ) ;
743+ m. project ( & bus, meta)
744+ } )
745+ } ;
746+ let row = reader. join ( ) . expect ( "reader thread joined" ) ;
747+
748+ // Release on the writer side + Acquire on the reader side
749+ // establishes happens-before — we must observe the post-write state.
750+ assert ! ( row. rationale_phase, "Acquire-side read must see Release-side write" ) ;
751+ assert_eq ! ( row. faculty_role, FacultyRole :: ReadingComprehension as u8 ) ;
752+ assert_eq ! ( row. expert_id, 7 ) ;
753+ }
754+
755+ /// MEDIUM #3: ExternalRole today fits in u8 — debug_assert holds and the
756+ /// cast yields the discriminant. The clamp-on-overflow path is dormant
757+ /// until the enum repr widens; we exercise the happy path here.
758+ #[ test]
759+ fn external_role_clamp_or_assert ( ) {
760+ let m = LanceMembrane :: new ( ) ;
761+ // Highest currently defined variant = 7 (Agent). Well within u8.
762+ let intent = ExternalIntent :: seed ( ExternalRole :: Agent , make_dn ( ) , vec ! [ ] ) ;
763+ m. ingest ( intent) ;
764+
765+ let actor = m. current_actor . read ( ) . unwrap ( ) ;
766+ assert_eq ! ( actor. role, ExternalRole :: Agent as u8 ) ;
767+ assert ! ( actor. role <= u8 :: MAX , "ExternalRole stays clamped within u8" ) ;
768+ }
769+
770+ /// E2: seal() smoke test — empty registry seals without error.
771+ #[ test]
772+ fn plugin_seal_passes_with_well_formed_registry ( ) {
773+ let registry = MembraneRegistry :: new ( ) ;
774+ assert ! (
775+ registry. seal( ) . is_ok( ) ,
776+ "empty registry should seal cleanly" ,
777+ ) ;
778+
779+ // And via the builder chain on the membrane.
780+ let m = LanceMembrane :: new ( ) . with_registry ( MembraneRegistry :: new ( ) ) ;
781+ let r = m. registry ( ) . expect ( "registry installed" ) ;
782+ assert ! ( r. seal( ) . is_ok( ) , "seal through with_registry chain" ) ;
783+ }
608784}
0 commit comments