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 GrassmannianMultiController manages cross-domain access

The Industrial Algebra Ecosystem

Schubert depends on three sibling projects:

CrateVersionRole
CrateVersionRole
---------
Amari0.23Schubert calculus engine — Grassmannians, intersection numbers
Karpal0.6Formal verification — type-level proofs, SMT/Lean obligations
Minuet0.5Holographic 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

DecisionMeaning
Granted { configurations: n }Access allowed in exactly n ways
Impossible { conflicting }Conditions are geometrically incompatible
DeniedToo 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)4Standard RBAC (recommended starting point)
Gr(3,6)9Complex multi-tenant policies
Gr(4,8)16Enterprise-scale policy space

Larger Grassmannians support more distinct capabilities but have higher computational cost.

Next Steps

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)DimensionMax Independent Conditions
Gr(2,4)44
Gr(3,6)99
Gr(4,8)1616

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:

PartitionCodimensionTypical Use
[1]1Read access
[2]2Write access
[1,1]2Read + audit
[2,1]3Manage
[2,2]4Admin (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

SymbolMeaning
σ_λ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

Prior Work in Access Control

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:

KindTrust SensitivityExamples
ReadLikeLow — stable even at low trustRead, list, view
WriteLikeMedium — degrades at moderate trustWrite, update, delete
AdminLikeHigh — degrades rapidly under trust lossAdmin, manage, configure
CustomApplication-definedAny 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:

PathWhen to use
LRDefault — balanced performance, exact results
LocalizationWhen you need geometric insight into why
TropicalLarge-scale batch operations (>1000 principals)
MatroidWhen 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

CapabilityKindDegradation Pattern
ReadLikeDegrades below ~0.3 trust
WriteLikeDegrades below ~0.5 trust
AdminLikeDegrades 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 ∘ b is impossible, b ∘ a is 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"] }
FeatureEnables
std (default)HashMap, SystemTime, thread-safe audit
serdeSerialization on all types, JSON I/O
karpalType-level proofs (Proven, Rewrite)
parallelBatch operations via rayon
policyTOML policy language
wasmWebAssembly JS bindings
cryptoEd25519 capability tokens
karpal-verifyFormal verification (SMT/Lean)
surrealExact surreal trust arithmetic
holographicMinuet holographic memory

All features compose — enable any combination.

no_std Support

[dependencies]
schubert = { version = "0.1", default-features = false }

When std is disabled: HashMapBTreeMap, 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

FeatureWhat It Enables
std (default)HashMap, SystemTime, thread-safe Mutex audit
serdeSerialize/Deserialize on all types, JSON I/O
karpalproof module: Proven, Property, Rewrite, law checks
parallelcheck_batch(), stability_batch(), compose_batch() via rayon
policypolicy module: TOML parsing, validate, roundtrip
wasmwasm module: WasmController with JS bindings
cryptocrypto module: Ed25519 CapabilityToken, GrantToken, Issuer, Verifier, KeyStore
axumaxum module: AuthPrincipal bearer-token extractor (enables crypto)
karpal-verifyverify module: SMT/Lean proof obligations, Certified trust boundary
surrealsurreal_trust module: RationalSurreal + EpsilonPolynomial
holographicholographic 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:

  1. Add to [features] in Cargo.toml
  2. Use #[cfg(feature = "my-feature")] on module declarations
  3. Use #[cfg(feature = "my-feature")] on impl blocks and functions
  4. 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

FieldTypeDescription
id&strUnique capability identifier
label&strHuman-readable description
partitionVec<usize>Schubert partition (weakly decreasing)
kindCapabilityKindSemantic category affecting trust sensitivity
expires_atOption<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

ConditionDecision
Intersection number > 0Granted { configurations }
Intersection number = 0Impossible { conflicting }
Total codimension > Gr dimensionDenied
Total codimension < Gr dimensionUnderconstrained { 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: multiplicity configurations 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

  1. For each granted capability, compute its stability as a function of trust
  2. Higher-codimension capabilities cross stability walls at higher trust levels
  3. AdminLike capabilities cross first, ReadLike last
  4. 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: AuditSink requires the std feature
  • No_std: InMemoryAudit uses RefCell, no thread safety

SchubertError

All errors use SchubertError with 11 variants:

VariantWhen
InvalidGrassmanniank ≥ n or k = 0
CapabilityNotFoundReferenced capability not registered
PrincipalNotFoundReferenced principal doesn't exist
AlreadyHoldsDuplicate grant
DoesNotHoldRevoke on unheld capability
InvalidPartitionPartition not weakly decreasing
ImpossibleCompositionGeometric incompatibility
UnderconstrainedToo few conditions
OverconstrainedToo many conditions
SerializationErrorserde I/O failure
VerificationErrorKarpal 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 CapabilityIssuer and distribute its public key; old tokens continue to verify against their original issuer's key.

Partition components are stored as u8 in 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 variantHTTPMeaning
AuthError::Unauthorized(_)401Client problem: missing/malformed header, bad base64, invalid signature.
AuthError::ServerMisconfigured(_)500Server 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

SubpathWhatDependencies
@industrialalgebra/schubert-tsukoshiCore: AccessController, LR tables, impossibility detectionnone (zero-dep)
@industrialalgebra/schubert-tsukoshi/cryptoEd25519 CapabilityToken / GrantToken issue + verify@noble/ed25519, @noble/hashes
@industrialalgebra/schubert-tsukoshi/protocolsGrantCRDT — 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 (run cargo run --example generate_ts_lr_tables), so the TypeScript math is byte-faithful to the Rust math.
  • schubert-tsukoshi targets 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: AuditSink is not available on wasm32 since it requires std.

CapabilityKind Values

JS StringVariant
"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:

BundleProperty
grant_check_consistencyIf granted, check must return Granted
revoke_removes_accessRevoke → check returns Denied or Impossible
grant_revoke_identityGrant then revoke = no net change
composition_associativity(a ∘ b) ∘ c = a ∘ (b ∘ c)
impossibility_symmetrya 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

LevelBackendGuarantee
QuickCheckProperty testingStatistical confidence
SMTZ3/CVC4Symbolic model checking
LeanLean 4Full 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

  1. Access patterns are encoded as vectors via FNV hash
  2. Cosine similarity measures how close two access patterns are
  3. Schubert intersection provides geometric validation of similarity
  4. 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 PrincipalId values
  • 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: CapabilityIssuer generates 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 the parallel feature for high-throughput scenarios.
  • Tropical path: Switch to ComputationPath::Tropical for >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

CapabilityMitigation
Observe access decisionsSchubert is embedded; the host application controls logging via AuditSink
Attempt impossible capability combinationsSchubert returns AccessDecision::Impossible — the geometric intersection is zero regardless of adversary input
Submit many capabilities to exhaust computationSee 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

CapabilityWhy
Bypass the geometric intersectionThe Littlewood-Richardson coefficient is a mathematical fact. No input can make σ₂·σ₁₁ ≠ 0 in Gr(2,4).
Corrupt the computation silentlyThe 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

ThreatOwner
AuthenticationExternal (OAuth, JWT, mTLS). Schubert receives PrincipalId from the caller.
Transport securityExternal (TLS, WireGuard). Schubert has no network surface.
Key managementExternal. The CapabilityIssuer generates keys; storage and rotation are the caller's responsibility.
Replay attacks on tokensExternal. Schubert tokens are stateless. The caller tracks used tokens.
Side-channel timingPartial. 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 AuditSink records 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:

PartitionIntersection with FlagAccess 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 maximallyFull 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:

  1. You need a custom clearance hierarchy
  2. You're debugging why two same-codimension conditions have different intersection behavior
  3. 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 AccessController uses a HashMap for 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

AttackSeverityMitigation
Computational DoSMediumBound capabilities per check, validate partitions
Dimensionality poisoningLowDon't expose new(k,n) to untrusted callers
Capability floodingLowAdmin-only registration, HashMap O(1) lookup
Token replayMediumShort-lived tokens, replay tracking, TLS
CRDT poisoningMediumStaleness gating, geometric validation post-merge
Timing side channelLowConstant-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:

ModuleExact SideApproximate Side
controller.rsLittlewood-Richardson coefficients (integer)Principal identities (external, opaque strings)
surreal_trust.rsRationalSurreal + EpsilonPolynomialTrust updates from external systems
crdt.rsGeometric intersection (exact LR coefficient)Eventually-consistent grant state
holographic.rsCosine similarity threshold (float, but bounded)Vector encoding via FNV hash
crypto.rsEd25519 signature verificationToken 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:

ProjectExact SideApproximate Side
MinuetHolographic memory interfacesOptical hardware stub
VirtuosoCognitive agent architectureModule stubs
SchubertGeometric access decisionsCRDT-distributed state
MinoritySurreal 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.

ConcernResolution
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 unguardedset_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

FindingResolution
No formal mapping from access control to Schubert geometryDistributed 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

FindingResolution
No threat modelThreat Model — adversary capabilities, security properties
Missing flag structure explanationFlag 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

FindingResolution
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

FindingWhy Deferred
Empirical comparison to Cedar/OPA/CasbinNeeds benchmark implementation against real policy engines
Trust calibration (formal model)Formal mathematical work — belongs in the paper
Cross-domain intersection derivationNeeds flag variety embedding proof — paper material
Rate limiting geometric justificationNeeds empirical data connecting intersection numbers to throughput
Verification artifacts (proofs, certificates)Depends on Karpal proof publication pipeline
Composition associativity proofFormal category-theoretic proof — paper material

Ongoing Challenges

Learning Curve

Schubert calculus is not standard security engineering knowledge. We recommend:

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, roundtrip
  • policy — TOML policy language with validation
  • wasm — WasmController with JS bindings
  • crypto — Ed25519 capability tokens
  • karpal — Type-level proofs (Proven, Rewrite, law checks)
  • karpal-verify — SMT/Lean proof obligations, Certified trust boundary
  • surreal — RationalSurreal + EpsilonPolynomial trust arithmetic
  • holographic — 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:

  1. Persistent backends — SQLite, PostgreSQL, Redis storage layers
  2. gRPC policy distribution — Wire protocol for multi-node policy sync
  3. Policy diff engine — Incremental policy updates with minimal recomputation
  4. Visualization — SVG/WebGL rendering of Schubert varieties
  5. OpenFGA/Rego bridge — Translation between Schubert policies and standard DSLs
  6. Holographic persistence — Full Minuet store integration with cosine indexing
  7. Async runtime — tokio-based async AccessController
  8. Policy fuzzing — Automated discovery of impossible capability combinations
  9. Benchmark suite — Standardized workloads with published results
  10. 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.