x.uma
Alpha (0.0.2) — API is stabilizing. Expect breaking changes before 1.0.
One matcher engine. Five implementations. Same semantics everywhere.
x.uma implements the xDS Unified Matcher API — the same matching protocol Envoy uses at Google scale — across Rust, Python, and TypeScript.
Write matching rules once. Evaluate them in any language. Get the same answer every time.
Context → DataInput → MatchingData → InputMatcher → bool
domain- erased domain-
specific agnostic
An ExactMatcher doesn’t know whether it’s matching HTTP paths, Claude Code hook events, or your custom domain. It matches data. The domain-specific part — extracting that data from your context — is a separate port.
Implementations
| Package | Language | What it is |
|---|---|---|
| rumi | Rust | Core engine (reference implementation) |
| xuma | Python 3.12+ | Pure Python, zero native deps beyond RE2 |
| xuma | TypeScript/Bun | Pure TypeScript, zero native deps beyond RE2 |
| xuma-crust | Python | Rust bindings via PyO3 |
| xuma-crust | TypeScript | Rust bindings via WASM |
All five pass the same conformance test suite (~958 tests total).
Get Started
- Rust —
rumi+rumi-httpin yourCargo.toml - Python —
uv add xuma, build a matcher in 10 lines - TypeScript —
bun add xuma, same API shape as Python
Guarantees
| Guarantee | How |
|---|---|
| No ReDoS | Rust regex crate (linear time). Python uses google-re2. TypeScript uses re2js. |
| Bounded depth | Max 32 levels of nesting, validated at config load |
| Fail-closed | Missing data → predicate returns false. Never matches by accident. |
| Thread-safe | All types are Send + Sync (Rust) / immutable (Python, TypeScript) |
Rust Quick Start
Build an HTTP route matcher with rumi and rumi-http.
Install
[dependencies]
rumi-core = "0.0.2"
rumi-http = "0.0.2"
rumi-http brings in rumi-core as a transitive dependency. The lib name is rumi, so you write use rumi::prelude::*.
The CLI is a separate binary:
cargo install --path rumi/cli
Write a Config
Create routes.yaml:
matchers:
- predicate:
type: and
predicates:
- type: single
input: { type_url: "xuma.http.v1.PathInput", config: {} }
value_match: { Prefix: "/api" }
- type: single
input: { type_url: "xuma.http.v1.MethodInput", config: {} }
value_match: { Exact: "GET" }
on_match: { type: action, action: "api_read" }
- predicate:
type: and
predicates:
- type: single
input: { type_url: "xuma.http.v1.PathInput", config: {} }
value_match: { Prefix: "/api" }
- type: single
input: { type_url: "xuma.http.v1.MethodInput", config: {} }
value_match: { Exact: "POST" }
on_match: { type: action, action: "api_write" }
on_no_match: { type: action, action: "not_found" }
The type_url selects which data input to extract. value_match tests the extracted value. See Config Format for the full schema.
Validate with the CLI
$ rumi check http routes.yaml
Config valid
Catches unknown type URLs, invalid regex patterns, and depth limit violations at load time.
Run with the CLI
$ rumi run http routes.yaml --method GET --path /api/users
api_read
$ rumi run http routes.yaml --method POST --path /api/items
api_write
$ rumi run http routes.yaml --method DELETE --path /api/users
not_found
Load in Your App
The same config file works programmatically via the Registry API:
use rumi::prelude::*;
use rumi_http::{HttpRequest, register_simple};
fn main() {
// Build registry with HTTP inputs
let registry = register_simple(RegistryBuilder::new()).build();
// Load the config
let yaml = std::fs::read_to_string("routes.yaml").unwrap();
let config: MatcherConfig<String> = serde_yaml::from_str(&yaml).unwrap();
let matcher = registry.load_matcher(config).unwrap();
// Evaluate
let request = HttpRequest::builder()
.method("GET")
.path("/api/users")
.build();
assert_eq!(matcher.evaluate(&request), Some("api_read".to_string()));
}
The registry resolves type_url strings to concrete DataInput implementations at load time. Unknown type URLs produce an error listing available types.
Compiler Shorthand
For type-safe HTTP matching without config files, use the Gateway API compiler:
use rumi::prelude::*;
use rumi_http::prelude::*;
// Declarative config
let routes = vec![
HttpRouteMatch {
path: Some(HttpPathMatch::Prefix { value: "/api".into() }),
method: Some(HttpMethod::Get),
..Default::default()
},
];
// One call compiles all routes into a matcher
let matcher = compile_route_matches(&routes, "allowed", "denied").unwrap();
let req = HttpRequest::builder().method("GET").path("/api/users").build();
assert_eq!(matcher.evaluate(&req), Some(&"allowed"));
This requires rumi-http with the ext-proc feature (enabled by default).
Claude Code Hooks
rumi also matches Claude Code hook events. Create hooks.yaml:
matchers:
- predicate:
type: and
predicates:
- type: single
input: { type_url: "xuma.claude.v1.EventInput", config: {} }
value_match: { Exact: "PreToolUse" }
- type: single
input: { type_url: "xuma.claude.v1.ToolNameInput", config: {} }
value_match: { Exact: "Bash" }
- type: single
input: { type_url: "xuma.claude.v1.ArgumentInput", config: { name: "command" } }
value_match: { Contains: "rm -rf" }
on_match: { type: action, action: "block" }
on_no_match: { type: action, action: "allow" }
$ rumi check claude hooks.yaml
Config valid
$ rumi run claude hooks.yaml --event PreToolUse --tool Bash --arg command="rm -rf /"
block
$ rumi run claude hooks.yaml --event PreToolUse --tool Read
allow
Safety
- ReDoS protection – the
regexcrate guarantees linear-time matching. No backtracking. - Depth limits – nested matchers capped at 32 levels, validated at construction.
- No unsafe in core – all
Send + Syncis compiler-derived.
Next Steps
- The Matching Pipeline – how data flows through the matcher
- CLI Reference – all commands and domains
- Config Format – full config schema and type URL tables
- API Reference – generated docs for all languages
Python Quick Start
Build an HTTP route matcher with xuma (pure Python) or xuma-crust (Rust-backed).
Install
# Pure Python
uv add xuma
# Rust-backed (faster, same API surface)
uv add xuma-crust
Requires Python 3.12+. xuma uses google-re2 for linear-time regex.
Write a Config
Create routes.yaml:
matchers:
- predicate:
type: and
predicates:
- type: single
input: { type_url: "xuma.http.v1.PathInput", config: {} }
value_match: { Prefix: "/api" }
- type: single
input: { type_url: "xuma.http.v1.MethodInput", config: {} }
value_match: { Exact: "GET" }
on_match: { type: action, action: "api_read" }
- predicate:
type: single
input: { type_url: "xuma.http.v1.PathInput", config: {} }
value_match: { Exact: "/health" }
on_match: { type: action, action: "health" }
on_no_match: { type: action, action: "not_found" }
Validate with the CLI
$ rumi check http routes.yaml
Config valid
Run with the CLI
$ rumi run http routes.yaml --method GET --path /api/users
api_read
$ rumi run http routes.yaml --method GET --path /health
health
$ rumi run http routes.yaml --method DELETE --path /other
not_found
Load in Your App (xuma)
The pure Python implementation loads the same config:
import yaml
from xuma import Registry, RegistryBuilder
from xuma.http import HttpRequest, register_http
# Build registry with HTTP inputs
builder = RegistryBuilder()
register_http(builder)
registry = builder.build()
# Load config
with open("routes.yaml") as f:
config = yaml.safe_load(f)
matcher = registry.load_matcher(config)
# Evaluate
request = HttpRequest(method="GET", raw_path="/api/users")
assert matcher.evaluate(request) == "api_read"
Load in Your App (xuma-crust)
The Rust-backed bindings use the same config format:
from xuma_crust import load_http_matcher, HttpMatcher
# Load config and build matcher in one call
matcher: HttpMatcher = load_http_matcher("routes.yaml")
# Evaluate with method + path
assert matcher.evaluate("GET", "/api/users") == "api_read"
assert matcher.evaluate("DELETE", "/other") == "not_found"
xuma-crust is 10-100x faster than pure Python for evaluation.
Compiler Shorthand
For type-safe HTTP matching without config files:
from xuma.http import (
HttpRouteMatch,
HttpPathMatch,
HttpRequest,
compile_route_matches,
)
routes = [
HttpRouteMatch(
path=HttpPathMatch(type="PathPrefix", value="/api"),
method="GET",
),
HttpRouteMatch(
path=HttpPathMatch(type="PathPrefix", value="/admin"),
method="POST",
),
]
matcher = compile_route_matches(
matches=routes,
action="allowed",
on_no_match="denied",
)
assert matcher.evaluate(HttpRequest(method="GET", raw_path="/api/users")) == "allowed"
assert matcher.evaluate(HttpRequest(method="DELETE", raw_path="/api/users")) == "denied"
Within a single HttpRouteMatch, all conditions are ANDed. Multiple routes are ORed. First match wins.
Safety
- ReDoS protection –
google-re2guarantees linear-time regex matching. - Immutable – all types are
frozen=Truedataclasses. - Depth limits – nested matchers capped at 32 levels.
- Fail-closed – missing headers or query params return
NonefromDataInput, which makes the predicate evaluate toFalse.
Next Steps
- The Matching Pipeline – how data flows through the matcher
- CLI Reference – all commands and domains
- Config Format – full config schema and type URL tables
- API Reference – generated docs for all languages
TypeScript Quick Start
Build an HTTP route matcher with xuma (pure TypeScript) or xuma-crust (WASM-backed).
Install
# Pure TypeScript
bun add xuma
# WASM-backed (faster, same API surface)
bun add xuma-crust
Requires Bun runtime. xuma uses re2js for linear-time regex.
Write a Config
Create routes.yaml:
matchers:
- predicate:
type: and
predicates:
- type: single
input: { type_url: "xuma.http.v1.PathInput", config: {} }
value_match: { Prefix: "/api" }
- type: single
input: { type_url: "xuma.http.v1.MethodInput", config: {} }
value_match: { Exact: "GET" }
on_match: { type: action, action: "api_read" }
- predicate:
type: single
input: { type_url: "xuma.http.v1.PathInput", config: {} }
value_match: { Exact: "/health" }
on_match: { type: action, action: "health" }
on_no_match: { type: action, action: "not_found" }
Validate with the CLI
$ rumi check http routes.yaml
Config valid
Run with the CLI
$ rumi run http routes.yaml --method GET --path /api/users
api_read
$ rumi run http routes.yaml --method GET --path /health
health
$ rumi run http routes.yaml --method DELETE --path /other
not_found
Load in Your App (xuma)
The pure TypeScript implementation loads the same config:
import { RegistryBuilder, registerHttp, type MatcherConfig } from "xuma";
import { HttpRequest } from "xuma/http";
import { parse } from "yaml";
// Build registry with HTTP inputs
const builder = new RegistryBuilder();
registerHttp(builder);
const registry = builder.build();
// Load config
const yaml = await Bun.file("routes.yaml").text();
const config: MatcherConfig = parse(yaml);
const matcher = registry.loadMatcher(config);
// Evaluate
const request = new HttpRequest("GET", "/api/users");
console.assert(matcher.evaluate(request) === "api_read");
Load in Your App (xuma-crust)
The WASM-backed bindings use the same config format:
import { loadHttpMatcher, type HttpMatcher } from "xuma-crust";
// Load config and build matcher in one call
const matcher: HttpMatcher = loadHttpMatcher("routes.yaml");
// Evaluate with method + path
console.assert(matcher.evaluate("GET", "/api/users") === "api_read");
console.assert(matcher.evaluate("DELETE", "/other") === "not_found");
xuma-crust is 3-10x faster than pure TypeScript for evaluation.
Compiler Shorthand
For type-safe HTTP matching without config files:
import { compileRouteMatches, HttpRequest } from "xuma/http";
import type { HttpRouteMatch } from "xuma/http";
const routes: HttpRouteMatch[] = [
{
path: { type: "PathPrefix", value: "/api" },
method: "GET",
},
{
path: { type: "PathPrefix", value: "/admin" },
method: "POST",
},
];
const matcher = compileRouteMatches(routes, "allowed", "denied");
console.assert(matcher.evaluate(new HttpRequest("GET", "/api/users")) === "allowed");
console.assert(matcher.evaluate(new HttpRequest("DELETE", "/api/users")) === "denied");
Within a single HttpRouteMatch, all conditions are ANDed. Multiple routes are ORed. First match wins.
Integration: Bun HTTP Server
import { compileRouteMatches, HttpRequest } from "xuma/http";
const matcher = compileRouteMatches(
[{ path: { type: "PathPrefix", value: "/api" }, method: "GET" }],
"allowed",
"denied",
);
Bun.serve({
port: 3000,
fetch(req) {
const url = new URL(req.url);
const request = new HttpRequest(
req.method,
url.pathname + url.search,
Object.fromEntries(req.headers),
);
if (matcher.evaluate(request) === "denied") {
return new Response("Not found", { status: 404 });
}
return new Response("OK");
},
});
Safety
- ReDoS protection –
re2jsguarantees linear-time regex matching. - Immutable – all types use
readonlyfields. - Depth limits – nested matchers capped at 32 levels.
- Fail-closed – missing data from
DataInputreturnsnull, which makes the predicate evaluate tofalse.
Next Steps
- The Matching Pipeline – how data flows through the matcher
- CLI Reference – all commands and domains
- Config Format – full config schema and type URL tables
- API Reference – generated docs for all languages
The Matching Pipeline
Every evaluation follows the same flow. Understanding this pipeline is understanding x.uma.
The Flow
Context (your data)
↓
DataInput.get() ← extract a value from the context
↓
MatchingData ← type-erased: string | int | bool | bytes | null
↓
InputMatcher.matches() ← compare the value
↓
bool ← did it match?
↓
Predicate.evaluate() ← combine with other conditions (AND/OR/NOT)
↓
bool ← combined result
↓
Matcher.evaluate() ← find the first matching rule
↓
Action ← your decision (or null if nothing matched)
Two things to notice:
-
The pipeline splits at
MatchingData. Everything above is domain-specific (knows about your context type). Everything below is domain-agnostic (works with any domain). -
The same
InputMatcherworks everywhere. AnExactMatcherdoesn’t care whether the string came from an HTTP path or a Claude Code tool name. It matches strings.
Concrete Example
Route GET /api/users to the API backend:
Python:
from xuma import SinglePredicate, PrefixMatcher, Matcher, FieldMatcher, Action
from xuma.http import HttpRequest, PathInput
# DataInput: extract the path from the request
# InputMatcher: check if the path starts with /api
predicate = SinglePredicate(
input=PathInput(), # domain-specific
matcher=PrefixMatcher("/api") # domain-agnostic
)
matcher = Matcher(
matcher_list=(
FieldMatcher(predicate=predicate, on_match=Action("api_backend")),
),
)
request = HttpRequest(method="GET", raw_path="/api/users")
assert matcher.evaluate(request) == "api_backend"
Rust:
use rumi::prelude::*;
use rumi_http::*;
let predicate = Predicate::Single(SinglePredicate::new(
Box::new(SimplePathInput), // domain-specific
Box::new(PrefixMatcher::new("/api")), // domain-agnostic
));
let matcher: Matcher<HttpRequest, &str> = Matcher::new(
vec![FieldMatcher::new(predicate, OnMatch::Action("api_backend"))],
None,
);
let request = HttpRequest::builder().method("GET").path("/api/users").build();
assert_eq!(matcher.evaluate(&request), Some(&"api_backend"));
TypeScript:
import { SinglePredicate, PrefixMatcher, Matcher, FieldMatcher, Action } from "xuma";
import { HttpRequest, PathInput } from "xuma/http";
const predicate = new SinglePredicate(
new PathInput(), // domain-specific
new PrefixMatcher("/api"), // domain-agnostic
);
const matcher = new Matcher(
[new FieldMatcher(predicate, new Action("api_backend"))],
);
const request = new HttpRequest("GET", "/api/users");
console.assert(matcher.evaluate(request) === "api_backend");
Same structure in all three languages. Same result.
The Same Pipeline, Different Domain
The power of this split: the same PrefixMatcher works for HTTP paths and custom event types.
from dataclasses import dataclass
from xuma import SinglePredicate, PrefixMatcher, Matcher, FieldMatcher, Action, MatchingData
# Custom context
@dataclass(frozen=True)
class CloudEvent:
type: str
source: str
# Custom DataInput — extract the event type
@dataclass(frozen=True)
class EventTypeInput:
def get(self, ctx: CloudEvent) -> MatchingData:
return ctx.type
# Use the SAME PrefixMatcher — it doesn't know about CloudEvent
predicate = SinglePredicate(
input=EventTypeInput(),
matcher=PrefixMatcher("com.example."),
)
matcher = Matcher(
matcher_list=(
FieldMatcher(predicate=predicate, on_match=Action("handle_event")),
),
)
event = CloudEvent(type="com.example.user.created", source="api")
assert matcher.evaluate(event) == "handle_event"
PrefixMatcher operates on MatchingData (the erased string), not on CloudEvent or HttpRequest. Domain adapters (PathInput, EventTypeInput) are context-specific. Matchers are universal.
Pipeline Stages
| Stage | Role | Generic? | Examples |
|---|---|---|---|
| Context | Your domain data | Yes (Ctx) | HttpRequest, HookContext, your type |
| DataInput | Extract a value | Yes (Ctx) | PathInput, ToolNameInput, your input |
| MatchingData | Type-erased value | No | string, int, bool, bytes, null |
| InputMatcher | Match the value | No | ExactMatcher, PrefixMatcher, RegexMatcher |
| Predicate | Boolean logic | Yes (Ctx) | SinglePredicate, And, Or, Not |
| Matcher | First-match-wins | Yes (Ctx, A) | Routes to actions |
| Action | Your decision | Yes (A) | Strings, enums, structs — anything |
The boundary at MatchingData is what makes the engine domain-agnostic. Cross it once, and every matcher works for every domain.
Next
- Type Erasure and Ports — why
InputMatcheris non-generic
Type Erasure and Ports
Why does the same ExactMatcher work for HTTP headers and custom event types? Because type erasure happens at the data level, not the matcher level.
The Problem
If InputMatcher were generic over the context type, every domain would need its own matcher implementations:
# If InputMatcher were generic (DON'T DO THIS)
class InputMatcher[Ctx]:
def matches(self, ctx: Ctx) -> bool: ...
# You'd need separate matchers for each domain
http_matcher = ExactMatcher[HttpRequest]("/api")
event_matcher = ExactMatcher[CloudEvent]("com.example")
# Can't put them in the same registry. No code reuse.
The Solution
Erase the type at the data level. Extract the value first, then match the erased value:
# DataInput is generic — knows about the context
class DataInput[Ctx]:
def get(self, ctx: Ctx) -> MatchingData: ...
# InputMatcher is NOT generic — knows only about MatchingData
class InputMatcher:
def matches(self, value: MatchingData) -> bool: ...
Now one ExactMatcher works everywhere:
# HTTP path matching
path_pred = SinglePredicate(input=PathInput(), matcher=ExactMatcher("/api"))
# Event type matching — SAME ExactMatcher
event_pred = SinglePredicate(input=EventTypeInput(), matcher=ExactMatcher("/api"))
MatchingData: The Bridge
MatchingData is the boundary between domain-specific and domain-agnostic code. Same name in all three implementations:
Rust:
pub enum MatchingData {
None,
String(String),
Int(i64),
Bool(bool),
Bytes(Vec<u8>),
Custom(Box<dyn CustomMatchData>),
}
Python:
type MatchingData = str | int | bool | bytes | None
TypeScript:
type MatchingData = string | number | boolean | Uint8Array | null;
Rust uses an enum. Python and TypeScript use union types. Same concept, idiomatic syntax.
The Two Ports
Type erasure creates two ports — the seams where domain-specific and domain-agnostic code meet:
┌─────────────────────────────────────────┐
│ Domain-Specific Layer │
│ PathInput, HeaderInput, ToolNameInput │
│ (knows about Ctx) │
└──────────────┬──────────────────────────┘
│ get() returns MatchingData
↓
┌──────────────▼──────────────────────────┐
│ Domain-Agnostic Layer │
│ ExactMatcher, PrefixMatcher, Regex... │
│ (knows only about MatchingData) │
└─────────────────────────────────────────┘
Extraction port (DataInput) — converts Ctx into MatchingData. Domain-specific. You write one per field you want to match.
Matching port (InputMatcher) — converts MatchingData into bool. Domain-agnostic. x.uma ships five: ExactMatcher, PrefixMatcher, SuffixMatcher, ContainsMatcher, RegexMatcher.
Cross-Language Comparison
The same architecture in all three languages:
| Concept | Rust | Python | TypeScript |
|---|---|---|---|
| Erased data | enum MatchingData | type MatchingData (union) | type MatchingData (union) |
| Extraction port | trait DataInput<Ctx> | Protocol[Ctx] | interface DataInput<Ctx> |
| Matching port | trait InputMatcher | Protocol | interface InputMatcher |
| Predicate tree | enum Predicate<Ctx> | type Predicate[Ctx] (union) | type Predicate<Ctx> (union) |
| Pattern match | match expression | match/case | instanceof checks |
| Immutability | Owned types | @dataclass(frozen=True) | readonly fields |
The None Convention
When a DataInput returns None/null (data not present), the predicate evaluates to false without calling the matcher. This is enforced across all implementations.
from xuma import SinglePredicate, ExactMatcher
from xuma.http import HttpRequest, HeaderInput
predicate = SinglePredicate(
input=HeaderInput("x-api-key"),
matcher=ExactMatcher("secret"),
)
# Header not present → DataInput returns None → predicate returns False
request = HttpRequest(headers={})
assert predicate.evaluate(request) == False
The matcher never sees None. Missing data is handled upstream. This is a security guarantee: missing data never accidentally matches.
Next
- The Matching Pipeline — how data flows through the full evaluation
Architecture
x.uma makes one bet: the boundary between “what data do I have?” and “how do I match it?” is the most valuable seam in a matcher engine.
The Bet
Every matcher engine faces a choice. Couple the matching logic to the domain, and you get performance and simplicity — but you rebuild the engine for every new domain. Decouple them, and you get reuse — but you pay in abstraction tax and runtime indirection.
x.uma’s answer: erase the type at the data level, not the matcher level. One ExactMatcher works for HTTP paths, Claude Code tool names, gRPC service identifiers, and types that don’t exist yet.
Context (your data)
↓
DataInput.get() ← knows your type, returns erased data
↓
MatchingData ← string | int | bool | bytes | null
↓
InputMatcher.matches() ← doesn't know your type, doesn't need to
↓
bool
The split happens at MatchingData. Above it, domain-specific code that knows about HttpRequest or HookContext. Below it, domain-agnostic matchers that work with primitives. This is the seam.
Why This Works
The insight comes from Envoy, where it runs at Google scale. Envoy’s xDS Unified Matcher API uses the same split — domain-specific inputs feed type-erased data into generic matchers. x.uma implements these semantics in Rust, Python, and TypeScript.
What the seam buys:
Write a matcher once, use it everywhere. PrefixMatcher("/api") matches HTTP paths, event source URIs, file paths — anything that produces a string through MatchingData. Five string matchers (Exact, Prefix, Suffix, Contains, Regex) cover most matching needs across all domains.
Add a domain without touching core. HTTP matching, Claude Code hooks, and the test domain all plug in by implementing DataInput — a single method that extracts a value from the context. The core engine never changes.
Share config across languages. The same JSON/YAML config produces the same matcher tree in Rust, Python, and TypeScript. MatchingData is the same name, same semantics, in all three.
The Shape
┌─────────────────────────────────────────┐
│ Domain Adapters │
│ xuma.http xuma.claude xuma.test │
│ (DataInput implementations) │
└──────────────────┬──────────────────────┘
│ get() → MatchingData
↓
┌──────────────────▼──────────────────────┐
│ Core Engine │
│ Matcher · Predicate · InputMatcher │
│ (domain-agnostic, immutable) │
└─────────────────────────────────────────┘
Two ports define the boundary:
| Port | Direction | Generic? | You implement |
|---|---|---|---|
| DataInput | Domain → Core | Yes (knows Ctx) | One per field you want to match |
| InputMatcher | Core → bool | No (knows MatchingData) | Rarely — five ship with x.uma |
Domain adapters implement DataInput. The core ships InputMatcher implementations. SinglePredicate wires one to the other.
ACES
The architecture follows four properties:
Adaptable. New domains plug in without modifying core. HTTP matching didn’t require changes to the predicate engine. Claude Code hooks didn’t require changes to HTTP matching. Each domain is independent.
Composable. Predicates compose with AND, OR, NOT. Matchers nest up to 32 levels deep. A matcher’s action can be another matcher, creating trees of arbitrary complexity from simple building blocks.
Extensible. TypedExtensionConfig from the xDS protobuf spec is the extension seam. Every input and action is identified by a type URL (xuma.http.v1.PathInput, xuma.claude.v1.ToolNameInput). New types register without modifying existing ones.
Sustainable. Core is stable. Growth happens at the edges. Adding a domain means adding DataInput implementations and a compiler — not touching Matcher, Predicate, or InputMatcher. The architecture sustains extension without rewrites.
What Core Owns
The core engine (rumi in Rust, xuma in Python/TypeScript) provides:
- Matcher — first-match-wins evaluation over a list of field matchers
- Predicate — Boolean tree (Single, And, Or, Not) with short-circuit evaluation
- SinglePredicate — pairs a
DataInputwith anInputMatcher - MatchingData — the type-erased bridge (
string | int | bool | bytes | null) - InputMatcher — five string matchers plus
BoolMatcher - OnMatch — action XOR nested matcher (illegal states unrepresentable)
- Depth/width limits — MAX_DEPTH=32, MAX_FIELD_MATCHERS=256
- Registry — immutable type registry for config-driven construction
- Trace — step-by-step evaluation debugging
Core does not own domain knowledge. It does not know what an HTTP request is, what a Claude Code hook event is, or what your custom context type contains. It matches erased values.
What Domains Own
Each domain provides:
- Context type —
HttpRequest,HookContext, your type - DataInput implementations — extractors for each matchable field
- Compiler — transforms domain-specific config into matcher trees
- Registry function — registers domain inputs with the type registry
The compiler is the user-facing API. Instead of manually constructing predicate trees, you write:
#![allow(unused)]
fn main() {
// HTTP: Gateway API config → matcher
let matcher = compile_route_matches(&routes, "allowed", "denied");
// Claude: hook rules → matcher
let matcher = rule.compile("block")?;
}
Compilers are syntactic sugar over the core engine. They produce the same Matcher<Ctx, A> you’d build by hand.
Matcher Engine, Not Policy Engine
x.uma is a matcher engine. It finds the first matching rule and returns an action. It does not interpret that action.
The generic A in Matcher<Ctx, A> is the boundary. A can be a string, an enum, a struct — anything. Core never inspects it. Whether "allow" means permit and "deny" means block is your concern, not the engine’s.
Policy (allow/deny, rate limits, routing decisions) lives above the matcher. This is the Istio pattern — the data plane matches, the control plane decides. x.uma is the data plane.
This means x.uma doesn’t compete with OPA or Cedar. It complements them. Use x.uma for fast, structured matching. Use a policy engine for policy logic that operates on the match result.
Two Construction Paths
Matchers can be built two ways:
Compiler path — domain-specific DSL produces matchers directly. Ergonomic, type-safe, no serialization overhead.
from xuma.http import HttpRouteMatch, compile_route_matches
routes = [HttpRouteMatch(path=HttpPathMatch(type="PathPrefix", value="/api"), method="GET")]
matcher = compile_route_matches(routes, "api", "not_found")
Config path — JSON/YAML config loaded through the registry. Portable across languages, storable, versionable.
{
"matcher_list": [{
"predicate": {
"single": {
"input": { "type_url": "xuma.test.v1.StringInput", "config": { "key": "method" } },
"matcher": { "type_url": "xuma.core.v1.StringMatcher", "config": { "exact": "GET" } }
}
},
"on_match": { "action": "route-get" }
}],
"on_no_match": { "action": "fallback" }
}
Both paths produce the same Matcher. The compiler path is for programmatic construction. The config path is for declarative, cross-language use.
Five Implementations, One Spec
| Implementation | Language | Type |
|---|---|---|
| rumi | Rust | Reference implementation |
| xuma (Python) | Python | Pure Python |
| xuma (TypeScript) | TypeScript | Pure TypeScript |
| xuma-crust | Python | Rust core via PyO3 |
| xuma-crust | TypeScript | Rust core via WASM |
All five pass the same conformance test suite. Same config format, same evaluation semantics, same results. Choose based on your runtime and performance needs.
Next
- The Matching Pipeline — how data flows through evaluation
- Type Erasure and Ports — the technical details of the seam
Config Format
The config format is shared across all five implementations. Same JSON/YAML structure, same semantics.
MatcherConfig
Top-level config for a matcher:
{
"matchers": [ ... ],
"on_no_match": { ... }
}
| Field | Type | Required | Description |
|---|---|---|---|
matchers | array of FieldMatcherConfig | Yes | Field matchers evaluated in order |
on_no_match | OnMatchConfig | No | Fallback when no field matcher matches |
FieldMatcherConfig
A single rule: predicate + action:
{
"predicate": { ... },
"on_match": { ... }
}
| Field | Type | Required | Description |
|---|---|---|---|
predicate | PredicateConfig | Yes | Condition to evaluate |
on_match | OnMatchConfig | Yes | What to do when predicate matches |
PredicateConfig
Boolean logic over conditions. Discriminated by type:
single
Extract a value and match it:
{
"type": "single",
"input": { "type_url": "xuma.test.v1.StringInput", "config": { "key": "method" } },
"value_match": { "Exact": "GET" }
}
| Field | Type | Required | Description |
|---|---|---|---|
type | "single" | Yes | Discriminator |
input | TypedConfig | Yes | Data input reference (resolved via registry) |
value_match | ValueMatch | One of | Built-in string match |
custom_match | TypedConfig | One of | Custom matcher via registry |
Exactly one of value_match or custom_match must be set.
and
All child predicates must match:
{
"type": "and",
"predicates": [ { "type": "single", ... }, { "type": "single", ... } ]
}
or
Any child predicate must match:
{
"type": "or",
"predicates": [ { "type": "single", ... }, { "type": "single", ... } ]
}
not
Negate a predicate:
{
"type": "not",
"predicate": { "type": "single", ... }
}
OnMatchConfig
Either a terminal action or a nested matcher. Discriminated by type:
action
Return a value:
{ "type": "action", "action": "route-get" }
The action field can be any JSON value – string, number, object. The engine doesn’t interpret it.
matcher
Continue evaluation with a nested matcher:
{
"type": "matcher",
"matcher": {
"matchers": [ ... ],
"on_no_match": { ... }
}
}
Action XOR matcher – never both. This enforces OnMatch exclusivity from the xDS spec.
TypedConfig
Reference to a registered type:
{ "type_url": "xuma.test.v1.StringInput", "config": { "key": "method" } }
| Field | Type | Required | Description |
|---|---|---|---|
type_url | string | Yes | Registered type identifier |
config | object | No (defaults to {}) | Type-specific configuration |
The type_url is resolved at load time via the Registry. Unknown type URLs produce an error listing available types.
ValueMatch
Built-in string matchers:
{ "Exact": "hello" }
{ "Prefix": "/api" }
{ "Suffix": ".json" }
{ "Contains": "admin" }
{ "Regex": "^Bearer .+$" }
| Variant | Matches |
|---|---|
Exact | Exact string equality |
Prefix | String starts with value |
Suffix | String ends with value |
Contains | String contains value |
Regex | RE2 regex pattern (linear time) |
Type URL Reference
Core (all domains)
Registered by register_core_matchers() in all implementations:
| Type URL | Type | Config |
|---|---|---|
xuma.core.v1.StringMatcher | InputMatcher | StringMatchSpec |
xuma.core.v1.BoolMatcher | InputMatcher | { "value": true } |
Test Domain
| Type URL | Config | Extracts |
|---|---|---|
xuma.test.v1.StringInput | { "key": "method" } | Value for key from test context |
HTTP Domain
| Type URL | Config | Extracts |
|---|---|---|
xuma.http.v1.PathInput | {} | Request path |
xuma.http.v1.MethodInput | {} | HTTP method |
xuma.http.v1.HeaderInput | { "name": "content-type" } | Header value by name |
xuma.http.v1.QueryParamInput | { "name": "page" } | Query parameter by name |
Claude Domain
| Type URL | Config | Extracts |
|---|---|---|
xuma.claude.v1.EventInput | {} | Hook event name (e.g. PreToolUse) |
xuma.claude.v1.ToolNameInput | {} | Tool name (e.g. Bash) |
xuma.claude.v1.ArgumentInput | { "name": "command" } | Tool argument by name |
xuma.claude.v1.SessionIdInput | {} | Session ID |
xuma.claude.v1.CwdInput | {} | Working directory |
xuma.claude.v1.GitBranchInput | {} | Git branch |
Full Examples
HTTP Route Matching
matchers:
- predicate:
type: and
predicates:
- type: single
input: { type_url: "xuma.http.v1.PathInput", config: {} }
value_match: { Prefix: "/api" }
- type: single
input: { type_url: "xuma.http.v1.MethodInput", config: {} }
value_match: { Exact: "GET" }
on_match: { type: action, action: "api_read" }
- predicate:
type: single
input: { type_url: "xuma.http.v1.HeaderInput", config: { name: "content-type" } }
value_match: { Exact: "application/json" }
on_match: { type: action, action: "json_handler" }
on_no_match: { type: action, action: "not_found" }
Claude Code Hook Policy
matchers:
- predicate:
type: and
predicates:
- type: single
input: { type_url: "xuma.claude.v1.EventInput", config: {} }
value_match: { Exact: "PreToolUse" }
- type: single
input: { type_url: "xuma.claude.v1.ToolNameInput", config: {} }
value_match: { Exact: "Bash" }
- type: single
input: { type_url: "xuma.claude.v1.ArgumentInput", config: { name: "command" } }
value_match: { Contains: "rm -rf" }
on_match: { type: action, action: "block" }
- predicate:
type: single
input: { type_url: "xuma.claude.v1.EventInput", config: {} }
value_match: { Exact: "PreToolUse" }
on_match: { type: action, action: "allow" }
on_no_match: { type: action, action: "allow" }
Test Domain (key-value)
matchers:
- predicate:
type: and
predicates:
- type: single
input: { type_url: "xuma.test.v1.StringInput", config: { key: "method" } }
value_match: { Exact: "GET" }
- type: single
input: { type_url: "xuma.test.v1.StringInput", config: { key: "path" } }
value_match: { Prefix: "/api" }
on_match: { type: action, action: "api_get" }
- predicate:
type: single
input: { type_url: "xuma.test.v1.StringInput", config: { key: "path" } }
value_match: { Exact: "/health" }
on_match: { type: action, action: "health" }
on_no_match: { type: action, action: "not_found" }
Validation Limits
Configs are validated at load time:
| Limit | Value | Error |
|---|---|---|
| Max nesting depth | 32 levels | DepthExceeded |
| Max field matchers per matcher | 256 | TooManyFieldMatchers |
| Max predicates per AND/OR | 256 | TooManyPredicates |
| Max pattern length | 8192 chars | PatternTooLong |
| Max regex pattern length | 4096 chars | PatternTooLong |
If a config loads successfully, the resulting matcher is guaranteed to be structurally valid. Parse, don’t validate.
CLI Reference (rumi)
rumi is the command-line interface for running and validating matcher configs across three domains.
Installation
cargo install --path rumi/cli
Usage
rumi <command> [domain] [options]
Domains
The CLI supports three matching domains. Each domain has its own registry of type URLs and context type.
| Domain | Context | Description |
|---|---|---|
| (default) | Key-value pairs | Test domain (xuma.test.v1.*) |
http | HTTP request | Method, path, headers, query params (xuma.http.v1.*) |
claude | Hook event | Claude Code hook events (xuma.claude.v1.*) |
Commands
run
Run a config file against a context and print the resulting action.
Test domain (default):
rumi run config.yaml --context method=GET path=/api
| Flag | Description |
|---|---|
--context key=value... | Context key-value pairs |
HTTP domain:
rumi run http routes.yaml --method GET --path /api/users
rumi run http routes.yaml --method POST --path /api --header content-type=application/json
| Flag | Description |
|---|---|
--method METHOD | HTTP method (required) |
--path PATH | Request path (required) |
--header key=value | Header (repeatable) |
--query key=value | Query parameter (repeatable) |
Claude domain:
rumi run claude hooks.yaml --event PreToolUse --tool Bash --arg command="ls -la"
rumi run claude hooks.yaml --event SessionStart --cwd /home/user --branch main
| Flag | Description |
|---|---|
--event EVENT | Hook event name (required) |
--tool NAME | Tool name |
--arg key=value | Tool argument (repeatable) |
--cwd PATH | Working directory |
--branch NAME | Git branch |
--session ID | Session ID |
Valid events: PreToolUse, PostToolUse, Stop, SubagentStop, UserPromptSubmit, SessionStart, SessionEnd, PreCompact, Notification.
Prints the matched action string, or (no match) if nothing matched.
check
Validate a config file without evaluating:
rumi check config.yaml
rumi check http routes.yaml
rumi check claude hooks.yaml
Loads the config against the domain’s registry (including type URL resolution and depth validation). Catches: unknown type URLs, invalid regex patterns, depth limit violations, malformed config.
Prints Config valid on success. Exits with non-zero status on error.
info
List all registered type URLs for a domain:
$ rumi info
Registered inputs:
xuma.test.v1.StringInput
Registered matchers:
xuma.core.v1.StringMatcher
xuma.core.v1.BoolMatcher
$ rumi info http
Registered inputs:
xuma.http.v1.PathInput
xuma.http.v1.MethodInput
xuma.http.v1.HeaderInput
xuma.http.v1.QueryParamInput
Registered matchers:
xuma.core.v1.StringMatcher
xuma.core.v1.BoolMatcher
$ rumi info claude
Registered inputs:
xuma.claude.v1.EventInput
xuma.claude.v1.ToolNameInput
xuma.claude.v1.ArgumentInput
xuma.claude.v1.SessionIdInput
xuma.claude.v1.CwdInput
xuma.claude.v1.GitBranchInput
Registered matchers:
xuma.core.v1.StringMatcher
xuma.core.v1.BoolMatcher
help
rumi help
rumi --help
rumi -h
Config File Format
The CLI accepts the same config format used by all implementations. See Config Format for the full schema. Files can be YAML (.yaml, .yml) or JSON (.json).
Exit Codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Error (invalid config, unknown command, missing flags, etc.) |
Design
The CLI has zero runtime dependencies beyond rumi, rumi-http, and rumi-test. No clap – argument parsing is hand-written. The binary is small and builds fast.
Each domain registers its own Registry<Ctx>:
- Test:
rumi_test::register()->Registry<TestContext> - HTTP:
rumi_http::register_simple()->Registry<HttpRequest> - Claude:
rumi::claude::register()->Registry<HookContext>
API Reference
Generated from source. Always current.
- Rust —
cargo doc --manifest-path rumi/Cargo.toml --workspace --exclude rumi-proto --no-deps --open - Python —
cd puma && uv run pdoc xuma - TypeScript —
cd bumi && bunx typedoc src/index.ts
Hosted API docs coming soon.
Security Model
x.uma’s security model prevents four classes of attack against matcher engines. Every guarantee is enforced at construction time, not evaluation time.
Threat Model
Matcher configs can come from untrusted sources — user-provided routing rules, dynamically loaded policy files, configs from external systems. The engine must be safe even when the config is adversarial.
ReDoS Protection
Threat: Regular expression Denial of Service. A crafted regex pattern causes exponential backtracking, consuming CPU indefinitely.
Mitigation: All implementations use RE2-class linear-time regex engines:
| Implementation | Regex Engine | Guarantee |
|---|---|---|
| rumi (Rust) | regex crate (DFA) | Linear time, proven |
| xuma (Python) | google-re2 (C++ RE2 binding) | Linear time, Google RE2 |
| xuma (TypeScript) | re2js (pure JS RE2 port) | Linear time, RE2 semantics |
| xuma-crust (Python) | Rust regex via PyO3 | Same as rumi |
| xuma-crust (TypeScript) | Rust regex via WASM | Same as rumi |
No implementation uses a backtracking regex engine. Patterns that would cause catastrophic backtracking in PCRE/Python re/JavaScript RegExp are either rejected or matched in linear time.
Pattern length limit: Regex patterns are capped at 4,096 characters (MAX_REGEX_PATTERN_LENGTH). Non-regex patterns are capped at 8,192 characters (MAX_PATTERN_LENGTH).
Depth Limit
Threat: Stack overflow from deeply nested matchers. A config with 1,000 levels of nested matchers could exhaust the call stack during evaluation.
Mitigation: Maximum nesting depth of 32 levels (MAX_DEPTH), validated at construction time. If a config exceeds this limit, MatcherError::DepthExceeded is returned and no matcher is constructed.
32 levels is generous — real-world matchers rarely exceed 5 levels. The limit catches misconfigured or adversarial configs.
Width Limits
Threat: Resource exhaustion from extremely wide matchers. A config with millions of field matchers at depth 1 bypasses depth limits but still causes excessive memory and CPU usage.
Mitigation: Three width limits, all validated at construction time:
| Limit | Value | Protects |
|---|---|---|
MAX_FIELD_MATCHERS | 256 per Matcher | Memory from wide matcher lists |
MAX_PREDICATES_PER_COMPOUND | 256 per AND/OR | CPU from wide predicate trees |
MAX_PATTERN_LENGTH | 8,192 chars | Memory from large string patterns |
None-to-False
Threat: Missing data accidentally matching a rule. If a header doesn’t exist, it should not match ExactMatcher("secret").
Mitigation: When DataInput.get() returns None/null, the predicate evaluates to false. The InputMatcher is never called. This is enforced in all five implementations.
This is a security invariant, not a convenience feature. It ensures fail-closed behavior: missing data means no match.
Immutability
Threat: Race conditions from concurrent access. Matchers shared across threads could produce inconsistent results if modified during evaluation.
Mitigation:
- Rust: All core types are
Send + Sync. Matchers are immutable after construction and safe to share viaArc<Matcher>. - Python: All types use
@dataclass(frozen=True). Fields cannot be reassigned after construction. - TypeScript: All types use
readonlyfields. - Registry: Immutable after
.build(). The builder produces the registry, then the builder is consumed. No runtime registration.
Construction-Time Validation
All validation happens when the matcher is built, not when it’s evaluated. If a Matcher object exists, it’s guaranteed to be:
- Within depth limits
- Within width limits
- Free of invalid regex patterns
- Free of unknown type URLs (config path)
- Structurally sound (OnMatch exclusivity enforced by the type system)
This follows the “parse, don’t validate” principle. The construction boundary is the trust boundary.
Error Messages
MatcherError variants include actionable context:
UnknownTypeUrllists all registered type URLsDepthExceededshows actual vs maximum depthPatternTooLongshows actual vs maximum lengthInvalidPatternincludes the regex compilation error
Self-correcting error messages help operators fix configs without guessing.
What Is NOT Protected
- Semantic correctness: x.uma doesn’t verify that your rules do what you intend. First-match-wins means rule order matters — a too-broad rule early in the list can shadow specific rules.
- Action interpretation: The engine returns actions without interpreting them. Whether
"allow"means permit is your responsibility. - Context injection: x.uma trusts the context you provide. If your
DataInputproduces unsafe values from user input, the engine cannot protect you. - Side effects: Evaluation is pure (no I/O, no state mutation). But the code that acts on the result is outside x.uma’s scope.
Next
- Architecture — how safety is built into the design