Skip to content

Admin Guide — Governance Policy and Permissions

Status: Phase 6 reference. Date: 2026-05-19. Audience: platform administrators and operators running PyCharter's semantic / ontology workflow in production.

This guide collects every governance lever an administrator needs to configure roles, tier policy, the proposal workflow, deprecation, audit export, health monitoring, backup, and the known production limitations of the current auth surface. Every command, env var, and endpoint below was verified against the source under src/pycharter/ on the feat/kb-phase-6-wave-3 branch.

For end-user (steward / reviewer) workflow, see the End-to-end user guide. For legacy ontology migration, see the Vocabulary migration guide. For an executable sample domain, see the src/pycharter/data/seed/examples/ecommerce/manifest.yaml (Ecommerce seed example).

1. Role model

PyCharter ships a four-rung hierarchical role model. Numeric ranks live in pycharter.api.dependencies.requester_context.ROLE_ORDER; a request satisfies require_role(min_role) iff ROLE_ORDER[request.role] >= ROLE_ORDER[min_role].

Rank Role Scope Visible policy states
0 viewer workspace-scoped approved
1 reviewer workspace-scoped approved, under_review
2 admin workspace-scoped approved, under_review, deprecated, rejected
3 superuser cross-workspace same as admin, plus bypass workspace filter

Workspace identity in this codebase is the concept scheme — there is no separate workspace table. Non-superuser callers must carry a concept-scheme UUID in their JWT workspace_id claim (or the legacy X-Workspace header during the transition window). A non-superuser with no workspace sees nothing by design.

For the full route × minimum-role table see Role / Permissions Matrix; for the read-side visibility rules see Semantic Context — Role Mapping.

2. Configuring roles

2.1 JWT subsystem

Authentication is JWT-first. Tokens are issued by POST /api/v1/auth/login and verified by pycharter.api.dependencies.auth.verify_jwt. Claim shape:

{
  "sub": "alice",
  "role": "reviewer",
  "workspace_id": "8f0a51c5-0e62-4f3a-...",
  "exp": 1747676419,
  "iat": 1747672819
}

role is one of viewer | reviewer | admin | superuser. Tokens predating Phase 6 that still carry Admin / User are mapped to admin / viewer by _LEGACY_ROLE_MAP so the transition is non-breaking.

2.2 Required environment variables

Auth runs on the following env vars (each may also be set in pycharter.cfg [auth] — see pycharter.config.auth):

Env var Purpose
PYCHARTER_AUTH_USERS JSON array of {username, password, role} objects.
PYCHARTER_AUTH_JWT_SECRET HS256 signing secret for issued tokens.
PYCHARTER_AUTH_DISABLED Set 1 / true to bypass auth (dev only).
PYCHARTER_AUTH_SERVICE_URL Optional external IdP introspect base URL.
PYCHARTER_AUTH_SERVICE_INTROSPECT_PATH Path component for the introspect endpoint.
PYCHARTER_AUTH_ADMIN_ROLE_CLAIMS Comma-separated external-token role names that grant admin. Default admin.
PYCHARTER_AUTH_ADMIN_GROUP_CLAIMS Comma-separated external-token group names that grant admin.
PYCHARTER_REQUESTER_CONTEXT_FROM_HEADERS Legacy X-Role / X-Workspace fallback flag. Default true.

If PYCHARTER_AUTH_SERVICE_URL is unset and PYCHARTER_AUTH_USERS plus PYCHARTER_AUTH_JWT_SECRET are both empty, the deployment is considered auth-disabled (see is_auth_disabled in pycharter.config.auth). Production must set both.

2.3 Bootstrap the first superuser

superuser is a JWT role claim, so the bootstrap path is the user list plus a role override:

export PYCHARTER_AUTH_JWT_SECRET="$(openssl rand -hex 32)"
export PYCHARTER_AUTH_USERS='[
  {"username": "root", "password": "change-me", "role": "superuser"}
]'

Then exchange the credentials for a JWT:

curl -X POST https://api.example.com/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username": "root", "password": "change-me"}'

The returned access_token has role=superuser and bypasses workspace scoping. Rotate the password immediately after bootstrap; see §10.3 for the rotation procedure.

2.4 Assigning roles to workspace users

Add one entry per user to PYCHARTER_AUTH_USERS, choosing the role that matches the rank above:

[
  {"username": "root",      "password": "...", "role": "superuser"},
  {"username": "ops-admin", "password": "...", "role": "admin"},
  {"username": "steward",   "password": "...", "role": "reviewer"},
  {"username": "analyst",   "password": "...", "role": "viewer"}
]

When an external IdP issues tokens, point PyCharter at it via PYCHARTER_AUTH_SERVICE_URL / PYCHARTER_AUTH_SERVICE_INTROSPECT_PATH and emit JWTs whose role claim matches the four-rung enum. The workspace claim is encoded as workspace_id (the concept-scheme UUID).

2.5 Header fallback (transition only)

PYCHARTER_REQUESTER_CONTEXT_FROM_HEADERS defaults to true so the P5-006 placeholder headers (X-Role, X-Workspace) keep working for local dev, Playwright suites, and the existing pytest fixtures. Set it to false in production once every client carries a JWT:

export PYCHARTER_REQUESTER_CONTEXT_FROM_HEADERS=false

See §10.2 for the removal horizon.

2.6 Migration plan toward a real users table

PYCHARTER_AUTH_USERS keeps users in process environment as a bootstrap convenience; it is not a long-term identity store. The documented migration path is:

  1. Day 0 — bootstrap one superuser via the env var; issue JWTs.
  2. Day +N — point PyCharter at an external IdP via PYCHARTER_AUTH_SERVICE_URL. Issue JWTs with the four-rung role claim and a workspace_id claim.
  3. Day +M — set PYCHARTER_REQUESTER_CONTEXT_FROM_HEADERS=false and audit clients.
  4. Day +K — once a database-backed user table lands (tracked as a separate follow-up card), migrate the env-var seed list into that table and retire PYCHARTER_AUTH_USERS.

3. Concept tier policy

Concepts carry a tier column on concepts (free / domain / core; see pycharter.db.models.semantic.concept.ConceptModel). Tier governs how a suggestion lands in the ontology:

Tier Direct accept (P3-005) Proposal required (P3-006)
free yes, when the five direct-accept rules pass no
domain no yes
core no yes

3.1 Direct-accept rule set (free tier)

pycharter.persistence.collaboration._direct_accept_rules evaluates a suggestion against five axes in this fixed order:

  1. status must be pending.
  2. tier (from payload["tier"], fallback "free") must be free.
  3. confidence_score must be non-null and >= 0.9 (_FREE_TIER_MIN_CONFIDENCE).
  4. is_duplicate_of must be None.
  5. proposed_action must be in {add_concept, add_relationship} (_FREE_TIER_ALLOWED_ACTIONS).

Rule failures leave the row in pending for a human reviewer; they never raise.

3.2 Proposal-required gate (domain / core tier)

pycharter.persistence.collaboration._proposal_gate.evaluate_proposal_gate blocks materialisation of domain or core suggestions unless a ProposalModel row exists with suggestion_id set to the suggestion's UUID. Blocked acceptances raise ProposalRequiredError (a PublicFacingError, safe to surface).

3.3 Assigning tiers

Tiers are stamped on the suggestion at extraction time via payload["tier"]. When constructing a suggestion via the ingest or extraction pipeline, set the key explicitly:

payload = {
    "tier": "domain",
    "concept_name": "MonthlyRecurringRevenue",
    ...
}

On the concept itself, the tier column defaults to "free". To change it, mutate the ontology via the proposal workflow (so the change carries an audit trail) and let the proposal apply the new tier value when it merges. Tier changes outside the proposal workflow are possible at the database level but lose audit evidence and are not recommended.

4. Proposal workflow

Domain / core suggestions block on the proposal gate. To unblock a suggestion, link a proposal to it:

curl -X POST https://api.example.com/api/v1/proposals/link \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "suggestion_id": "8f0a51c5-0e62-4f3a-9c41-1b2d3e4f5a6b",
    "actor": "ops-admin",
    "summary": "Add MonthlyRecurringRevenue concept",
    "details": "Domain-tier concept requested by revenue ops."
  }'

Required role: admin (gated by require_role(Role.ADMIN); see the route in pycharter.api.routes.v1.semantic_proposals.link_proposal_to_suggestion). The response is 201 with the proposal id, the linked suggestion id, the status, the actor, and the creation timestamp.

After linking, the proposal goes through the standard proposal lifecycle (PUT /api/v1/proposals/{proposal_id}/status etc.). Once the proposal is approved and merged, the original suggestion clears the gate and may be accepted in the usual way.

4.1 Legacy payload["proposal_id"] fallback

pycharter.semantic.extraction._acceptance._default_proposal_lookup_factory retains a one-cycle fallback that reads payload["proposal_id"] when no proposal row points at the suggestion via proposals.suggestion_id. The legacy path emits a DeprecationWarning whenever it fires.

Deprecation horizon: removal is scheduled for the next minor release after P3-006-FU-A. New callers must use the /proposals/link endpoint above. Operators should monitor logs for DeprecationWarning from pycharter.semantic.extraction._acceptance and migrate any producer that still emits payload["proposal_id"].

5. Deprecation

Concepts carry a deprecated boolean column. Once a concept is deprecated, it disappears from viewer and reviewer reads (response-layer filter drops deprecated concept refs) and the health panel surfaces every binding that still points at it.

5.1 Marking a concept deprecated

There is no dedicated HTTP endpoint to flip the column today; the recommended path is through the proposal workflow so the change carries an audit trail:

  1. Open a proposal that targets the concept (POST /api/v1/proposals).
  2. Encode the deprecation as a deprecate_concept action in the proposal body.
  3. Walk the proposal through review and merge it via the standard lifecycle endpoints.

For one-off backfills, an admin with direct database access can update the row with a SQLAlchemy session:

from pycharter.db.models.semantic.concept import ConceptModel

concept = session.query(ConceptModel).filter_by(concept_key="legacy_term").one()
concept.deprecated = True
session.commit()

Backfill writes outside the proposal workflow bypass the audit stream — emit a manual review event if traceability is required.

5.2 Health surface

The deprecated_concept_usage check (severity error) flags every binding that still points at a deprecated concept. See §7 for the health endpoint.

6. Review decisions audit

6.1 Per-suggestion review timeline

Each suggestion accumulates durable review-decision rows on the semantic_review_events table (P3-004). Fetch them per suggestion via:

curl https://api.example.com/api/v1/suggestions/{id}/review-events \
  -H "Authorization: Bearer $TOKEN" \
  -G --data-urlencode "limit=100" --data-urlencode "offset=0"

Rows return newest-first; standard limit/offset paging (limit 1..500, default 100). See the pagination convention in Pagination and Filtering.

6.2 Audit export endpoint

The full governance audit stream lives on GET /api/v1/ops/audit-export (P6-004). It is gated by require_role(Role.ADMIN) and merges five tables in chronological order:

event_type Source table
review semantic_review_events (P3-004)
binding concept_binding_events (P1-008)
accepted_suggestion suggestions rows with accepted_at set
proposal_link proposals rows with suggestion_id set
health_snapshot semantic_health_issue_snapshots

Workspace-scoped event types (review, accepted_suggestion) are filtered by the caller's workspace_id for non-superuser admins. Globally-scoped types pass through.

Example query — export the last 24 h, all event types:

SINCE=$(date -u -d '24 hours ago' '+%Y-%m-%dT%H:%M:%SZ')
curl https://api.example.com/api/v1/ops/audit-export \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -G --data-urlencode "since=$SINCE" \
  > audit-24h.ndjson

Each line is a JSON object matching pycharter.api.models.audit.AuditExportLine. Resume after a checkpoint by replaying with since=<last_timestamp_seen> and dedupe on event_id (the stable UUID primary key of the source row).

Defaults when query parameters are omitted:

  • since — 7 days before now (_DEFAULT_LOOKBACK).
  • until — current UTC time.
  • event_types — all five types in canonical order. Unknown tokens in a comma-separated list are silently dropped.

The response media type is application/x-ndjson. The handler uses heapq.merge over per-stream iterators, so the memory footprint stays at O(num_event_types) regardless of total export size.

7. Semantic health monitoring

7.1 Health endpoint

GET /api/v1/semantic/health (P4-001) returns an aggregated issue list. Filter / paginate per the standard convention:

curl https://api.example.com/api/v1/semantic/health \
  -H "Authorization: Bearer $TOKEN" \
  -G --data-urlencode "severity=error,warning" \
     --data-urlencode "kind=deprecated_concept_usage" \
     --data-urlencode "limit=50" --data-urlencode "offset=0"

Multi-value filters are comma-separated within a single query value; filters compose with AND across facets and OR within a facet. Unknown tokens are silently dropped.

7.2 Check kinds

The four bundled check kinds (see pycharter.semantic.health._checks):

Kind Default severity What it catches
duplicate_concept warning Concept rows whose names collide (case-insensitive).
missing_mapping warning Contract fields with no approved concept binding (excluding deprecated concepts).
deprecated_concept_usage error Bindings whose target concept has deprecated=True.
broken_relationship error Relationship rows whose endpoints no longer exist.

7.3 Per-issue history

GET /api/v1/semantic/health/issues/{fingerprint}/history returns observation rows for one issue (a 64-char SHA-256 hex fingerprint that survives across evaluations). POST /api/v1/semantic/health/issues/history fetches up to 200 fingerprints in one round-trip (P4-001-FU-C).

7.4 Snapshot persistence and TTL

Snapshot history is opt-in. By default make_semantic_health_service constructs the SQLAlchemy backend with persist_history=False; the 30 s default refresh interval on the editor health panel would otherwise grow semantic_health_issue_snapshots without bound.

Enable persistence at the boundary that wires the service:

service = make_semantic_health_service(
    session=db_session,
    persist_history=True,
)

Known limitation (P4-001-FU-D): the snapshot table has no TTL yet. Run a periodic prune until the follow-up card lands — for example:

DELETE FROM pycharter.semantic_health_issue_snapshots
WHERE detected_at < NOW() - INTERVAL '30 days';

7.5 Auto-refresh tuning

The editor's Health tab polls every 30 seconds by default. Tune this in the UI Settings page to match the freshness needs of the deployment; longer intervals reduce DB pressure when persist_history=True.

8. Backup and restore

8.1 Schema scope

The semantic governance schema is owned by Alembic; migrations live under src/pycharter/db/migrations/versions/. The latest revisions on the feat/kb-phase-6-wave-3 branch include 20260519000004_add_semantic_health_issue_snapshots.py, 20260519000005_extend_binding_events_for_actions.py, and 20260519000006_add_ingest_run_progress.py.

Bring a fresh database up with:

pycharter db upgrade

8.2 What to back up

For PostgreSQL, prioritise the pycharter schema with the following table groups (see docs/guides/database-erd.md for the full ERD):

  • Ontology core — concepts, concept_relationships, concept_schemes, mapping_projections.
  • Governance — suggestions, semantic_review_events, concept_binding_events, proposals.
  • Health history — semantic_health_issue_snapshots (when persist_history=True).
  • Ingest provenance — semantic_assets, ingest_runs.

A logical dump that includes the full pycharter schema captures everything needed:

pg_dump --format=custom --schema=pycharter \
  --file=pycharter-$(date -u +%Y%m%d).pgcustom \
  "$DATABASE_URL"

8.3 Restore + replay

  1. Provision an empty database and run pycharter db upgrade to the same revision the dump targets.
  2. Restore the schema with pg_restore --data-only.
  3. Verify by replaying the audit stream: GET /api/v1/ops/audit-export?since=<dump_start> should return the same JSONL ordering as the source instance.

Health snapshot rows are derived state — they can be replayed from the underlying data by re-evaluating the health endpoint, so they are safe to drop and rebuild.

9. Performance and scale

9.1 Pagination

Every high-volume list route under /api/v1/ accepts limit (1..500, default 50 unless documented otherwise) and offset (>= 0). See the Pagination and Filtering Convention for the full route × shape inventory. Out-of-range values return HTTP 422.

9.2 Background imports

KB ingest endpoints (POST /api/v1/semantic/ingestion/kb-text, POST /api/v1/semantic/ingestion/kb-upload, POST /api/v1/semantic/ingestion/import, POST /api/v1/semantic/ingestion/import-all) are gated by require_role(Role.REVIEWER). Long imports stream progress via the ingest_run_progress table (migration 20260519000006_add_ingest_run_progress.py); poll GET /api/v1/semantic/ingestion/runs to watch a run finish.

9.3 Review-event backend

pycharter.semantic.review_events.factory.make_semantic_review_event_repository defaults to the SQLAlchemy backend whenever a session (or session factory) is provided (P3-009-FU-B). The in-memory backend remains available for tests and one-shot scripts that pass no session.

Override the default explicitly when needed:

# Force SQLAlchemy even without a session (must be paired with one).
export PYCHARTER_SEMANTIC_REVIEW_EVENT_BACKEND=sqlalchemy

# Force in-memory (review events lost on restart; tests only).
export PYCHARTER_SEMANTIC_REVIEW_EVENT_BACKEND=memory

9.4 Semantic-health backend

make_semantic_health_service defaults to SQLAlchemy too. Tests that want a deterministic issue list can flip the backend via:

export PYCHARTER_SEMANTIC_HEALTH_BACKEND=in_memory

Unknown values log a WARNING and fall back to sqlalchemy.

9.5 Health panel auto-refresh

The editor Health tab polls every 30 s. Increase the interval (UI Settings → Health refresh) when persist_history=True and the snapshot table is growing fast — every refresh writes one row per current issue.

10. Security considerations

10.0 Token revocation on logout

POST /api/v1/auth/logout invalidates the supplied bearer token server-side: the token's jti (added by create_access_token) is added to an in-process revocation set and verify_jwt rejects any subsequent request that carries the same token. Effects:

  • A user who clicks "Sign out" stays signed out for the lifetime of the API process. The token's exp no longer matters for re-use.
  • Logout is idempotent — a second call with the same (now revoked) token, or with no token at all, returns 200 OK.
  • The revocation set is in-process. After an API process restart, the set is empty and tokens that haven't reached their exp yet become valid again for the remainder of their TTL. Set PYCHARTER_AUTH_TOKEN_EXPIRE_SECONDS to a shorter value (e.g. 900 = 15 min) if this restart window is too wide for your threat model.
  • The set ages out entries past their exp automatically so the in-process store stays flat under steady load.

A durable revocation store (Redis or DB-backed) is a future follow-up — call revoke_token / is_token_revoked from pycharter.api.dependencies._token_revocation to slot it in.

10.1 JWT secret rotation

PYCHARTER_AUTH_JWT_SECRET is the HS256 signing secret. Rotate by:

  1. Generate a new secret: NEW_SECRET=$(openssl rand -hex 32).
  2. Roll the env var across every API worker. Existing tokens stop verifying at this point; clients must re-login.
  3. Confirm by issuing a new token and decoding it locally before removing the old secret from any secret store.

There is no rolling / dual-secret support today; rotation is a hard cut. Plan rotations for a maintenance window.

10.2 Header-fallback removal horizon

PYCHARTER_REQUESTER_CONTEXT_FROM_HEADERS=true is on by default to keep P5-006 placeholder headers working through the JWT migration. The schedule documented in Role / Permissions Matrix is:

  • Day +M — set the flag to false once every UI / client carries a JWT.
  • Day +K — once no deployments depend on the header path, retire _context_from_headers and the env flag entirely.

Production deployments should treat the default true as a transitional setting. Audit access logs for X-Role / X-Workspace headers and migrate clients before flipping the flag.

10.3 Password hashing

pycharter.api.dependencies.auth.verify_credentials accepts two formats for the password field of every PYCHARTER_AUTH_USERS entry:

  • Hashed (recommended): $scrypt$N$r$p$<salt>$<hash> envelope produced by pycharter.api.dependencies._password_hashing.hash_password. Mint one for an existing plaintext password with:
python -m pycharter.api.dependencies._password_hashing hash 'my-password'
# → $scrypt$32768$8$1$JnXa...$ZkLT...

Paste the returned string into the user record's password field. - Plaintext (legacy, still supported during migration): compared with hmac.compare_digest. Constant-time, so timing attacks are prevented, but the password sits in the env var unhashed.

The verifier auto-detects the format. Mixed deployments — some entries hashed, some plaintext — work during the transition. Operators are encouraged to migrate every entry to the hashed form; the longer-term fix is to move users into a database-backed table (tracked alongside the user-management follow-up referenced in §2.6), which would still use the same $scrypt$... envelope.

Regardless of format, PYCHARTER_AUTH_USERS must be treated as a secret on par with the JWT signing key — keep it out of version control and rotate the passwords whenever the secret store changes hands.

10.4 CodeQL detail=str( guard

raise HTTPException(..., detail=str(e)) is blocked by pre-commit. The single audited escape hatch is the raise_logged_*_from_exception family in pycharter.api._http_errors, which type-gates the surfaced exception to PublicFacingError subclasses or an explicit extra_safe_types allowlist (see the project CLAUDE.md "HTTP API errors" section for the helper picker). New code must follow the same pattern; CI will reject diffs that bypass it.

10.5 Information disclosure in audit export

The audit JSONL stream contains usernames, workspace IDs, and the raw payloads of governance events. Treat the /api/v1/ops/audit-export response as internal-only. The route is gated by require_role(Role.ADMIN) and emits Cache-Control: no-store so downstream proxies do not cache the body, but operators are responsible for not piping the stream to a public sink.

11. Cross-references

  • Role / Permissions Matrix
  • Semantic Context — Role Mapping
  • Pagination and Filtering Convention
  • End-to-end user guide
  • Vocabulary migration guide
  • src/pycharter/data/seed/examples/ecommerce/manifest.yaml (Ecommerce seed example)
  • Source: pycharter.api.dependencies.auth, pycharter.api.dependencies.role_check, pycharter.api.dependencies.requester_context, pycharter.api.routes.v1.audit, pycharter.api.routes.v1.semantic_proposals.link_proposal_to_suggestion, pycharter.api.routes.v1.semantic_health, pycharter.persistence.collaboration._direct_accept_rules, pycharter.persistence.collaboration._proposal_gate, pycharter.semantic.extraction._acceptance, pycharter.semantic.review_events.factory, pycharter.semantic.health.factory, pycharter.config.auth.