0008 - Data classification as a first-class request header with scope-configured vocabularies
Data classification header decision for tenant-specific sensitivity taxonomies
- Status: Accepted
- Date: 2026-07-03
- Deciders: nexus core team
- Tags: gateway, policy, classification, audit
Context and Problem Statement
Section titled “Context and Problem Statement”Regulated adopters need to label each request with a data-sensitivity tier and have that label follow the request through policy evaluation, analytics, and audit. The label must be client-controlled per request, because a single API key routinely serves workloads of different sensitivities. The mechanism must not mutate the OpenAI-compatible request body, which third-party SDKs construct and which nexus treats as a stable external surface. Adopters also bring their own sensitivity taxonomies (corporate tiers, traffic-light protocols, government markings), so a fixed vocabulary baked into the binary cannot serve them all.
Decision Drivers
Section titled “Decision Drivers”- The classification must be available before routing, so policy can deny or attach obligations up front.
- Any SDK that can set an HTTP header must be able to send it; no nexus-specific client library may be required.
- The vocabulary must be tenant-configurable without a rebuild or redeploy, and shared across services rather than re-declared per service.
- The policy decision point must stay stateless with respect to tenant configuration.
- Classification is tenant data and must never leak into operator telemetry.
Considered Options
Section titled “Considered Options”- A dedicated request header,
nexus-classification, validated against a per-org ordered label list — client-controlled, SDK-agnostic, visible to every hop without touching the body; the vocabulary lives in the control plane. - A field inside the request body — rejected because it mutates the OpenAI-compatible contract and requires stripping before the upstream call.
- Static classification per API key only — rejected because one key often serves mixed workloads; the key still supplies the subject’s classification ceiling, but the per-request label must be independent.
- A compile-time tier enum shared by all tenants — rejected because the vocabulary (and its government-flavored labels) becomes part of the binary; tenants cannot rename, add, or remove tiers.
- Out-of-band policy lookup keyed on request metadata — rejected as indirect: the caller who knows the data’s sensitivity is the request author, not a registry consulted after the fact.
Decision Outcome
Section titled “Decision Outcome”Chosen option: a dedicated nexus-classification request header validated against a per-scope ordered vocabulary (lattice).
The header name is defined in services/gateway/src/headers.rs.
The vocabulary primitives — ClassificationLabel (normalized lowercase kebab-case string) and ClassificationLattice (ordered, unique, non-empty label list, low to high) — live in the shared crate vortex-common-crates/vortex-classification, so the gateway and the policy service parse identically.
Workspaces, organizations, and projects each store their own lattice, a default label, and a ceiling label in the control plane.
A child scope (an org under a workspace, a project under an org) may extend its parent’s lattice by inserting additional labels, but the parent’s labels must remain an ordered subsequence of the child’s — inherited labels cannot be removed or reordered.
A child’s ceiling may only tighten (never exceed) its parent’s ceiling.
Parent edits cascade into children: child ceilings clamp downward to stay within the parent’s, and defaults that name a removed label are remapped without loosening.
ClassificationLattice::standard() seeds new workspaces with public < internal < confidential < restricted and ceiling restricted; new organizations and projects inherit their parent’s lattice, default, and ceiling at creation.
Organization creation requires a workspace on every surface (REST, gRPC, and nexus orgs create --workspace <ref>).
Managers edit a scope’s vocabulary, default, and ceiling via the REST endpoints PATCH /api/workspaces/:workspace_id/classification, PATCH /api/orgs/:org_id/classification, and PATCH /api/projects/:project_id/classification; via the CLI subcommands nexus workspaces update, nexus orgs update, and nexus projects update with --classification-tiers, --default-classification, and --classification-ceiling; or in the console settings pages.
REST is the only mutation surface for classification besides the CLI and console, which call it; there is no gRPC classification-mutation RPC.
Project defaults that fall outside a new vocabulary are remapped automatically.
Enforcement runs once per request at a single gate inside attempt construction (services/gateway/src/routing/builder.rs): edge handlers resolve the route’s candidate endpoints and hand them to the gate, which calls the enforcement helper in services/gateway/src/policy.rs.
The helper resolves the most-specific scope’s lattice, default, and ceiling with the authenticated principal, parses the header against the lattice (unlabeled requests take the project or org default; labels outside the vocabulary are rejected with 400 naming the allowed labels), and places three things on the gateway.request policy query: the request label, the subject’s max_classification ceiling (the most-specific scope’s classification_ceiling — project if set, otherwise org), and the ordered classification_tiers list in the query context.
The query’s resource attributes carry the full ordered candidate endpoint set (fallbacks included) as candidate_endpoints, so one decision covers every endpoint the request may dispatch to, and provider keys are fetched only after an allow — a denied request never touches provider credentials.
Dispatch paths accept only the gate’s output type, so no request can reach a provider without passing through this evaluation.
The policy engine in services/policy/src/engine.rs ranks labels within the lattice supplied by the query context and fails closed: a missing or invalid vocabulary, a missing label, or a label the lattice does not define is a deny (classification_vocabulary_missing, classification_label_missing, classification_label_unknown); a request label outranking the ceiling is denied with classification_ceiling_exceeded.
The engine also attaches a redact obligation for email addresses, per the contract in Policy Obligations.
The resolved label is recorded on gateway request events, stored on request analytics rows by the ingest consumer, and carried on trace-capture metadata.
Consequences
Section titled “Consequences”nexus-classificationis part of the public wire contract; changing its name is a breaking change to callers. The label vocabulary is scope configuration, not wire contract: valid labels are whatever the governing scope’s lattice defines at request time.- Unlabeled traffic takes the project (or org) default label, so a request can never be evaluated without a label.
- The subject’s classification ceiling is the most-specific scope’s
classification_ceiling(project, else org); ceilings only tighten down the hierarchy, and per-API-key ceilings are not implemented. - Because children extend rather than replace the parent lattice, a label valid at the workspace remains valid (with the same relative order) in every descendant scope.
- The policy decision point receives the vocabulary with every query and holds no tenant state; the decision cache key covers the full query, so vocabulary edits change the cache key.
- Policy decisions incorporate the label on every request, and analytics and traces can be filtered by it.
- Classification never flows into Tier 2 operator telemetry; the forbidden-attribute list in Telemetry Conventions excludes tenant-identifying and content-derived attributes from that channel.
- Data classification is a distinct namespace from prompt-complexity tiers and shares no code path with them.
