From 3ca08382397e8dcfa50081d6f4120c0c3336bd7b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 19:46:14 +0000 Subject: [PATCH 1/3] dt64: lane binding refuses a predicate change at a bound position (S8) bind_lanes records the predicate IRI each bound position resolved to; the hot check compares it after lane_of and refuses with PredicateChanged { class, field } when a view reorder put another predicate at that position. Label-only renames and reorders outside the bound positions still admit. No hashing on the hot path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMPJjVvasjwqhjJ6c9p --- .../examples/lane_check_cost.rs | 216 ++++++++++++++++++ crates/lance-graph-dt64/src/lane.rs | 56 +++-- crates/lance-graph-dt64/tests/lane_socket.rs | 126 +++++++++- 3 files changed, 372 insertions(+), 26 deletions(-) create mode 100644 crates/lance-graph-dt64/examples/lane_check_cost.rs 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 From 08eb7c3bc0910163e0d0506b1999209ead4d1913 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 19:46:45 +0000 Subject: [PATCH 2/3] plan: S8 wording follows the shipped predicate comparison Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMPJjVvasjwqhjJ6c9p --- .claude/plans/dr-widefieldmask-dt-classview-v1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 00a12393b37cc1def01cd1711b21af815e801172 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 19:54:12 +0000 Subject: [PATCH 3/3] rbac: scoped lane binding tests over the dt64 socket (S4, tests only) 18 tests bind dt64 lanes under the field mask and scope an authorization decision yields: each role binds exactly its visible requested fields, a non-allow decision binds nothing, a binding is refused through another reader's socket and scope, and row planes admit exactly each role's rows and are refused after the source moves or for another decision. Four findings pin current gaps (a binding does not know its reader; a copied scope id binds another reader's fields; admits ignores the tenant; a source move also refuses the address binding) for S4b-1 / S4b-2 to turn. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMPJjVvasjwqhjJ6c9p --- crates/lance-graph-rbac/Cargo.toml | 5 + .../tests/scoped_lane_binding.rs | 835 ++++++++++++++++++ 2 files changed, 840 insertions(+) create mode 100644 crates/lance-graph-rbac/tests/scoped_lane_binding.rs 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 + )) + ); +}