Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

PackageLanguageWhat it is
rumiRustCore engine (reference implementation)
xumaPython 3.12+Pure Python, zero native deps beyond RE2
xumaTypeScript/BunPure TypeScript, zero native deps beyond RE2
xuma-crustPythonRust bindings via PyO3
xuma-crustTypeScriptRust bindings via WASM

All five pass the same conformance test suite (~958 tests total).

Get Started

  • Rustrumi + rumi-http in your Cargo.toml
  • Pythonuv add xuma, build a matcher in 10 lines
  • TypeScriptbun add xuma, same API shape as Python

Guarantees

GuaranteeHow
No ReDoSRust regex crate (linear time). Python uses google-re2. TypeScript uses re2js.
Bounded depthMax 32 levels of nesting, validated at config load
Fail-closedMissing data → predicate returns false. Never matches by accident.
Thread-safeAll 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 regex crate guarantees linear-time matching. No backtracking.
  • Depth limits – nested matchers capped at 32 levels, validated at construction.
  • No unsafe in core – all Send + Sync is compiler-derived.

Next Steps

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 protectiongoogle-re2 guarantees linear-time regex matching.
  • Immutable – all types are frozen=True dataclasses.
  • Depth limits – nested matchers capped at 32 levels.
  • Fail-closed – missing headers or query params return None from DataInput, which makes the predicate evaluate to False.

Next Steps

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 protectionre2js guarantees linear-time regex matching.
  • Immutable – all types use readonly fields.
  • Depth limits – nested matchers capped at 32 levels.
  • Fail-closed – missing data from DataInput returns null, which makes the predicate evaluate to false.

Next Steps

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:

  1. The pipeline splits at MatchingData. Everything above is domain-specific (knows about your context type). Everything below is domain-agnostic (works with any domain).

  2. The same InputMatcher works everywhere. An ExactMatcher doesn’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

StageRoleGeneric?Examples
ContextYour domain dataYes (Ctx)HttpRequest, HookContext, your type
DataInputExtract a valueYes (Ctx)PathInput, ToolNameInput, your input
MatchingDataType-erased valueNostring, int, bool, bytes, null
InputMatcherMatch the valueNoExactMatcher, PrefixMatcher, RegexMatcher
PredicateBoolean logicYes (Ctx)SinglePredicate, And, Or, Not
MatcherFirst-match-winsYes (Ctx, A)Routes to actions
ActionYour decisionYes (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 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:

ConceptRustPythonTypeScript
Erased dataenum MatchingDatatype MatchingData (union)type MatchingData (union)
Extraction porttrait DataInput<Ctx>Protocol[Ctx]interface DataInput<Ctx>
Matching porttrait InputMatcherProtocolinterface InputMatcher
Predicate treeenum Predicate<Ctx>type Predicate[Ctx] (union)type Predicate<Ctx> (union)
Pattern matchmatch expressionmatch/caseinstanceof checks
ImmutabilityOwned 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

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:

PortDirectionGeneric?You implement
DataInputDomain → CoreYes (knows Ctx)One per field you want to match
InputMatcherCore → boolNo (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 DataInput with an InputMatcher
  • 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 typeHttpRequest, 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

ImplementationLanguageType
rumiRustReference implementation
xuma (Python)PythonPure Python
xuma (TypeScript)TypeScriptPure TypeScript
xuma-crustPythonRust core via PyO3
xuma-crustTypeScriptRust 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

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": { ... }
}
FieldTypeRequiredDescription
matchersarray of FieldMatcherConfigYesField matchers evaluated in order
on_no_matchOnMatchConfigNoFallback when no field matcher matches

FieldMatcherConfig

A single rule: predicate + action:

{
  "predicate": { ... },
  "on_match": { ... }
}
FieldTypeRequiredDescription
predicatePredicateConfigYesCondition to evaluate
on_matchOnMatchConfigYesWhat 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" }
}
FieldTypeRequiredDescription
type"single"YesDiscriminator
inputTypedConfigYesData input reference (resolved via registry)
value_matchValueMatchOne ofBuilt-in string match
custom_matchTypedConfigOne ofCustom 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" } }
FieldTypeRequiredDescription
type_urlstringYesRegistered type identifier
configobjectNo (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 .+$" }
VariantMatches
ExactExact string equality
PrefixString starts with value
SuffixString ends with value
ContainsString contains value
RegexRE2 regex pattern (linear time)

Type URL Reference

Core (all domains)

Registered by register_core_matchers() in all implementations:

Type URLTypeConfig
xuma.core.v1.StringMatcherInputMatcherStringMatchSpec
xuma.core.v1.BoolMatcherInputMatcher{ "value": true }

Test Domain

Type URLConfigExtracts
xuma.test.v1.StringInput{ "key": "method" }Value for key from test context

HTTP Domain

Type URLConfigExtracts
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 URLConfigExtracts
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:

LimitValueError
Max nesting depth32 levelsDepthExceeded
Max field matchers per matcher256TooManyFieldMatchers
Max predicates per AND/OR256TooManyPredicates
Max pattern length8192 charsPatternTooLong
Max regex pattern length4096 charsPatternTooLong

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.

DomainContextDescription
(default)Key-value pairsTest domain (xuma.test.v1.*)
httpHTTP requestMethod, path, headers, query params (xuma.http.v1.*)
claudeHook eventClaude 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
FlagDescription
--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
FlagDescription
--method METHODHTTP method (required)
--path PATHRequest path (required)
--header key=valueHeader (repeatable)
--query key=valueQuery 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
FlagDescription
--event EVENTHook event name (required)
--tool NAMETool name
--arg key=valueTool argument (repeatable)
--cwd PATHWorking directory
--branch NAMEGit branch
--session IDSession 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

CodeMeaning
0Success
1Error (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.

  • Rustcargo doc --manifest-path rumi/Cargo.toml --workspace --exclude rumi-proto --no-deps --open
  • Pythoncd puma && uv run pdoc xuma
  • TypeScriptcd 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:

ImplementationRegex EngineGuarantee
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 PyO3Same as rumi
xuma-crust (TypeScript)Rust regex via WASMSame 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:

LimitValueProtects
MAX_FIELD_MATCHERS256 per MatcherMemory from wide matcher lists
MAX_PREDICATES_PER_COMPOUND256 per AND/ORCPU from wide predicate trees
MAX_PATTERN_LENGTH8,192 charsMemory 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 via Arc<Matcher>.
  • Python: All types use @dataclass(frozen=True). Fields cannot be reassigned after construction.
  • TypeScript: All types use readonly fields.
  • 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:

  • UnknownTypeUrl lists all registered type URLs
  • DepthExceeded shows actual vs maximum depth
  • PatternTooLong shows actual vs maximum length
  • InvalidPattern includes 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 DataInput produces 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