@industrialalgebra/schubert-tsukoshi
v0.5.0
Published
Pure TypeScript geometric access control via Schubert calculus — zero-dependency extraction of the Schubert Rust crate, with impossibility detection in the browser.
Maintainers
Readme
schubert-tsukoshi
Pure-TypeScript geometric access control via Schubert calculus — zero runtime dependencies, with impossibility detection in the browser.
schubert-tsukoshi is a TypeScript extraction of the Schubert Rust crate's access-control model. Capabilities are Schubert conditions on a Grassmannian; access is the intersection of those conditions, and the intersection number counts exactly how many valid configurations exist. The headline feature — detecting that a policy is geometrically impossible (the σ₂·σ₁₁ = 0 case) — works in the browser with no backend.
It follows the @cliffy-ga/tsukoshi pattern: pure TypeScript, zero dependencies, works everywhere (browser, Node, Deno, React Native).
Install
npm install @industrialalgebra/schubert-tsukoshiPublish: the
@industrialalgebranpm org exists; this package is publish-ready (runnpm publishfromschubert-tsukoshi/).
Quick start
import { AccessController } from "@industrialalgebra/schubert-tsukoshi";
const acl = new AccessController("gr24"); // Gr(2,4): a 4-dimensional policy space
acl.registerCapability({ id: "read", partition: [1], kind: "read" });
acl.registerCapability({ id: "write", partition: [2], kind: "write" });
acl.registerCapability({ id: "manage", partition: [2,1], kind: "manage" });
const alice = acl.createPrincipal("alice");
acl.grant(alice, "read");
acl.grant(alice, "manage");
const decision = acl.check(alice, ["read", "manage"]);
// => { kind: "granted", configurations: 1 }The killer feature: impossibility detection
A principal can hold every required capability and still be denied, because the conditions are geometrically incompatible. This catches policy conflicts that pass a naive set-membership check:
const acl = new AccessController("gr24");
acl.registerCapability({ id: "write", partition: [2], kind: "write" });
acl.registerCapability({ id: "dwide", partition: [1,1], kind: "custom" });
const mallory = acl.createPrincipal("mallory");
acl.grant(mallory, "write");
acl.grant(mallory, "dwide"); // set-membership: ✓ both held
acl.check(mallory, ["write", "dwide"]);
// => { kind: "impossible", conflicting: ["write", "dwide"] }
// σ₂ · σ₁₁ = 0 on Gr(2,4) — the policy is geometrically void.Notably, σ₂·σ₁₁ is not impossible on the larger Gr(3,6) — the bigger box admits the product. Same code, different Grassmannian, different verdict.
Decisions
check() returns a discriminated union:
| kind | Meaning |
| ----------------- | ----------------------------------------------------------------------- |
| "granted" | Access permitted; configurations = Schubert intersection number. |
| "impossible" | All caps held, but geometrically incompatible (conflicting ids). |
| "underconstrained" | Conditions don't pin a finite config set (dimension > 0). |
| "denied" | Principal lacks a required capability (missing id) — set-membership. |
Capability tokens (./crypto)
Ed25519-signed capability and grant tokens — a TypeScript mirror of the Rust
crypto module, with a wire format byte-compatible with the Rust crate.
Tokens issued in Rust verify here and vice-versa.
The
./cryptosubpath depends on@noble/ed25519and@noble/hashes(audited, standard). The core.entry remains zero-dependency.
import {
Issuer,
Verifier,
grantToBytes,
grantFromBytes,
} from "@industrialalgebra/schubert-tsukoshi/crypto";
// Persist the issuer by its 32-byte seed (store securely, e.g. 0600 file).
const issuer = Issuer.fromSeedHex(process.env.ISSUER_SEED!);
const grant = issuer.issueGrant("alice", [
{ id: "memory:read", partition: [1] },
{ id: "memory:write", partition: [2] },
]);
// v0.5.0 — grant lifecycle options: pin the nonce, or set a signed expiry.
const session = issuer.issueGrant(
"alice",
[{ id: "memory:read", partition: [1] }],
{ expiresAt: sessionEndUnix }, // random nonce by default
);
// Verifiers only need the public key.
const verifier = new Verifier(issuer.publicKey());
verifier.verifyGrant(grant); // throws if signature invalid / expired
verifier.verifyGrantAt(session, now); // deterministic clock (tests, replay)
verifier.may(grant, [1]); // true — geometric containment
// Wire format roundtrips and is Rust-compatible:
const bytes = grantToBytes(grant);
grantFromBytes(bytes);Interop guarantee: the cross-validation test suite
(src/crypto/tokens.test.ts) issues tokens from a fixed seed in both Rust and
TypeScript and asserts byte-identical output. Regenerate the Rust vectors with
cargo run --example tsukoshi_crypto_vectors --features crypto.
Supported Grassmannians
Precomputed tables ship for three policy spaces:
| Tag | Gr(k,n) | Dimension | Use case |
| ------ | -------- | --------- | ----------------------- |
| gr24 | Gr(2,4) | 4 | Standard RBAC |
| gr36 | Gr(3,6) | 9 | Complex multi-tenant |
| gr48 | Gr(4,8) | 16 | Enterprise policy space |
To add more, regenerate the tables from the Rust crate:
cargo run --example generate_ts_lr_tables > schubert-tsukoshi/src/lr-tables.tsThe generator uses amari-enumerative's exact schubert_product, so the TypeScript tables are byte-faithful to the Rust math — no transcription risk.
How it works
Schubert classes are multiplied via precomputed Littlewood-Richardson coefficient tables. A check folds the position and required classes into a single Schubert polynomial, then reads the coefficient of the point class σ_{(n−k)^k}:
- coefficient
> 0→ granted (that many configurations) - coefficient
== 0, codimension sum== dim→ impossible - codimension sum
< dim→ underconstrained
This mirrors the Littlewood-Richardson branch of Schubert's AccessController::check.
Distributed grants (./protocols)
A replicated capability-grant set — GrantCRDT — for synchronizing who has
what capability across leaderless replicas, built on @cliffy-ga/tsukoshi's
VectorClock. This is the trusted-replica counterpart to the
proof-carrying tokens in ./crypto:
./crypto(GrantToken) — untrusted clients present a signed bearer token (v0.5.0: signed expiry + per-issuance nonce; renewal = re-issue)../protocols(GrantCRDT) — trusted replicas converge on a shared grant set and answer access queries from merged state. v0.5.0 tombstones:revokeGrant(nonceHex)kills one specific issuance everywhere — a grow-only set merged by union, so no merge order can resurrect it (unlike the add-wins concurrent rule above, which is exactly why tombstones are a separate set).
import { GrantCRDT } from "@industrialalgebra/schubert-tsukoshi/protocols";
const hub = new GrantCRDT("hub");
hub.grant("alice", { id: "memory:read", partition: [1] });
hub.grant("alice", { id: "memory:write", partition: [2] });
const edge = new GrantCRDT("edge");
edge.merge(hub); // edge converges to hub's grant set
edge.may("alice", [1]); // true — geometric containment over merged state
edge.revoke("alice", "memory:write");
// Incident response (v0.5.0): tombstone one bearer by its hex nonce
// (toHex(grant.nonce), where toHex comes from the ./crypto subpath).
hub.revokeGrant(leakedNonceHex);
hub.isGrantRevoked(leakedNonceHex); // true — everywhere, eventually
// Snapshot/restore for transport:
const snap = edge.toJSON();
const restored = GrantCRDT.fromJSON(snap, "edge-2");Semantics: state-based last-writer-wins map keyed by (principal,
capability). The later vector clock wins; on concurrent ops grant wins over
revoke (add-wins), with nodeId as a deterministic tiebreak. Merge is
commutative, associative, and idempotent, so replicas reach strong eventual
consistency regardless of delivery order.
The
./protocolssubpath depends on@cliffy-ga/tsukoshi(forVectorClock) but not on@noble/ed25519— itsmay()uses the shared, dependency-freepartitionsLe. (@cliffy-ga/tsukoshiwill migrate to the@industrialalgebrascope during its refactor.)
License
Apache-2.0. © Industrial Algebra.
