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:
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:
- Day 0 — bootstrap one
superuservia the env var; issue JWTs. - Day +N — point PyCharter at an external IdP via
PYCHARTER_AUTH_SERVICE_URL. Issue JWTs with the four-rungroleclaim and aworkspace_idclaim. - Day +M — set
PYCHARTER_REQUESTER_CONTEXT_FROM_HEADERS=falseand audit clients. - 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:
statusmust bepending.tier(frompayload["tier"], fallback"free") must befree.confidence_scoremust be non-null and>= 0.9(_FREE_TIER_MIN_CONFIDENCE).is_duplicate_ofmust beNone.proposed_actionmust 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:
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:
- Open a proposal that targets the concept
(
POST /api/v1/proposals). - Encode the deprecation as a
deprecate_conceptaction in the proposal body. - 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:
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:
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(whenpersist_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¶
- Provision an empty database and run
pycharter db upgradeto the same revision the dump targets. - Restore the schema with
pg_restore --data-only. - 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:
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
expno 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
expyet become valid again for the remainder of their TTL. SetPYCHARTER_AUTH_TOKEN_EXPIRE_SECONDSto 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
expautomatically 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:
- Generate a new secret:
NEW_SECRET=$(openssl rand -hex 32). - Roll the env var across every API worker. Existing tokens stop verifying at this point; clients must re-login.
- 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
falseonce every UI / client carries a JWT. - Day +K — once no deployments depend on the header path,
retire
_context_from_headersand 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 bypycharter.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.