diff --git a/.claude/plans/d-lanesock-dr-dt64-lane-socket-v1.md b/.claude/plans/d-lanesock-dr-dt64-lane-socket-v1.md index e4c301d19..5941f0686 100644 --- a/.claude/plans/d-lanesock-dr-dt64-lane-socket-v1.md +++ b/.claude/plans/d-lanesock-dr-dt64-lane-socket-v1.md @@ -156,3 +156,18 @@ Read in code at lance-graph `fb27c46`, OGAR main and report-rs `83093de`. Nothin ### Still closed Value ingress and the durable workbook writer; D4/S-INGRESS and layout admission; text/codebook authority; exact decimal; production CRM_COMPANY XVERWEIS; cross-coordinate Gather, ADMISSION B, NotASide, OP-14; the `RegistryClassView` DOLCE `entity_type_id` defect (tracked separately). + +## Source issuer: the store is bound to the source (2026-11-07) + +`LaneSource` now has a required method, `frames()`. It returns the `FrameStore` that lives with the data the source borrows from. `BindStore::bind_lanes` and `LaneBinding::check`/`lane` refuse `Frame(ForeignFrame)` unless the caller's `frames` argument is that same store, compared by identity. The refusal happens before any `lane_of` or `borrow`. + +Until now the caller chose the pairing. A binding made over one source could be checked and read against a different source whose lane numbers matched, and the substitute's values were returned. That measurement is in the prior audit; `a_substituted_source_is_refused_not_read` in `crates/lance-graph-dt64/tests/lane_socket.rs` now pins the refusal. + +**Owner rule.** An implementation takes `frames()` from the same owner its lanes come from. A constructor never accepts an outside store. In-repo impls: +- dt64 test `Source` and dir-sim `Directory` own their store. +- soa64 `U64Source` returns `SoaU64::frames()`, so swapping the SoA swaps the issuer with it. +- report `AbiLanes` builds its store from the batch. + +**Residual (OPEN).** The trait cannot prove that an implementer's store issued that implementer's bytes, so implementer trust remains. Closing it needs byte authentication or a sealed trait. No external implementer is known. + +Validation stays per call. There is no check-once or lease; see the optional safe-lease PR. diff --git a/crates/lance-graph-dir-sim/tests/ordinal_chain_lab.rs b/crates/lance-graph-dir-sim/tests/ordinal_chain_lab.rs index f7bfb7db8..d00c5e159 100644 --- a/crates/lance-graph-dir-sim/tests/ordinal_chain_lab.rs +++ b/crates/lance-graph-dir-sim/tests/ordinal_chain_lab.rs @@ -1076,13 +1076,15 @@ mod lanesock { } /// The source owner: AD lanes in AD row order, an Entra lane in Entra row - /// order. It hands out its own slices. + /// order. It hands out its own slices, and owns the frame store its data + /// lives with (`LaneSource::frames`): a reorder advances that store. pub struct Directory { pub ad_space: CoordSpace, pub entra_space: CoordSpace, pub department: Vec, pub cost_center: Vec, pub licenses: Vec, + pub frames: FrameStore, } impl Directory { @@ -1099,6 +1101,7 @@ mod lanesock { department: (0..l.ad.len()).map(|i| (i % 7) as i32).collect(), cost_center: (0..l.ad.len()).map(|i| ((i * 37) % 300) as i32).collect(), licenses: (0..l.entra.len()).map(|j| (j % 50) as i32).collect(), + frames: FrameStore::new(revisions(1, 1, 1)), } } } @@ -1136,6 +1139,9 @@ mod lanesock { _ => return None, })) } + fn frames(&self) -> &FrameStore { + &self.frames + } } pub fn method(id: u32, reads: u8) -> MethodBinding { @@ -1156,8 +1162,6 @@ mod lanesock { pub struct Bound { pub store: BindStore, pub view: View, - pub frames: FrameStore, - pub frame: Frame, pub route: RouteAt, } @@ -1177,13 +1181,9 @@ mod lanesock { ) .unwrap(); let version = store.route(POSITION, HYBRID_USER).unwrap(); - let frames = FrameStore::new(revisions(1, 1, 1)); - let frame = frames.seal(); Bound { store, view, - frames, - frame, route: RouteAt { position: POSITION, version, @@ -1198,15 +1198,16 @@ mod lanesock { selection: CoordSpace, ) -> Result { let mask = WideFieldMask::from_positions(fields); + let frame = dir.frames.seal(); b.store.bind_lanes( &b.view, - &b.frames, + &dir.frames, dir, LaneRequest { route: b.route, rev: SchemaRev(1), fields: &mask, - frame: &b.frame, + frame: &frame, scope: READER, selection, }, @@ -1235,7 +1236,7 @@ fn bound_property_lanes_fold_under_the_ruler_selection() { assert!(admitted.reads.has(DEPARTMENT) && binding.fields().has(DEPARTMENT)); let sum = lowered(confirmed_hybrid(), Agg::SumI32(Col(0))); let dept = binding - .lane(&b.store, &b.view, &b.frames, &dir, DEPARTMENT) + .lane(&b.store, &b.view, &dir.frames, &dir, DEPARTMENT) .unwrap(); let masks = plane_refs(&f.planes); let lanes = [dept]; @@ -1255,7 +1256,7 @@ fn bound_property_lanes_fold_under_the_ruler_selection() { Agg::Count, ); let cost = binding - .lane(&b.store, &b.view, &b.frames, &dir, COST_CENTER) + .lane(&b.store, &b.view, &dir.frames, &dir, COST_CENTER) .unwrap(); let lanes = [cost]; let inputs = QueryPlanes { @@ -1313,17 +1314,16 @@ fn an_entra_lane_is_refused_under_an_ad_selection() { fn a_reordered_source_refuses_the_old_binding() { use lanesock::*; let l = faulted(130, 6); - let mut b = bound(); - let dir = Directory::over(&l, 1); + let b = bound(); + let mut dir = Directory::over(&l, 1); let binding = bind(&b, &dir, &[DEPARTMENT], dir.ad_space).unwrap(); - b.frames.advance(revisions(1, 1, 2)); + dir.frames.advance(revisions(1, 1, 2)); assert_eq!( - binding.check(&b.store, &b.view, &b.frames, &dir), + binding.check(&b.store, &b.view, &dir.frames, &dir), Err(lance_graph_dt64::LaneRefusal::Frame( FrameRefusal::CoordinatesChanged )) ); - b.frame = b.frames.seal(); let dir2 = Directory::over(&l, 2); assert!(bind(&b, &dir2, &[DEPARTMENT], dir2.ad_space).is_ok()); // The old directory's row-order revision no longer matches the selection. @@ -1355,7 +1355,7 @@ fn bound_lane_fold_at_64k_allocates_nothing() { let mut got = Value::SumI64(0); for _ in 0..PASSES { let dept = binding - .lane(&b.store, &b.view, &b.frames, &dir, DEPARTMENT) + .lane(&b.store, &b.view, &dir.frames, &dir, DEPARTMENT) .unwrap(); let lanes = [dept]; let inputs = QueryPlanes { @@ -1369,7 +1369,7 @@ fn bound_lane_fold_at_64k_allocates_nothing() { let t = Instant::now(); for _ in 0..PASSES { let _ = binding - .lane(&b.store, &b.view, &b.frames, &dir, DEPARTMENT) + .lane(&b.store, &b.view, &dir.frames, &dir, DEPARTMENT) .unwrap(); } let socket = t.elapsed(); diff --git a/crates/lance-graph-dt64/src/dr.rs b/crates/lance-graph-dt64/src/dr.rs index efffaf6ad..c085ba89f 100644 --- a/crates/lance-graph-dt64/src/dr.rs +++ b/crates/lance-graph-dt64/src/dr.rs @@ -439,6 +439,13 @@ impl FrameStore { *self.current } + /// Whether `other` is this same store (identity, never value): the + /// lane-socket gate (D-LANESOCK) uses it to refuse a caller-chosen store + /// that is not the one a source's data lives with. + pub(crate) fn same_store(&self, other: &FrameStore) -> bool { + Arc::ptr_eq(&self.identity, &other.identity) + } + /// `Ok` iff `frame` is this store's current seal. A frame of another store /// is `ForeignFrame` even at equal numbers; a stale frame of this store /// names the first revision that moved, or `FrameResealed` if none did. diff --git a/crates/lance-graph-dt64/src/lane.rs b/crates/lance-graph-dt64/src/lane.rs index 2917899ff..8ea2618b5 100644 --- a/crates/lance-graph-dt64/src/lane.rs +++ b/crates/lance-graph-dt64/src/lane.rs @@ -64,6 +64,19 @@ pub trait LaneSource { ) -> Option; fn borrow(&self, addr: &LaneAddr) -> Option>; + + /// The [`FrameStore`] that lives with the data this source borrows from. + /// + /// The source owns this pairing, not the caller: an implementation derives + /// the store from the same owner its lanes come from and never takes one + /// from outside. [`BindStore::bind_lanes`] and [`LaneBinding::check`] + /// refuse `Frame(ForeignFrame)` when the caller's `frames` is any other + /// store, so a binding made over one source cannot be checked or read + /// against another source whose lane numbers happen to match. + /// + /// The trait cannot prove that the returned store issued this source's + /// bytes; that remains the implementer's obligation. + fn frames(&self) -> &FrameStore; } /// A DR position and the route version it was resolved at. @@ -162,6 +175,7 @@ impl BindStore { source: &S, req: LaneRequest<'_>, ) -> Result { + source_owns(frames, source)?; frames.check(req.frame).map_err(LaneRefusal::Frame)?; let class = self .routed_at(req.route.version, req.route.position)? @@ -202,6 +216,15 @@ impl BindStore { } } +/// The caller's `frames` must be the store the source's data lives with. +fn source_owns(frames: &FrameStore, source: &S) -> Result<(), LaneRefusal> { + if frames.same_store(source.frames()) { + Ok(()) + } else { + Err(LaneRefusal::Frame(FrameRefusal::ForeignFrame)) + } +} + /// The position must still route to `class` at the store's current version. fn current_route(store: &BindStore, position: u16, class: u32) -> Result<(), LaneRefusal> { let now = store.routed_at(store.current_version(), position)?; @@ -288,6 +311,7 @@ impl LaneBinding { frames: &FrameStore, source: &S, ) -> Result<(), LaneRefusal> { + source_owns(frames, source)?; frames.check(&self.frame).map_err(LaneRefusal::Frame)?; current_route(store, self.route.position, self.class)?; // Revision and count only: the ordered predicate witness was checked diff --git a/crates/lance-graph-dt64/tests/lane_socket.rs b/crates/lance-graph-dt64/tests/lane_socket.rs index 923710a31..73a5de71d 100644 --- a/crates/lance-graph-dt64/tests/lane_socket.rs +++ b/crates/lance-graph-dt64/tests/lane_socket.rs @@ -57,6 +57,9 @@ const READER: Scope = Scope { id: 7 }; struct Source { lanes: Vec>, map: Vec<(u8, LaneAddr)>, + /// The store this source's data lives with (it never takes one from a + /// caller; see `LaneSource::frames`). + frames: FrameStore, } impl Source { @@ -69,6 +72,11 @@ impl Source { Source { lanes: vec![vec![6, 7, 6], vec![100, 200, 300], vec![25, 40]], map: vec![(0, at(AD, 0)), (1, at(AD, 1)), (2, at(ENTRA, 2))], + frames: FrameStore::new(FrameRevisions { + source: 1, + relation_map: 1, + coordinates: 1, + }), } } } @@ -91,6 +99,9 @@ impl LaneSource for Source { fn borrow(&self, addr: &LaneAddr) -> Option<&[i32]> { self.lanes.get(usize::from(addr.lane)).map(Vec::as_slice) } + fn frames(&self) -> &FrameStore { + &self.frames + } } fn schema(fields: usize) -> ClassSchema { @@ -104,7 +115,6 @@ fn schema(fields: usize) -> ClassSchema { struct World { store: BindStore, view: View, - frames: FrameStore, frame: Frame, source: Source, route: RouteAt, @@ -116,18 +126,13 @@ fn world() -> World { .register(&View::three(), HYBRID, schema(3), Vec::new()) .unwrap(); let version = store.route(POSITION, HYBRID).unwrap(); - let frames = FrameStore::new(FrameRevisions { - source: 1, - relation_map: 1, - coordinates: 1, - }); - let frame = frames.seal(); + let source = Source::hybrid(); + let frame = source.frames.seal(); World { store, view: View::three(), - frames, frame, - source: Source::hybrid(), + source, route: RouteAt { position: POSITION, version, @@ -148,7 +153,7 @@ fn request<'a>(w: &'a World, fields: &'a WideFieldMask) -> LaneRequest<'a> { fn bind(w: &World, fields: &WideFieldMask) -> Result { w.store - .bind_lanes(&w.view, &w.frames, &w.source, request(w, fields)) + .bind_lanes(&w.view, &w.source.frames, &w.source, request(w, fields)) } #[test] @@ -158,13 +163,17 @@ fn two_fields_of_one_basis_bind_and_borrow_the_source_lanes() { let b = bind(&w, &both).unwrap(); assert_eq!(b.class(), HYBRID); assert!(b.fields().has(0) && b.fields().has(1)); - let dept = b.lane(&w.store, &w.view, &w.frames, &w.source, 0).unwrap(); - let cost = b.lane(&w.store, &w.view, &w.frames, &w.source, 1).unwrap(); + let dept = b + .lane(&w.store, &w.view, &w.source.frames, &w.source, 0) + .unwrap(); + let cost = b + .lane(&w.store, &w.view, &w.source.frames, &w.source, 1) + .unwrap(); // Borrowed, not copied: the very slices the source holds. assert!(std::ptr::eq(dept, w.source.lanes[0].as_slice())); assert!(std::ptr::eq(cost, w.source.lanes[1].as_slice())); assert_eq!( - b.lane(&w.store, &w.view, &w.frames, &w.source, 2), + b.lane(&w.store, &w.view, &w.source.frames, &w.source, 2), Err(LaneRefusal::NotBound { field: 2 }) ); } @@ -187,7 +196,7 @@ fn a_lane_in_another_row_order_is_refused() { req.selection = ENTRA; assert!(w .store - .bind_lanes(&w.view, &w.frames, &w.source, req) + .bind_lanes(&w.view, &w.source.frames, &w.source, req) .is_ok()); } @@ -198,14 +207,15 @@ fn wrong_schema_revision_and_changed_basis_are_refused() { let mut req = request(&w, &mask); req.rev = SchemaRev(2); assert!(matches!( - w.store.bind_lanes(&w.view, &w.frames, &w.source, req), + w.store + .bind_lanes(&w.view, &w.source.frames, &w.source, req), Err(LaneRefusal::Bind(Refusal::SchemaRevisionMismatch { .. })) )); let b = bind(&w, &mask).unwrap(); let narrower = View(View::three().0[..2].to_vec()); assert!(matches!( - b.check(&w.store, &narrower, &w.frames, &w.source), + b.check(&w.store, &narrower, &w.source.frames, &w.source), Err(LaneRefusal::Bind(Refusal::FieldBasisChanged { .. })) )); } @@ -255,7 +265,7 @@ fn a_changed_mapping_or_a_vanished_lane_is_refused_on_use() { let b = bind(&w, &both).unwrap(); w.source.map[0].1.lane = 1; assert_eq!( - b.check(&w.store, &w.view, &w.frames, &w.source), + b.check(&w.store, &w.view, &w.source.frames, &w.source), Err(LaneRefusal::MappingChanged { field: 0 }) ); @@ -263,7 +273,7 @@ fn a_changed_mapping_or_a_vanished_lane_is_refused_on_use() { let b = bind(&w, &both).unwrap(); w.source.lanes.truncate(1); assert_eq!( - b.lane(&w.store, &w.view, &w.frames, &w.source, 1), + b.lane(&w.store, &w.view, &w.source.frames, &w.source, 1), Err(LaneRefusal::Vanished { field: 1 }) ); } @@ -286,17 +296,55 @@ fn stale_and_foreign_frames_are_refused() { let mut w = w; let b = bind(&w, &mask).unwrap(); - w.frames.advance(FrameRevisions { + w.source.frames.advance(FrameRevisions { source: 1, relation_map: 2, coordinates: 1, }); assert_eq!( - b.check(&w.store, &w.view, &w.frames, &w.source), + b.check(&w.store, &w.view, &w.source.frames, &w.source), Err(LaneRefusal::Frame(FrameRefusal::MapChanged)) ); } +/// A binding made over one source cannot be checked or read against another +/// source whose lane numbers match (D-LANESOCK, PR B). Before the gate the +/// substitute's values came back; now the pairing is refused before any lane +/// is looked up or borrowed. +#[test] +fn a_substituted_source_is_refused_not_read() { + let w = world(); + let mask = WideFieldMask::from_positions(&[1]); + let b = bind(&w, &mask).unwrap(); + assert_eq!( + b.lane(&w.store, &w.view, &w.source.frames, &w.source, 1), + Ok(&[100, 200, 300][..]), + "the bound source reads its own lane" + ); + + // Same map, same lane numbers, different data and its own store. + let mut other = Source::hybrid(); + other.lanes[1] = vec![30000, 31000, 32000]; + + // Caller pairs the substitute with the bound source's store. + assert_eq!( + b.lane(&w.store, &w.view, &w.source.frames, &other, 1), + Err(LaneRefusal::Frame(FrameRefusal::ForeignFrame)) + ); + // Caller pairs the substitute with its own store: the frame is foreign. + assert_eq!( + b.lane(&w.store, &w.view, &other.frames, &other, 1), + Err(LaneRefusal::Frame(FrameRefusal::ForeignFrame)) + ); + // And a fresh bind cannot pair the bound source with another store. + assert_eq!( + w.store + .bind_lanes(&w.view, &other.frames, &w.source, request(&w, &mask)) + .err(), + Some(LaneRefusal::Frame(FrameRefusal::ForeignFrame)) + ); +} + /// The route and the frame are checked as a pair: a re-route alone, with the /// frame untouched, refuses. #[test] @@ -308,9 +356,12 @@ fn a_re_route_refuses_even_under_a_current_frame() { .register_derived(&w.view, HYBRID, HYBRID_V2, schema(3), Vec::new()) .unwrap(); w.store.route(POSITION, HYBRID_V2).unwrap(); - assert!(w.frames.check(b.frame()).is_ok(), "the frame did not move"); + assert!( + w.source.frames.check(b.frame()).is_ok(), + "the frame did not move" + ); assert_eq!( - b.check(&w.store, &w.view, &w.frames, &w.source), + b.check(&w.store, &w.view, &w.source.frames, &w.source), Err(LaneRefusal::RouteChanged { position: POSITION, bound: HYBRID, @@ -340,10 +391,12 @@ fn a_binding_checked_against_another_store_with_another_basis_refuses() { .unwrap(); other.route(POSITION, HYBRID).unwrap(); assert_eq!( - b.check(&other, &w.view, &w.frames, &w.source), + b.check(&other, &w.view, &w.source.frames, &w.source), Err(LaneRefusal::StoreBasisMismatch { class: HYBRID }) ); - assert!(b.check(&w.store, &w.view, &w.frames, &w.source).is_ok()); + assert!(b + .check(&w.store, &w.view, &w.source.frames, &w.source) + .is_ok()); } /// FINDING (L5d): the hot check does not re-hash the view, so a same-count @@ -358,5 +411,7 @@ fn finding_the_hot_check_does_not_see_a_view_reorder_under_a_positional_source() let mut swapped = View::three(); swapped.0.swap(0, 1); assert!(w.store.resolve(&swapped, HYBRID, SchemaRev(1)).is_err()); - assert!(b.check(&w.store, &swapped, &w.frames, &w.source).is_ok()); + assert!(b + .check(&w.store, &swapped, &w.source.frames, &w.source) + .is_ok()); } diff --git a/crates/lance-graph-report/src/lane_source.rs b/crates/lance-graph-report/src/lane_source.rs index a7663dbbe..3b932a4db 100644 --- a/crates/lance-graph-report/src/lane_source.rs +++ b/crates/lance-graph-report/src/lane_source.rs @@ -81,8 +81,10 @@ impl<'c> AbiLanes<'c> { } } - /// The frame owner for this source. Pass it to `BindStore::bind_lanes` - /// and `LaneBinding::check`. + /// The frame owner for this source, built from the batch in the + /// constructor (never taken from a caller). Pass it to + /// `BindStore::bind_lanes` and `LaneBinding::check`, which refuse any other + /// store. Same store as [`LaneSource::frames`]. pub fn frames(&self) -> &FrameStore { &self.frames } @@ -166,6 +168,10 @@ impl LaneSource for AbiLanes<'_> { } } + fn frames(&self) -> &FrameStore { + &self.frames + } + fn borrow(&self, addr: &LaneAddr) -> Option> { if addr.space != space_of(&self.batch) || addr.scope != self.scope { return None; diff --git a/crates/lance-graph-report/src/lane_source/tests.rs b/crates/lance-graph-report/src/lane_source/tests.rs index 81597bd9c..bc349d37d 100644 --- a/crates/lance-graph-report/src/lane_source/tests.rs +++ b/crates/lance-graph-report/src/lane_source/tests.rs @@ -539,9 +539,11 @@ fn the_old_row_order_is_refused_after_a_publish() { ); } -/// The adapter answers only for frames its own store sealed: another -/// source's store and frame, presented together, pass DT64's frame check -/// but leave every field unbound here. +/// Another source's store and frame, presented together, bind nothing. Before +/// DT64 bound the store to the source (`LaneSource::frames`) the pair passed +/// DT64's frame check and only this adapter's own `lane_of` check left the +/// field unbound; DT64 now refuses the caller-chosen store first, before any +/// lane is looked up. #[test] fn a_frame_and_store_of_another_source_bind_nothing() { let (store, route) = store(); @@ -560,10 +562,7 @@ fn a_frame_and_store_of_another_source_bind_nothing() { request(route, &fields, &frame, lanes.space()) ) .err(), - Some(LaneRefusal::Unbound { - class: class(), - field: REVENUE - }) + Some(LaneRefusal::Frame(FrameRefusal::ForeignFrame)) ); } diff --git a/crates/soa64-u64-lab/src/arms.rs b/crates/soa64-u64-lab/src/arms.rs index 8cbe0476c..497f8a0d5 100644 --- a/crates/soa64-u64-lab/src/arms.rs +++ b/crates/soa64-u64-lab/src/arms.rs @@ -12,6 +12,8 @@ use std::cell::Cell; use std::collections::HashMap; +use lance_graph_dt64::dr::{FrameRevisions, FrameStore}; + use crate::fixture::{field_bits, mask_to, present, value, Fixture, FIELDS}; /// The predicate constant: `A == K` selects about 1/16 of rows. @@ -163,6 +165,10 @@ pub struct SoaU64 { data: Box<[u64]>, n: usize, pres: Vec, + /// The frame owner that lives with this data. A socket source over this + /// SoA returns it (`LaneSource::frames`), so swapping the data swaps the + /// issuer with it. + frames: FrameStore, } impl SoaU64 { @@ -179,9 +185,19 @@ impl SoaU64 { data: data.into_boxed_slice(), n, pres: build_presence(fx), + frames: FrameStore::new(FrameRevisions { + source: 1, + relation_map: 1, + coordinates: 1, + }), } } + /// The frame owner of this data (see the field). + pub fn frames(&self) -> &FrameStore { + &self.frames + } + /// Row count. pub fn n(&self) -> usize { self.n diff --git a/crates/soa64-u64-lab/src/socket.rs b/crates/soa64-u64-lab/src/socket.rs index aefb8eb88..290936e22 100644 --- a/crates/soa64-u64-lab/src/socket.rs +++ b/crates/soa64-u64-lab/src/socket.rs @@ -9,7 +9,7 @@ use std::cell::Cell; use lance_graph_contract::class_view::{ClassId, ClassView, WideFieldMask}; use lance_graph_contract::ontology::{DisplayTemplate, FieldRef}; -use lance_graph_dt64::dr::{Frame, FrameRevisions, FrameStore, Scope}; +use lance_graph_dt64::dr::{Frame, FrameStore, Scope}; use lance_graph_dt64::{ BindStore, ClassSchema, CoordSpace, Extent, LaneAddr, LaneBinding, LaneRefusal, LaneRequest, LaneSource, RouteAt, SchemaRev, @@ -66,12 +66,11 @@ impl ClassView for View { } } -/// Store, view, sealed frame and route of the class. +/// Store, view and route of the class. The frame comes from the source's +/// data owner ([`SoaU64::frames`]), never from here. pub struct World { pub store: BindStore, pub view: View, - pub frames: FrameStore, - pub frame: Frame, pub route: RouteAt, } @@ -92,17 +91,9 @@ impl World { ) .unwrap(); let version = store.route(POSITION, CLASS).unwrap(); - let frames = FrameStore::new(FrameRevisions { - source: 1, - relation_map: 1, - coordinates: 1, - }); - let frame = frames.seal(); World { store, view, - frames, - frame, route: RouteAt { position: POSITION, version, @@ -175,6 +166,10 @@ impl<'s> LaneSource for U64Source<'s> { self.borrowed.set(self.borrowed.get() | (1u64 << f)); Some(self.soa.lane(f)) } + + fn frames(&self) -> &FrameStore { + self.soa.frames() + } } /// Cold: bind exactly the demanded `fields` under `selection`'s row order. @@ -185,15 +180,16 @@ pub fn bind_demanded( selection: CoordSpace, ) -> Result { let mask = WideFieldMask::from_positions(fields); + let frame = source.frames().seal(); world.store.bind_lanes( &world.view, - &world.frames, + source.frames(), source, LaneRequest { route: world.route, rev: SchemaRev(1), fields: &mask, - frame: &world.frame, + frame: &frame, scope: READER, selection, }, @@ -207,8 +203,8 @@ pub fn sum_where_via_socket( binding: &LaneBinding, source: &U64Source<'_>, ) -> Result { - let a = binding.lane(&world.store, &world.view, &world.frames, source, 0)?; - let b = binding.lane(&world.store, &world.view, &world.frames, source, 1)?; + let a = binding.lane(&world.store, &world.view, source.frames(), source, 0)?; + let b = binding.lane(&world.store, &world.view, source.frames(), source, 1)?; let mut s = 0u128; for (&x, &y) in a.iter().zip(b) { if x == K { @@ -228,7 +224,7 @@ pub fn fold_via_socket( ) -> Result { let mut acc = 0u64; for &f in fields { - let lane = binding.lane(&world.store, &world.view, &world.frames, source, f)?; + let lane = binding.lane(&world.store, &world.view, source.frames(), source, f)?; let fx = u64::from(f); for &v in lane { acc = acc.wrapping_add(v ^ fx); diff --git a/crates/soa64-u64-lab/tests/integration/socket_refusals.rs b/crates/soa64-u64-lab/tests/integration/socket_refusals.rs index 740f56a1c..b73b6ee66 100644 --- a/crates/soa64-u64-lab/tests/integration/socket_refusals.rs +++ b/crates/soa64-u64-lab/tests/integration/socket_refusals.rs @@ -1,7 +1,7 @@ //! The lane socket refuses what it must, and a passing bind borrows the //! source's own lanes. -use lance_graph_dt64::dr::Scope; +use lance_graph_dt64::dr::{FrameRefusal, Scope}; use lance_graph_dt64::LaneRefusal; use soa64_u64_lab::arms::{Query, SoaU64, K}; use soa64_u64_lab::fixture::Fixture; @@ -32,10 +32,10 @@ fn one_coordinate_space_binds_sums_exactly_and_borrows_in_place() { assert_eq!(got, oracle(&fx, &Query::SumWhere)); let a = binding - .lane(&world.store, &world.view, &world.frames, &source, 0) + .lane(&world.store, &world.view, soa.frames(), &source, 0) .unwrap(); let b = binding - .lane(&world.store, &world.view, &world.frames, &source, 1) + .lane(&world.store, &world.view, soa.frames(), &source, 1) .unwrap(); assert!(std::ptr::eq(a, soa.lane(0))); assert!(std::ptr::eq(b, soa.lane(1))); @@ -89,3 +89,30 @@ fn a_lane_for_another_scope_is_refused() { }) ); } + +/// Swapping the SoA under a bound source swaps the issuer with it: the same +/// fixture rebuilt is equal data but another store, and the binding made over +/// the first is refused, never read, against the second. +#[test] +fn a_source_over_swapped_data_is_refused() { + let (fx, soa) = setup(); + let world = World::new(); + let source = U64Source::new(&soa); + let binding = bind_demanded(&world, &source, &[0, 1], SPACE_A).unwrap(); + + let twin = SoaU64::build(&fx); + let swapped = U64Source { + soa: &twin, + ..U64Source::new(&soa) + }; + assert_eq!( + sum_where_via_socket(&world, &binding, &swapped).err(), + Some(LaneRefusal::Frame(FrameRefusal::ForeignFrame)) + ); + assert_eq!(swapped.borrows.get(), 0, "refused before any borrow"); + // The original pairing still reads. + assert_eq!( + sum_where_via_socket(&world, &binding, &source).unwrap(), + oracle(&fx, &Query::SumWhere) + ); +}