Skip to content

0008 - Data classification as a first-class request header with scope-configured vocabularies

  • Status: Accepted
  • Date: 2026-07-03
  • Deciders: nexus core team
  • Tags: gateway, policy, classification, audit

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.

  • 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.
  • 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.

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 vortex-common-crates/contracts/policy-obligations.md. The resolved label is recorded on gateway request events, stored on request analytics rows by the ingest consumer, and carried on trace-capture metadata.

  • nexus-classification is 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 the telemetry contract 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.