/

CrowdVision · source-available, not open source · © 2026 Nicolò Ghignatti

Claims Contracts

claims-schema is the Rust half of the Stable Claims Contract — the x-gateway-claims header the edge injects after verifying the gateway token once. It is the counterpart to Auth Contracts, which does the same job for the Go services, and it exists for the same reason: the shape that carries every authorization decision in the system should have one definition per language, not one per service.

Before this crate, six Rust services each hand-rolled a decoder for that header. They did not agree.


Why one crate, and what it cost not to have one

graph TD
    CC["claims-contracts\nClaimsPayload · Membership · decode_claims_header"]
    CHAT["chat"]
    CON["dashboard"]
    NOT["notification"]
    SOC["socket"]
    TEL["telemetry"]
    TWIN["digital-twin"]
    FIX["schemas/fixtures/standard-claims.json"]
    AC["auth-contracts (Go)"]
    AG["agent (Python)"]

    CHAT --> CC
    CON --> CC
    NOT --> CC
    SOC --> CC
    TEL --> CC
    TWIN --> CC
    CC -.->|conformance test| FIX
    AC -.->|conformance test| FIX
    AG -.->|conformance test| FIX

The six decoders had drifted in ways that were invisible until a specific token hit a specific service:

DivergenceConsequence
sub required in three services, optional in threeThe same subject-less payload was a 401 in some services and a pass in others
Membership.role was String, Option<String>, serde_json::Value, or absentA membership without a role parsed in four services and rejected the whole token in two
sid and externalId modelled in zero Rust servicesFields the Go contract guarantees were silently unavailable
digital-twin and telemetry decoders were byte-identical copiesTwo files to change for one fix, and nothing enforcing that both got changed

None of these throw. They deny, or they allow, and the reason is three services away from the symptom — the same failure mode that emptied the dashboard metric catalog and produced Telemetry Schema.


1. The shape

File: src/lib.rs

pub const CLAIMS_HEADER: &str = "x-gateway-claims";

pub struct Membership {
    pub domain: String,
    pub role: Option<String>,
    pub external_id: Option<String>,   // "externalId" on the wire
}

pub struct ClaimsPayload {
    pub sub: Option<String>,
    pub account_name: Option<String>,  // "accountName" on the wire
    pub sid: Option<String>,
    pub memberships: Vec<Membership>,
}

Mirrors Go’s StandardClaims field for field. Every field is Option at the type level — that is not laxness, it is a separation of duties. Parsing answers “what does this header say”; requiring a field answers “may this caller proceed”, which differs per service and belongs in the service:

// digital-twin: a subject is mandatory, absence is 401
if payload.user_id().is_none() {
    return Err(unauthorized("Invalid authentication token"));
}

// notification: it is accountName that must be there
match payload.account() {
    Some(_) => Ok(GatewayClaims { payload, raw }),
    None => Err(unauthorized("Authentication token is missing an account")),
}

The accessors (user_id, account, session_id) trim and reject blanks, so {"sub":" "} is absent rather than an empty-string identity.


2. Two parsing decisions worth knowing

Four base64 alphabets, not one. Node’s Buffer.from(header, "base64") accepts padded, unpadded, standard and url-safe alike; a Rust engine accepts exactly one. Trying only STANDARD rejects tokens the edge legitimately emits.

pub fn decode_claims_header(header: &str) -> Option<Vec<u8>> {
    [STANDARD, URL_SAFE, STANDARD_NO_PAD, URL_SAFE_NO_PAD]
        .iter()
        .find_map(|engine| engine.decode(header).ok())
}

A malformed membership is dropped; the rest survive. Rejecting the whole payload over one bad entry turns a 403 (you are not in that domain) into a 401 (your token is broken), which sends whoever debugs it to the wrong service. A memberships that is not a list is still fatal — that is a malformed token, not a malformed entry.

fn lenient_memberships<'de, D>(deserializer: D) -> Result<Vec<Membership>, D::Error> {
    let entries = Option::<Vec<Value>>::deserialize(deserializer)?.unwrap_or_default();
    Ok(entries
        .into_iter()
        .filter_map(|entry| serde_json::from_value(entry).ok())
        .collect())
}

3. What deliberately stays in the service

The GatewayClaims wrapper and the axum extractor. FromRequestParts is a foreign trait, so Rust’s orphan rule forbids implementing it for a type owned by this crate. That constraint happens to agree with the design: rejection types genuinely differ (StatusCode in dashboard, DomainError elsewhere), and chat’s wrapper holds an already-validated user_id: String rather than a payload.

Policy. is_system() (the system: subject prefix), the global-admin check, Cedar entity construction — all service-local. A contracts crate that grows behaviour becomes a distributed monolith; this one has serde, serde_json and base64 as its only dependencies, and no I/O at all.


4. The shared fixture — the part a path dependency cannot do

Files: tests/conformance.rs, schemas/fixtures/standard-claims.json

A path dependency makes Rust-to-Rust drift a compile error. It says nothing about Go or Python, which parse the same header from their own definitions. One fixture, asserted by all three, closes that gap:

LanguageTest
Rustclaims-schema/tests/conformance.rs
Goauth-contracts/conformance_test.go
Pythonagent/tests/unit/test_claims_conformance.py

The Rust test also round-trips the fixture back to JSON and compares, so a field the type forgets to model fails loudly instead of being quietly dropped on the way through.

The crate also hosts the schema check for schemas/fixtures/tenancy-domains.json (tests/tenancy_domains_schema.rs). That shape travels between tenancy (Go) and the frontend, so no Rust type owns it; this is the identity crate, so it lives here. tests/chat_conversation_schema.rs does the same for schemas/fixtures/chat-conversation.json: chat serves it to the frontend and is its only Rust reader, so it has no crate of its own.

CI enforces the same reach: .github/workflows/ci-gate.yml re-runs all six consumer services when claims-schema changes, and re-runs the Go, Rust and Python conformance legs when the fixture changes.


5. Building against it

Every consumer builds from repo-root context so the sibling path dependency resolves. Three services (chat, notification, socket) were converted from their own directory context to make this work, exactly as telemetry and dashboard already were:

FROM chef AS planner
COPY schemas/claims-schema /app/schemas/claims-schema
COPY backend/socket .
RUN cargo chef prepare --recipe-path recipe.json

The crate must be copied before cargo chef prepare and before every cargo chef cook — cook restores the dependency graph, and a path dependency that is not on disk at that moment is not a warning, it is a build failure halfway through a cold image build. .github/services.json carries cd_context: "." and an explicit dockerfile for each consumer so CD builds the same way.