* refactor(session): make ModelLane the provider boundary (#979) ## Summary This closes the model-lane ownership gap left by #832: `ChatSession` no longer stores raw provider/client handles. `ResolvedModelBinding` now carries the provider, client, model, capabilities, registry generation, and backend-auth configuration as one coherent snapshot. - Atomically rebind existing sessions after model-registry changes while pinning each in-flight send, fallback, judge, output guard, task agent, title, compaction, perception, and voice operation to its initiating principal and binding. - Fence UI publication, canonical trajectory folds, durable writes, streams, retries, child scopes, and judge work by generation. Stop can hand off to a successor without accepting late state; cancelled tools retain typed effect receipts, and concurrent approval batches resolve by exact cycle or call. - Make create, fork, open, close, and delete race-safe with hidden `creating` reservations, incarnation-aware state tails, and an ACL-rechecked transaction that clones checkpoint-bounded history, configuration, project/persona state, and attachment references. - Extend REST/OpenAPI and Python/TypeScript SDK contracts for create/fork inputs, routed-create metadata, live-workstream probes, targeted approvals, and structured cancellation results. - Update architecture, storage, authentication, judge, channel, console, API, and SDK documentation, including regenerated architecture diagrams and OpenAPI artifacts. ## Validation - SQLite suite: 11,188 passed, 9 skipped, 10 deselected - PostgreSQL suite: 11,195 passed, 2 skipped, 10 deselected - Live backend: 3 passed - SSE recovery: 6 passed; browser recovery harness passed all scenarios - Ruff: clean; 595 files correctly formatted - mypy: 243 source files clean - TypeScript: typecheck/build and 35 tests passed - OpenAPI artifacts fresh; all 14 changed diagrams reproduce byte-for-byte - `git diff --check` and Git LFS integrity clean Closes #979. * fix(deps): update nanoid for GHSA-2v37-7h3g-55p8 Refresh the transitive lock entry admitted by PostCSS so the TypeScript security gate no longer resolves the vulnerable custom-generator implementation. Validation: - npm ci - npm audit --audit-level=moderate: 0 vulnerabilities - TypeScript typecheck and build - TypeScript tests: 35 passed * fix(test): assert canonical model registry URLs Replace prefix checks with exact canonical base URL assertions so the tests do not model incomplete URL validation. Validation: tests/test_model_registry.py (185 passed); Ruff check/format; mypy.
13 KiB
Governance
Turnstone governance provides role-based access control (RBAC), tool execution policies, skills, usage tracking, and audit logging for the admin console.
Architecture
See diagram: 19-governance-architecture.puml.
RBAC (Roles & Permissions)
The permission model has two layers:
- Scopes (legacy) —
read,write,approve. Checked byAuthMiddlewareon every request based on URL path classification. - Permissions (granular) — named permission strings checked per-endpoint by
require_permission().
Built-in roles (seeded by migration 008 and extended by later feature migrations):
| Role | Permissions |
|---|---|
| admin | Admin-default baseline: ordinary admin, lifecycle, tool-approval, coordinator, project, and persona capabilities. Explicit opt-in capabilities such as model.skills.write remain ungranted. |
| operator | read, write, workstreams.create, workstreams.close, conversation.modify |
| viewer | read |
Custom roles can be created with any subset of the valid permissions. Built-in
role permission overrides can grant or revoke individual capabilities, so the
admin console is authoritative for the effective set on a deployment.
The persona.create / persona.read / persona.write family gates
persona administration; migration 063 seeds all three onto
builtin-admin, and any role can be granted them through the standard
role and permission-override editors.
Auth flow:
- User logs in (password or API token) →
_load_user_permissions()aggregates permissions from all assigned roles _permissions_to_scopes()derives legacy scopes (anyadmin.*→approve)- JWT created with both
scopesandpermissionsclaims - Middleware checks scope → handler checks permission via
require_permission()
Tool Policies
Admin-defined rules that control tool execution:
- Pattern matching: Glob syntax via
fnmatch(e.g.,bash*,file_write,*) - Actions:
allow(auto-approve),deny(block),ask(normal approval flow) - Priority: Higher priority evaluated first, first match wins
- Enforcement:
evaluate_tool_policies_batch()called inWebUI.approve_tools()before theauto_approvecheck - MCP granular policies: MCP resources and prompts are evaluated using their
approval_labelfor fine-grained control:- Resource reads:
mcp_resource__{uri}(e.g.,mcp_resource__file:///docs/*to allow,mcp_resource__*to deny all) - Prompt invocations:
mcp__{server}__{prompt}(e.g.,mcp__trusted__*to allow,mcp__*to require approval for all) - Built-in tools continue to use
func_namefor backward compatibility
- Resource reads:
Skills
Admin-curated system message skills injected at workstream startup. Skills also include session configuration (model, temperature, auto-approve, token budget, etc.) since workstream templates were merged into the skills system in v0.8.0.
- Runtime behavior: Skills are loaded once at session creation and injected
into the system message before user
instructions. Skills set the baseline; instructions customize per-workstream behavior. - Default skills: All
is_default=trueskills auto-apply to new workstreams, concatenated in alphabetical order by name. Use name prefixes (e.g.01-safety,02-style) to control ordering. - Explicit selection:
--skill <name>CLI flag,skillfield onPOST /v1/api/workstreams/new, console launcher dropdown, scheduled task config, and channel adapter config. An explicit skill replaces defaults. - Variables: Three built-in placeholders resolved at load time:
{{model}}(active model name),{{ws_id}}(workstream ID),{{node_id}}(server node ID). Unrecognized placeholders are kept as-is. - Runtime switching:
/skill <name>to switch,/skill clearto revert to defaults,/skillto show current. Persisted across resume. - Model-driven loading: The
skillbuilt-in tool lets the model discover and activate skills mid-conversation.searchaction finds skills by query (auto-approved);loadaction activates by name (requires user approval since it changes session behavior). Main session only. - Categories: general, engineering, support, custom, mcp
- Content limit: 32 KB per skill (enforced on create/update)
- Storage:
prompt_templatestable (stores skills) with JSONvariablesarray. Migration 010 addstemplatecolumn toscheduled_tasks. - MCP sync: MCP server prompts auto-sync into the
prompt_templatestable withorigin="mcp",mcp_serverset, andreadonly=True. Manual skills take precedence on name collision. MCP-synced content updates resetis_defaultto prevent compromised servers from injecting defaults. Admin UI shows origin badge and disables edit/delete for MCP-sourced skills. - Spec fields: Skills support the full Agent Skills standard frontmatter:
name,description,license,compatibility,metadata(author, version),allowed-tools. Thelicenseandcompatibilityfields are preserved on import and editable in the admin UI. See https://agentskills.io/specification. - Security scanning: Skills are automatically scanned at creation and update
time. The scanner evaluates four risk axes: content risk (command execution,
data exfiltration), supply chain risk (pipe-to-shell, transitive installs),
vulnerability risk (prompt injection, insecure credentials), and declared
capability risk (from
allowed-toolsin SKILL.md). Results populate therisk_level(safe/low/medium/high/critical) andscan_report(JSON breakdown) columns. These fields are system-managed and cannot be overwritten via the admin API. - Discovery: External skills can be discovered and installed from registries:
GET /v1/api/admin/skills/discover?q=...— search the skills.sh registry (or a custom registry viaskills.discovery_urlsetting)POST /v1/api/admin/skills/install— install from skills.sh or GitHub. Fetches theSKILL.mdfile, parses YAML frontmatter, creates a skill withorigin="source"andreadonly=True, stores bundled resources.- Admin UI: Skills tab has "Installed" / "Discover" pill toggle. Discovery view has search bar, result cards, and "Import from GitHub" modal.
- SDK:
discover_skills(q)andinstall_skill(source, skill_id=..., url=...)on both Python and TypeScript console clients.
- Runtime config on installed skills: Installed (readonly) skills can have
their runtime configuration edited — model, temperature, reasoning effort,
token budget, max tokens, agent max turns, auto-approve, allowed tools,
and enabled flag. The server restricts updates to these fields only via
_SKILL_RUNTIME_CONFIG_FIELDSfiltering; spec/content fields (name, description, tags, license, compatibility, content, activation) remain immutable. The admin UI shows "Save Config" instead of "Save" for these skills. Audit action:skill.update.config. - Admin UI: Create/Edit skill modals use a two-column spec manifest layout (left: Identity / Manifest / Deployment; right: Skill Content editor with monospace font). Runtime Config is a collapsible 3-column grid below. License uses an SPDX identifier dropdown (MIT, Apache-2.0, GPL-3.0, etc.). Installed skills show a cyan origin badge with source URL, spec fields are disabled, and all collapsible sections auto-expand in view mode.
Usage Tracking
Per-LLM-request token and tool call metrics:
- Recording:
on_status()inWebUIrecords ausage_eventafter each LLM response with prompt/completion tokens, cache tokens, tool call count, model, ws_id - Prompt caching: Anthropic automatic caching (
cache_control: ephemeral) and OpenAI caching are enabled by default. Pre-5.6 GPT-5 models requestprompt_cache_retention: 24h; GPT-5.6 usesprompt_cache_options: {"ttl": "30m"}. GPT-5.6 cache writes use the provider's 1.25× input-token rate.cache_creation_tokensandcache_read_tokensare tracked per request inusage_eventsand surfaced in the Usage admin tab - Querying:
GET /v1/api/admin/usagewithgroup_by(day/hour/model/user) and time range filtering — includes cache token aggregates - Prometheus:
turnstone_tokens_total{type="cache_creation|cache_read"}counters on/metrics - Pruning:
prune_usage_events(retention_days=90)andprune_audit_events(retention_days=365)run automatically via the console scheduler's periodic cleanup cycle
Audit Logging
Append-only trail of admin actions:
- Recording:
record_audit()helper called from all admin mutation handlers - Events captured: user.create, user.delete, token.create, token.revoke, channel.link, channel.unlink, role.create, role.update, role.delete, role.assign, role.unassign, policy.create, policy.update, policy.delete, template.create, template.update, template.delete, skill.create, skill.update, skill.delete, org.update
- Querying:
GET /v1/api/admin/auditwith action/user/time filters + pagination
Database Schema
Migration 008 adds 7 tables:
| Table | Purpose |
|---|---|
orgs |
Organizations (single default org for now) |
roles |
Named permission bundles (3 builtin + custom) |
user_roles |
User-to-role assignments (composite PK) |
tool_policies |
Per-tool approve/deny/ask rules |
prompt_templates |
Reusable system message skills |
usage_events |
Per-request token/tool/cache metrics |
audit_events |
Admin action log |
Also adds org_id column to users table.
API Endpoints
All under /v1/api/admin/ (requires approve scope + granular permission).
| Group | Endpoints | Permission |
|---|---|---|
| Users / Tokens / Channels | 9 (CRUD) | admin.users |
| Roles | 7 (CRUD + assignment) | admin.roles / admin.users |
| Orgs | 3 (list, get, update) | admin.orgs |
| Tool Policies | 4 (CRUD) | admin.policies |
| Skills | 4 (CRUD) | admin.skills |
| Personas | 4 (list, create, get, edit/archive) | persona.read / persona.create / persona.write |
| Schedules | 6 (CRUD + runs) | admin.schedules |
| Watches | 3 (list, create, cancel) | admin.watches |
| Usage | 1 (aggregated query) | admin.usage |
| Audit | 1 (paginated, filtered) | admin.audit |
Full OpenAPI spec at /openapi.json and Swagger UI at /docs.
Admin Console UI
Governance-related tabs within the 18-tab admin panel:
- Roles — CRUD roles, permission checkbox grid, user role assignment modal
- Policies — CRUD tool policies with colored action badges (green/red/amber)
- Prompts — Prompt-policy editor (heuristics for admin guardrails)
- Skills — CRUD skills with wide modal, textarea editor; Discover pill for installing from skills.sh / GitHub; per-row scan badges (safe/low/med/high/critical)
- Judge — Intent validation configuration and verdict history
- Usage — Summary readouts + CSS bar chart, time range + group-by selectors
- Audit — Filterable log with relative timestamps, load-more pagination
Tabs are permission-gated: hidden if the user lacks the required permission. See docs/console.md for the full tab list and docs/settings.md for the Settings tab that edits live ConfigStore values.
SDK
Both Python and TypeScript console SDKs expose governance methods:
Python (TurnstoneConsole / AsyncTurnstoneConsole):
list_roles(),create_role(),update_role(),delete_role()list_user_roles(),assign_role(),unassign_role()list_orgs(),get_org(),update_org()list_policies(),create_policy(),update_policy(),delete_policy()list_templates(),create_template(),update_template(),delete_template()get_usage(since, group_by=...),get_audit(action=..., limit=...)
TypeScript (TurnstoneConsole):
- Same methods with camelCase naming and typed interfaces
Security Considerations
- Privilege escalation prevented:
admin_assign_roleblocks self-assignment and requires caller to hold a superset of the target role's permissions - Permission validation: Role create/update validates permissions against
the permission allowlist (
_VALID_PERMISSIONS) - Self-deletion blocked:
admin_delete_userrejects attempts to delete your own account (matching the self-assignment guard on role endpoints) - Field allowlists: Storage
update_*methods filter fields against allowlists (_ROLE_MUTABLE,_POLICY_MUTABLE, etc.) — handler bugs cannot overwriterole_id,builtin,created, or other protected columns - Bootstrap safety:
handle_auth_setupfails and rolls back if admin role assignment fails, preventing locked-out first user - API token RBAC:
_authenticate_api_tokenloads permissions from user's roles, ensuring API tokens are subject to RBAC enforcement - Policy evaluation is fail-open: If storage is unavailable, tool policies degrade to the existing approval flow (not auto-approve)
- Audit IP resolution:
_audit_context()prefersX-Forwarded-Forfor client IP when behind a reverse proxy, falling back torequest.client.host