diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 559f0cb..2248878 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,31 @@ +## 2026-08-17 (dispatch 4) — W5b bricks shipped: authorization IS a mask, measured + +`wave-consumer-bricks.md` executed (2 Sonnet workers K1/K2, disjoint main/test +scopes, orchestrator-gated). `consumers/bricks/` is the second consumer proof: +**RBAC as a natively-evaluated predicate in the same lazy chain as `where(...)`** +— `Role.EU_ONLY` folds `REGION.eq(EU)` into the plan, `DENY_ALL` is a real +impossible predicate (`REGION.eq(0xFFFF)`) that pays a real crossing and counts +0, and an unauthorized chain throws `UnauthorizedQueryException` BEFORE any +native crossing (fail-closed; no default-allow path exists in the package). +Aggregate-only egress is structural: every public method returns +`BricksQuery`/`long`/`Map` — no row-shaped public type exists to leak. + +- **BricksAuthTest 62/62.** Parity vs the transcribed generator at 1K+64K rows; + EU_ONLY == GLOBAL+explicit-where equivalence; crossing arithmetic measured. +- **A real finding, not just a green suite: a sum terminal costs 2 crossings** + (plan evaluation into the mask + `lgj_reduce_sum_i32`), unlike `count()` + whose plan eval returns the count and pays 1. `sumBy()` therefore measures + **32 crossings (16 groups × 2) — IDENTICAL at 1K and 64K rows**, which is the + thesis (crossings ∝ groups, never rows). K1's Javadoc claimed "one crossing + per group"; the measurement corrected the doc, not the other way round. +- **Disable-run:** `requireAuthorized` short-circuited → exactly the 3 + can-fire fail-closed checks red (59 green), restored, 62/62. Core suite + untouched at 188/188. +- Board-hygiene note, owned: W5a (trades, PR #11) shipped without a + LATEST_STATE entry — STATUS_BOARD D-LGJ-W5 carried it; both consumers are + now recorded there in full. W5c (graph) stays SHELVED on the D1 ruling + + the edge-bearing generator substrate change. + ## 2026-08-17 (dispatch 2) — W4 measured: the boundary re-asked on the REAL layout `wave-substrate-w3-w4.md` Dispatch 2 executed: one Sonnet worker diff --git a/.claude/board/PR_ARC_INVENTORY.md b/.claude/board/PR_ARC_INVENTORY.md index 4922824..be70d07 100644 --- a/.claude/board/PR_ARC_INVENTORY.md +++ b/.claude/board/PR_ARC_INVENTORY.md @@ -8,6 +8,25 @@ > anti-pattern the imported board rules name. Backfilled below in one > pass rather than left stale; PR #4 onward gets its entry at merge time. +## PR #11 — consumer example: World/Trades (merged 2026-08-17, squash `db7bdf1`) + +- **Added:** `consumers/trades/` — own compile unit, core consumed as a + third party would. `Trade` (schema-not-entity, reflection-proven), + `World.open` → the existing lazy `View` under domain names; + `TradesParityTest` 12/12, `TradesAllocationTest` 3/3. +- **Locked:** the poster's number, measured — **240 bytes allocated per + query, IDENTICAL at 64K and 1M rows** (row-count independence is the + thesis assertion); 0 crossings composing / 1 at terminal, through the + domain vocabulary; the membrane's own `LANE_KIND_MISMATCH` catches a + misbound schema (disable-run green-red-green). Zero new membrane + surface, zero core changes — the consumer iron rule held on its first + real test. +- **Deferred:** QUANTITY (honestly absent — arrives with ClassView/W6); + bricks + graph consumer waves (still shelved). +- **Docs:** STATUS_BOARD W5 row → trades DONE. +- **Confidence:** High — every falsifier two-sided or anti-vacuity + guarded; no `java/` file changed. Bot reviewers at usage limits. + ## PR #10 — third parity read path + R2IL handoff boundary (merged 2026-08-17, squash `4114c4e`) - **Added:** `RowStoreParityTest` section reading all 32,000 classids of a diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 1e06fe0..c69b5d9 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -40,4 +40,4 @@ layout wired end to end. Doctrine: `E-LGJ-THE-MIDDLE-TIER-IS-DELETED-NOT-WRAPPED | D-LGJ-W2 | lgj-abi row store: `rowstore.rs`, `LGJ_RESOURCE_ROWSTORE`, `lgj_rowstore_open`, strided facet lanes through the unchanged `LgjLaneDesc`, `lgj_op_eq_classid`, `lgj_row_facet_match`, ABI minor 1→2, `docs/abi.md` §11 | **DONE 2026-08-17** — `cargo test` **84/84**, clippy/fmt clean, release build exports **18/18** symbols (`nm -D`). Parity: both kernels vs independent scalar references over 10 row counts × 2 seeds × 4 facets × 4 needles, cross-checked a THIRD way against `RowStore::classid_at`. Two-sided payload-vs-classid falsifier. End-to-end membrane test covers describe → predicate → mask algebra → count → facet-match → lifecycle | | D-LGJ-W3 | Java `RowStore` facade: structured `MemoryLayout`, minor-≥2 gate, `FacetMatchView`, parity test transcribing the generator | **DONE 2026-08-17** — dispatched per `.claude/waves/wave-substrate-w3-w4.md` (3 Sonnet workers, disjoint scopes: FFM membrane extension / public facade / tests), orchestrator-integrated. `javac -Xlint:all` clean (same 7 pre-existing `[restricted]` warnings, zero new). `AllTests` **185/185** (was 132; +53 new checks: 29 parity + 24 lifetime). **One real bug caught by the suite and fixed**: `FacetMatchView.rowCount()` was missing the closed-store guard `matchesOf`/`cardinality` both had — a stale row count was readable after the owning store closed. Fixed, re-verified. Both mandated disable-runs ran red-then-green: (1) `Abi.requireMinor` inflated by 1 → exactly `RowStoreParityTest`+`RowStoreLifetimeTest` failed, all 8 other suites stayed green; (2) the pure-Java generator's a/b draw order swapped in `RowStoreParityTest` → exactly that suite broke (17/29), `RowStoreLifetimeTest` (generator-independent) stayed green — confirming the parity test is a real falsifier, not decorative. `Mask.source()` retyped `NativePattern → NativeResource` (new interface) so a `Mask` can parent onto either a `NativePattern` or a `RowStore` — zero call-site breakage (verified: no existing caller bound the narrower type) | | D-LGJ-W4 | Bench Component F: Vector API facet scan vs the crossing, on the REAL layout | **DONE 2026-08-17** — 1 Sonnet worker (F_RowStoreFacetScan + RowStoreData + Kernels facet-match arms, cross-check-in-@Setup discipline), orchestrator-run JMH: 9/9 combos, cross-checks green at every row count. **Finding: Component C's direction survives, its margin collapses** — Vector API wins the 32-facet strided scan at every row count but by 2.51×/1.92×/1.14× (4K/65K/1M rows) vs C's 56×; at 512 MiB traversed all three arms converge on memory bandwidth. Native arm's per-call allocation asymmetry disclosed in §F with a named follow-up (`facetMatchesInto`), not hidden. summarise.sh extended with the F table (and the old 'E/F' section retitled 'E' — a real naming collision); tables regenerated from the merged CSV | -| D-LGJ-W5 | Three consumer examples (trades / bricks / graph) — one plan file each | **trades DONE 2026-08-17** — `consumers/trades/` (own compile unit, core consumed as a third-party would): `Trade` (schema-not-entity: zero public ctors, zero instance fields, reflection-forced construction still throws), `World.open` → the existing lazy `View` under domain names, zero new membrane surface. TradesParityTest 12/12 (chain vs transcribed-generator recomputation at 1K+64K rows; 0 crossings composing / 1 at terminal THROUGH the domain vocabulary; reflection guard). TradesAllocationTest 3/3 — **the poster's number, measured: 240 bytes/query, IDENTICAL at 64K and 1M rows** (row-count independence is the thesis assertion; 64 KiB absolute backstop). Disable-run: VENUE pointed at the wrong lane → the membrane's own LANE_KIND_MISMATCH rejected it (the binding is checked, not trusted); restored green. bricks + graph still shelved | +| D-LGJ-W5 | Three consumer examples (trades / bricks / graph) — one plan file each | **trades DONE 2026-08-17** — `consumers/trades/` (own compile unit, core consumed as a third-party would): `Trade` (schema-not-entity: zero public ctors, zero instance fields, reflection-forced construction still throws), `World.open` → the existing lazy `View` under domain names, zero new membrane surface. TradesParityTest 12/12 (chain vs transcribed-generator recomputation at 1K+64K rows; 0 crossings composing / 1 at terminal THROUGH the domain vocabulary; reflection guard). TradesAllocationTest 3/3 — **the poster's number, measured: 240 bytes/query, IDENTICAL at 64K and 1M rows** (row-count independence is the thesis assertion; 64 KiB absolute backstop). Disable-run: VENUE pointed at the wrong lane → the membrane's own LANE_KIND_MISMATCH rejected it (the binding is checked, not trusted); restored green. **bricks DONE 2026-08-17** — `consumers/bricks/` (2 Sonnet workers K1/K2 per `.claude/waves/wave-consumer-bricks.md`): mask-first RBAC where `authorize(Role)` is a real natively-evaluated predicate in the SAME lazy chain as `where(...)` (`Role.EU_ONLY` = `REGION.eq(EU)`, `DENY_ALL` = `REGION.eq(0xFFFF)` — a genuine impossible predicate, not a Java branch), fail-closed (`UnauthorizedQueryException` BEFORE any crossing; no default-allow path exists), aggregate-only egress (every public method returns `BricksQuery`/`long`/`Map` — structurally no row-shaped type). BricksAuthTest **62/62**: parity vs transcribed generator at 1K+64K; RBAC-as-predicate equivalence (EU_ONLY result == GLOBAL+explicit-where); DENY_ALL counts 0 while paying a real crossing; crossing arithmetic — count()=1, sumBy()=**32 crossings (16 groups × 2: plan_eval + lgj_reduce_sum_i32), IDENTICAL at both row counts** (the thesis: crossings ∝ groups, never rows — the measured 32 corrected K1's "1 per group" Javadoc claim, a real finding about sum-terminal cost); reflection guards. Disable-run: `requireAuthorized` short-circuited → **exactly the 3 can-fire fail-closed checks red, 59 green**; restored, 62/62. Core suite unaffected (188/188). graph still shelved on the D1 ruling + edge-generator substrate change | diff --git a/consumers/bricks/src/main/java/com/adaworldapi/bricks/Bricks.java b/consumers/bricks/src/main/java/com/adaworldapi/bricks/Bricks.java new file mode 100644 index 0000000..d0cb339 --- /dev/null +++ b/consumers/bricks/src/main/java/com/adaworldapi/bricks/Bricks.java @@ -0,0 +1,42 @@ +package com.adaworldapi.bricks; + +import com.adaworldapi.lancegraph.NativePattern; + +/** + * Entry point for the OGAR-Bricks poster example: security as a mask composed before + * execution, never a post-filter over already-fetched data. + * + *
This package adds no ABI symbol, no native call, no query engine, and no row-shaped public + * type. It consumes {@code com.adaworldapi.lancegraph} exactly as a third-party developer would: + * {@link NativePattern#open} to obtain rows, {@link NativePattern#view()} (via {@link + * BricksSession#query()}) to obtain the lazy description, {@link + * com.adaworldapi.lancegraph.View#where} (via {@link BricksQuery#where} and {@link + * BricksQuery#authorize}) to narrow it. + * + *
{@code
+ * try (var session = Bricks.open(1_000_000, Bricks.DEFAULT_SEED)) {
+ * long euOrderCount = session.query()
+ * .authorize(Role.EU_ONLY)
+ * .where(Orders.REVENUE.gt(0))
+ * .count();
+ * }
+ * }
+ */
+public final class Bricks {
+
+ private Bricks() {}
+
+ /** The default seed, re-exported from {@link NativePattern#DEFAULT_SEED}. */
+ public static final long DEFAULT_SEED = NativePattern.DEFAULT_SEED;
+
+ /**
+ * Open {@code nRows} orders generated deterministically from {@code seed}.
+ *
+ * @throws IllegalArgumentException if {@code nRows} is negative
+ */
+ public static BricksSession open(long nRows, long seed) {
+ return new BricksSession(NativePattern.open(nRows, seed));
+ }
+}
diff --git a/consumers/bricks/src/main/java/com/adaworldapi/bricks/BricksQuery.java b/consumers/bricks/src/main/java/com/adaworldapi/bricks/BricksQuery.java
new file mode 100644
index 0000000..734aba4
--- /dev/null
+++ b/consumers/bricks/src/main/java/com/adaworldapi/bricks/BricksQuery.java
@@ -0,0 +1,144 @@
+package com.adaworldapi.bricks;
+
+import com.adaworldapi.lancegraph.Predicate;
+import com.adaworldapi.lancegraph.View;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * An immutable, lazy, fail-closed description of a set of order rows.
+ *
+ * {@link #where} and {@link #authorize(Role)} are the same kind of operation: both + * return a new {@code BricksQuery} wrapping a {@link View} narrowed by one more {@link Predicate}, + * via {@link View#where}. There is no separate enforcement pass here that fetches rows and then + * throws some away — authorization is a predicate in the identical lazy chain a caller's own {@code + * where(...)} conditions live in, fused into the same single native crossing a terminal operation + * pays for. See {@link Role} for how a role's constraint is itself a real, natively-evaluated + * predicate rather than a Java-side branch. + * + *
Every terminal operation ({@link #count()}, {@link #sum}, {@link #sumBy}) checks first whether + * {@link #authorize(Role)} was ever called on this chain, and throws {@link + * UnauthorizedQueryException} if not — before touching the native side at all. + * There is no default-allow path: a chain that never called {@code authorize} cannot be executed, + * full stop. + * + *
Every public method on this class returns exactly one of {@code BricksQuery}, {@code long}, or
+ * {@code Map Crosses the membrane zero times, exactly as {@link View#where} does not. Does not affect
+ * authorization: a chain built entirely from {@code where(...)} calls is still unauthorized
+ * until {@link #authorize(Role)} is called.
+ */
+ public BricksQuery where(Predicate predicate) {
+ return new BricksQuery(view.where(predicate), authorized);
+ }
+
+ /**
+ * A new query, authorized under {@code role}.
+ *
+ * If {@code role} carries an additional constraint (see {@link Role}), it is folded into the
+ * same lazy chain via {@link View#where} — a real predicate, composed once, evaluated natively
+ * alongside everything else. This is the single point in this package where a role's constraint
+ * enters the chain; there is no other place authorization happens.
+ */
+ public BricksQuery authorize(Role role) {
+ java.util.Objects.requireNonNull(role, "role");
+ View next = role.constraint().map(view::where).orElse(view);
+ return new BricksQuery(next, true);
+ }
+
+ /**
+ * How many rows this query selects.
+ *
+ * @throws UnauthorizedQueryException if {@link #authorize(Role)} was never called on this chain
+ */
+ public long count() {
+ requireAuthorized("count()");
+ return view.count();
+ }
+
+ /**
+ * Sum a signed 32-bit column over the rows this query selects.
+ *
+ * @throws UnauthorizedQueryException if {@link #authorize(Role)} was never called on this chain
+ */
+ public long sum(com.adaworldapi.lancegraph.I32Field field) {
+ requireAuthorized("sum()");
+ return view.sumOf(field);
+ }
+
+ /**
+ * Sum {@code value} grouped by every possible value of {@code group}.
+ *
+ * {@code group} is a {@link com.adaworldapi.lancegraph.U32Field}, and this consumer's fixture
+ * gives such fields exactly 16 distinct values ({@code 0..15}) — see {@link Orders#REGION}. This
+ * method issues one fused native query per group value (16 total), each narrowing this query's
+ * already-authorized chain by one more {@code group.eq(v)} condition and summing {@code value}
+ * over the result. Each of those 16 sums costs two native crossings — plan evaluation
+ * into the selection mask, then the {@code lgj_reduce_sum_i32} reduction — so the measured
+ * total is 32 crossings (unlike {@link #count()}, whose plan evaluation returns the count and
+ * pays one). The crossing count scales with the number of groups, never with the
+ * number of rows — the same laziness guarantee every other terminal operation in this
+ * codebase carries, just paid per group instead of once.
+ *
+ * Every group value {@code 0..15} appears as a key in the returned map, including groups with
+ * zero matching rows (mapped to a sum of {@code 0L}): a group's absence from a real dataset is
+ * itself a legitimate aggregate fact, not something to hide by omitting the key.
+ *
+ * If measurement ever shows this 32-crossing loop is a bottleneck, a native grouped-aggregate
+ * kernel (one crossing, sixteen output buckets) is the natural W6-tier follow-up — not built
+ * here, because nothing has measured a need for it yet.
+ *
+ * @throws UnauthorizedQueryException if {@link #authorize(Role)} was never called on this chain
+ */
+ public Map Owns exactly one {@link NativePattern}. {@link #query()} returns a fresh, unauthorized
+ * {@link BricksQuery} over that pattern's rows every time it is called — see {@link
+ * BricksQuery} for why every chain must call {@code authorize(...)} before any terminal operation,
+ * with no exceptions for {@link Role#GLOBAL}.
+ *
+ * Crosses the membrane zero times to build, exactly as {@link NativePattern#view()} does not.
+ * The returned query throws {@link UnauthorizedQueryException} from any terminal operation until
+ * {@link BricksQuery#authorize(Role)} is called on it.
+ */
+ public BricksQuery query() {
+ return new BricksQuery(pattern.view(), false);
+ }
+
+ /**
+ * Release the native storage.
+ *
+ * Delegates directly to {@link NativePattern#close()} — same double-close semantics: a second
+ * call throws {@link com.adaworldapi.lancegraph.ClosedResourceException} rather than being a
+ * silent no-op, and every {@link BricksQuery} derived from this session's rows stops working
+ * once this returns.
+ */
+ @Override
+ public void close() {
+ pattern.close();
+ }
+}
diff --git a/consumers/bricks/src/main/java/com/adaworldapi/bricks/Orders.java b/consumers/bricks/src/main/java/com/adaworldapi/bricks/Orders.java
new file mode 100644
index 0000000..3e79693
--- /dev/null
+++ b/consumers/bricks/src/main/java/com/adaworldapi/bricks/Orders.java
@@ -0,0 +1,80 @@
+package com.adaworldapi.bricks;
+
+import com.adaworldapi.lancegraph.Field;
+import com.adaworldapi.lancegraph.I32Field;
+import com.adaworldapi.lancegraph.LaneId;
+import com.adaworldapi.lancegraph.Ordinal;
+import com.adaworldapi.lancegraph.Pattern;
+import com.adaworldapi.lancegraph.U32Field;
+
+import java.util.List;
+
+/**
+ * The domain schema for the OGAR-Bricks poster: security as a mask composed before
+ * execution, never a post-filter over already-fetched rows.
+ *
+ * There is no such thing as an {@code Orders} instance, on purpose — see the constructor. This
+ * class is written the same generated shape as {@link Pattern} and {@code
+ * com.adaworldapi.trades.Trade}: constants only, no logic, names taken from the schema, lane
+ * indices stated once.
+ *
+ * The generated fixture ({@link com.adaworldapi.lancegraph.NativePattern}) has exactly two
+ * data lanes beyond identity: an unsigned 32-bit class tag ({@code 0..15}) and a signed 32-bit
+ * value. This schema binds order-domain names onto those same two lanes — {@link #REGION} is the
+ * class lane, {@link #REVENUE} is the value lane — rather than inventing a lane the substrate does
+ * not have.
+ *
+ * The poster's domain fiction implies a time dimension (orders by year, revenue
+ * trending). It is not fabricated here: the current flat two-lane fixture
+ * has no third numeric lane to bind it to. Exactly the same boundary {@code Trade} draws around its
+ * absent {@code QUANTITY} field — a multi-lane {@code ClassView} facet slice ({@code W6} in the
+ * substrate plan) is what a real {@code YEAR} lane would bind onto; until then, honesty about what
+ * exists beats completeness of the poster.
+ */
+public final class Orders {
+
+ /**
+ * Never constructed. See {@code com.adaworldapi.trades.Trade}'s constructor for the same
+ * guarantee stated in full: this class is vocabulary, not a row template.
+ */
+ private Orders() {
+ throw new AssertionError(
+ "an Orders instance is never materialized — orders are addressed as a native lane"
+ + " set through Bricks/BricksQuery, never hydrated one-by-one. There is no"
+ + " Orders instance to construct.");
+ }
+
+ /**
+ * Region identifier — {@link Pattern#CLASS}'s lane ({@code lane 1}), read under a domain name.
+ * Fixture values are {@code 0..15}.
+ */
+ public static final U32Field REGION =
+ new U32Field("region", LaneId.of(1), Ordinal.of(0));
+
+ /**
+ * Revenue — {@link Pattern#VALUE}'s lane ({@code lane 2}), read under a domain name. Signed,
+ * matching the fixture's underlying value lane; see {@link I32Field} for why only the
+ * comparisons the membrane implements are offered.
+ */
+ public static final I32Field REVENUE =
+ new I32Field("revenue", LaneId.of(2), Ordinal.of(1));
+
+ /**
+ * The region id used by {@link Role#EU_ONLY} to build its authorization mask. One of the
+ * fixture's 16 distinct {@link #REGION} values.
+ */
+ public static final int EU = 7;
+
+ /** A second, distinct region id, for predicates that need to compare or exclude by region. */
+ public static final int APAC = 3;
+
+ /** Every field this schema defines, in schema order. See the class doc for why there is no
+ * {@code YEAR} entry yet. */
+ public static final List Every {@code Role} constant carries an optional additional {@link Predicate}. When present,
+ * {@link BricksQuery#authorize(Role)} folds it into the same lazy chain a caller's own {@code
+ * where(...)} conditions live in — via {@link com.adaworldapi.lancegraph.View#where}, the identical
+ * membrane-zero-crossings machinery. There is no branch in Java that decides "let this row through"
+ * or "drop it": the constraint is evaluated natively, fused with every other predicate, in the same
+ * single crossing the rest of the chain pays for.
+ *
+ * {@link #DENY_ALL} makes this concrete rather than aspirational: it is not a
+ * Java-side short-circuit that skips execution. It is a real predicate ({@code
+ * Orders.REGION.eq(0xFFFF)}) that can never match any row, because {@link Orders#REGION} only ever
+ * holds fixture values {@code 0..15}. Denial is a mask that is unsatisfiable, evaluated the same way
+ * any other mask is evaluated — the fail-closed philosophy extends all the way down, not just to the
+ * "did you call authorize()" gate.
+ *
+ * These three constants are a stand-in for a real RBAC source. A future integration replaces them
+ * — same {@code Role} shape, same single composition point in {@link BricksQuery#authorize(Role)} —
+ * without touching any caller. Multi-value roles (a role permitting several regions, or intersecting
+ * several field-level constraints) arrive with mask-level composition; the underlying {@code Mask}
+ * algebra already exists in the core module ({@code com.adaworldapi.lancegraph.Mask}) but is not
+ * wired into this consumer.
+ */
+public final class Role {
+
+ /** Authorized with no additional constraint: every row an unrestricted query would see. */
+ public static final Role GLOBAL = new Role(Optional.empty());
+
+ /** Authorized, restricted to {@link Orders#EU} rows via a real, natively-evaluated predicate. */
+ public static final Role EU_ONLY = new Role(Optional.of(Orders.REGION.eq(Orders.EU)));
+
+ /**
+ * Authorized, restricted by a predicate that can never match any row ({@code
+ * Orders.REGION.eq(0xFFFF)} — {@link Orders#REGION} only ever holds {@code 0..15} in the
+ * fixture). See the class documentation: this is a real mask, evaluated natively, not a
+ * Java-side "return nothing" branch.
+ */
+ public static final Role DENY_ALL = new Role(Optional.of(Orders.REGION.eq(0xFFFF)));
+
+ private final Optional This is the workspace's own RBAC doctrine, restated as a Java exception rather than left as a
+ * convention: "a missing role mask never falls back to emit everything." A {@link
+ * BricksQuery} that has not been authorized is not treated as globally readable — it is treated as
+ * a programming error, thrown before any native crossing happens. There is no default-allow path
+ * anywhere in this package; the only way past this exception is an explicit {@link
+ * BricksQuery#authorize(Role)} call, even if that role is {@link Role#GLOBAL}.
+ */
+public final class UnauthorizedQueryException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ UnauthorizedQueryException(String message) {
+ super(message);
+ }
+}
diff --git a/consumers/bricks/src/test/java/com/adaworldapi/bricks/BricksAuthTest.java b/consumers/bricks/src/test/java/com/adaworldapi/bricks/BricksAuthTest.java
new file mode 100644
index 0000000..c204591
--- /dev/null
+++ b/consumers/bricks/src/test/java/com/adaworldapi/bricks/BricksAuthTest.java
@@ -0,0 +1,435 @@
+package com.adaworldapi.bricks;
+
+import com.adaworldapi.lancegraph.Checks;
+import com.adaworldapi.lancegraph.Diagnostics;
+import com.adaworldapi.lancegraph.NativeRuntime;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.Map;
+
+/**
+ * The falsifier suite for the mask-first, fail-closed authorization consumer example.
+ *
+ * Mirrors {@code TradesParityTest}'s shape (main + {@link #run}, a transcribed SplitMix64
+ * generator with its own provenance comment, {@code Diagnostics.crossings()} as the laziness
+ * instrument) but proves a different, sharper thesis: a query never reads a row before it
+ * is authorized, and that "never" is checked three separate ways — by value (two roles
+ * see two different, correctly-related answers), by control flow (an unauthorized terminal throws
+ * before doing any work), and by crossing count (composing {@code .authorize(...)} costs the
+ * membrane nothing; only the terminal does, and its cost does not grow with role complexity).
+ *
+ * Named {@code Fixture} rather than {@code Orders} deliberately: this file also needs the
+ * unqualified name {@code Orders} to resolve to the real schema class ({@code Orders.REGION},
+ * {@code Orders.EU}, {@code Orders.REVENUE}) throughout, and a nested type sharing that simple
+ * name would shadow it within this compilation unit — the same reason {@code TradesParityTest}
+ * names its own recomputation record {@code Trades}, not {@code Trade}.
+ */
+ private record Fixture(int[] regions, int[] revenues) {}
+
+ private static Fixture generate(int nRows, long seed) {
+ SplitMix64 rng = new SplitMix64(seed);
+ int[] regions = new int[nRows];
+ int[] revenues = new int[nRows];
+ for (int i = 0; i < nRows; i++) {
+ long draw1 = rng.next(); // FIRST draw of the row
+ long draw2 = rng.next(); // SECOND draw of the row
+ regions[i] = (int) ((draw1 >>> 33) & 0xF);
+ revenues[i] = (int) (((draw2 >>> 40) & 0x1FF) - 150);
+ }
+ return new Fixture(regions, revenues);
+ }
+
+ public static void run(Checks c) {
+ checkRoleDiscrimination(c);
+ checkFailClosed(c);
+ checkComposeNotExecute(c);
+ checkSumByParity(c);
+ checkAggregateOnlyEgress(c);
+ }
+
+ // ------------------------------------------------------------------
+ // 1. Two-sided role discrimination
+ // ------------------------------------------------------------------
+
+ private static void checkRoleDiscrimination(Checks c) {
+ c.section("two-sided role discrimination at n = 64,000");
+
+ final int rows = 64_000;
+ Fixture expected = generate(rows, SEED);
+
+ long expectAll = 0; // (a): REVENUE > 100, no role constraint
+ long expectEu = 0; // (b): REVENUE > 100 AND REGION == EU
+ for (int i = 0; i < rows; i++) {
+ if (expected.revenues()[i] > 100) {
+ expectAll++;
+ if (expected.regions()[i] == Orders.EU) {
+ expectEu++;
+ }
+ }
+ }
+
+ try (BricksSession session = Bricks.open(rows, SEED)) {
+ long actualGlobal = session.query()
+ .where(Orders.REVENUE.gt(100))
+ .authorize(Role.GLOBAL)
+ .count();
+ long actualEu = session.query()
+ .where(Orders.REVENUE.gt(100))
+ .authorize(Role.EU_ONLY)
+ .count();
+ long actualDeny = session.query()
+ .where(Orders.REVENUE.gt(100))
+ .authorize(Role.DENY_ALL)
+ .count();
+
+ c.eq("Role.GLOBAL: REVENUE > 100, no constraint, vs pure-Java recomputation",
+ expectAll, actualGlobal);
+ c.eq("Role.EU_ONLY: REVENUE > 100 AND REGION == EU, vs pure-Java recomputation",
+ expectEu, actualEu);
+ c.eq("Role.DENY_ALL: an unmatchable native predicate selects exactly zero rows",
+ 0, actualDeny);
+
+ c.that("EU count is not vacuous (count > 0)", expectEu > 0);
+ c.that("EU is a genuine subset, strictly fewer rows than GLOBAL (count < GLOBAL)",
+ expectEu < expectAll);
+ c.that("GLOBAL itself does not select every row (count < n)", expectAll < rows);
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // 2. Fail-closed: can-fire (throws without authorize) and its silence twin
+ // (no throw, correct answer, with authorize) — both orders of composition.
+ // ------------------------------------------------------------------
+
+ private static void checkFailClosed(Checks c) {
+ c.section("fail-closed terminals — can-fire half (no authorize -> throws)");
+
+ final int rows = 64_000;
+ try (BricksSession session = Bricks.open(rows, SEED)) {
+ c.throwsUp("count() without authorize() throws UnauthorizedQueryException",
+ UnauthorizedQueryException.class,
+ () -> session.query().where(Orders.REVENUE.gt(100)).count());
+ c.throwsUp("sum() without authorize() throws UnauthorizedQueryException",
+ UnauthorizedQueryException.class,
+ () -> session.query().where(Orders.REVENUE.gt(100)).sum(Orders.REVENUE));
+ c.throwsUp("sumBy() without authorize() throws UnauthorizedQueryException",
+ UnauthorizedQueryException.class,
+ () -> session.query().where(Orders.REVENUE.gt(100))
+ .sumBy(Orders.REGION, Orders.REVENUE));
+ }
+
+ c.section("fail-closed terminals — silence twin (authorize present -> no throw)");
+
+ Fixture expected = generate(rows, SEED);
+ long expectGlobalCount = 0;
+ for (int i = 0; i < rows; i++) {
+ if (expected.revenues()[i] > 100) {
+ expectGlobalCount++;
+ }
+ }
+
+ try (BricksSession session = Bricks.open(rows, SEED)) {
+ long counted = session.query()
+ .where(Orders.REVENUE.gt(100))
+ .authorize(Role.GLOBAL)
+ .count();
+ c.eq("with authorize() present, count() does not throw and matches the recomputation",
+ expectGlobalCount, counted);
+
+ // sum()/sumBy() must be reachable too, not merely count() — a design that special-cased
+ // only count()'s guard would pass the can-fire test above by accident.
+ long summed = session.query()
+ .where(Orders.REVENUE.gt(100))
+ .authorize(Role.GLOBAL)
+ .sum(Orders.REVENUE);
+ c.that("sum() with authorize() present does not throw (reached this line)", true);
+ c.that("sum() with authorize() present returns a real, non-trivially-zero total",
+ summed != 0);
+
+ Map{@code
+ * try (var session = Bricks.open(1_000_000, Bricks.DEFAULT_SEED)) {
+ * long euRevenue = session.query()
+ * .authorize(Role.EU_ONLY)
+ * .sum(Orders.REVENUE);
+ * }
+ * }
+ */
+public final class BricksSession implements AutoCloseable {
+
+ private final NativePattern pattern;
+
+ BricksSession(NativePattern pattern) {
+ this.pattern = pattern;
+ }
+
+ /**
+ * A fresh, unauthorized query over every row in this session.
+ *
+ * This is a schema, not an entity
+ *
+ * Field mapping — honest about what the fixture actually offers
+ *
+ * YEAR is deliberately absent
+ *
+ * A mask, not an if
+ *
+ * Extension point, stated honestly
+ *
+ * Fail-closed, not default-allow
+ *
+ *
+ *
+ */
+public final class BricksAuthTest {
+
+ private BricksAuthTest() {}
+
+ public static void main(String[] args) {
+ System.out.println("BricksAuthTest");
+ if (!NativeRuntime.isAvailable()) {
+ System.exit(Checks.reportUnavailable("BricksAuthTest"));
+ }
+ Checks c = new Checks("BricksAuthTest");
+ run(c);
+ System.exit(c.report());
+ }
+
+ /**
+ * Same seed as {@code TradesParityTest} — chosen there to match {@code
+ * NativePattern.DEFAULT_SEED} (docs/abi.md's normative fixture seed), reused here so this
+ * file's numbers are cross-checkable against the rest of the corpus.
+ */
+ private static final long SEED = 0xABCDL;
+
+ /**
+ * SplitMix64, transcribed from the normative fixture generator. Provenance: identical
+ * constant-for-constant, shift-for-shift transcription already checked against the published
+ * reference vector in {@code FixtureParityTest} and re-verified in {@code TradesParityTest}.
+ * Duplicated (not shared) because the original is {@code private} to a different compile unit
+ * — see {@code TradesParityTest}'s own copy for the same reasoning, applied here to the Bricks
+ * consumer module.
+ */
+ private static final class SplitMix64 {
+ private long state;
+
+ SplitMix64(long seed) {
+ this.state = seed; // no warm-up draws
+ }
+
+ long next() {
+ state += 0x9E3779B97F4A7C15L;
+ long z = state;
+ z = (z ^ (z >>> 30)) * 0xBF58476D1CE4E5B9L;
+ z = (z ^ (z >>> 27)) * 0x94D049BB133111EBL;
+ return z ^ (z >>> 31);
+ }
+ }
+
+ /**
+ * {@code (region, revenue)} per row, recomputed independently in pure Java from the fixture
+ * generator's published shape: two SplitMix64 draws per row, the first then the second, with
+ * {@code region = (draw1 >>> 33) & 0xF} and {@code revenue = ((draw2 >>> 40) & 0x1FF) - 150}
+ * — {@code Orders.REGION} binds the same lane-1 U32 tag {@code Trade.VENUE} does, and {@code
+ * Orders.REVENUE} the same lane-2 I32 value {@code Trade.PRICE} does, so the derivation is
+ * identical, renamed to match this schema's domain.
+ *
+ *