Skip to content

Semantic Context — Role Mapping (P5-006)

Scheme identifiers

The legacy context endpoint, vocabulary graph export, and extraction run accept either a concept scheme key or its UUID string. Both forms resolve to the same scheme on SQLite and Postgres; an unknown key or UUID returns 404. This corrects a UUID lookup that previously failed during SQL binding. No client migration is needed, and existing key-based requests keep their behavior. Governed context routes continue to apply the role and workspace rules below.

The four governed context-pack endpoints under /api/v1/semantic/context/* apply policy filtering based on the caller's role and workspace. The role + workspace are resolved into a RequesterContext by pycharter.api.dependencies.requester_context.get_requester_context.

Update (2026-05-20). Role + workspace come exclusively from the verified JWT (Authorization: Bearer ...). The legacy X-Role / X-Workspace header fallback was retired. See role-permissions-matrix.md for the JWT claim shape, the full route × minimum-role table, and the operator + client migration outcome. When PYCHARTER_AUTH_DISABLED=true every caller is a superuser for dev / smoke convenience.

Roles

The role enum (pycharter.api.dependencies.requester_context.Role) widens visibility as the role escalates:

Role Visible policy states Workspace scope
viewer approved Workspace-scoped
reviewer approved + under_review Workspace-scoped
admin approved + under_review + deprecated + rejected Workspace-scoped
superuser same as admin Cross-workspace (bypass)

How visibility is enforced

There are two filter layers (defense in depth):

  1. Query layer — the loader helpers in pycharter.semantic.context._helpers apply WHERE clauses on MappingProjectionModel.state and ConceptModel.scheme_id before returning rows. The pure filter primitives live in pycharter.semantic.context._policy.
  2. Response layer — _filter_pack() in pycharter.api.routes.v1.semantic_context re-applies the same allowlists on the already-built ContextPack body. Any row that slipped through the builder is stripped before serialization.

Workspace scoping

Workspace identity in this codebase is the concept scheme — there is no separate workspace table today. Non-superuser callers must provide a concept-scheme UUID via X-Workspace; the filter layer checks ConceptModel.scheme_id == X-Workspace (and ConceptRelationshipModel.scheme_id for edges).

MappingProjectionModel has no native workspace column, so contract bindings are workspace-scoped via a JOIN against ConceptModel on concept_key. Bindings whose concept_key is null cannot be attributed to a workspace and are therefore excluded for non-superuser callers.

Binding-state allowlist

Role Allowed MappingProjectionModel.state values
viewer approved
reviewer approved, mapped, suggested
admin all states (no filter)
superuser all states (no filter)

Note: a viewer looking at a contract that bindings to a deprecated=True concept still has those bindings hidden — the response-layer filter drops deprecated concept refs, and the viewer cannot reach a deprecated concept's id directly (the concept route 404s for them).

Defaults

get_requester_context defaults to viewer with no workspace when the headers are absent. A non-superuser without a workspace sees nothing: that is the safe default for local development and keeps a misconfigured client from accidentally reading content across schemes.

Enforcement on writes (P6-001)

Visibility filtering on reads (this document) is paired with the P6-001 require_role(min_role) dependency for write endpoints on the suggestion / binding / proposal / ingest surfaces. See role-permissions-matrix.md for the full route table.