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. + * + *

Zero membrane growth — the iron rule

+ * + *

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. + * + *

Security is a mask composed before execution, never a post-filter

+ * + *

{@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. + * + *

Fail-closed

+ * + *

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. + * + *

Aggregate-only egress

+ * + *

Every public method on this class returns exactly one of {@code BricksQuery}, {@code long}, or + * {@code Map}. No method here returns, nor could return, anything row-shaped — there + * is no public type in this consumer that represents a single order. A caller can learn how many + * rows matched, or a sum, or a per-group breakdown of sums; a caller cannot ever get a row, a field + * value from one row, or an iterator over rows. That is a structural fact about this package's + * public surface, not a convention someone could accidentally violate from outside it. + */ +public final class BricksQuery { + + private final View view; + private final boolean authorized; + + BricksQuery(View view, boolean authorized) { + this.view = view; + this.authorized = authorized; + } + + /** + * A new query narrowed by one more condition. + * + *

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 sumBy( + com.adaworldapi.lancegraph.U32Field group, com.adaworldapi.lancegraph.I32Field value) { + requireAuthorized("sumBy()"); + java.util.Objects.requireNonNull(group, "group"); + java.util.Objects.requireNonNull(value, "value"); + Map result = new LinkedHashMap<>(16); + for (int v = 0; v < 16; v++) { + result.put(v, view.where(group.eq(v)).sumOf(value)); + } + return result; + } + + private void requireAuthorized(String what) { + if (!authorized) { + throw new UnauthorizedQueryException( + what + " was called on a query that was never authorize()'d. This is fail-closed" + + " by design: a missing role mask never falls back to emitting" + + " everything. Call .authorize(Role.GLOBAL) (or a narrower role) before" + + " any terminal operation, even when every row should be visible."); + } + } + + @Override + public String toString() { + return "BricksQuery[" + (authorized ? "authorized" : "UNAUTHORIZED") + ", " + view + "]"; + } +} diff --git a/consumers/bricks/src/main/java/com/adaworldapi/bricks/BricksSession.java b/consumers/bricks/src/main/java/com/adaworldapi/bricks/BricksSession.java new file mode 100644 index 0000000..4000c66 --- /dev/null +++ b/consumers/bricks/src/main/java/com/adaworldapi/bricks/BricksSession.java @@ -0,0 +1,53 @@ +package com.adaworldapi.bricks; + +import com.adaworldapi.lancegraph.NativePattern; + +/** + * A set of order rows held natively, opened once and closed once — the RBAC-poster sibling of + * {@code com.adaworldapi.trades.World}. + * + *

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}. + * + *

{@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. + * + *

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. + * + *

This is a schema, not an entity

+ * + *

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. + * + *

Field mapping — honest about what the fixture actually offers

+ * + *

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. + * + *

YEAR is deliberately absent

+ * + *

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 FIELDS = List.of(REGION, REVENUE); +} diff --git a/consumers/bricks/src/main/java/com/adaworldapi/bricks/Role.java b/consumers/bricks/src/main/java/com/adaworldapi/bricks/Role.java new file mode 100644 index 0000000..b911380 --- /dev/null +++ b/consumers/bricks/src/main/java/com/adaworldapi/bricks/Role.java @@ -0,0 +1,65 @@ +package com.adaworldapi.bricks; + +import com.adaworldapi.lancegraph.Predicate; + +import java.util.Optional; + +/** + * An authorization constraint — the mask a {@link BricksQuery} composes into its chain before + * any terminal operation executes. + * + *

A mask, not an if

+ * + *

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. + * + *

Extension point, stated honestly

+ * + *

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 constraint; + + private Role(Optional constraint) { + this.constraint = constraint; + } + + /** + * The additional predicate this role imposes, if any. Package-private: composing it into a + * chain is {@link BricksQuery#authorize(Role)}'s job, not a caller's. + */ + Optional constraint() { + return constraint; + } +} diff --git a/consumers/bricks/src/main/java/com/adaworldapi/bricks/UnauthorizedQueryException.java b/consumers/bricks/src/main/java/com/adaworldapi/bricks/UnauthorizedQueryException.java new file mode 100644 index 0000000..5afeef6 --- /dev/null +++ b/consumers/bricks/src/main/java/com/adaworldapi/bricks/UnauthorizedQueryException.java @@ -0,0 +1,24 @@ +package com.adaworldapi.bricks; + +/** + * Thrown by a {@link BricksQuery} terminal operation ({@link BricksQuery#count()}, {@link + * BricksQuery#sum}, {@link BricksQuery#sumBy}) when {@link BricksQuery#authorize(Role)} was never + * called on that chain. + * + *

Fail-closed, not default-allow

+ * + *

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). + * + *

    + *
  1. Two-sided role discrimination — {@code Role.GLOBAL}, {@code + * Role.EU_ONLY}, and {@code Role.DENY_ALL} produce three different, independently + * predicted counts over the identical base query, agreeing with a pure-Java recomputation + * that never touches {@code BricksQuery} or the native library. + *
  2. Fail-closed, both directions — a terminal without {@code authorize(...)} + * throws {@code UnauthorizedQueryException} (checked on all three terminals: {@code + * count()}, {@code sum()}, {@code sumBy()}); the identical chain with {@code authorize(...)} + * present does not throw and returns the predicted answer, in either composition order. + *
  3. Compose-not-execute — {@code Diagnostics.crossings()} does not move while + * {@code .where(...)}/{@code .authorize(...)} are composed, only when a terminal runs — the + * strongest checkable form of "nothing crosses until the fully-clamped plan does." + *
  4. {@code sumBy} parity — every one of its 16 returned per-region sums + * agrees with an independent pure-Java recomputation, and the 16 sums are internally + * consistent with the grand total. + *
  5. Aggregate-only egress — {@code BricksQuery}'s public surface returns only + * {@code BricksQuery}, {@code long}, or {@code Map}; {@code Orders} is a schema, never an + * entity, exactly as {@code Trade} is proven to be in {@code TradesParityTest}. + *
+ */ +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. + * + *

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 grouped = session.query() + .where(Orders.REVENUE.gt(100)) + .authorize(Role.GLOBAL) + .sumBy(Orders.REGION, Orders.REVENUE); + c.that("sumBy() with authorize() present does not throw (reached this line)", true); + c.eq("sumBy() with authorize() present reports all 16 regions", 16, grouped.size()); + } + + c.section("authorize() composition order does not matter"); + + try (BricksSession session = Bricks.open(rows, SEED)) { + long whereThenAuthorize = session.query() + .where(Orders.REVENUE.gt(100)) + .authorize(Role.EU_ONLY) + .count(); + long authorizeThenWhere = session.query() + .authorize(Role.EU_ONLY) + .where(Orders.REVENUE.gt(100)) + .count(); + c.eq(".where(...).authorize(...) vs .authorize(...).where(...) — identical count", + whereThenAuthorize, authorizeThenWhere); + } + } + + // ------------------------------------------------------------------ + // 3. Compose-not-execute: crossings stay 0 through where()/authorize(), and the exact + // crossing cost of each terminal (count(): 1; sumBy(): 16, group-count-scaled, never + // row-count-scaled). + // ------------------------------------------------------------------ + + private static void checkComposeNotExecute(Checks c) { + c.section("compose-not-execute (Diagnostics.crossings())"); + + final int rows = 64_000; + try (BricksSession session = Bricks.open(rows, SEED)) { + long before = Diagnostics.crossings(); + BricksQuery chain = session.query() + .where(Orders.REVENUE.gt(100)) + .authorize(Role.EU_ONLY); + long afterBuild = Diagnostics.crossings(); + + c.eq("composing .where(...).authorize(...) costs no crossings — " + + "NOTHING crosses before the fully-clamped plan does", + 0, afterBuild - before); + + // Warm the resource's reusable scratch selection so the steady-state cost is what is + // measured, matching LazinessTest's own discipline. + chain.count(); + + long c0 = Diagnostics.crossings(); + chain.count(); + long terminalCost = Diagnostics.crossings() - c0; + c.eq("count() over an authorized chain costs exactly one crossing", + 1, terminalCost); + } + } + + private static void checkSumByCrossingCost(Checks c, int rows) { + try (BricksSession session = Bricks.open(rows, SEED)) { + BricksQuery chain = session.query() + .where(Orders.REVENUE.gt(100)) + .authorize(Role.GLOBAL); + + // Warm-up call, unmeasured — same discipline as count()'s crossing check above. + chain.sumBy(Orders.REGION, Orders.REVENUE); + + long c0 = Diagnostics.crossings(); + chain.sumBy(Orders.REGION, Orders.REVENUE); + long cost = Diagnostics.crossings() - c0; + + // A sum terminal is TWO crossings, not one: plan evaluation into the selection mask + // (lgj_plan_eval) plus the reduction itself (lgj_reduce_sum_i32) — unlike count(), + // whose plan evaluation RETURNS the count and so pays only one. 16 groups x 2 = 32. + // The thesis assertion is the arithmetic's SHAPE: crossings are proportional to the + // number of groups, never to the number of rows — which is why this same literal is + // asserted at BOTH n = 1000 and n = 64000. + c.eq("sumBy() at n = " + rows + " costs exactly 32 crossings" + + " (2 per region group — plan eval + reduce — never one per row)", + 32, cost); + } + } + + // ------------------------------------------------------------------ + // 4. sumBy() parity — value correctness plus internal consistency. + // ------------------------------------------------------------------ + + private static void checkSumByParity(Checks c) { + c.section("sumBy() parity"); + + checkSumByCrossingCost(c, 1_000); + checkSumByCrossingCost(c, 64_000); + + final int rows = 64_000; + Fixture expected = generate(rows, SEED); + + long[] expectedByRegion = new long[16]; + long expectedGrandTotal = 0; + for (int i = 0; i < rows; i++) { + if (expected.revenues()[i] > 100) { + int region = expected.regions()[i]; + expectedByRegion[region] += expected.revenues()[i]; + expectedGrandTotal += expected.revenues()[i]; + } + } + + try (BricksSession session = Bricks.open(rows, SEED)) { + Map actual = session.query() + .where(Orders.REVENUE.gt(100)) + .authorize(Role.GLOBAL) + .sumBy(Orders.REGION, Orders.REVENUE); + + c.eq("sumBy() returns exactly 16 region entries", 16, actual.size()); + + long actualGrandTotal = 0; + for (int region = 0; region < 16; region++) { + Long value = actual.get(region); + c.that("sumBy() has an entry for region " + region, value != null); + long v = value == null ? Long.MIN_VALUE : value; + c.eq("region " + region + " sum matches pure-Java recomputation", + expectedByRegion[region], v); + actualGrandTotal += v; + } + + c.eq("sum of all 16 region sums equals the recomputed grand total" + + " (internal consistency, independent of the per-region parity above)", + expectedGrandTotal, actualGrandTotal); + + long actualDirectSum = session.query() + .where(Orders.REVENUE.gt(100)) + .authorize(Role.GLOBAL) + .sum(Orders.REVENUE); + c.eq("sum(REVENUE) under the identical predicate agrees with the sumBy() grand total", + actualDirectSum, actualGrandTotal); + } + } + + // ------------------------------------------------------------------ + // 5. Aggregate-only egress: BricksQuery's return-type surface, and Orders as a schema. + // ------------------------------------------------------------------ + + private static void checkAggregateOnlyEgress(Checks c) { + c.section("aggregate-only egress — BricksQuery's public return types"); + + for (Method m : BricksQuery.class.getMethods()) { + if (m.getDeclaringClass() == Object.class) { + continue; // wait()/notify()/getClass()/... are not this class's surface + } + // BricksQuery OVERRIDES toString() (its debug rendering), so getDeclaringClass() no + // longer reports Object for it — but an Object-defined contract method is still not + // part of the aggregate-egress surface being audited here, whichever class's body + // happens to serve it. Exclude the overridable Object trio by NAME. + String name = m.getName(); + if ((name.equals("toString") || name.equals("equals") || name.equals("hashCode")) + && switch (name) { + case "toString", "hashCode" -> m.getParameterCount() == 0; + default -> m.getParameterCount() == 1 + && m.getParameterTypes()[0] == Object.class; + }) { + continue; + } + Class ret = m.getReturnType(); + boolean aggregateOnly = + ret == BricksQuery.class || ret == long.class || ret == Map.class; + c.that("BricksQuery." + m.getName() + "(...) returns BricksQuery, long, or Map" + + " (was " + ret.getSimpleName() + ") — no row-shaped public type exists", + aggregateOnly); + } + + c.section("Orders is a schema, not an entity — the reflection guard"); + + c.eq("Orders declares zero public constructors", + 0, publicConstructorCount(Orders.class)); + c.eq("Orders declares zero non-static instance fields", + 0, instanceFieldCount(Orders.class)); + + // AssertionError specifically, matching TradesParityTest's guard on Trade — a plain + // "some exception" check would let a typo (e.g. a stray NullPointerException) pass for + // the wrong reason. + c.throwsUp("forcing the private constructor accessible still cannot build an Orders", + AssertionError.class, () -> { + try { + var ctor = Orders.class.getDeclaredConstructor(); + ctor.setAccessible(true); + ctor.newInstance(); + } catch (java.lang.reflect.InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException re) { + throw re; + } + if (cause instanceof Error err) { + throw err; + } + throw new RuntimeException(cause); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + }); + } + + private static int publicConstructorCount(Class type) { + return type.getConstructors().length; + } + + private static int instanceFieldCount(Class type) { + int n = 0; + for (var field : type.getDeclaredFields()) { + if (!Modifier.isStatic(field.getModifiers())) { + n++; + } + } + return n; + } +}