diff --git a/.claude/plans/dr-widefieldmask-dt-classview-v1.md b/.claude/plans/dr-widefieldmask-dt-classview-v1.md index b3fab8fb4..9f8756ca8 100644 --- a/.claude/plans/dr-widefieldmask-dt-classview-v1.md +++ b/.claude/plans/dr-widefieldmask-dt-classview-v1.md @@ -384,7 +384,7 @@ the question. | S5 | Bind-time admission for the four direct paths, with the executor census test | v2 cut 4; W2, W13 | EXO-253, EXO-245, EXO-246 | red on a planted call site | needs council | | S6 | Behaviour as classes that outlives the process: binding digest recorded with the execution and the DR version; typed lane interpretation from the class | v2 cut 7; W5; G5 | EXO-199, EXO-225, EXO-221, EXO-202, EXO-247 | replay falsifier (row 17) red first | needs council | | S7 | The versioned DR × DT mappings over D-NFR-1 (identities, properties, source positions, class binding, criteria), keyed onto the shipped `MapTable` log (D-DR-VMAP, #1923, `RouteVersion`), no new store instance, handed out by reference; contents stay with the source owner; per-request masks stay outside | §1b; W5 | EXO-253, EXO-220 | D-NFR-1 first: bind, seal, execute, advance one revision, gates: mapping N replayed reproduces the same assignment (identities, properties, source positions) bit for bit; over unchanged contents the result is bit-equal too; over changed contents with unchanged positions the assignment is unchanged and the values follow the source; a mapping whose positions or identities do not match the source refuses; then the same for a mapping kept in the store; authorization checked independently | lab code | -| S8 | Close the reorder gap on the hot check (D-DT64-7 L5d), keyed on revision, not a digest | W2 | EXO-199 | the pinned test turns red-to-green; an unchanged view still admits; at most a few ns per check | dt64 | +| S8 | Close the reorder gap on the hot check (D-DT64-7 L5d), by comparing the predicate IRI recorded at bind time at each bound position (no hashing on the hot path; a revision key alone cannot see a reorder that keeps the field count) | W2 | EXO-199 | the pinned test turns red-to-green; an unchanged view and a label-only rename still admit; check cost measured (whole check, not the increment) | dt64 | Order: S1 (finish) and S2 now. S4 next (a consumer is waiting on EXO-243 and EXO-244). Then D-NFR-1 and S7, S3 and S8 in parallel. S5 and S6 need diff --git a/crates/lance-graph-dt64/examples/lane_check_cost.rs b/crates/lance-graph-dt64/examples/lane_check_cost.rs new file mode 100644 index 000000000..014dc2a96 --- /dev/null +++ b/crates/lance-graph-dt64/examples/lane_check_cost.rs @@ -0,0 +1,216 @@ +//! Cost of the hot `LaneBinding::check`, per check and per bound lane. +//! +//! Run: `cargo run --release -p lance-graph-dt64 --example lane_check_cost` +//! +//! The "before" figure (no per-position predicate compare) is obtained by +//! applying the disable edit that turns the guard condition in +//! `LaneBinding::check` into `if false {` and re-running. The numbers are a +//! report, not an assertion: the example does not fail on a threshold. + +use std::hint::black_box; +use std::time::Instant; + +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::{ + BindStore, ClassSchema, CoordSpace, Extent, LaneAddr, LaneBinding, LaneRefusal, LaneRequest, + LaneSource, RouteAt, SchemaRev, +}; + +const CLASS: u32 = 0x0C1A_0001; +const VIEW_CLASS: ClassId = 0x0C1A; +const POSITION: u16 = 5; +const FIELDS: usize = 16; +const SPACE: CoordSpace = CoordSpace { + population: 1, + coordinates: 1, +}; +const READER: Scope = Scope { id: 7 }; + +struct View(Vec); + +impl View { + fn sixteen() -> View { + View( + (0..FIELDS) + .map(|i| { + FieldRef::new( + format!("https://example.org/dir/attribute{i:02}"), + format!("Attribute {i:02}"), + ) + }) + .collect(), + ) + } +} + +impl ClassView for View { + fn fields(&self, class: ClassId) -> &[FieldRef] { + if class == VIEW_CLASS { + &self.0 + } else { + &[] + } + } + fn template(&self, _class: ClassId) -> DisplayTemplate { + DisplayTemplate::Detail + } + fn dolce_category_id(&self, _class: ClassId) -> u8 { + 0 + } +} + +/// Positional source: field `n` lives in lane `n`; the basis is ignored. +struct Source { + map: [LaneAddr; FIELDS], + lanes: Vec, + frames: FrameStore, +} + +impl Source { + fn new() -> Source { + let map = core::array::from_fn(|i| LaneAddr { + space: SPACE, + lane: i as u16, + scope: READER, + }); + Source { + map, + lanes: vec![1, 2, 3, 4], + frames: FrameStore::new(FrameRevisions { + source: 1, + relation_map: 1, + coordinates: 1, + }), + } + } +} + +impl LaneSource for Source { + type Lane<'a> = &'a [i32]; + fn lane_of( + &self, + _class: u32, + _rev: SchemaRev, + field: u8, + _basis: &FieldRef, + _frame: &Frame, + ) -> Option { + Some(self.map[usize::from(field)]) + } + fn borrow(&self, _addr: &LaneAddr) -> Option<&[i32]> { + Some(&self.lanes[..]) + } + fn frames(&self) -> &FrameStore { + &self.frames + } +} + +fn bind( + store: &BindStore, + view: &View, + source: &Source, + frame: &Frame, + route: RouteAt, + k: usize, +) -> LaneBinding { + let positions: Vec = (0..k as u8).collect(); + let mask = WideFieldMask::from_positions(&positions); + store + .bind_lanes( + view, + source.frames(), + source, + LaneRequest { + route, + rev: SchemaRev(1), + fields: &mask, + frame, + scope: READER, + selection: SPACE, + }, + ) + .expect("bind") +} + +/// Minimum over 7 repetitions of 2,000,000 calls, in nanoseconds per call. +fn time bool>(mut call: F) -> f64 { + for _ in 0..100_000 { + black_box(call()); + } + let mut best = f64::MAX; + for _ in 0..7 { + let start = Instant::now(); + for _ in 0..2_000_000u32 { + black_box(call()); + } + let ns = start.elapsed().as_nanos() as f64 / 2_000_000.0; + if ns < best { + best = ns; + } + } + best +} + +fn main() { + let view = View::sixteen(); + let mut store = BindStore::new(); + store + .register( + &view, + CLASS, + ClassSchema { + schema_rev: SchemaRev(1), + field_count: FIELDS, + extent: Extent::DR64_CUBE, + }, + Vec::new(), + ) + .expect("register"); + let version = store.route(POSITION, CLASS).expect("route"); + let route = RouteAt { + position: POSITION, + version, + }; + let source = Source::new(); + let frame = source.frames().seal(); + + for k in [1usize, 4, 16] { + let binding = bind(&store, &view, &source, &frame, route, k); + assert!(binding + .check(&store, &view, source.frames(), &source) + .is_ok()); + let ns = time(|| { + black_box(&binding) + .check( + black_box(&store), + black_box(&view), + source.frames(), + black_box(&source), + ) + .is_ok() + }); + println!( + "lane_check k={k:<2} ns_per_check={ns:.2} ns_per_lane={:.2}", + ns / k as f64 + ); + } + + let binding = bind(&store, &view, &source, &frame, route, 1); + let mut swapped = View::sixteen(); + swapped.0.swap(0, 1); + let refused = binding.check(&store, &swapped, source.frames(), &source); + assert!(matches!(refused, Err(LaneRefusal::PredicateChanged { .. }))); + let ns = time(|| { + black_box(&binding) + .check( + black_box(&store), + black_box(&swapped), + source.frames(), + black_box(&source), + ) + .is_err() + }); + println!("lane_check k=1 refused ns_per_check={ns:.2}"); +} diff --git a/crates/lance-graph-dt64/src/lane.rs b/crates/lance-graph-dt64/src/lane.rs index a0aa6e665..4f7cc98ed 100644 --- a/crates/lance-graph-dt64/src/lane.rs +++ b/crates/lance-graph-dt64/src/lane.rs @@ -6,7 +6,7 @@ //! owner says, through [`LaneSource`], which of its own lanes holds a field //! and in whose row order. [`BindStore::bind_lanes`] joins that answer with //! the class route and the source frame into a [`LaneBinding`], which holds -//! addresses only, never a value. +//! addresses and the bound predicate identities, never a value. //! //! The class route and the frame are checked as a pair: the route against //! this store's own route log, the frame against the [`FrameStore`] that @@ -131,8 +131,14 @@ pub enum LaneRefusal { /// The store binds the class over another basis than the one this binding /// was made against. A store never changes a bound class (D5), so this /// fires only when the binding is checked against ANOTHER store; it says - /// nothing about the view, which the hot check does not re-hash. + /// nothing about the view; a view that changed at a bound position is + /// `PredicateChanged`. StoreBasisMismatch { class: u32 }, + /// The view names another predicate at a bound position than the one the + /// binding was made over. Compared per bound field by `predicate_iri`, + /// without hashing; a label-only rename does not fire it, and a change + /// at a position the binding did not bind does not either. + PredicateChanged { class: u32, field: u8 }, /// The source maps the field to another lane than it did at bind. MappingChanged { field: u8 }, /// The source no longer holds the lane. @@ -148,7 +154,8 @@ impl From for LaneRefusal { } /// A validated set of lane addresses for one class under one route and one -/// frame. Holds addresses only. +/// frame. Holds addresses and, per bound field, the predicate identity it was +/// bound over. Never a value. #[derive(Debug, Clone)] pub struct LaneBinding { class: u32, @@ -159,6 +166,10 @@ pub struct LaneBinding { selection: CoordSpace, fields: WideFieldMask, lanes: Vec<(u8, LaneAddr)>, + /// Parallel to `lanes`: the `predicate_iri` each bound field had in the + /// witnessed basis at bind. An identity, never a value. The hot check + /// compares the view's field at each bound position against it. + predicates: Vec>, /// The witness of the basis the class was bound with when this binding /// was made: one `u128`, compared on the hot path without hashing. basis: crate::BasisDigest, @@ -189,6 +200,7 @@ impl BindStore { .bound_basis(class) .ok_or(Refusal::UnknownClass(class))?; let mut lanes = Vec::with_capacity(req.fields.count() as usize); + let mut predicates = Vec::with_capacity(req.fields.count() as usize); for field in 0..=u8::MAX { if !req.fields.has(field) { continue; @@ -201,6 +213,7 @@ impl BindStore { .ok_or(LaneRefusal::Unbound { class, field })?; admit_lane(field, &addr, req.scope, req.selection)?; lanes.push((field, addr)); + predicates.push(Box::::from(field_ref.predicate_iri.as_str())); } Ok(LaneBinding { class, @@ -211,6 +224,7 @@ impl BindStore { selection: req.selection, fields: req.fields.clone(), lanes, + predicates, basis, }) } @@ -296,14 +310,15 @@ impl LaneBinding { /// Hot: the bind's checks again, without allocating and without hashing /// the basis. The frame against its store; the route against the store's /// CURRENT version (a re-route since the bind is refused); the store's - /// basis witness against the one recorded at bind (a `u128` compare); the - /// view's basis at the bound revision by revision and field count only; - /// and every lane against what the source maps now. + /// basis witness against the one recorded at bind (one `u128`); the + /// view's basis at the bound revision by revision and field count; each + /// bound position's `predicate_iri` against the one recorded at bind (a + /// string compare, no hashing); and every lane against what the source + /// maps now. /// - /// NOT checked here: that `view` still has the ordered predicates it had - /// at bind. A same-count reorder in the view after the bind is caught only - /// when the source maps lanes by predicate (`MappingChanged`); a - /// positional source passes it. Re-bind after any basis change. + /// NOT checked here: positions the binding did not bind. A same-count + /// change confined to unbound positions passes this check and is refused + /// by `BindStore::resolve`. pub fn check( &self, store: &BindStore, @@ -314,18 +329,17 @@ impl LaneBinding { 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 - // when the binding was made (bind_lanes resolves through the - // witnessed `resolve`), and re-hashing the basis here would put - // schema hashing on the hot path. A basis that changes order after - // the bind is caught below only when the source maps lanes by - // predicate (D-LANESOCK-3, `MappingChanged`); re-bind after any - // basis change. (D-DT64-7 L5d decides whether the R2IL arm re-binds.) + // Revision and count here: the whole-basis witness was checked when + // the binding was made (bind_lanes resolves through the witnessed + // `resolve`), and re-hashing the basis would put schema hashing on + // the hot path. The bound positions are compared below, each against + // the predicate recorded at bind, so a reorder that moves a bound + // field is refused even when the source maps lanes by position. let (fields, bound) = store.resolve_counted(view, self.class, self.rev)?; if bound != self.basis { return Err(LaneRefusal::StoreBasisMismatch { class: self.class }); } - for (field, addr) in &self.lanes { + for ((field, addr), bound_iri) in self.lanes.iter().zip(&self.predicates) { let field = *field; let field_ref = fields .get(usize::from(field)) @@ -343,6 +357,12 @@ impl LaneBinding { }) } } + if field_ref.predicate_iri.as_str() != &**bound_iri { + return Err(LaneRefusal::PredicateChanged { + class: self.class, + field, + }); + } admit_lane(field, addr, self.scope, self.selection)?; } Ok(()) diff --git a/crates/lance-graph-dt64/tests/lane_socket.rs b/crates/lance-graph-dt64/tests/lane_socket.rs index 4a22ad8a0..b2f0c09c2 100644 --- a/crates/lance-graph-dt64/tests/lane_socket.rs +++ b/crates/lance-graph-dt64/tests/lane_socket.rs @@ -408,21 +408,131 @@ fn a_binding_checked_against_another_store_with_another_basis_refuses() { .is_ok()); } -/// FINDING (L5d): the hot check does not re-hash the view, so a same-count -/// reorder of the VIEW after the bind passes it when the source maps lanes by -/// position (this fixture's `Source` does). `resolve` through the same view -/// refuses. Re-bind after a basis change; L5d decides whether R2IL must. +/// Closes the L5a FINDING +/// `finding_the_hot_check_does_not_see_a_view_reorder_under_a_positional_source` +/// (plan gap G7): a same-count reorder of the view after the bind, at a bound +/// position, is refused by the hot check even when the source maps by +/// position. #[test] -fn finding_the_hot_check_does_not_see_a_view_reorder_under_a_positional_source() { +fn a_view_reorder_at_a_bound_position_is_refused_under_a_positional_source() { let w = world(); let mask = WideFieldMask::from_positions(&[0]); let b = bind(&w, &mask).unwrap(); 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.source.frames, &w.source) - .is_ok()); + let refusal = LaneRefusal::PredicateChanged { + class: HYBRID, + field: 0, + }; + assert_eq!( + b.check(&w.store, &swapped, &w.source.frames, &w.source), + Err(refusal.clone()) + ); + w.source.borrows.set(0); + assert_eq!( + b.lane(&w.store, &swapped, &w.source.frames, &w.source, 0), + Err(refusal) + ); + assert_eq!(w.source.borrows.get(), 0, "nothing is borrowed"); +} + +/// An unchanged view admits every bound lane, and the compare is by content, +/// not by address: a separately built view with equal content passes too. +#[test] +fn an_unchanged_view_admits_every_bound_lane() { + let w = world(); + let b = bind(&w, &WideFieldMask::from_positions(&[0, 1])).unwrap(); + assert_eq!( + b.check(&w.store, &w.view, &w.source.frames, &w.source), + Ok(()) + ); + let rebuilt = View::three(); + assert_eq!( + b.check(&w.store, &rebuilt, &w.source.frames, &w.source), + Ok(()) + ); + let n = b + .borrow_all(&w.store, &rebuilt, &w.source.frames, &w.source) + .unwrap() + .count(); + assert_eq!(n, 2); +} + +/// A label is not identity (Decision J item 4): renaming a bound field's label +/// leaves the binding admitted. +#[test] +fn a_label_only_rename_at_a_bound_position_admits() { + let w = world(); + let b = bind(&w, &WideFieldMask::from_positions(&[0])).unwrap(); + let mut renamed = View::three(); + renamed.0[0].label = "Abteilung".to_string(); + assert!(w.store.resolve(&renamed, HYBRID, SchemaRev(1)).is_ok()); + assert_eq!( + b.check(&w.store, &renamed, &w.source.frames, &w.source), + Ok(()) + ); +} + +/// The accepted residual: a reorder that leaves every bound position naming +/// the same predicate is admitted hot. The bound position's meaning is +/// unchanged, so the binding's mapping and interpretation hold; the +/// whole-basis witness is checked at bind and at `resolve` (which refuses +/// this view), never on the hot path. +#[test] +fn a_reorder_outside_the_bound_positions_admits_hot_and_is_refused_cold() { + let w = world(); + let b = bind(&w, &WideFieldMask::from_positions(&[0])).unwrap(); + let mut swapped = View::three(); + swapped.0.swap(1, 2); + assert!(w.store.resolve(&swapped, HYBRID, SchemaRev(1)).is_err()); + assert_eq!( + b.check(&w.store, &swapped, &w.source.frames, &w.source), + Ok(()) + ); +} + +/// The refusal names the first bound position whose predicate changed. +#[test] +fn the_refusal_names_the_first_changed_bound_position() { + let w = world(); + let b = bind(&w, &WideFieldMask::from_positions(&[0, 1])).unwrap(); + + let mut replaced = View::three(); + replaced.0[1] = FieldRef::new("ad:manager", "Manager"); + assert_eq!( + b.check(&w.store, &replaced, &w.source.frames, &w.source), + Err(LaneRefusal::PredicateChanged { + class: HYBRID, + field: 1, + }) + ); + + let mut swapped = View::three(); + swapped.0.swap(0, 1); + assert_eq!( + b.check(&w.store, &swapped, &w.source.frames, &w.source), + Err(LaneRefusal::PredicateChanged { + class: HYBRID, + field: 0, + }) + ); +} + +/// The very view object the binding was made through, mutated in place, is +/// refused: no address-identity shortcut. +#[test] +fn a_view_mutated_in_place_after_the_bind_is_refused() { + let mut w = world(); + let b = bind(&w, &WideFieldMask::from_positions(&[0])).unwrap(); + w.view.0.swap(0, 1); + assert_eq!( + b.check(&w.store, &w.view, &w.source.frames, &w.source), + Err(LaneRefusal::PredicateChanged { + class: HYBRID, + field: 0, + }) + ); } /// `borrow_all` hands out the source's own lanes, in field order, the same diff --git a/crates/lance-graph-rbac/Cargo.toml b/crates/lance-graph-rbac/Cargo.toml index 7b87fbc02..1df8809ff 100644 --- a/crates/lance-graph-rbac/Cargo.toml +++ b/crates/lance-graph-rbac/Cargo.toml @@ -6,3 +6,8 @@ description = "Role-based access control for the Ada cognitive stack" [dependencies] lance-graph-contract = { path = "../lance-graph-contract" } + +[dev-dependencies] +# S4 (tests/scoped_lane_binding.rs): binds lanes through the dt64 lane socket +# under the scope an authorization decision yields. Tests only. +lance-graph-dt64 = { path = "../lance-graph-dt64" } diff --git a/crates/lance-graph-rbac/tests/scoped_lane_binding.rs b/crates/lance-graph-rbac/tests/scoped_lane_binding.rs new file mode 100644 index 000000000..858885818 --- /dev/null +++ b/crates/lance-graph-rbac/tests/scoped_lane_binding.rs @@ -0,0 +1,835 @@ +//! S4 / EXO-243: applying a scoped authorization decision to a DT64 lane +//! binding. Test-only: no production code changes. +//! +//! Field half: `requested ∩ ScopedDecision::visible_fields()` is the mask that +//! goes to `bind_lanes`. Row half: a SKETCH (`mod row_plane`) of a per-row +//! plane prepared from a `ScopeSpec`; no production type does this yet. +//! Tests named `finding_*` pin today's behaviour, gaps included. + +use std::cell::Cell; + +use lance_graph_contract::class_view::{ClassId, ClassView, FieldMask, WideFieldMask}; +use lance_graph_contract::ontology::{DisplayTemplate, FieldRef}; +use lance_graph_contract::property::PrefetchDepth; +use lance_graph_contract::rbac::{ActorId, ClassRbac, Operation, RoleId, ScopePath, ScopeSpec}; +use lance_graph_dt64::dr::{Focus, Frame, FrameRevisions, FrameStore, GuardRefusal, Guards, Scope}; +use lance_graph_dt64::{ + view_class, BindStore, ClassSchema, CoordSpace, Extent, LaneAddr, LaneBinding, LaneRefusal, + LaneRequest, LaneSource, RouteAt, SchemaRev, +}; +use lance_graph_rbac::access::AccessDecision; +use lance_graph_rbac::authorize::{authorize_scoped, ScopedDecision}; + +const CLASS: u32 = 0x0C1A_0001; +const POSITION: u16 = 5; +const ROWS: usize = 1000; +const FIELDS: usize = 12; +const WORDS: usize = 16; +const POP: CoordSpace = CoordSpace { + population: 1, + coordinates: 1, +}; +// Hand-assigned consistency witnesses. They carry no authority. +const SCOPE_A: Scope = Scope { id: 0xA }; +const SCOPE_B: Scope = Scope { id: 0xB }; + +struct View(Vec); + +impl View { + fn twelve() -> View { + View( + (0..FIELDS) + .map(|i| FieldRef::new(format!("med:f{i:02}"), format!("F{i:02}"))) + .collect(), + ) + } +} + +impl ClassView for View { + fn fields(&self, class: ClassId) -> &[FieldRef] { + if class == view_class(CLASS) { + &self.0 + } else { + &[] + } + } + fn template(&self, _class: ClassId) -> DisplayTemplate { + DisplayTemplate::Detail + } + fn dolce_category_id(&self, _class: ClassId) -> u8 { + 0 + } +} + +/// The source owner's data: 12 lanes of 1000 rows, per-row tenant and path. +struct Data { + lanes: Vec>, + row_tenant: Vec, + row_path: Vec, + frames: FrameStore, + lookups: Cell, + borrows: Cell, +} + +impl Data { + fn new() -> Data { + Data { + lanes: (0..FIELDS) + .map(|f| (0..ROWS).map(|r| (f * 10_000 + r) as i32).collect()) + .collect(), + row_tenant: (0..ROWS).map(|r| if r < 800 { 9 } else { 10 }).collect(), + row_path: (0..ROWS) + .map(|r| { + ScopePath::new(&[1 + (r % 4) as u64, ((r / 4) % 5) as u64]).expect("depth") + }) + .collect(), + frames: FrameStore::new(FrameRevisions { + source: 1, + relation_map: 1, + coordinates: 1, + }), + lookups: Cell::new(0), + borrows: Cell::new(0), + } + } +} + +/// What the data owner hands one reader: a socket serving one scope over the +/// same data and the same `FrameStore`. +struct Socket<'d> { + data: &'d Data, + serves: Scope, +} + +impl LaneSource for Socket<'_> { + type Lane<'a> + = &'a [i32] + where + Self: 'a; + fn lane_of( + &self, + class: u32, + rev: SchemaRev, + field: u8, + _basis: &FieldRef, + _frame: &Frame, + ) -> Option { + self.data.lookups.set(self.data.lookups.get() + 1); + if class != CLASS || rev != SchemaRev(1) || usize::from(field) >= FIELDS { + return None; + } + Some(LaneAddr { + space: POP, + lane: u16::from(field), + scope: self.serves, + }) + } + fn borrow(&self, addr: &LaneAddr) -> Option<&[i32]> { + self.data.borrows.set(self.data.borrows.get() + 1); + self.data + .lanes + .get(usize::from(addr.lane)) + .map(Vec::as_slice) + } + fn frames(&self) -> &FrameStore { + &self.data.frames + } +} + +/// alice: fields 0..=5, rows tenant 9 under [1]. bob: fields 4..=9, rows +/// tenant 9 under [2, 3]. carol: no Read grant. ghost: no roles. +struct Policy; + +impl ClassRbac for Policy { + fn actor_roles(&self, actor: ActorId<'_>) -> &[RoleId] { + match actor { + "alice" => &["role_a"], + "bob" => &["role_b"], + "carol" => &["role_c"], + _ => &[], + } + } + fn grant_permits(&self, role: RoleId, class: u32, op: &Operation<'_>) -> bool { + class == CLASS + && match role { + "role_a" | "role_b" => matches!(op, Operation::Read { .. }), + "role_c" => matches!(op, Operation::Act { .. }), + _ => false, + } + } + fn row_scope(&self, role: RoleId, _class: u32) -> Option { + let at = |seg: &[u64]| ScopeSpec { + tenant: Some(9), + path: ScopePath::new(seg).expect("depth"), + ..ScopeSpec::default() + }; + match role { + "role_a" => Some(at(&[1])), + "role_b" => Some(at(&[2, 3])), + _ => None, + } + } + fn field_mask(&self, role: RoleId, _class: u32) -> WideFieldMask { + match role { + "role_a" => WideFieldMask::from_positions(&[0, 1, 2, 3, 4, 5]), + "role_b" => WideFieldMask::from_positions(&[4, 5, 6, 7, 8, 9]), + "role_c" => WideFieldMask::from_positions(&[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]), + _ => WideFieldMask::EMPTY, + } + } +} + +struct World { + store: BindStore, + view: View, + data: Data, + route: RouteAt, + frame: Frame, +} + +fn world() -> World { + let mut store = BindStore::new(); + store + .register( + &View::twelve(), + CLASS, + ClassSchema { + schema_rev: SchemaRev(1), + field_count: FIELDS, + extent: Extent::DR64_CUBE, + }, + Vec::new(), + ) + .expect("register"); + let version = store.route(POSITION, CLASS).expect("route"); + let data = Data::new(); + let frame = data.frames.seal(); + World { + store, + view: View::twelve(), + data, + route: RouteAt { + position: POSITION, + version, + }, + frame, + } +} + +fn read() -> Operation<'static> { + Operation::Read { + depth: PrefetchDepth::Detail, + } +} + +fn decide(actor: &str) -> ScopedDecision { + authorize_scoped(&Policy, actor, CLASS, read()) +} + +fn requested() -> WideFieldMask { + WideFieldMask::from_positions(&[0, 2, 4, 6, 8, 10, 11]) +} + +/// The answer to EXO-243 ask 1. +fn fields_for(d: &ScopedDecision) -> WideFieldMask { + requested().intersect(&d.visible_fields()) +} + +fn bind_as( + w: &World, + serves: Scope, + reader: Scope, + fields: &WideFieldMask, +) -> Result { + let sock = Socket { + data: &w.data, + serves, + }; + w.store.bind_lanes( + &w.view, + &w.data.frames, + &sock, + LaneRequest { + route: w.route, + rev: SchemaRev(1), + fields, + frame: &w.frame, + scope: reader, + selection: POP, + }, + ) +} + +#[derive(Debug, PartialEq)] +enum Blocked { + Denied(AccessDecision), + Lane(LaneRefusal), +} + +/// The consumer recipe: decide first, touch the socket only on Allow. +fn bind_decided( + w: &World, + d: &ScopedDecision, + serves: Scope, + reader: Scope, +) -> Result { + if d.decision != AccessDecision::Allow { + return Err(Blocked::Denied(d.decision.clone())); + } + bind_as(w, serves, reader, &fields_for(d)).map_err(Blocked::Lane) +} + +fn sock(w: &World, serves: Scope) -> Socket<'_> { + Socket { + data: &w.data, + serves, + } +} + +fn check(w: &World, b: &LaneBinding, serves: Scope) -> Result<(), LaneRefusal> { + b.check(&w.store, &w.view, &w.data.frames, &sock(w, serves)) +} + +// ---------------------------------------------------------------- A. fields + +#[test] +fn role_a_binds_exactly_the_requested_and_visible_fields() { + let w = world(); + let d = decide("alice"); + assert_eq!(d.decision, AccessDecision::Allow); + let fields = fields_for(&d); + assert_eq!(fields, WideFieldMask::from_positions(&[0, 2, 4])); + assert_eq!(fields.count(), 3); + + let b = bind_as(&w, SCOPE_A, SCOPE_A, &fields).expect("alice binds"); + assert_eq!(*b.fields(), fields); + for f in 0..FIELDS as u8 { + assert_eq!(b.addr(f).is_some(), matches!(f, 0 | 2 | 4), "field {f}"); + } + assert_eq!(w.data.lookups.get(), 3, "no lookup for an excluded field"); + + let s = sock(&w, SCOPE_A); + let lane = b + .lane(&w.store, &w.view, &w.data.frames, &s, 0) + .expect("field 0"); + assert!(std::ptr::eq(lane, w.data.lanes[0].as_slice())); + assert_eq!(lane.len(), ROWS); + assert_eq!( + b.lane(&w.store, &w.view, &w.data.frames, &s, 6).err(), + Some(LaneRefusal::NotBound { field: 6 }) + ); + let order: Vec = b + .borrow_all(&w.store, &w.view, &w.data.frames, &s) + .expect("check") + .map(|r| r.expect("lane").0) + .collect(); + assert_eq!(order, vec![0, 2, 4]); +} + +#[test] +fn role_b_gets_its_own_fields_and_the_overlap_is_one_field() { + let w = world(); + let fa = fields_for(&decide("alice")); + let fb = fields_for(&decide("bob")); + assert_eq!(fb, WideFieldMask::from_positions(&[4, 6, 8])); + assert_ne!(fa, fb); + assert_ne!(fa, requested()); + assert_ne!(fb, requested()); + assert!(!fa.is_empty() && !fb.is_empty()); + assert_eq!(fa.intersect(&fb), WideFieldMask::from_positions(&[4])); + for f in [10u8, 11] { + assert!( + !fa.has(f) && !fb.has(f), + "field {f} is visible to no reader" + ); + } + let b = bind_as(&w, SCOPE_B, SCOPE_B, &fb).expect("bob binds"); + assert_eq!(b.fields().count(), 3); +} + +#[test] +fn a_non_allow_decision_yields_no_bindable_field() { + let w = world(); + let carol = decide("carol"); + assert_eq!( + carol.decision, + AccessDecision::Deny { + reason: "insufficient read depth" + } + ); + let ghost = decide("ghost"); + assert_eq!( + ghost.decision, + AccessDecision::Deny { + reason: "unknown role" + } + ); + for d in [&carol, &ghost] { + assert_eq!(d.scope, None); + assert!(fields_for(d).is_empty()); + assert_eq!( + bind_decided(&w, d, SCOPE_A, SCOPE_A).err(), + Some(Blocked::Denied(d.decision.clone())) + ); + } + assert_eq!(w.data.lookups.get(), 0); + assert_eq!(w.data.borrows.get(), 0); + + // Anti-vacuity: the same recipe binds for an allowed reader. + let b = bind_decided(&w, &decide("alice"), SCOPE_A, SCOPE_A).expect("alice binds"); + assert_eq!(b.fields().count(), 3); + assert_eq!(w.data.lookups.get(), 3); +} + +#[test] +fn finding_the_raw_field_mask_of_a_refused_decision_binds_every_requested_field() { + // Why ask 1 says `visible_fields()`, never `field_mask`: dt64 sees only a + // mask and cannot protect here. + let w = world(); + let d = decide("carol"); + assert_eq!(d.field_mask, WideFieldMask::from(FieldMask::FULL)); + let leaked = requested().intersect(&d.field_mask); + assert_eq!(leaked, requested()); + assert_eq!(leaked.count(), 7); + let b = bind_as(&w, SCOPE_A, SCOPE_A, &leaked).expect("binds"); + assert_eq!( + (0..FIELDS as u8).filter(|f| b.addr(*f).is_some()).count(), + 7 + ); +} + +#[test] +fn finding_bind_lanes_admits_an_empty_request() { + // "Binds nothing" holds as zero lanes, but dt64 cannot tell a refused + // reader from an empty request: branch on the decision (A3), not on the + // bind result. Re-pin if an empty request ever becomes a refusal. + let w = world(); + let b = bind_as(&w, SCOPE_A, SCOPE_A, &WideFieldMask::EMPTY).expect("empty binds"); + assert_eq!(b.fields().count(), 0); + assert!((0..FIELDS as u8).all(|f| b.addr(f).is_none())); + let s = sock(&w, SCOPE_A); + let n = b + .borrow_all(&w.store, &w.view, &w.data.frames, &s) + .expect("check") + .count(); + assert_eq!(n, 0); + assert_eq!(check(&w, &b, SCOPE_A), Ok(())); + assert_eq!(w.data.lookups.get(), 0); +} + +// ------------------------------------------------- B. refused for the other + +#[test] +fn role_b_cannot_bind_through_role_a_socket() { + let w = world(); + let fb = fields_for(&decide("bob")); + assert_eq!( + bind_as(&w, SCOPE_A, SCOPE_B, &fb).err(), + Some(LaneRefusal::Scope { + field: 4, + reader: SCOPE_B, + lane: SCOPE_A + }) + ); + assert!(bind_as(&w, SCOPE_B, SCOPE_B, &fb).is_ok()); +} + +#[test] +fn role_a_guards_are_refused_under_role_b_scope() { + let w = world(); + let g = Guards { + frame: w.frame.clone(), + focus: Focus { revision: 1 }, + scope: SCOPE_A, + }; + assert_eq!( + g.check(&w.data.frames, (Focus { revision: 1 }, SCOPE_B)), + Err(GuardRefusal::ScopeChanged) + ); + assert_eq!( + g.check(&w.data.frames, (Focus { revision: 1 }, SCOPE_A)), + Ok(()) + ); +} + +#[test] +fn finding_a_lane_binding_alone_does_not_know_its_reader() { + // `check` takes no reader and the binding has no `scope()` accessor, so + // whoever holds alice's binding and her socket reads field 0, which is + // not in bob's fields. Step S4b-1 turns this red-to-green. + let w = world(); + let b_a = bind_decided(&w, &decide("alice"), SCOPE_A, SCOPE_A).expect("alice"); + assert!(!fields_for(&decide("bob")).has(0)); + let s = sock(&w, SCOPE_A); + let lane = b_a + .lane(&w.store, &w.view, &w.data.frames, &s, 0) + .expect("transferable binding reads"); + assert!(std::ptr::eq(lane, w.data.lanes[0].as_slice())); +} + +#[test] +fn role_a_binding_checked_through_role_b_socket_is_refused() { + let w = world(); + let b_a = bind_decided(&w, &decide("alice"), SCOPE_A, SCOPE_A).expect("alice"); + assert_eq!( + check(&w, &b_a, SCOPE_B), + Err(LaneRefusal::MappingChanged { field: 0 }) + ); + assert_eq!(check(&w, &b_a, SCOPE_A), Ok(())); +} + +#[test] +fn finding_a_copied_scope_id_binds_another_readers_fields() { + // Bob copies the public `u64` id of alice's scope. Equality of two ids is + // not a grant; a sealed scope (S4b-2) closes this. + let w = world(); + assert!(!fields_for(&decide("bob")).has(0)); + let alices = fields_for(&decide("alice")); + let b = bind_as(&w, SCOPE_A, SCOPE_A, &alices).expect("copied id admitted"); + let s = sock(&w, SCOPE_A); + let lane = b + .lane(&w.store, &w.view, &w.data.frames, &s, 0) + .expect("reads alice-only field"); + assert!(std::ptr::eq(lane, w.data.lanes[0].as_slice())); +} + +/// SKETCH (test-local). Interns (actor, class, decision) by VALUE and hands +/// out a serial id. Not a hash: two different keys never share an id. +struct Issuer { + seen: Vec<(String, u32, ScopedDecision)>, +} + +impl Issuer { + fn scope_for(&mut self, actor: &str, class: u32, d: &ScopedDecision) -> Option { + if d.decision != AccessDecision::Allow { + return None; + } + let at = match self + .seen + .iter() + .position(|(a, c, s)| a == actor && *c == class && s == d) + { + Some(i) => i, + None => { + self.seen.push((actor.to_owned(), class, d.clone())); + self.seen.len() - 1 + } + }; + Some(Scope { id: at as u64 + 1 }) + } +} + +#[test] +fn scope_ids_issued_per_decision_separate_readers_and_follow_a_changed_decision() { + let w = world(); + let mut issuer = Issuer { seen: Vec::new() }; + let da = decide("alice"); + let a1 = issuer.scope_for("alice", CLASS, &da).expect("allow"); + assert_eq!(issuer.scope_for("alice", CLASS, &da), Some(a1)); + let b1 = issuer + .scope_for("bob", CLASS, &decide("bob")) + .expect("allow"); + assert_ne!(a1, b1); + + let mut changed = da.clone(); + changed.field_mask = changed + .field_mask + .union(&WideFieldMask::from_positions(&[7])); + let a2 = issuer.scope_for("alice", CLASS, &changed).expect("allow"); + assert_ne!(a1, a2); + + let guards = Guards { + frame: w.frame.clone(), + focus: Focus { revision: 1 }, + scope: a1, + }; + let now = |s| (Focus { revision: 1 }, s); + assert_eq!( + guards.check(&w.data.frames, now(a2)), + Err(GuardRefusal::ScopeChanged) + ); + let unchanged = issuer.scope_for("alice", CLASS, &da).expect("allow"); + assert_eq!(guards.check(&w.data.frames, now(unchanged)), Ok(())); + + for actor in ["carol", "ghost"] { + assert_eq!(issuer.scope_for(actor, CLASS, &decide(actor)), None); + } +} + +// -------------------------------------------------------------- C. row half + +mod row_plane { + //! SKETCH, test-local. No production type prepares or stores a row plane + //! yet (plan S7). This module fixes what such a plane must be witnessed + //! by: the source frame it was prepared under, the ScopeSpec it was + //! prepared from (compared by value), and the row order. A row plane + //! depends on row CONTENT, so by EXO-255 it is recomputed when the source + //! moves; it is never reused because the coordinates are unchanged. + + use super::*; + use lance_graph_dt64::dr::FrameRefusal; + + pub struct RowPlane { + words: [u64; WORDS], + frame: Frame, + spec: ScopeSpec, + space: CoordSpace, + } + + #[derive(Debug, PartialEq, Eq)] + pub enum PlaneRefusal { + Unscoped, + PredicateNotInterpreted, + Frame(FrameRefusal), + ScopeChanged, + CoordinateSpace, + } + + pub fn prepare( + data: &Data, + frame: &Frame, + scope: Option, + space: CoordSpace, + ) -> Result { + data.frames.check(frame).map_err(PlaneRefusal::Frame)?; + let spec = scope.ok_or(PlaneRefusal::Unscoped)?; + if spec.predicate_key != 0 { + return Err(PlaneRefusal::PredicateNotInterpreted); + } + let mut words = [0u64; WORDS]; + for (r, (tenant, path)) in data.row_tenant.iter().zip(&data.row_path).enumerate() { + if spec.tenant.is_none_or(|t| t == *tenant) && spec.admits(path) { + words[r / 64] |= 1u64 << (r % 64); + } + } + Ok(RowPlane { + words, + frame: frame.clone(), + spec, + space, + }) + } + + impl RowPlane { + pub fn admit( + &self, + frames: &FrameStore, + scope_now: Option, + space_now: CoordSpace, + ) -> Result<&[u64; WORDS], PlaneRefusal> { + frames.check(&self.frame).map_err(PlaneRefusal::Frame)?; + if scope_now != Some(self.spec) { + return Err(PlaneRefusal::ScopeChanged); + } + if space_now != self.space { + return Err(PlaneRefusal::CoordinateSpace); + } + Ok(&self.words) + } + } + + pub fn popcount(words: &[u64; WORDS]) -> u32 { + words.iter().map(|w| w.count_ones()).sum() + } + + pub fn bit(words: &[u64; WORDS], r: usize) -> bool { + (words[r / 64] >> (r % 64)) & 1 == 1 + } + + /// Sum of `lane` over the rows set in `words`, in place; no row list. + pub fn fold_sum(words: &[u64; WORDS], lane: &[i32]) -> i64 { + let mut sum = 0i64; + for (wi, &word) in words.iter().enumerate() { + let mut w = word; + while w != 0 { + let idx = wi * 64 + w.trailing_zeros() as usize; + sum += i64::from(lane[idx]); + w &= w - 1; + } + } + sum + } +} + +use row_plane::{bit, fold_sum, popcount, prepare, PlaneRefusal}; + +fn plane_for(w: &World, actor: &str) -> row_plane::RowPlane { + prepare(&w.data, &w.frame, decide(actor).scope, POP).expect("plane") +} + +#[test] +fn row_planes_admit_exactly_each_roles_rows() { + let w = world(); + let pa = plane_for(&w, "alice"); + let pb = plane_for(&w, "bob"); + let a = pa + .admit(&w.data.frames, decide("alice").scope, POP) + .expect("a"); + let b = pb + .admit(&w.data.frames, decide("bob").scope, POP) + .expect("b"); + assert_eq!(popcount(a), 200); + assert!(bit(a, 0) && bit(a, 796) && !bit(a, 1) && !bit(a, 800)); + assert_eq!(popcount(b), 40); + assert!(bit(b, 13) && bit(b, 33) && bit(b, 53)); + let both: u32 = a.iter().zip(b).map(|(x, y)| (x & y).count_ones()).sum(); + assert_eq!(both, 0); + assert_eq!(a[15] >> 40, 0); + assert_eq!(b[15] >> 40, 0); +} + +#[test] +fn finding_admits_alone_ignores_the_tenant() { + let w = world(); + let spec = ScopeSpec { + tenant: Some(9), + ..ScopeSpec::default() + }; + assert_eq!(w.data.row_tenant[800], 10); + assert!(spec.admits(&w.data.row_path[800])); + let plane = prepare(&w.data, &w.frame, Some(spec), POP).expect("plane"); + let words = plane.admit(&w.data.frames, Some(spec), POP).expect("admit"); + assert_eq!(popcount(words), 800); + let by_admits_alone = w.data.row_path.iter().filter(|p| spec.admits(p)).count(); + assert_eq!(by_admits_alone, 1000); +} + +#[test] +fn a_row_plane_is_refused_after_the_source_moves_and_recomputed() { + let mut w = world(); + let scope = decide("alice").scope; + let plane = plane_for(&w, "alice"); + assert_eq!( + popcount(plane.admit(&w.data.frames, scope, POP).expect("ok")), + 200 + ); + + w.data.row_path[0] = ScopePath::new(&[3, 0]).expect("depth"); + let _version = w.data.frames.advance(FrameRevisions { + source: 2, + relation_map: 1, + coordinates: 1, + }); + assert_eq!( + plane.admit(&w.data.frames, scope, POP).err(), + Some(PlaneRefusal::Frame( + lance_graph_dt64::dr::FrameRefusal::SourceChanged + )) + ); + let fresh = w.data.frames.seal(); + let again = prepare(&w.data, &fresh, scope, POP).expect("recomputed"); + let words = again.admit(&w.data.frames, scope, POP).expect("admit"); + assert_eq!(popcount(words), 199); + assert!(!bit(words, 0)); + + // Silent half: a second world with no change and no advance. + let w2 = world(); + let p2 = plane_for(&w2, "alice"); + let ok = p2.admit(&w2.data.frames, scope, POP).expect("unchanged"); + assert_eq!(popcount(ok), 200); +} + +#[test] +fn a_row_plane_prepared_for_one_decision_is_refused_for_another() { + let w = world(); + let plane = plane_for(&w, "alice"); + assert_eq!( + plane.admit(&w.data.frames, decide("bob").scope, POP).err(), + Some(PlaneRefusal::ScopeChanged) + ); + assert!(plane + .admit(&w.data.frames, decide("alice").scope, POP) + .is_ok()); + let other = CoordSpace { + population: 1, + coordinates: 2, + }; + assert_eq!( + plane + .admit(&w.data.frames, decide("alice").scope, other) + .err(), + Some(PlaneRefusal::CoordinateSpace) + ); +} + +#[test] +fn an_unscoped_decision_and_a_reserved_predicate_prepare_no_plane() { + let w = world(); + assert_eq!(decide("carol").scope, None); + assert_eq!(decide("ghost").scope, None); + assert_eq!( + prepare(&w.data, &w.frame, None, POP).err(), + Some(PlaneRefusal::Unscoped) + ); + let reserved = ScopeSpec { + predicate_key: 2, + ..ScopeSpec::default() + }; + assert_eq!( + prepare(&w.data, &w.frame, Some(reserved), POP).err(), + Some(PlaneRefusal::PredicateNotInterpreted) + ); + let deny = prepare(&w.data, &w.frame, Some(ScopeSpec::DENY), POP).expect("deny prepares"); + let words = deny + .admit(&w.data.frames, Some(ScopeSpec::DENY), POP) + .expect("admit"); + assert_eq!(popcount(words), 0); + assert!(prepare(&w.data, &w.frame, decide("alice").scope, POP).is_ok()); +} + +#[test] +fn role_a_reads_exactly_its_fields_and_rows() { + let w = world(); + let da = decide("alice"); + let b = bind_decided(&w, &da, SCOPE_A, SCOPE_A).expect("alice"); + let plane = plane_for(&w, "alice"); + let rows = plane.admit(&w.data.frames, da.scope, POP).expect("plane"); + assert_eq!(popcount(rows), 200); + let s = sock(&w, SCOPE_A); + let sums: Vec<(u8, i64)> = b + .borrow_all(&w.store, &w.view, &w.data.frames, &s) + .expect("check") + .map(|r| { + let (f, lane) = r.expect("lane"); + (f, fold_sum(rows, lane)) + }) + .collect(); + assert_eq!(sums, vec![(0, 79_600), (2, 4_079_600), (4, 8_079_600)]); + assert_eq!(w.data.borrows.get(), 3); + + let db = decide("bob"); + let bb = bind_decided(&w, &db, SCOPE_B, SCOPE_B).expect("bob"); + let pb = plane_for(&w, "bob"); + let brows = pb.admit(&w.data.frames, db.scope, POP).expect("plane"); + assert_eq!(popcount(brows), 40); + let sb = sock(&w, SCOPE_B); + let bob4: Vec = bb + .borrow_all(&w.store, &w.view, &w.data.frames, &sb) + .expect("check") + .filter_map(|r| { + let (f, lane) = r.expect("lane"); + (f == 4).then(|| fold_sum(brows, lane)) + }) + .collect(); + assert_eq!(bob4, vec![1_616_120]); +} + +#[test] +fn finding_a_source_revision_move_refuses_the_address_binding_too() { + // One `FrameRevisions.source` number is the only change signal a source + // has, so advancing it for a content change also refuses address + // bindings. Safe direction; re-pin if content gets its own witness. + let mut w = world(); + let b = bind_decided(&w, &decide("alice"), SCOPE_A, SCOPE_A).expect("alice"); + let _version = w.data.frames.advance(FrameRevisions { + source: 2, + relation_map: 1, + coordinates: 1, + }); + assert_eq!( + check(&w, &b, SCOPE_A), + Err(LaneRefusal::Frame( + lance_graph_dt64::dr::FrameRefusal::SourceChanged + )) + ); +}