Schubert
Replace boolean allow/deny with geometric access control. Quantitative decisions. Impossibility detection. Continuous trust.
Schubert is a Rust library that reimagines access control through Schubert calculus — a
branch of algebraic geometry. Instead of returning true or false, Schubert tells you
how many valid configurations exist for a given set of capabilities. When conditions are
geometrically impossible to satisfy together, Schubert catches conflicts that traditional
boolean AND checks would silently approve.
Why Schubert?
Traditional access control gives you a boolean. You either can or you can't. This breaks down in complex systems:
- Two capabilities conflict but individually are fine — a boolean AND approves. Schubert detects the geometrical impossibility.
- Trust degrades over time — boolean systems can't express partial trust. Schubert models continuous trust with wall-crossing analysis.
- Cross-domain access is guesswork — can a capability in one domain translate to another? Schubert's Schubert intersection answers exactly.
- Rate limiting is arbitrary — Schubert scales rate limits by intersection numbers, giving higher-trust principals more throughput.
The Killer Feature: Impossibility Detection
Consider a user with write (σ₂) and internal-audit (σ₁₁) capabilities in Gr(2,4). Each capability is individually valid. Together? They're geometrically impossible — no subspace of ℝ⁴ can simultaneously satisfy both conditions.
A traditional RBAC system with boolean AND would approve. Schubert returns
AccessDecision::Impossible and tells you exactly which capabilities conflict.
Quick Start
use schubert::{AccessController, Capability, CapabilityKind, AccessDecision}; fn main() -> Result<(), Box<dyn std::error::Error>> { let mut acl = AccessController::new(2, 4)?; acl.register_capability(Capability::new("read", "Read", vec![1], CapabilityKind::ReadLike))?; acl.register_capability(Capability::new("write", "Write", vec![2], CapabilityKind::WriteLike))?; let alice = acl.create_principal("alice")?; acl.grant(&alice, "read")?; acl.grant(&alice, "write")?; match acl.check(&alice, &["read", "write"])? { AccessDecision::Granted { configurations, .. } => { println!("Granted: {configurations} valid configurations"); } AccessDecision::Impossible { conflicting } => { println!("Impossible: {conflicting:?}"); } _ => println!("Denied or underconstrained"), } Ok(()) }
What Schubert Is Not
- Not an authentication system — identity belongs to your OAuth/OIDC provider
- Not a network service — Schubert is a library you embed
- Not a policy server — no REST API, no gRPC, no wire protocol
- Not a single Grassmannian —
MultiControllermanages cross-domain access
The Industrial Algebra Ecosystem
Schubert depends on three sibling projects:
| Crate | Version | Role |
|---|---|---|
| Crate | Version | Role |
| --- | --- | --- |
| Amari | 0.23 | Schubert calculus engine — Grassmannians, intersection numbers |
| Karpal | 0.6 | Formal verification — type-level proofs, SMT/Lean obligations |
| Minuet | 0.5 | Holographic memory — cosine-similarity access patterns |
Only Amari is a hard dependency. Karpal and Minuet are opt-in features for formal verification and holographic access patterns respectively.
License
Schubert is licensed under Apache-2.0 — a permissive open-source license with patent grant and attribution requirements. See LICENSE for the full text. All contributors must sign the CLA.
For licensing inquiries: license@industrialalgebra.com
Getting Started
Installation
Add Schubert to your Cargo.toml:
[dependencies]
schubert = "0.1"
Schubert requires nightly Rust (the Industrial Algebra ecosystem standard):
rustup toolchain install nightly
rustup default nightly
Your First Access Controller
use schubert::{ AccessController, Capability, CapabilityKind, AccessDecision, PrincipalId, }; fn main() -> Result<(), Box<dyn std::error::Error>> { // Create a controller for Gr(2,4) — the standard RBAC space let mut acl = AccessController::new(2, 4)?; // Register capabilities (Schubert conditions) acl.register_capability(Capability::new( "read:data", "Read data access", vec![1], // σ₁ — codimension 1 CapabilityKind::ReadLike, ))?; acl.register_capability(Capability::new( "write:data", "Write data access", vec![2], // σ₂ — codimension 2 CapabilityKind::WriteLike, ))?; // Create a principal and grant capabilities let alice = acl.create_principal("alice")?; acl.grant(&alice, "read:data")?; acl.grant(&alice, "write:data")?; // Check access — returns a quantitative decision match acl.check(&alice, &["read:data", "write:data"])? { AccessDecision::Granted { configurations } => { println!("Granted with {configurations} configurations"); } AccessDecision::Impossible { conflicting } => { println!("Geometrically impossible: {conflicting:?}"); } AccessDecision::Denied => { println!("Access denied (overconstrained)"); } AccessDecision::Underconstrained { dimension } => { println!("Policy too permissive (dimension {dimension})"); } } Ok(()) }
Understanding the Output
| Decision | Meaning |
|---|---|
Granted { configurations: n } | Access allowed in exactly n ways |
Impossible { conflicting } | Conditions are geometrically incompatible |
Denied | Too many conditions for the policy space |
Underconstrained { dimension } | Not enough conditions (policy is loose) |
Choosing a Grassmannian
| Gr(k,n) | Dimension k(n−k) | Use Case |
|---|---|---|
| Gr(2,4) | 4 | Standard RBAC (recommended starting point) |
| Gr(3,6) | 9 | Complex multi-tenant policies |
| Gr(4,8) | 16 | Enterprise-scale policy space |
Larger Grassmannians support more distinct capabilities but have higher computational cost.
Next Steps
- Mathematical Foundation — understand the geometry
- Capabilities as Schubert Conditions — designing capabilities
- Feature Flags — enabling optional features
- Installation & Configuration — production setup
Mathematical Foundation
Schubert uses Schubert calculus — a branch of algebraic geometry — to make access control decisions. You don't need to be a mathematician, but understanding the core concepts helps design better policies.
The Grassmannian as Policy Space
A Grassmannian Gr(k,n) is the space of all k-dimensional subspaces of an n-dimensional vector space. In Schubert, we use it as the policy space — each point represents a possible access configuration.
The dimension of Gr(k,n) is k(n−k). This is the maximum number of independent Schubert conditions you can impose before the space collapses:
| Gr(k,n) | Dimension | Max Independent Conditions |
|---|---|---|
| Gr(2,4) | 4 | 4 |
| Gr(3,6) | 9 | 9 |
| Gr(4,8) | 16 | 16 |
Schubert Conditions
A Schubert condition is a geometric constraint defined by a partition — a
weakly decreasing sequence of integers like [1], [2,1], or [2,2]. Each partition
corresponds to a specific subspace constraint.
The codimension of a condition is the sum of the partition entries. Higher codimension = more restrictive:
| Partition | Codimension | Typical Use |
|---|---|---|
[1] | 1 | Read access |
[2] | 2 | Write access |
[1,1] | 2 | Read + audit |
[2,1] | 3 | Manage |
[2,2] | 4 | Admin (point class) |
Schubert Intersection
When you check multiple capabilities, Schubert computes their Schubert intersection. The intersection number (Littlewood-Richardson coefficient) tells you how many configurations satisfy all conditions simultaneously:
- Positive integer: access is granted with that many configurations
- Zero: the conditions are geometrically impossible together (the killer feature)
- Too many conditions (> dimension): overconstrained — access denied
Key Algebraic Identities
Note: These are identities in algebraic geometry (facts about Grassmannians), not validated security properties. The security relevance depends on the formal mapping from your domain to Schubert conditions. See Distributed Game Sync for the formal mapping.
- σ₁⁴ = 2 in Gr(2,4) — four read-like conditions yield exactly 2 configurations
- σ₂ · σ₁₁ = 0 — write + internal-audit is geometrically impossible
- Composition is commutative — grant order doesn't matter
- Grant-revoke identity — grant then revoke = no net change
Notation Guide
| Symbol | Meaning |
|---|---|
| σ_λ | Schubert class indexed by partition λ |
| σ₁ | Schubert class for partition [1] (codimension 1) |
| σ₂ | Schubert class for partition [2] (codimension 2) |
| σ₁₁ | Schubert class for partition [1,1] (codimension 2, different direction from σ₂) |
| σ₂₂ | Schubert class for partition [2,2] — the point class (codimension 4 in Gr(2,4), the class of a single point) |
The partition [λ₁, λ₂, ...] and the Schubert class σ_λ refer to the same object. We use partitions when defining capabilities and σ notation when computing intersections.
External References
- Grassmannian — Wikipedia
- Schubert calculus — Wikipedia
- Littlewood-Richardson rule — Wikipedia
- Schubert variety — Wolfram MathWorld
- Capability-based security — Wikipedia
Prior Work in Access Control
- Fuzzy Multi-Level Security — Cheng et al. (2007) — Continuous trust for MLS (scalar, not geometric)
- RBAC Models — Sandhu et al. (1996) — Standard role-based model (boolean)
- ABAC Guide — NIST SP 800-162 — Attribute-based model (boolean)
- ReBAC — Fong (2011) — Relationship-based access control (graph-based)
- Proof-Carrying Authentication — Appel & Felten (1999) — Logical proofs as auth tokens
- Conflict-Free Replicated Data Types — Shapiro et al. (2011) — CRDT foundations
Capabilities as Schubert Conditions
Every capability in Schubert is a Schubert condition — a geometric constraint with a partition, a kind, and a label.
Defining a Capability
#![allow(unused)] fn main() { use schubert::{Capability, CapabilityKind}; let read = Capability::new( "read:data", // unique ID "Read data access", // human-readable label vec![1], // partition: σ₁ (codimension 1) CapabilityKind::ReadLike, ); let write = Capability::new( "write:data", "Write data access", vec![2], // partition: σ₂ (codimension 2) CapabilityKind::WriteLike, ); let admin = Capability::new( "admin", "Full administrative access", vec![2, 2], // partition: σ₂₂ (codimension 4, point class) CapabilityKind::AdminLike, ); }
CapabilityKind
The CapabilityKind affects how capabilities behave under trust degradation:
| Kind | Trust Sensitivity | Examples |
|---|---|---|
ReadLike | Low — stable even at low trust | Read, list, view |
WriteLike | Medium — degrades at moderate trust | Write, update, delete |
AdminLike | High — degrades rapidly under trust loss | Admin, manage, configure |
Custom | Application-defined | Any custom semantic |
Higher-codimension AdminLike capabilities are the first to become unstable as trust erodes. This models the real-world principle that powerful capabilities should require more trust.
Partition Design
Partitions determine how capabilities interact. Key rules:
- Partitions must be weakly decreasing:
[2,1]is valid,[1,2]is not - Codimension = sum of entries:
[2,1]has codimension 3 - One element = one row:
[1]is a single-row condition - Equal partitions = same restriction: two capabilities with
[1]are equivalent in their geometric constraint
Temporal Capabilities
Capabilities can have an expiry time:
#![allow(unused)] fn main() { let temp = Capability::new("temp", "Temporary access", vec![1], CapabilityKind::ReadLike) .with_expiry(now + 3_600_000); // 1 hour from now acl.register_capability(temp)?; acl.grant(&principal, "temp")?; // Later: acl.check_temporal(&principal, &["temp"], now)?; // OK acl.check_temporal(&principal, &["temp"], later)?; // Denied }
Registration and Grants
Capabilities must be registered with the controller before they can be granted:
#![allow(unused)] fn main() { // 1. Register acl.register_capability(read)?; // 2. Grant to principal acl.grant(&alice, "read:data")?; // 3. Check acl.check(&alice, &["read:data"])?; }
Registration defines the capability's geometry. Granting assigns it to a principal. Checking evaluates the intersection.
Access Decisions
When you call acl.check(), Schubert returns an AccessDecision — not a boolean,
but a quantitative result with four variants.
The Four Decisions
#![allow(unused)] fn main() { pub enum AccessDecision { /// Access allowed with exactly this many configurations Granted { configurations: usize }, /// Conditions are geometrically incompatible (Littlewood-Richardson = 0) Impossible { conflicting: Vec<String> }, /// Too many conditions for the policy space (overconstrained) Denied, /// Too few conditions — policy is loose Underconstrained { dimension: usize }, } }
Granted
The intersection number is positive. The principal can access the resource in
configurations distinct ways.
#![allow(unused)] fn main() { acl.grant(&alice, "read")?; acl.grant(&alice, "write")?; let result = acl.check(&alice, &["read", "write"])?; match result { AccessDecision::Granted { configurations } => { // configurations is the Littlewood-Richardson coefficient // for σ₁ ∩ σ₂ in this Grassmannian } _ => {} } }
Impossible
The killer feature. Individual capabilities are valid but together they're geometrically impossible. A traditional boolean AND would approve.
#![allow(unused)] fn main() { // σ₂ (write) and σ₁₁ (internal audit) in Gr(2,4) acl.grant(&principal, "write")?; acl.grant(&principal, "internal_audit")?; let result = acl.check(&principal, &["write", "internal_audit"])?; // AccessDecision::Impossible { conflicting: ["write", "internal_audit"] } }
The Littlewood-Richardson coefficient σ₂ · σ₁₁ = 0 in Gr(2,4). No subspace can simultaneously satisfy both conditions.
Denied
Too many independent conditions — the intersection is empty because the total codimension exceeds the Grassmannian dimension.
#![allow(unused)] fn main() { // Gr(2,4) has dimension 4 — can't impose 5 independent conditions acl.check(&principal, &["c1", "c2", "c3", "c4", "c5"])?; // AccessDecision::Denied }
Underconstrained
Too few conditions — the policy doesn't pin down a specific configuration. The remaining dimension tells you how loose the policy is.
#![allow(unused)] fn main() { // Only one condition in Gr(2,4) with dimension 4 // Remaining dimension = 4 - 1 = 3 acl.check(&principal, &["read"])?; // AccessDecision::Underconstrained { dimension: 3 } }
Computation Paths
Schubert supports four computation engines for computing intersection numbers:
| Path | When to use |
|---|---|
LR | Default — balanced performance, exact results |
Localization | When you need geometric insight into why |
Tropical | Large-scale batch operations (>1000 principals) |
Matroid | When parallel evaluation is enabled |
#![allow(unused)] fn main() { use schubert::ComputationPath; acl.set_computation_path(ComputationPath::Tropical); }
Trust and Stability
Schubert models trust as a continuous value from 0.0 to 1.0, and analyzes how capabilities degrade as trust erodes.
Continuous Trust
Unlike boolean access control (trusted / not trusted), Schubert supports continuous trust:
#![allow(unused)] fn main() { use schubert::AccessContext; let ctx = AccessContext { resource: Some("customer-data".into()), time_budget_ms: Some(500), required_trust: 0.85, // 85% trust required }; acl.check_with_context(&principal, &["read:data"], &ctx)?; }
Wall-Crossing Stability
The wall-crossing engine (analyze_stability()) finds the trust levels where
capabilities become unstable:
#![allow(unused)] fn main() { use schubert::analyze_stability; let report = analyze_stability(&acl, &principal)?; // report.phase_diagram — breakpoints where stability changes // report.walls — individual stability walls per capability // report.most_sensitive — which capability degrades first }
A phase diagram shows how many configurations are available at each trust level. As trust drops, capabilities cross stability walls — higher-codimension (AdminLike) capabilities cross first.
Trust Sensitivity by Kind
| CapabilityKind | Degradation Pattern |
|---|---|
| ReadLike | Degrades below ~0.3 trust |
| WriteLike | Degrades below ~0.5 trust |
| AdminLike | Degrades below ~0.7 trust |
This models the security principle that powerful operations require higher trust.
Surreal Trust Levels
For applications requiring exact arithmetic on infinitesimal trust differences,
enable the surreal feature:
#![allow(unused)] fn main() { // Requires: features = ["surreal"] use schubert::surreal_trust::SurrealTrust; let trust = SurrealTrust::new(rational_surreal_value); }
The surreal trust module uses Amari's RationalSurreal (v0.23) for exact arithmetic
on trust values, including infinitesimal ε and ε². This enables:
- Exact comparison of arbitrarily close trust levels
- Infinitesimal trust recovery after temporary degradation
- Provable trust monotonicity for formal verification
#![allow(unused)] fn main() { // Compare infinitesimal trust levels let a = SurrealTrust::from_epsilon(1); // ε (infinitesimal) let b = SurrealTrust::from_epsilon(2); // 2ε (twice infinitesimal) assert!(a < b); // Exact trust composition let combined = a.compose_with(b)?; }
For a deep dive, see Surreal Trust Levels.
Composition and Composability
Schubert supports operadic composition — combining capabilities across principals to model service chains, delegation, and capability translation.
Operadic Composition
Two capabilities are composable if their Schubert intersection is non-empty. The result includes a multiplicity — how many configurations survive the composition:
#![allow(unused)] fn main() { use schubert::compose; let result = compose(&acl, &producer, "output", &consumer, "input")?; match result { CompositionResult::Composable { multiplicity } => { println!("{multiplicity} configurations survive composition"); } CompositionResult::NotComposable { reason } => { println!("Cannot compose: {reason}"); } } }
Service Chain Model
Composition models real-world service chains:
Service A (produces "report") ─┐
├─► Compose? Multiplicity?
Service B (consumes "report") ─┘
If Service A's output capability and Service B's input capability are composable with multiplicity > 0, the service chain is valid.
Mathematical Properties
- Commutativity: Grant order doesn't affect composition result
- Associativity:
(a ∘ b) ∘ c = a ∘ (b ∘ c) - Identity: Grant then revoke = no net change
- Impossibility is symmetric: If
a ∘ bis impossible,b ∘ ais too
Cross-Domain Composition
For multi-Grassmannian setups, use MultiController:
#![allow(unused)] fn main() { use schubert::MultiController; let mut mc = MultiController::new(); let rbac = mc.add_domain(2, 4)?; // RBAC domain let tenant = mc.add_domain(3, 6)?; // Multi-tenant domain mc.create_principal("alice", &rbac)?; mc.grant_in_domain(&alice, "read", &rbac)?; // Check if an RBAC capability works in the tenant domain: mc.check_cross_domain(&alice, &["read"], &rbac, &tenant)?; }
Cross-domain checks use Schubert intersection to determine if capabilities translate between Grassmannians.
Checking Composability
Before attempting composition, check if capabilities are composable:
#![allow(unused)] fn main() { if are_composable(&acl, "read", "write")? { let result = compose(&acl, &alice, "read", &bob, "write")?; // ... } }
The are_composable() check is cheaper than full composition — it only checks
whether the Littlewood-Richardson coefficient is non-zero.
Installing and Configuring
Cargo.toml
[dependencies]
schubert = "0.1"
Feature Flags
Enable additional features as needed:
[dependencies]
schubert = { version = "0.1", features = ["serde", "policy", "crypto"] }
| Feature | Enables |
|---|---|
std (default) | HashMap, SystemTime, thread-safe audit |
serde | Serialization on all types, JSON I/O |
karpal | Type-level proofs (Proven, Rewrite) |
parallel | Batch operations via rayon |
policy | TOML policy language |
wasm | WebAssembly JS bindings |
crypto | Ed25519 capability tokens |
karpal-verify | Formal verification (SMT/Lean) |
surreal | Exact surreal trust arithmetic |
holographic | Minuet holographic memory |
All features compose — enable any combination.
no_std Support
[dependencies]
schubert = { version = "0.1", default-features = false }
When std is disabled: HashMap → BTreeMap, InMemoryAudit is single-threaded,
AuditSink trait is unavailable.
Production Configuration
[dependencies]
schubert = { version = "0.1", features = [
"serde", # Serialization for policy persistence
"policy", # TOML policy files
"crypto", # Signed capability tokens
"parallel", # Batch operations
] }
For high-assurance systems, add karpal, karpal-verify, and surreal.
Feature Flags
Schubert uses additive feature gates — enabling a feature adds functionality without breaking existing API.
Available Features
| Feature | What It Enables |
|---|---|
std (default) | HashMap, SystemTime, thread-safe Mutex audit |
serde | Serialize/Deserialize on all types, JSON I/O |
karpal | proof module: Proven, Property, Rewrite, law checks |
parallel | check_batch(), stability_batch(), compose_batch() via rayon |
policy | policy module: TOML parsing, validate, roundtrip |
wasm | wasm module: WasmController with JS bindings |
crypto | crypto module: Ed25519 CapabilityToken, GrantToken, Issuer, Verifier, KeyStore |
axum | axum module: AuthPrincipal bearer-token extractor (enables crypto) |
karpal-verify | verify module: SMT/Lean proof obligations, Certified trust boundary |
surreal | surreal_trust module: RationalSurreal + EpsilonPolynomial |
holographic | holographic module: Minuet integration |
Common Combinations
# Production with crypto tokens and policy loading
cargo build --features serde,policy,crypto
# Web service with bearer-token auth
cargo build --features axum
# Research with proofs and verification
cargo build --features karpal,karpal-verify,surreal
# Browser with wasm bindings
cargo build --target wasm32-unknown-unknown --features wasm
# Everything (for development)
cargo build --all-features
All features are designed to compose freely. Enable only what you need — each feature adds compile time and binary size.
Adding Your Own Feature
Feature gates follow the IA ecosystem convention:
- Add to
[features]inCargo.toml - Use
#[cfg(feature = "my-feature")]on module declarations - Use
#[cfg(feature = "my-feature")]on impl blocks and functions - Document in
src/lib.rs
Policy Language (TOML)
Schubert supports declarative policies via TOML files. Enable with the policy feature.
Policy File Format
# policy.toml
[grassmannian]
k = 2
n = 4
[capabilities.read]
partition = [1]
kind = "ReadLike"
label = "Read access"
[capabilities.write]
partition = [2]
kind = "WriteLike"
label = "Write access"
[capabilities.admin]
partition = [2, 2]
kind = "AdminLike"
label = "Full administration"
[principals.alice]
grants = ["read", "write"]
[principals.bob]
grants = ["read"]
[principals.admin_user]
grants = ["admin"]
Loading Policies
#![allow(unused)] fn main() { use schubert::AccessController; let toml_str = std::fs::read_to_string("policy.toml")?; let acl = AccessController::from_policy_toml(&toml_str)?; // Use the loaded controller let alice = acl.get_principal("alice")?; acl.check(&alice, &["read", "write"])?; }
Exporting Policies
#![allow(unused)] fn main() { let toml_str = acl.to_policy_toml()?; std::fs::write("exported-policy.toml", toml_str)?; }
Validation
Policies are validated on load:
- Grassmannian dimensions must satisfy 0 < k < n
- Partitions must be weakly decreasing
- Capability IDs must be unique
- Principal grants must reference registered capabilities
- CapabilityKind must be a valid variant
Invalid policies return descriptive errors with context.
Multi-Domain Access
MultiController manages access across multiple Grassmannian domains with
cross-domain capability translation.
Setup
#![allow(unused)] fn main() { use schubert::MultiController; let mut mc = MultiController::new(); // Register domains let rbac_domain = mc.add_domain_named(2, 4, "rbac")?; let tenant_domain = mc.add_domain_named(3, 6, "multi-tenant")?; // Create principal in a domain let alice = mc.create_principal("alice", &rbac_domain)?; // Grant capabilities within a domain mc.grant_in_domain(&alice, "read", &rbac_domain)?; mc.grant_in_domain(&alice, "write", &rbac_domain)?; }
Same-Domain Check
#![allow(unused)] fn main() { let result = mc.check_in_domain(&alice, &["read", "write"], &rbac_domain)?; }
Cross-Domain Check
Translates capabilities between Grassmannians using Schubert intersection:
#![allow(unused)] fn main() { // Check if RBAC read/write capabilities work in the tenant domain let result = mc.check_cross_domain( &alice, &["read", "write"], &rbac_domain, // from this domain &tenant_domain, // to this domain )?; }
Domain Discovery
#![allow(unused)] fn main() { // Find domains that accept a given partition let domains = mc.domains_for_partition(&[1])?; // List capabilities translatable between domains let translatable = mc.translatable_capabilities(&rbac_domain, &tenant_domain)?; }
Rate Limiting
Schubert's RateLimiter uses a token-bucket algorithm scaled by Schubert intersection
numbers — higher-trust principals get proportionally more throughput.
Basic Rate Limiter
#![allow(unused)] fn main() { use schubert::RateLimiter; // 10 tokens per second, burst capacity of 20 tokens let mut rl = RateLimiter::new(10.0, 1.0); // Per-request rate check if rl.try_consume("alice").is_err() { return Err("rate limit exceeded"); } }
Configure from Access Decision
#![allow(unused)] fn main() { let granted = acl.check(&alice, &["read", "write"])?; let mut rl = RateLimiter::new(10.0, 1.0); // Scale the rate limiter based on access decision: // Higher intersection numbers → more tokens rl.configure_from_decision("alice", &granted)?; }
Bucket State Queries
#![allow(unused)] fn main() { let available = rl.tokens_available("alice"); let capacity = rl.capacity("alice"); let fill_rate = rl.refill_rate(); }
How It Works
The token bucket is parameterized by the Schubert intersection number from the access decision. A principal with more valid configurations (higher intersection number) gets a larger token bucket — this models the principle that higher-trust, multi-capability access should have proportionally more throughput.
When trust degrades and the intersection number drops, the rate limit tightens automatically.
Schubert Routing
Geometric network routing — paths through Schubert conditions.
RouteTable
#![allow(unused)] fn main() { use schubert::RouteTable; let mut table = RouteTable::new(2, 4); // Advertise a route as a Schubert condition table.advertise("service-a", vec![1])?; table.advertise("service-b", vec![2])?; // Find a path through the route table let path = table.find_path(&["service-a", "service-b"])?; }
Route Advertisements
Each route advertisement is a Schubert condition with a partition. Routes are compatible if their Schubert intersection is non-empty:
#![allow(unused)] fn main() { // Advertise with different codimensions table.advertise("gateway", vec![1])?; // σ₁ — lightweight route table.advertise("database", vec![2, 1])?; // σ₂₁ — restricted route // Routes compose if intersection > 0 let route = table.find_path(&["gateway", "database"])?; }
Congestion Detection
If the intersection number drops (fewer valid paths), the route table detects congestion:
#![allow(unused)] fn main() { if let Some(congested) = table.check_congestion(&["gateway", "database"])? { println!("Route congested: {congested:?}"); } }
Distributed CRDTs
Eventually-consistent access grants using Conflict-Free Replicated Data Types (CRDTs). Multiple nodes can independently grant/revoke capabilities and merge.
CrdtState
#![allow(unused)] fn main() { use schubert::crdt::{CrdtState, CrdtGrant, VersionVector}; let mut node_a = CrdtState::new(); let mut node_b = CrdtState::new(); // Node A grants a capability node_a.apply(CrdtGrant::grant("alice", "read"))?; // Node B grants a capability (concurrently) node_b.apply(CrdtGrant::grant("alice", "write"))?; // Merge — both grants survive node_a.merge(&node_b)?; assert!(node_a.has_grant("alice", "read")); assert!(node_a.has_grant("alice", "write")); }
Version Vectors
Each grant carries a version vector tracking causal history:
#![allow(unused)] fn main() { let grant = CrdtGrant::grant("alice", "read"); println!("Version: {:?}", grant.version()); }
Last-Write-Wins
Conflicting grants (same principal, same capability) resolve via last-write-wins:
#![allow(unused)] fn main() { // Node A grants, Node B revokes concurrently let grant = CrdtGrant::grant("alice", "read"); let revoke = CrdtGrant::revoke("alice", "read"); // Merge resolves to the operation with the higher timestamp node_a.apply(grant)?; node_a.merge(&node_b)?; // state_b has the revoke with higher timestamp }
Merge Properties
- Commutative:
a.merge(b) == b.merge(a) - Associative:
(a.merge(b)).merge(c) == a.merge(b.merge(c)) - Idempotent:
a.merge(a) == a
CLI Discovery Tool
Schubert includes a lightweight CLI for LLM agents to discover and use its API. Three subcommands cover the full lifecycle.
Install
cargo install schubert
schubert discover — API Catalog
Compact JSON schema of the full API surface (~200-500 tokens).
# Full catalog
schubert discover
# Filter by feature
schubert discover --feature crypto
# Filter by module
schubert discover --module routing
# Markdown output
schubert discover --format md
schubert recommend — Config Recommender
# Interactive mode
schubert recommend
# Batch mode (LLM automation)
schubert recommend --input constraints.toml
Recommends optimal Gr(k,n), computation path, and feature flags given constraints like number of roles, domains, audit requirements, and trust model.
schubert explore — Decision Sandbox
# REPL mode
schubert explore
# One-shot evaluator (LLM tool-calling)
schubert explore --eval '{"action":"create","k":2,"n":4}'
schubert explore --eval '{"action":"check","principal":"alice","capabilities":["read","write"]}'
Supports actions: create, grant, check, stability, compose, revoke, list.
For the full guide, see CLI Guide.
AccessController
The main entry point for all access control operations.
Construction
#![allow(unused)] fn main() { use schubert::AccessController; let mut acl = AccessController::new(2, 4)?; // Gr(2,4) }
Principal Management
#![allow(unused)] fn main() { // Create a principal let alice = acl.create_principal("alice")?; // Get an existing principal let bob = acl.get_principal("bob")?; // List all principals let principals = acl.principals(); }
Capability Management
#![allow(unused)] fn main() { use schubert::{Capability, CapabilityKind}; // Register a capability acl.register_capability(Capability::new( "read:data", "Read data access", vec![1], CapabilityKind::ReadLike, ))?; // List registered capabilities let capabilities = acl.capabilities(); // Check if a capability is registered if acl.has_capability("read:data") { // ... } }
Grant / Revoke
#![allow(unused)] fn main() { acl.grant(&alice, "read:data")?; acl.grant(&alice, "write:data")?; // Revoke acl.revoke(&alice, "write:data")?; // Check what a principal holds let held = acl.held_by(&alice); }
Access Check
#![allow(unused)] fn main() { let result = acl.check(&alice, &["read:data"])?; match result { AccessDecision::Granted { configurations } => { /* ... */ } AccessDecision::Impossible { conflicting } => { /* ... */ } AccessDecision::Denied => { /* ... */ } AccessDecision::Underconstrained { dimension } => { /* ... */ } } }
Context-Aware Check
#![allow(unused)] fn main() { use schubert::AccessContext; let ctx = AccessContext { resource: Some("customer-data".into()), time_budget_ms: Some(500), required_trust: 0.85, }; acl.check_with_context(&alice, &["read:data"], &ctx)?; }
Batch Operations (parallel feature)
#![allow(unused)] fn main() { let queries = vec![ (alice.clone(), vec!["read:data"]), (bob.clone(), vec!["write:data"]), ]; let results = acl.check_batch(&queries)?; }
Computation Path
#![allow(unused)] fn main() { use schubert::ComputationPath; acl.set_computation_path(ComputationPath::LR); acl.set_computation_path(ComputationPath::Tropical); }
Audit Sink
#![allow(unused)] fn main() { use schubert::audit::InMemoryAudit; acl.set_audit_sink(Box::new(InMemoryAudit::new())); // Every check() call now records to the sink }
Capability & Principal
Capability
A Schubert condition with a partition, kind, and label.
#![allow(unused)] fn main() { use schubert::{Capability, CapabilityKind}; let cap = Capability::new( "read:data", // unique ID "Read data access", // human label vec![1], // partition (Schubert condition) CapabilityKind::ReadLike, ); }
Fields
| Field | Type | Description |
|---|---|---|
id | &str | Unique capability identifier |
label | &str | Human-readable description |
partition | Vec<usize> | Schubert partition (weakly decreasing) |
kind | CapabilityKind | Semantic category affecting trust sensitivity |
expires_at | Option<u64> | Optional expiry timestamp (milliseconds) |
Methods
#![allow(unused)] fn main() { // Temporal capabilities let temp = cap.with_expiry(now + 3_600_000); // 1 hour let remaining = temp.time_remaining_at(check_time); let is_expired = temp.is_expired_at(check_time); // Codimension (sum of partition entries) let codim = cap.codimension(); // 1 for [1], 3 for [2,1] }
CapabilityKind
#![allow(unused)] fn main() { pub enum CapabilityKind { ReadLike, // Low trust sensitivity WriteLike, // Medium trust sensitivity AdminLike, // High trust sensitivity Custom, // Application-defined } }
PrincipalId
An opaque identity wrapper. Schubert never authenticates — identity is provided by your external auth system.
#![allow(unused)] fn main() { use schubert::PrincipalId; let alice = PrincipalId::new("alice"); let from_jwt = PrincipalId::new(jwt_claims.sub); }
PrincipalId implements Clone, Eq, Hash, Debug, and with serde:
Serialize/Deserialize.
Decision & Context
AccessDecision
The quantitative result of an access check.
#![allow(unused)] fn main() { pub enum AccessDecision { Granted { configurations: usize }, Impossible { conflicting: Vec<String> }, Denied, Underconstrained { dimension: usize }, } }
Decision Logic
| Condition | Decision |
|---|---|
| Intersection number > 0 | Granted { configurations } |
| Intersection number = 0 | Impossible { conflicting } |
| Total codimension > Gr dimension | Denied |
| Total codimension < Gr dimension | Underconstrained { dimension } |
Methods
#![allow(unused)] fn main() { impl AccessDecision { pub fn is_granted(&self) -> bool; pub fn is_impossible(&self) -> bool; pub fn is_denied(&self) -> bool; pub fn is_underconstrained(&self) -> bool; pub fn grant_count(&self) -> Option<usize>; } }
ComputationPath
Four engines for computing Schubert intersections:
#![allow(unused)] fn main() { pub enum ComputationPath { LR, // Default — balanced performance Localization, // Geometric insight into *why* Tropical, // Large-scale batch operations Matroid, // Parallel evaluation } }
AccessContext
Context-aware access with resource scoping and trust requirements:
#![allow(unused)] fn main() { pub struct AccessContext { /// Optional resource identifier for scoping pub resource: Option<String>, /// Time budget for the check (milliseconds) pub time_budget_ms: Option<u64>, /// Minimum trust level required (0.0–1.0) pub required_trust: f64, } }
Used with check_with_context() for time-aware, resource-scoped, trust-gated
access decisions.
Composition Engine
Operadic composition of principals through shared capabilities.
compose()
#![allow(unused)] fn main() { use schubert::compose; let result = compose(&acl, &producer, "output", &consumer, "input")?; }
CompositionResult
#![allow(unused)] fn main() { pub enum CompositionResult { Composable { multiplicity: usize }, NotComposable { reason: String }, } }
- Composable:
multiplicityconfigurations survive the composition - NotComposable: Geometrically incompatible capabilities
are_composable()
Cheaper pre-check before full composition:
#![allow(unused)] fn main() { if are_composable(&acl, "read", "write")? { let result = compose(&acl, &alice, "read", &bob, "write")?; } }
Use Cases
- Service chaining: Service A produces output that Service B consumes
- Delegation: Principal delegates a capability to another principal
- Cross-domain translation: Translate capabilities between Grassmannians
- Composability checking: Verify two services can interoperate
Properties
- Commutative: Order of composition doesn't affect result
- Associative:
(a ∘ b) ∘ c = a ∘ (b ∘ c) - Zero-preserving: If either side is impossible, composition is impossible
Stability Analysis
Wall-crossing analysis of capability stability under trust degradation.
analyze_stability()
#![allow(unused)] fn main() { use schubert::analyze_stability; let report = analyze_stability(&acl, &principal)?; }
StabilityReport
#![allow(unused)] fn main() { pub struct StabilityReport { /// Breakpoints where stability changes pub phase_diagram: Vec<(f64, usize)>, /// Individual stability walls per capability pub walls: Vec<StabilityWall>, /// Which capability degrades first pub most_sensitive: String, /// Current stability at trust = 1.0 pub at_full_trust: usize, /// Current stability at trust = 0.0 pub at_zero_trust: usize, } }
StabilityWall
#![allow(unused)] fn main() { pub struct StabilityWall { pub capability: String, pub cap_kind: CapabilityKind, /// Trust level where this capability crosses its stability wall pub trust_threshold: f64, } }
How It Works
- For each granted capability, compute its stability as a function of trust
- Higher-codimension capabilities cross stability walls at higher trust levels
- AdminLike capabilities cross first, ReadLike last
- The phase diagram shows total viable configurations at each trust level
Batch Stability (parallel feature)
#![allow(unused)] fn main() { let principals = vec![alice, bob, carol]; let reports = analyze_stability_batch(&acl, &principals)?; }
Audit & Error
AuditSink Trait
Pluggable audit interface for recording access decisions.
#![allow(unused)] fn main() { use schubert::audit::{AuditSink, DecisionRecord}; struct DatabaseAudit { pool: PgPool } impl AuditSink for DatabaseAudit { fn record(&self, record: &DecisionRecord) -> schubert::Result<()> { // Write to database, file, log, etc. Ok(()) } } acl.set_audit_sink(Box::new(DatabaseAudit { pool })); }
InMemoryAudit
Built-in audit sink that stores records in memory:
#![allow(unused)] fn main() { use schubert::audit::InMemoryAudit; let sink = InMemoryAudit::new(); acl.set_audit_sink(Box::new(sink)); // After checks... let records = sink.records(); let filtered = sink.records_for_principal(&alice); sink.clear(); }
Audit Design
- Fire-and-forget: Failing sinks never block access decisions
- Feature-gated:
AuditSinkrequires thestdfeature - No_std:
InMemoryAuditusesRefCell, no thread safety
SchubertError
All errors use SchubertError with 11 variants:
| Variant | When |
|---|---|
InvalidGrassmannian | k ≥ n or k = 0 |
CapabilityNotFound | Referenced capability not registered |
PrincipalNotFound | Referenced principal doesn't exist |
AlreadyHolds | Duplicate grant |
DoesNotHold | Revoke on unheld capability |
InvalidPartition | Partition not weakly decreasing |
ImpossibleComposition | Geometric incompatibility |
Underconstrained | Too few conditions |
Overconstrained | Too many conditions |
SerializationError | serde I/O failure |
VerificationError | Karpal proof obligation failed |
Proof-Carrying Tokens
Cryptographic capability tokens using Ed25519 signatures. Enable the crypto
feature:
[dependencies]
schubert = { version = "0.4", features = ["crypto"] }
Schubert ships two token kinds, both Ed25519-signed:
CapabilityToken— a single capability, for the simple case.GrantToken— multiple capabilities, each carrying its Schubert partition, enabling geometric containment checks (write implies read, admin implies all) at verification time without a capability registry.
Issuing Tokens
#![allow(unused)] fn main() { use schubert::crypto::CapabilityIssuer; // Recommended: derive the issuer from a persisted 32-byte seed (see KeyStore). let issuer = CapabilityIssuer::generate(); // Single-capability token let token = issuer.issue("alice", "memory:read")?; // Multi-capability grant — order-independent (canonicalized before signing) use schubert::CapabilityId; let grant = issuer.issue_grant("bob", &[ (CapabilityId::new("memory:read"), vec![1]), (CapabilityId::new("memory:write"), vec![2]), ])?; }
Distribute the public key (issuer.public_key() / issuer.public_key_hex())
to every verifier. The seed stays secret.
Verifying Tokens
#![allow(unused)] fn main() { use schubert::crypto::{CapabilityVerifier, GrantVerifier}; // Single-capability tokens let verifier = CapabilityVerifier::new(issuer.public_key()); verifier.verify(&token)?; // signature check let (principal, capability) = verifier.verify_and_extract(&token)?; // + claims // Multi-capability grants let grant_verifier = GrantVerifier::new(issuer.public_key()); grant_verifier.verify(&grant)?; // signature check grant_verifier.may(&grant, &[1]); // geometric containment (bool, see below) }
verify reconstructs the canonical signing message and checks the Ed25519
signature with verify_strict. A token whose fields are altered after signing
fails verification.
Geometric Containment (GrantVerifier::may)
The killer feature of grant tokens: a verifier can answer does this grant
authorize capability P? using only the signed partition data — no registry
lookup. may(grant, required_partition) returns true iff some granted
partition λ satisfies required ≤ λ component-wise:
#![allow(unused)] fn main() { // A grant carrying only `write` (partition [2]): assert!( grant_verifier.may(&grant, &[1]) ); // read — [1] ≤ [2] assert!( grant_verifier.may(&grant, &[2]) ); // write — explicit assert!(!grant_verifier.may(&grant, &[2, 1]) ); // manage — not implied // A grant carrying `admin` (partition [4,4,4,4] on Gr(4,8)) implies everything: assert!( admin_grant_verifier.may(&admin_grant, &[1]) ); assert!( admin_grant_verifier.may(&admin_grant, &[2, 1]) ); }
No special-casing — "write implies read" and "admin implies all" fall out of the partition lattice order.
Wire Format
Both tokens serialize to a length-prefixed binary blob via associated
to_bytes / from_bytes functions, suitable for base64-encoding as a bearer
token:
#![allow(unused)] fn main() { use schubert::crypto::{CapabilityToken, GrantToken}; let bytes = CapabilityToken::to_bytes(&token); // Vec<u8> let roundtrip = CapabilityToken::from_bytes(&bytes)?; // Result<_> let g_bytes = GrantToken::to_bytes(&grant); }
CapabilityToken layout: u16 BE principal_len | principal | u16 BE capability_len | capability | 32B issuer key | 64B signature.
GrantToken layout: u16 BE principal_len | principal | u16 BE cap_count | per cap: u16 BE id_len | id | u8 partition_len | partition bytes | 32B issuer key | 64B signature.
The TypeScript extraction (
schubert-tsukoshi) uses this exact wire format — tokens issued in Rust verify in TS and vice-versa.
Key Persistence (KeyStore)
Persist an issuer identity across restarts by storing its 32-byte seed. On Unix
the file is created mode 0600 (owner read/write only):
#![allow(unused)] fn main() { use schubert::crypto::KeyStore; // Load an existing seed, or create one if absent. let seed = KeyStore::load_or_create(std::path::Path::new("/var/lib/app/issuer.key"))?; let issuer = CapabilityIssuer::from_seed(seed); // Or read-only: let seed = KeyStore::load(std::path::Path::new("issuer.key"))?; }
load_or_create is atomic against concurrent startup (create_new); it fails
closed rather than clobbering an existing key.
Token Structures
#![allow(unused)] fn main() { pub struct CapabilityToken { pub principal: PrincipalId, pub capability: CapabilityId, pub issuer_key: Vec<u8>, // 32 bytes pub signature: Vec<u8>, // 64 bytes } pub struct GrantToken { pub principal: PrincipalId, pub capabilities: Vec<GrantCapability>, pub issuer_key: Vec<u8>, pub signature: Vec<u8>, } pub struct GrantCapability { pub id: CapabilityId, pub partition: Vec<usize>, } }
Security Properties
- Ed25519 signatures — 128-bit security, 64-byte detached signatures,
verified with
verify_strict(rejects malleable signatures). - Issuer key bound into the message — prevents key substitution: a token cannot be re-credited to a different issuer.
- Tamper detection — any field changed after signing fails verification.
- Order-independent grants — capabilities are canonically sorted before signing, so grant construction order does not affect the signature.
- No replay protection — tokens are stateless. For one-time use, track consumed token nonces server-side, or pair with short-lived expiry windows.
- Key rotation — generate a new
CapabilityIssuerand distribute its public key; old tokens continue to verify against their original issuer's key.
Partition components are stored as
u8in the grant signing message, so partitions are limited to parts ≤ 255 (ample for any realistic Grassmannian; Gr(4,8)'s largest part is 4).
Axum Integration
Bearer-token authentication for Axum web services, built on the
crypto grant tokens. Enable the axum feature (which also
enables crypto):
[dependencies]
schubert = { version = "0.4", features = ["axum"] }
The AuthPrincipal extractor validates a
Schubert GrantToken from the Authorization: Bearer <token> header and yields
the verified grant to your handler. The token is base64-encoded using
GrantToken::to_bytes.
Minimal Example
#![allow(unused)] fn main() { use axum::{Extension, Router, routing::get}; use std::sync::Arc; use schubert::axum::AuthPrincipal; use schubert::crypto::{CapabilityIssuer, GrantVerifier}; let issuer = CapabilityIssuer::from_seed(/* persisted seed */); let verifier = Arc::new(GrantVerifier::new(issuer.public_key())); let app = Router::new() .route("/data", get(read_handler)) .layer(Extension(verifier)); // <- verifier shared with every handler async fn read_handler(auth: AuthPrincipal) -> String { format!("hello {}", auth.0.principal) } }
The AuthPrincipal Extractor
AuthPrincipal(pub GrantToken) implements FromRequestParts. On success the
inner GrantToken is fully signature-verified — handlers can trust its
principal and capabilities fields.
The extractor pulls the shared verifier from an Extension<Arc<GrantVerifier>>
layer, so you must install it on the router (as above). Handlers may also
extract the verifier themselves to call may() for capability-specific
authorization:
#![allow(unused)] fn main() { use axum::Extension; use std::sync::Arc; use schubert::axum::AuthPrincipal; use schubert::crypto::GrantVerifier; use axum::http::StatusCode; async fn write_handler( auth: AuthPrincipal, Extension(verifier): Extension<Arc<GrantVerifier>>, ) -> Result<String, (StatusCode, &'static str)> { // Geometric containment: does this grant authorize a write ([2])? if !verifier.may(&auth.0, &[2]) { return Err((StatusCode::FORBIDDEN, "write not granted")); } Ok(format!("writing as {}", auth.0.principal)) } }
Error Responses: 401 vs 500
Rejections are typed, not lumped into a single 401:
| Error variant | HTTP | Meaning |
|---|---|---|
AuthError::Unauthorized(_) | 401 | Client problem: missing/malformed header, bad base64, invalid signature. |
AuthError::ServerMisconfigured(_) | 500 | Server problem: the Extension<Arc<GrantVerifier>> layer is missing. |
A missing verifier layer is a server bug, not an authentication failure — so it
returns 500, not 401.
No information leak: all Unauthorized causes (missing header, malformed
token, bad signature) yield the identical generic Unauthorized response
body. The diagnostic detail is kept on the error value (visible via Debug if
you handle the rejection before it becomes a response), but is never sent to the
client — an attacker cannot tell how far a forged token got.
#![allow(unused)] fn main() { // Handle rejections yourself (optional) to log the diagnostic detail: match AuthPrincipal::from_request_parts(&mut parts, &state).await { Ok(auth) => { /* ... */ } Err(schubert::axum::AuthError::Unauthorized(detail)) => { tracing::warn!("auth failed: {detail}"); // logged, not sent to client // return a uniform 401 } Err(schubert::axum::AuthError::ServerMisconfigured(detail)) => { tracing::error!("misconfiguration: {detail}"); // return 500 } } }
Token Lifecycle
Tokens are issued server-side with the crypto module and handed
to clients (e.g. on login). The client sends them back on each request:
Authorization: Bearer <base64(GrantToken::to_bytes(grant))>
The extractor decodes, parses, and verifies in one step; the handler receives a
ready-to-use AuthPrincipal. See crypto for issuing grants,
key persistence (KeyStore), and the geometric-containment may() check.
schubert-tsukoshi (TypeScript)
Schubert's core access-control model is also available as a pure-TypeScript
package — @industrialalgebra/schubert-tsukoshi
— for browser, Node, Deno, and React Native. It ships zero runtime dependencies
and runs the Schubert geometry (including impossibility detection) with no
backend and no WASM.
The TypeScript package lives in this repository under
schubert-tsukoshi/and has its own README with full API docs. This page is a cross-reference.
What it provides
| Subpath | What | Dependencies |
|---|---|---|
@industrialalgebra/schubert-tsukoshi | Core: AccessController, LR tables, impossibility detection | none (zero-dep) |
@industrialalgebra/schubert-tsukoshi/crypto | Ed25519 CapabilityToken / GrantToken issue + verify | @noble/ed25519, @noble/hashes |
@industrialalgebra/schubert-tsukoshi/protocols | GrantCRDT — replicated grant set over cliffy-tsukoshi's VectorClock | @cliffy-ga/tsukoshi |
The killer feature, in the browser
A principal can hold every required capability and still be denied, because the conditions are geometrically incompatible:
import { AccessController } from "@industrialalgebra/schubert-tsukoshi";
const acl = new AccessController("gr24");
acl.registerCapability({ id: "write", partition: [2], kind: "write" });
acl.registerCapability({ id: "dwide", partition: [1,1], kind: "custom" });
const m = acl.createPrincipal("mallory");
acl.grant(m, "write");
acl.grant(m, "dwide"); // both held
acl.check(m, ["write", "dwide"]);
// => { kind: "impossible", conflicting: ["write", "dwide"] }
// σ₂ · σ₁₁ = 0 on Gr(2,4)
Rust ↔ TypeScript interop
The crypto subpath uses the exact same Ed25519 wire format as the Rust
crate. Tokens issued in Rust verify in TypeScript and vice-versa — proven by a
cross-validation test suite that asserts byte-identical output from a fixed
seed. A Rust-backed service can issue grants that a TypeScript client verifies,
or a TypeScript issuer can mint tokens a Rust verifier accepts.
Relationship to the Rust crate
- The LR tables are generated from the Rust crate's own exact
schubert_product(runcargo run --example generate_ts_lr_tables), so the TypeScript math is byte-faithful to the Rust math. schubert-tsukoshitargets the three standard policy spaces:Gr(2,4),Gr(3,6),Gr(4,8). Larger spaces require regenerating the tables.- It does not include Karpal verification or surreal trust (those need the Rust type system / exact rationals).
See schubert-tsukoshi/README.md in this repository for installation, the full
API, and how to add Grassmannians.
WebAssembly
JavaScript bindings for in-browser access control. Enable the wasm feature.
Build
cargo build --target wasm32-unknown-unknown --features wasm
WasmController
import init, { WasmController } from 'schubert';
await init();
const acl = new WasmController(2, 4);
acl.register_capability("read", [1], "ReadLike", "Read data");
acl.register_capability("write", [2], "WriteLike", "Write data");
const alice = acl.create_principal("alice");
acl.grant(alice, "read");
acl.grant(alice, "write");
const decision = acl.check(alice, ["read", "write"]);
// { kind: "Granted", configurations: 1 }
Note:
AuditSinkis not available on wasm32 since it requiresstd.
CapabilityKind Values
| JS String | Variant |
|---|---|
"ReadLike" | Read-like capability |
"WriteLike" | Write-like capability |
"AdminLike" | Admin-like capability |
"Custom" | Custom capability |
Limitations
- Single-threaded (browser context)
- No audit sink (requires std)
- No parallel batch operations (requires rayon/threads)
Verification Integration (Karpal)
Schubert integrates with Karpal (v0.5) for formal verification of access control
properties. Enable the karpal-verify feature.
Architecture
AccessControl ──► verify.rs ──► karpal-verify (SMT/Lean)
│
├── ObligationBundle ──► Proof obligations
├── Certified<T> ──► Trust boundary
└── VerificationResult ──► Pass/Fail/Caveat
Obligation Bundles
Five obligation bundles verify key properties:
| Bundle | Property |
|---|---|
grant_check_consistency | If granted, check must return Granted |
revoke_removes_access | Revoke → check returns Denied or Impossible |
grant_revoke_identity | Grant then revoke = no net change |
composition_associativity | (a ∘ b) ∘ c = a ∘ (b ∘ c) |
impossibility_symmetry | a impossible with b ⇔ b impossible with a |
Certified Trust Boundary
#![allow(unused)] fn main() { use schubert::verify::Certified; // Wrap a value in a proof obligation let certified_decision: Certified<AccessDecision> = verify.check_certified(&acl, &principal, &["read"])?; }
Certified<T> carries a formal proof that T satisfies specified properties.
At the boundary, the proof is discharged or rejected.
Verification Levels
| Level | Backend | Guarantee |
|---|---|---|
QuickCheck | Property testing | Statistical confidence |
SMT | Z3/CVC4 | Symbolic model checking |
Lean | Lean 4 | Full formal proof |
Integration
#![allow(unused)] fn main() { use schubert::verify; use karpal_verify::Verifier; let verifier = Verifier::new(); let obligations = verify::build_obligations(&acl)?; for obligation in &obligations { let result = verifier.verify(obligation)?; match result { verify::VerificationResult::Pass => {}, verify::VerificationResult::Fail(reason) => { eprintln!("Verification failed: {reason}"); }, verify::VerificationResult::Caveat(msg) => { println!("Caveat: {msg}"); }, } } }
Surreal Trust Levels
Exact arithmetic on trust values using Amari's RationalSurreal. Enable the surreal
feature.
Motivation
Floating-point trust (f64) has rounding errors that accumulate in chained trust operations. Surreal numbers provide exact arithmetic with infinitesimal resolution.
RationalSurreal
A surreal number represented as a rational with infinitesimal extensions:
#![allow(unused)] fn main() { use schubert::surreal_trust::SurrealTrust; // Standard trust values let full = SurrealTrust::new(RationalSurreal::from_f64(1.0)); let half = SurrealTrust::new(RationalSurreal::from_f64(0.5)); // Exact comparison assert!(half < full); }
EpsilonPolynomial
Infinitesimal trust resolution using ε (epsilon) and its powers:
#![allow(unused)] fn main() { use schubert::surreal_trust::EpsilonPolynomial; let eps = EpsilonPolynomial::epsilon(); let two_eps = EpsilonPolynomial::epsilon() * 2; let eps_sq = EpsilonPolynomial::epsilon_squared(); // ε² < ε < 1 (infinitesimal ordering) assert!(eps_sq < eps); assert!(eps < 1.0); // Compare infinitesimal trust levels assert!(SurrealTrust::from_epsilon(1) < SurrealTrust::from_epsilon(2)); }
Use Cases
- Exact trust comparison: Arbitrarily close trust levels are distinct
- Infinitesimal recovery: Gradual trust restoration in ε increments
- Provable monotonicity: Trust never spontaneously increases
- Compositional trust: Exact trust arithmetic across service chains
Comparison
EpsilonPolynomial lacks PartialOrd — use compare_infinitesimal():
#![allow(unused)] fn main() { use schubert::surreal_trust::compare_infinitesimal; let a = EpsilonPolynomial::epsilon_squared(); let b = EpsilonPolynomial::epsilon(); assert_eq!(compare_infinitesimal(&a, &b), std::cmp::Ordering::Less); }
Comparison first checks valuations (degree of smallest ε term), then compares coefficients.
Holographic Memory (Minuet)
Cosine-similarity-based access patterns via Minuet (v0.3). Enable the holographic
feature.
HolographicAccessControl
#![allow(unused)] fn main() { use schubert::holographic::HolographicAccessControl; let mut holo = HolographicAccessControl::new(); // Encode a principal's access pattern holo.encode("alice", &["read", "write"])?; holo.encode("bob", &["read"])?; // Query by similarity let similar = holo.query_similar(&["read", "write"], 5)?; // Returns principals with similar access patterns, ranked by cosine similarity }
How It Works
- Access patterns are encoded as vectors via FNV hash
- Cosine similarity measures how close two access patterns are
- Schubert intersection provides geometric validation of similarity
- Results are ranked by combined similarity + intersection score
Use Cases
- Anomaly detection: Flag access patterns unlike any known principal
- Role discovery: Cluster principals by access pattern similarity
- Privilege escalation detection: Sudden change in access pattern vector
- Audit forensics: Find principals with similar access to a known attacker
Query
#![allow(unused)] fn main() { // Find top-K similar principals let results = holo.query_similar(&["read:data"], 10)?; for (principal, similarity) in results { println!("{principal}: {similarity:.4}"); } }
Limitations
- Cosine similarity is approximate, not exact match
- Encoding uses FNV hash (fast but not cryptographic)
- Not a full Minuet algebra binding — simplified for access control
- Memory-only storage (no persistence)
Security Considerations
Identity Model
Schubert never authenticates. PrincipalId is an opaque string provided by your
external identity system (OAuth, OIDC, JWT, mTLS). Schubert authorizes based on
identities you provide.
You are responsible for:
- Authenticating users
- Mapping authenticated identities to
PrincipalIdvalues - Ensuring identity consistency across service boundaries
Capability Design
- Partition collisions: Two capabilities with the same partition are geometrically equivalent. Design partitions to be distinct.
- Over-granting: Granting too many capabilities may create impossible
combinations. Use
analyze_stability()to detect this. - Admin capabilities:
[2,2](point class) is maximally restrictive. Grant admin capabilities sparingly.
Trust Boundaries
Certified<T>: A formal proof boundary. Values crossing this boundary have been verified by Karpal. Rejection means the proof obligation failed.AuditSink: Audit records are best-effort. A failing sink does not block access decisions.CapabilityToken: Ed25519 signatures provide integrity and authenticity but not confidentiality. Token contents are plaintext.
Known Limitations
- No built-in persistence: Policy state is in-memory. Use
serde+ your own storage for durability. - No network protocol: Schubert is purely a library. Implement wire protocols yourself.
- Cosine similarity: Holographic queries use approximate similarity, not exact access matching.
- No revocation propagation: CRDT revocations are eventually consistent, not immediately globally visible.
Cryptographic Tokens
- Key management:
CapabilityIssuergenerates keys. Store and rotate keys according to your security policy. - No replay protection: Tokens are stateless. Track used tokens in the verifier if replay is a concern.
- Signature verification: Always verify before extracting claims. Never trust unverified token contents.
Performance Considerations
- Grassmannian scaling: Larger Grassmannians (Gr(3,6), Gr(4,8)) have higher computational cost for intersection calculations.
- Batch operations: Use
check_batch()with theparallelfeature for high-throughput scenarios. - Tropical path: Switch to
ComputationPath::Tropicalfor >1000 concurrent principals.
Threat Model
Addresses Proserpina critique finding #4: "No threat model, adversary model, or security properties are specified."
Assumed Adversary Capabilities
Schubert is a library, not a network service. The threat model assumes:
What the Adversary CAN Do
| Capability | Mitigation |
|---|---|
| Observe access decisions | Schubert is embedded; the host application controls logging via AuditSink |
| Attempt impossible capability combinations | Schubert returns AccessDecision::Impossible — the geometric intersection is zero regardless of adversary input |
| Submit many capabilities to exhaust computation | See Adversarial Concerns — bounded intersection depth, input validation |
Forge capability tokens (if crypto feature enabled) | Ed25519 signatures — forging requires the issuer's private key |
What the Adversary CANNOT Do
| Capability | Why |
|---|---|
| Bypass the geometric intersection | The Littlewood-Richardson coefficient is a mathematical fact. No input can make σ₂·σ₁₁ ≠ 0 in Gr(2,4). |
| Corrupt the computation silently | The result is deterministic given the same Grassmannian and conditions. |
Forge a Certified<T> value (if karpal-verify feature enabled) | The Proven type requires a valid proof obligation. |
What Schubert Does NOT Protect Against
| Threat | Owner |
|---|---|
| Authentication | External (OAuth, JWT, mTLS). Schubert receives PrincipalId from the caller. |
| Transport security | External (TLS, WireGuard). Schubert has no network surface. |
| Key management | External. The CapabilityIssuer generates keys; storage and rotation are the caller's responsibility. |
| Replay attacks on tokens | External. Schubert tokens are stateless. The caller tracks used tokens. |
| Side-channel timing | Partial. Intersection computation time varies with Grassmannian size. See adversarial concerns. |
Security Properties
Property 1: Geometric Impossibility Is Unforgeable
Claim: If check(principal, &[cap_a, cap_b]) returns Impossible, then no
input can make it return Granted for the same Grassmannian and conditions.
Justification: The Littlewood-Richardson coefficient is a topological invariant of the Grassmannian. It is determined entirely by the partitions and the Grassmannian dimensions. No runtime input, adversary action, or state mutation can change a coefficient from 0 to positive.
Property 2: Grant Commutativity
Claim: Granting capabilities in any order produces the same access decision.
Justification: Schubert intersection is commutative — σ_a · σ_b = σ_b · σ_a. The order of grants does not affect the geometric result.
Limitation: This is a semantic property about the set of held capabilities, not a temporal property. Audit logs must still record the order of grants for forensics. See Audit Trail below.
Property 3: Grant-Revoke Identity
Claim: Granting then revoking a capability produces the same state as never granting it (assuming no prior grant).
Justification: The grant adds the Schubert condition; the revoke removes it. The net geometric effect is identity.
Limitation: If the capability was already held, revoke produces a different state. Temporal audit logs are still needed.
Audit Trail
Addresses critique finding #21: "Commutativity of grant order eliminates temporal auditing."
The geometric commutativity of grants (σ_a · σ_b = σ_b · σ_a) means the final access decision is order-independent. But temporal ordering matters for audit and forensics:
- Who granted what, when? — The
AuditSinkrecords every grant/revoke with timestamps. - Replay for investigation — Audit records allow reconstructing the sequence of grants leading to an access event.
- Regulatory compliance — GDPR, HIPAA, SOC 2 require temporal audit trails regardless of semantic commutativity.
Design principle: Schubert's geometry determines what access is possible. The audit trail determines how it came to be. These are orthogonal concerns — commutativity in the former does not eliminate the need for the latter.
Non-Identifiability
Addresses critique finding #20: "Positive intersection numbers do not identify which users have access."
The intersection number counts how many configurations satisfy the conditions. It does not enumerate which principals hold the capabilities.
This is by design:
- The intersection number is a property of the Schubert conditions (the policy), not the principal set (the population).
- To check whether a specific principal has access, call
check(principal, ...). - The intersection number tells you the policy's capacity — how many valid ways exist to satisfy it — not the occupancy.
Analogy: A building has a capacity of 100 (intersection number). To check
whether Alice is inside, you look at the roster (check(alice, ...)). The
capacity doesn't tell you who's inside; the roster does.
Flag Structure in Access Control
Addresses Proserpina critique finding #9: "Schubert varieties are defined relative to a fixed complete flag, but the document never explains what 'flag' means in an access control context."
What Is a Flag?
A complete flag in an n-dimensional vector space V is a nested sequence of subspaces:
{0} = V₀ ⊂ V₁ ⊂ V₂ ⊂ ... ⊂ Vₙ = V
where each Vᵢ has dimension i.
Schubert varieties are defined relative to a fixed flag. The flag determines the geometry of each Schubert condition — different flags give different Schubert varieties even for the same partition.
The Flag in Access Control
In Schubert's access control model, the flag represents the hierarchy of security clearance levels or trust zones:
V₀ = {0} — No access (empty subspace)
V₁ = 1-dim — Public data (read-only)
V₂ = 2-dim — Internal data (read + limited write)
V₃ = 3-dim — Confidential data (read + write + audit)
V₄ = V (full space) — Secret data (full admin)
Each level in the flag is a progressively larger subspace — more capabilities, more access.
How the Flag Determines Schubert Conditions
A Schubert condition σ_λ is defined by how the principal's state subspace intersects the flag subspaces. The partition λ encodes the intersection pattern:
| Partition | Intersection with Flag | Access Control Meaning |
|---|---|---|
| [1] | State meets V₁ but is generic in V₂ | Read public data |
| [2] | State meets V₁ and satisfies a codim-2 condition in V₂ | Write internal data |
| [1,1] | State meets V₁ in a codim-2 way (different from [2]) | Audit internal data |
| [2,2] | State is a point — meets all flag levels maximally | Full admin (point class) |
Why σ₂ and σ₁₁ differ: Both have codimension 2, but they interact with the flag differently:
-
σ₂ [2]: The state meets V₁ generically, then satisfies a codimension-2 condition in V₂. This means: "can read public data AND has write access to internal data."
-
σ₁₁ [1,1]: The state satisfies a codimension-1 condition in V₁ AND a codimension-1 condition in V₂/V₁. This means: "has restricted read access to public data AND restricted access to internal data."
These are geometrically different constraints even though they have the same codimension. That's why σ₂·σ₁₁ can be zero — the constraints pull the state in incompatible directions relative to the flag.
Choosing a Flag for Your Application
The flag is application-specific. Different domains have different hierarchies:
Web Application (Gr(2,4))
V₀ = No access
V₁ = Anonymous (public pages)
V₂ = Authenticated (user dashboard)
V₃ = Staff (admin panel)
V₄ = Root (system config)
Distributed Game (Gr(2,4))
V₀ = Disconnected
V₁ = Spectator (view-only)
V₂ = Player (move + interact)
V₃ = Moderator (kick + ban)
V₄ = Admin (server config)
Multi-Agent Coding Harness (Gr(2,4))
V₀ = No access
V₁ = Reader (clone + view)
V₂ = Contributor (branch + push)
V₃ = Reviewer (approve + merge)
V₄ = Maintainer (deploy + release)
Flag-Agnostic Properties
Some Schubert properties are flag-independent:
- The total codimension (sum of partition entries) is the same for any flag.
- The Grassmannian dimension k(n-k) is flag-independent.
- Commutativity of intersection (σ_a · σ_b = σ_b · σ_a) holds for any flag.
Flag-dependent properties:
- The specific intersection number can depend on the flag for non-standard conditions. For the standard Schubert classes used in Schubert (σ₁, σ₂, σ₁₁, etc.), the intersection numbers are flag-independent classical results.
Practical Note
For most applications, you don't need to think about the flag explicitly.
The AccessController::new(k, n) constructor sets up the standard flag
automatically. The flag becomes relevant only when:
- You need a custom clearance hierarchy
- You're debugging why two same-codimension conditions have different intersection behavior
- You're writing a formal proof or paper about the system
For the standard use case (RBAC, multi-tenant, game sync), the default flag works correctly.
Adversarial Concerns
Addresses Proserpina critique finding #13: "The geometric model is gameable via model injection, dimensionality poisoning, and denial-of-service attacks."
Attack Surface
Schubert is a library with no network surface. The attack surface is the API the host application exposes to untrusted input. The main concerns:
1. Computational Denial of Service (DoS)
Attack: An adversary submits many capabilities or large partitions to exhaust the intersection computation.
Impact: Littlewood-Richardson computation is #P-complete in general. Adversarial input could trigger exponential-time computation.
Mitigation:
#![allow(unused)] fn main() { // Bound the number of capabilities per check const MAX_CAPABILITIES_PER_CHECK: usize = 16; fn safe_check(acl: &AccessController, principal: &PrincipalId, caps: &[&str]) -> Result<AccessDecision> { if caps.len() > MAX_CAPABILITIES_PER_CHECK { return Ok(AccessDecision::Denied); } // Bound partition size — reject partitions with entries > n for cap_id in caps { if let Some(cap) = acl.get_capability(cap_id) { if cap.partition.iter().any(|&p| p > acl.n()) { return Ok(AccessDecision::Denied); } } } acl.check(principal, caps) } }
Built-in protection: Schubert's check() method validates partitions
against the Grassmannian dimensions. Invalid partitions return Err
before computation begins.
2. Dimensionality Poisoning
Attack: An adversary manipulates the Grassmannian dimensions (k, n) to create a degenerate policy space where all intersections are trivially positive.
Impact: If n is too large relative to k, every intersection returns a large positive number, making impossibility detection useless.
Mitigation:
- The host application controls Grassmannian dimensions — adversaries cannot
change them unless the API exposes
new(k, n)to untrusted callers. - Recommended dimensions are bounded: Gr(2,4), Gr(3,6), Gr(4,8). These have been benchmarked and their intersection behavior is well-understood.
- Do not expose
AccessController::new()to untrusted input.
3. Capability Flooding
Attack: An adversary registers thousands of capabilities to pollute the policy space.
Impact: Memory exhaustion and slow capability lookups.
Mitigation:
- Capability registration is an administrative operation, not a user-facing API.
- The
AccessControlleruses aHashMapfor O(1) capability lookup regardless of count. - Rate-limit capability registration in the host application.
4. Token Replay (crypto feature)
Attack: An adversary captures a valid CapabilityToken and replays it.
Impact: Unauthorized access if the token hasn't expired.
Mitigation:
- Tokens have optional expiry (
expires_at). Use short-lived tokens. - The host application tracks used tokens (Schubert tokens are stateless).
- Use TLS to prevent token interception.
5. CRDT State Poisoning
Attack: A malicious node injects poisoned grant state into the CRDT.
Impact: The merged state contains invalid grants.
Mitigation:
#![allow(unused)] fn main() { // Use staleness gating to reject stale state state.set_max_staleness(Some(30_000)); // 30 seconds // After merge, validate the resulting state geometrically state.merge(&remote_state); if let Some(staleness) = state.staleness_ms() { if staleness > 30_000 { log::warn!("Rejecting stale CRDT merge: {staleness}ms"); return Err(merge_error); } } }
- CRDT merge is commutative and idempotent — poisoned state can be overwritten by legitimate state.
- Use
is_converged_with()to detect nodes that are significantly behind. - The geometric intersection check catches impossible grant combinations even in poisoned state — if the intersection is zero, the grant is rejected regardless of CRDT state.
Timing Side Channels
Concern: Intersection computation time varies with partition complexity. An adversary could infer information about the policy by measuring response times.
Impact: Low. The adversary learns the complexity of the check, not the result. The result (Granted/Impossible) is observable regardless.
Mitigation: For high-security applications, pad response times to a constant value. This is the host application's responsibility.
Summary
| Attack | Severity | Mitigation |
|---|---|---|
| Computational DoS | Medium | Bound capabilities per check, validate partitions |
| Dimensionality poisoning | Low | Don't expose new(k,n) to untrusted callers |
| Capability flooding | Low | Admin-only registration, HashMap O(1) lookup |
| Token replay | Medium | Short-lived tokens, replay tracking, TLS |
| CRDT poisoning | Medium | Staleness gating, geometric validation post-merge |
| Timing side channel | Low | Constant-time padding (host responsibility) |
Schubert's core guarantee — geometric impossibility detection — is unforgeable. No adversarial input can make σ₂·σ₁₁ ≠ 0 in Gr(2,4). The attacks above target availability and state integrity, not the correctness of individual access decisions.
Architectural Philosophy
Schubert is built on a single architectural philosophy that runs through every module: the computation must be exact, but the infrastructure may be approximate.
The Boundary
This boundary appears in multiple places across the library:
| Module | Exact Side | Approximate Side |
|---|---|---|
controller.rs | Littlewood-Richardson coefficients (integer) | Principal identities (external, opaque strings) |
surreal_trust.rs | RationalSurreal + EpsilonPolynomial | Trust updates from external systems |
crdt.rs | Geometric intersection (exact LR coefficient) | Eventually-consistent grant state |
holographic.rs | Cosine similarity threshold (float, but bounded) | Vector encoding via FNV hash |
crypto.rs | Ed25519 signature verification | Token serialization format |
Why This Matters
Most access control systems blur this boundary. They use floating-point for trust and accept both data staleness AND computational approximation. Schubert refuses the computational approximation while accepting the data staleness.
The result: you should be able to trust the computation even when you can't trust the data. When state eventually converges, the decision you made from that state must be mathematically defensible.
CRDT Staleness Gating
The CRDT module (crdt.rs) provides explicit controls for this boundary:
#![allow(unused)] fn main() { let mut state = CrdtState::new(2, 4)?; // Set maximum allowed staleness — refuse decisions when grants are too old state.set_max_staleness(Some(30_000)); // 30 seconds // Check staleness if let Some(staleness) = state.staleness_ms() { if staleness > 30_000 { println!("State is {staleness}ms stale — refusing decisions"); } } // Cross-node convergence check if !state.is_converged_with(&other_node_version) { println!("Not yet converged with other node"); } }
Callers can choose: proceed with stale state (and accept eventual-consistency consequences), or gate on freshness and refuse decisions until convergence.
The Unanswered Question
What does it mean for an access decision to be "correct" when the trust level is exact but the state is stale?
Consider: Node A grants Alice read access with surreal trust level 0.5 (exact rational). Node B hasn't yet received the grant (CRDT state hasn't converged). Alice asks Node B for read access. Node B computes the intersection: the capability exists but Alice doesn't hold it. Decision: Denied.
The computation was exact. The data was incomplete. Was the decision wrong?
Schubert's answer: the library provides the tools to detect this situation
(staleness_ms, is_converged_with, set_max_staleness), but it's the
caller's choice whether to proceed. Some systems should refuse decisions
on stale data. Others should serve from whatever state they have and accept
that convergence will eventually resolve discrepancies.
Comparison with Other IA Projects
This pattern — exact interfaces for approximate infrastructure — appears across the Industrial Algebra ecosystem:
| Project | Exact Side | Approximate Side |
|---|---|---|
| Minuet | Holographic memory interfaces | Optical hardware stub |
| Virtuoso | Cognitive agent architecture | Module stubs |
| Schubert | Geometric access decisions | CRDT-distributed state |
| Minority | Surreal number types (Amari ecosystem) | Conway operation stubs (todo!) |
The pattern is deliberate: build the rigorous mathematical foundation first. Ship with stubs or approximate infrastructure where necessary. The math must be correct from day one; the infrastructure can evolve.
Critique & Future Work
v0.3.0 Snapshot — This page tracks the project's intellectual honesty: what critiques have been raised, what's been addressed, and what remains.
Critique Round 1 — Initial Self-Assessment (v0.1.0)
These concerns were identified during the initial development phase.
| Concern | Resolution |
|---|---|
| No CLI tooling | ✅ CLI with discover, recommend, explore subcommands |
| AGPL-only licensing | ✅ Apache-2.0 with CLA (v0.2.0) |
| Sparse documentation | ✅ User guide, book, API reference, cookbook |
| Feature-flag complexity | ✅ Feature flag guide with common combinations |
| Performance benchmarks missing | ✅ criterion benchmarks for 4 paths × 3 Grassmannians (v0.2.0) |
| No deployment examples | ✅ Axum middleware example (v0.2.0) |
| CRDT staleness unguarded | ✅ set_max_staleness(), staleness_ms(), is_converged_with() (v0.2.0) |
Critique Round 2 — Proserpina Panel (v0.3.0)
A 5-critic LLM panel (Devil's Advocate, Methodologist, Red Team, Domain Expert, Editor) cross-examined the README, book introduction, and mathematical concepts page. 31 findings total: 5 blockers, 10 major, 11 minor, 5 info.
Blockers — Addressed
| Finding | Resolution |
|---|---|
| No formal mapping from access control to Schubert geometry | ✅ Distributed Game Sync — formal derivation from game state to Grassmannian |
| "Configuration" count never operationally defined | ✅ Configuration = valid game state reconciliation (game sync §4) |
| Impossibility detection claim unsubstantiated | ✅ σ₂·σ₁₁ = 0 = combat mode + safe zone (game sync §3). Boolean AND false positive documented. |
| Algebraic identities mislabeled as security properties | ✅ Relabeled "algebraic identities" with caveat that security relevance depends on domain mapping |
| Scaling unaddressed (#P-hardness) | ✅ Game sync §6: complexity acknowledged, practical bounds given (10K players × Gr(2,4) = 2ms) |
Major — Addressed
| Finding | Resolution |
|---|---|
| No threat model | ✅ Threat Model — adversary capabilities, security properties |
| Missing flag structure explanation | ✅ Flag Structure — reference flag = clearance hierarchy |
| No Rust code in introduction | ✅ Quick start code block added |
| Fragile dependencies (pre-1.0 siblings) | ✅ Dependency table clarifies optional vs required |
Minor/Info — Addressed
| Finding | Resolution |
|---|---|
| Notation issues (σ vs partition, "point class") | ✅ Notation guide added to concepts |
| Doc structure (What's New interrupts math) | ✅ Moved to end of README |
| Adversarial concerns (DoS, model injection) | ✅ Adversarial Concerns |
| Non-identifiability (who has access?) | ✅ Capacity vs occupancy explanation in threat model |
| Audit trail (commutativity eliminates temporal) | ✅ Semantic commutativity ≠ temporal audit discussed |
| Missing prior art references | ✅ References added to concepts page |
Deferred to arXiv Preprint or Future Release
| Finding | Why Deferred |
|---|---|
| Empirical comparison to Cedar/OPA/Casbin | Needs benchmark implementation against real policy engines |
| Trust calibration (formal model) | Formal mathematical work — belongs in the paper |
| Cross-domain intersection derivation | Needs flag variety embedding proof — paper material |
| Rate limiting geometric justification | Needs empirical data connecting intersection numbers to throughput |
| Verification artifacts (proofs, certificates) | Depends on Karpal proof publication pipeline |
| Composition associativity proof | Formal category-theoretic proof — paper material |
Ongoing Challenges
Learning Curve
Schubert calculus is not standard security engineering knowledge. We recommend:
- Start with Distributed Game Sync — the most concrete motivating example
- Start with Gr(2,4) — the standard RBAC space
- Use the Getting Started walkthrough
- Use
schubert discoverto explore the API surface - Read Mathematical Foundation for the geometry
Persistence
No built-in storage layer. All state is in-memory. Use serde serialization +
your database of choice for persistence. Future work: SQLite/PostgreSQL backends.
Real-World Adoption
Schubert is a new project. We welcome:
- Production deployment reports
- Bug reports and edge cases
- Integration examples with common stacks (PostgreSQL, Redis, Kubernetes)
Future Directions
See the full Roadmap for speculative directions including:
- Persistent backends (SQLite, PostgreSQL)
- gRPC policy distribution protocol
- Policy diff and incremental updates
- Visualization of Schubert varieties
- Integration with OpenFGA / Rego policy languages
- Empirical comparison to Cedar, OPA, Casbin, Oso
- Formal trust calibration model
Roadmap & History
v0.1.0 Snapshot — All 14 core roadmap items are complete. Speculative directions are explorations for the research community, not commitments.
v0.1.0 — Foundation Complete
Core Infrastructure
- AccessController with principal management, capability registry, grant/revoke
- Quantitative AccessDecision (Granted{n}, Impossible, Denied, Underconstrained)
- 4 computation paths: LR, Localization, Tropical, Matroid
- Operadic composition, stability analysis, pluggable audit sinks
Feature-Gated Modules
serde— Serialization, JSON I/O, roundtrippolicy— TOML policy language with validationwasm— WasmController with JS bindingscrypto— Ed25519 capability tokenskarpal— Type-level proofs (Proven, Rewrite, law checks)karpal-verify— SMT/Lean proof obligations, Certified trust boundarysurreal— RationalSurreal + EpsilonPolynomial trust arithmeticholographic— Minuet cosine-similarity access patterns
Advanced Features
- Context-aware decisions (resource scoping, time-aware trust)
- MultiController with cross-domain capability translation
- Temporal access control (expiry, time-remaining)
- Rate limiting scaled by intersection numbers
- Schubert routing with geometric path computation
- Distributed CRDTs with version vectors
Quality
- 128 unit tests + 18 CLI tests = 146 total
- Zero clippy warnings (all feature combinations)
- 7 example programs
- CI/CD: fmt, clippy, test matrix (5 combos), docs, wasm build, verification
Speculative Directions
These are research explorations, not commitments:
- Persistent backends — SQLite, PostgreSQL, Redis storage layers
- gRPC policy distribution — Wire protocol for multi-node policy sync
- Policy diff engine — Incremental policy updates with minimal recomputation
- Visualization — SVG/WebGL rendering of Schubert varieties
- OpenFGA/Rego bridge — Translation between Schubert policies and standard DSLs
- Holographic persistence — Full Minuet store integration with cosine indexing
- Async runtime — tokio-based async AccessController
- Policy fuzzing — Automated discovery of impossible capability combinations
- Benchmark suite — Standardized workloads with published results
- WASM Component Model — WIT-based interface definitions
Role-Based Access Control (RBAC)
Traditional RBAC with quantitative access decisions.
Source: examples/rbac.rs
Setup
use schubert::{AccessController, Capability, CapabilityKind, AccessDecision}; fn main() -> Result<(), Box<dyn std::error::Error>> { let mut acl = AccessController::new(2, 4)?;
Capabilities
#![allow(unused)] fn main() { acl.register_capability(Capability::new( "read", "Read access", vec![1], CapabilityKind::ReadLike, ))?; acl.register_capability(Capability::new( "write", "Write access", vec![2], CapabilityKind::WriteLike, ))?; acl.register_capability(Capability::new( "admin", "Admin access", vec![2, 1], CapabilityKind::AdminLike, ))?; }
Principals and Grants
#![allow(unused)] fn main() { let alice = acl.create_principal("alice")?; acl.grant(&alice, "read")?; acl.grant(&alice, "write")?; let bob = acl.create_principal("bob")?; acl.grant(&bob, "read")?; }
Access Checks
#![allow(unused)] fn main() { // Alice can read and write match acl.check(&alice, &["read", "write"])? { AccessDecision::Granted { configurations } => { println!("Alice: granted with {configurations} configurations"); } _ => unreachable!(), } // Bob cannot write match acl.check(&bob, &["read", "write"])? { AccessDecision::Granted { .. } => unreachable!(), _ => println!("Bob: cannot read and write"), } Ok(()) } }
Key Takeaway
RBAC is the simplest pattern — define roles as capability sets, grant them to principals, and check. The quantitative nature of Schubert shines when roles overlap or conflict.
Row-Level Security
Tenant-scoped capabilities for database row-level security.
Source: examples/row_security.rs
Pattern
Create tenant-specific capabilities and check cross-tenant access for geometric impossibility detection:
#![allow(unused)] fn main() { // Tenant-scoped capabilities acl.register_capability(Capability::new( "read:tenant_a", "Read tenant A", vec![1], ReadLike, ))?; acl.register_capability(Capability::new( "read:tenant_b", "Read tenant B", vec![1], ReadLike, ))?; acl.register_capability(Capability::new( "read:tenant_c", "Read tenant C", vec![1], ReadLike, ))?; // Multi-tenant principal acl.grant(&principal, "read:tenant_a")?; acl.grant(&principal, "read:tenant_b")?; // Three tenant reads in Gr(2,4) — too many conditions let result = acl.check(&principal, &[ "read:tenant_a", "read:tenant_b", "read:tenant_c", ])?; // AccessDecision::Denied (overconstrained) }
Key Takeaway
Cross-tenant access patterns that a boolean system would approve are caught as overconstrained by Schubert's geometric analysis.
API Gateway
Pattern for an API gateway using Schubert for authorization.
Source: examples/api_gateway.rs
Pattern
The API gateway authenticates (external) and uses Schubert to authorize:
#![allow(unused)] fn main() { fn handle_request( acl: &AccessController, token: &str, endpoint: &str, ) -> Result<bool> { // 1. Authenticate (external — JWT, OAuth, etc.) let principal = authenticate(token)?; // 2. Map endpoint to capabilities let required = match endpoint { "/api/data" => &["read:data"], "/api/admin" => &["admin"], _ => return Ok(false), }; // 3. Authorize via Schubert match acl.check(&principal, required)? { AccessDecision::Granted { .. } => Ok(true), _ => Ok(false), } } }
Key Takeaway
Schubert is a library, not a network service. Embed it in your gateway, middleware, or sidecar — Schubert handles authorization, your infrastructure handles authentication and transport.
Cross-Domain Access
Capability translation between Grassmannians.
Source: examples/cross_domain.rs
Pattern
#![allow(unused)] fn main() { let mut mc = MultiController::new(); // Two domains with different policy spaces let rbac = mc.add_domain_named(2, 4, "rbac")?; // dim 4 let tenant = mc.add_domain_named(3, 6, "multi-tenant")?; // dim 9 let alice = mc.create_principal("alice", &rbac)?; mc.grant_in_domain(&alice, "read", &rbac)?; mc.grant_in_domain(&alice, "write", &rbac)?; // Check if RBAC capabilities translate to tenant domain let result = mc.check_cross_domain( &alice, &["read", "write"], &rbac, &tenant )?; }
Key Takeaway
Capabilities aren't globally meaningful — they live in a specific Grassmannian.
check_cross_domain() uses Schubert intersection to determine if a capability
set in one domain is valid in another.
Context-Aware Decisions
Resource-scoped, time-aware, trust-gated access checks.
Source: examples/context_aware.rs
Pattern
#![allow(unused)] fn main() { use schubert::AccessContext; // Time-critical operation with high trust requirement let ctx = AccessContext { resource: Some("/api/critical".into()), time_budget_ms: Some(100), required_trust: 0.95, }; let result = acl.check_with_context(&alice, &["admin"], &ctx)?; // Low-trust read with generous time budget let ctx = AccessContext { resource: Some("/api/public".into()), time_budget_ms: Some(5000), required_trust: 0.3, }; let result = acl.check_with_context(&alice, &["read"], &ctx)?; }
Key Takeaway
Not all access checks are equal. High-trust, time-critical operations should
be more restrictive — AccessContext captures all three dimensions
(resource, time, trust) in one struct.
Policy Loader
Loading an access controller from a TOML policy file.
Source: examples/policy_loader.rs
Pattern
#![allow(unused)] fn main() { let toml_str = std::fs::read_to_string("policy.toml")?; let acl = AccessController::from_policy_toml(&toml_str)?; // Use the loaded controller let alice = acl.get_principal("alice")?; let result = acl.check(&alice, &["read"])?; // Export current state let exported = acl.to_policy_toml()?; std::fs::write("exported.toml", exported)?; }
Key Takeaway
Policies-as-code enable version control, code review, and CI validation of access control configurations.
Rate Limiter
Token-bucket rate limiting scaled by Schubert intersection numbers.
Source: examples/rate_limiter.rs
Pattern
#![allow(unused)] fn main() { use schubert::RateLimiter; let mut rl = RateLimiter::new(10.0, 1.0); // 10 tokens/sec // Check access first let granted = acl.check(&alice, &["read", "write"])?; // Scale rate limiter by intersection number rl.configure_from_decision("alice", &granted)?; // Per-request check for _ in 0..100 { if rl.try_consume("alice").is_err() { println!("Rate limit reached"); break; } } }
Key Takeaway
Higher-trust principals (higher intersection numbers) get proportionally more throughput because the rate limiter is scaled by the access decision.