Permission System
Status: Implemented Reference epics: INK-838 ADRs: ADR-008, ADR-016 Crates: crates/domain/src/capability.rs, crates/application/src/permission/
Overview
Section titled “Overview”The permission system is capability-based. Each participant in a workspace — the owner, a human collaborator, and the World Agent — receives a set of medium-grained capabilities scoped to that workspace. There are no roles; there is no viewer/editor/admin tier. The model applies uniformly across all participant kinds.
Two invariants hold the whole system together:
- The workspace owner always has all capabilities, resolved locally with no network dependency (works offline).
PermissionGuardis unforgeable — it can only be constructed inside theapplicationcrate viaCapabilityResolveror the controlledsystem_guard()factory. Rust’s module privacy enforces this at compile time.
Permission checks live in the application layer (use cases), not in the framework layer. Every entry point — Tauri commands, MCP tools, HTTP bridge — resolves a guard and flows through the same guard.require() call inside a use case.
The World Agent as participant
Section titled “The World Agent as participant”The World Agent is a first-class participant in the capability system — not a bypass, not a sidecar check, not a separate policy engine. On every invocation (interactive turn, scheduled autonomous-task run, subagent dispatch), the Rust side resolves a PermissionGuard for the agent against the target workspace and hands it down through the call stack. The agent’s reach into the workspace is exactly what that guard says it is.
This is a consequence of ADR-016: World Runtime on LangGraph. The LangGraph runtime lives in the Python sidecar, but every boundary-crossing action — reads, writes, imports, sync controls — reaches the workspace through a Rust MCP tool, and every Rust MCP tool calls into an application-layer use case that calls guard.require() as its first line. The sidecar holds no workspace authority independently; its authority is whatever the resolved guard carries.
Three properties follow:
- Uniform enforcement. A user deleting a page, an automation calling the same use case over MCP, and the World Agent calling the same use case from Python all traverse the same check. No class of caller gets a cheaper path.
- Per-workspace resolution. The agent running against workspace A has an A-scoped guard; if it reaches into workspace B it resolves a new guard against B. There is no global “agent capability set.”
- Capability denial is reasoned about, not swallowed. See §Denial is not absence below.
Architecture
Section titled “Architecture”Resolution flow
Section titled “Resolution flow”Component inventory
Section titled “Component inventory”| Component | Location | Role |
|---|---|---|
Capability enum | crates/domain/src/capability.rs | 19 medium-grained capability variants |
PermissionGuard | crates/application/src/permission/permission_guard.rs | Unforgeable proof of resolved capabilities |
CapabilityResolver | crates/application/src/permission/capability_resolver.rs | Single trust boundary for guard construction |
CapabilityRepository trait | crates/application/src/permission/services.rs | Cloud capability fetch abstraction |
PermissionError | crates/application/src/permission/services.rs | Denied and ResolutionFailed variants |
system_guard() | crates/application/src/permission/mod.rs | Controlled factory for background-task guards |
empty_guard_for_testing() | crates/application/src/permission/mod.rs | Zero-capability guard for infrastructure tests |
SupabaseCapabilityRepository | crates/infrastructure/supabase/src/capability/mod.rs | PostgREST-based cloud capability fetch |
| Test helpers | crates/application/src/permission/test_helpers.rs | owner_guard(), guard_with(), MockCapabilityRepository |
Trust chain
Section titled “Trust chain”Framework Layer (Tauri / MCP / HTTP bridge) | vCapabilityResolver.resolve_owner() OR .resolve_non_owner(workspace, participant) | vIdentityStore.get_identity() <-- OS keychain (offline, cached) | vPermissionGuard { participant_id, capabilities } <-- unforgeable token | vuse_case.execute(&guard, ...args) | vguard.require(Capability::PagesWrite)? <-- first line of execute()The 19 capabilities
Section titled “The 19 capabilities”| Domain | Capabilities | Granularity |
|---|---|---|
| Pages | PagesRead, PagesWrite, PagesOrganize, PagesDelete | Tiered: read → write → organize → delete |
| Search | SearchUse | Single capability for FTS5 + semantic |
| History | HistoryRead | Event log and timeline queries |
| Bookmarks | BookmarksRead, BookmarksManage | Read vs. create/delete |
| Workspace | WorkspaceManage | Settings, admin operations |
| Import | ImportExecute | External markdown import |
| Sync | SyncManage | Cloud sync state control |
| Attachments | AttachmentsRead, AttachmentsWrite | File metadata and upload/delete |
| Types | TypesRead, TypesWrite | Type definition CRUD |
| Tags | TagsRead, TagsWrite | Tag and tag group CRUD |
| Properties | PropertiesRead, PropertiesWrite | Property definition management |
Page operations are intentionally tiered — an agent that can edit content should not automatically restructure the hierarchy or permanently delete pages. Granularity can evolve (e.g. PagesDelete splitting into PagesTrash and PagesPermanentDelete) without API changes; only Capability::all() and the exhaustive test need updating.
Key design decisions
Section titled “Key design decisions”Capability-based, not role-based
Section titled “Capability-based, not role-based”Capabilities are assigned directly to participants, not bundled into named roles. This avoids the rigidity of predefined roles and maps cleanly to product packaging (free vs. premium feature gating). If role-like convenience is needed later, roles would be implemented as named capability bundles — a UI shortcut, not a separate authorization concept.
Unforgeable guard via module privacy
Section titled “Unforgeable guard via module privacy”PermissionGuard::new() is pub(crate), so only code within the application crate can construct guards. Rust’s module privacy enforces this at compile time — no runtime check needed. This eliminates the class of bugs where a framework layer builds a guard directly and bypasses capability resolution.
Permission checks live in use cases, nowhere else
Section titled “Permission checks live in use cases, nowhere else”Framework layers (Tauri, MCP, HTTP bridge) resolve the guard via CapabilityResolver and pass it to use cases. They never call guard.require() themselves. This keeps every entry point flowing through the same check in the application layer.
// Framework: resolve guard, pass to use caselet guard = state.resolve_owner_guard()?;let use_case = DeletePageUseCase::new(state.page_repo());use_case.execute(&guard, &workspace.path, &slug)?;
// Application: single source of truth for the permission checkimpl DeletePageUseCase { pub fn execute(&self, guard: &PermissionGuard, ...) -> Result<...> { guard.require(Capability::PagesDelete)?; // ... business logic }}System guard for background tasks
Section titled “System guard for background tasks”Background tasks (embedding indexing, history collapse, import reconciliation) run without a user-facing auth context. The system_guard() factory in the permission module creates an all-capabilities guard with SYSTEM_PARTICIPANT_ID (nil UUID). This is the only authorized path for background-task guards and is deliberately auditable:
pub fn system_guard() -> PermissionGuard { PermissionGuard::system()}The World Agent does not use system_guard(). Agent invocations — interactive or autonomous — always resolve a participant-scoped guard, never the system-wide one. The system guard is for background data-keeping work that has no agent semantics (recompute an index, collapse old events).
Forward-compatible cloud parsing
Section titled “Forward-compatible cloud parsing”SupabaseCapabilityRepository parses the Postgres TEXT[] column from PostgREST JSON. Unknown capability names are silently ignored via serde_json::from_str fallback, so a newer server can send capabilities an older client does not recognize without breaking anything.
MCP tools are execution-gated, not visibility-gated
Section titled “MCP tools are execution-gated, not visibility-gated”The MCP tool surface is uniform. All tools appear in the tool schema the sidecar sees, regardless of the caller’s capabilities. Permission is checked at execution inside the use case, not at visibility in the schema.
This is deliberate:
- Agents need a stable tool surface to reason over. Tools that silently disappear from the schema on capability change produce agent behavior that depends on resolver state in ways that are hard to debug.
- Denial is informative. When a tool call returns
PermissionError::Denied, the agent has concrete grounds to surface a permission-request UI to the author, rather than quietly behaving as if the operation were not available. - It matches how the capability system reasons about denial as structurally distinct from absence (next section).
Denial is not absence
Section titled “Denial is not absence”A denial means the participant tried to do something they are not authorized to do. It does not mean the content was not there, the tool did not exist, or the operation was undefined. These are different situations and the system surfaces them differently.
| Situation | Where it shows up | Agent-facing semantics |
|---|---|---|
| Content absent | Use case returns domain-specific NotFound | ”X isn’t in the workspace” |
| Tool not registered | MCP schema does not contain the tool | Tool is not callable; agent has no reason to reference it |
| Capability denied | Use case returns PermissionError::Denied | ”I don’t have access to X” — distinct from absence |
The agent’s reasoning layer honors this distinction. A denied page read surfaces to the author as “I don’t have access to that page” — not as “that page doesn’t exist.” Conflating the two would let capability boundaries corrupt the agent’s model of the workspace, which would then leak into everything downstream: memory, re-validation, derivation reasoning, subagent dispatch.
Denial is not deviation
Section titled “Denial is not deviation”Capability denial at the submit boundary does not produce a deviation record. Deviations are a record type for canonical-content conflicts the agent attempted to resolve — they describe situations where the agent was entitled to operate on the content but produced something incompatible with current canon. A capability denial is the opposite: the agent was never entitled to operate on the content in the first place.
This is domain rule 7 — capability-denied is not deviation. The submit-boundary path checks capability before WorldWrite construction; if the check fails, no WorldWrite is built, no deviation record is created, and the call returns PermissionError::Denied. See also ADR-008 and the submit-boundary page for the write path.
Domain rules
Section titled “Domain rules”| Rule | Enforcement |
|---|---|
| Owner always has all capabilities | CapabilityResolver.resolve_owner() returns Capability::all() unconditionally |
| Guard is unforgeable | PermissionGuard::new() is pub(crate) — compile-time module privacy |
| Permission check is first line of use case | Convention enforced by code review and denial tests |
| Error messages do not leak capability names | PermissionError::Denied uses generic Display ("Permission denied") |
Capability::all() is exhaustive | Exhaustive match test fails to compile on new variant |
| Capabilities are per-workspace | CapabilityRepository.get_capabilities() takes workspace_id |
| Capability denial is not deviation | Submit-boundary check runs before WorldWrite construction (domain rule 7) |
Default capability sets by participant kind
Section titled “Default capability sets by participant kind”The World Agent’s default set is intentionally conservative. The author can grant additional capabilities per workspace. The (restricted) column shows a scoped autonomous-task profile — tighter than the default, used when the author grants autonomous-task scope per PL-C but wants to narrow the per-category reach.
| Capability | Owner | World Agent (default) | World Agent (restricted autonomous) |
|---|---|---|---|
PagesRead | yes | yes | yes |
PagesWrite | yes | yes | yes |
PagesOrganize | yes | yes | no |
PagesDelete | yes | no | no |
SearchUse | yes | yes | yes |
HistoryRead | yes | yes | no |
BookmarksRead | yes | no | no |
BookmarksManage | yes | no | no |
WorkspaceManage | yes | no | no |
ImportExecute | yes | no | no |
SyncManage | yes | no | no |
AttachmentsRead | yes | yes | yes |
AttachmentsWrite | yes | yes | no |
TypesRead | yes | yes | yes |
TypesWrite | yes | no | no |
TagsRead | yes | yes | yes |
TagsWrite | yes | yes | no |
PropertiesRead | yes | yes | yes |
PropertiesWrite | yes | yes | no |
Defaults: read, write, organize, search, history. No delete, no workspace management, no import, no sync, no bookmarks. The author grants additional capabilities explicitly.
The Python sandbox inherits the caller’s capabilities
Section titled “The Python sandbox inherits the caller’s capabilities”The sandboxed CPython executor is not a privileged runtime. It is exposed to the agent as an MCP tool, and like every other boundary-crossing MCP tool, the Rust side resolves a PermissionGuard at invocation and passes it through. Anything the sandbox tool does is capped by the caller’s capability set. See python-sidecar-security for the execution model and ADR-016 for why the sandbox is a tool, not a runtime.
A scoped restriction is also possible at the submit-boundary adapter: a sandbox invocation can be called with a capability subset tighter than the agent’s resolved set (e.g., strip PagesWrite for a read-only computation), producing a sandbox-local guard that narrows — never widens — what the sandbox can do.
Error handling
Section titled “Error handling”PermissionError variants
Section titled “PermissionError variants”| Variant | Meaning | Display |
|---|---|---|
Denied { capability, participant_id } | Participant lacks a required capability | "Permission denied" (generic, no leak) |
ResolutionFailed(String) | Could not resolve capabilities (no identity, network error, parse error) | "Capability resolution failed: {detail}" |
Denied hides capability and participant details from its Display implementation. The fields are available for server-side logging and diagnostics but are not exposed to external callers, preventing capability-enumeration attacks.
Error propagation
Section titled “Error propagation”Use case errors typically wrap PermissionError into their domain-specific error type:
pub enum PageError { Permission(PermissionError), NotFound(String), // ...}
impl From<PermissionError> for PageError { fn from(e: PermissionError) -> Self { PageError::Permission(e) }}Framework layers map these to transport-appropriate errors (Tauri CommandError, MCP tool error, HTTP status).
Testing
Section titled “Testing”Test helpers
Section titled “Test helpers”In crates/application/src/permission/test_helpers.rs:
owner_guard()— guard with all capabilities (simulates workspace owner)guard_with(&[Capability])— guard with specific capabilitiesMockCapabilityRepository— configurable mock for non-owner resolution tests
Denial test convention
Section titled “Denial test convention”Every use case with a guard.require() call has a matching denial test:
#[test]fn test_delete_page_rejected_without_pages_delete() { let guard = guard_with(&[Capability::PagesRead]); let use_case = DeletePageUseCase::new(Arc::new(repo)); let result = use_case.execute(&guard, Path::new("/tmp"), "test-page"); assert!(matches!(result, Err(PageError::Permission(_))));}Naming: test_[usecase]_rejected_without_[capability].
Exhaustive capability test
Section titled “Exhaustive capability test”A test in capability.rs uses an exhaustive match to ensure Capability::all() stays in sync with the enum. Adding a new variant without updating the match fails to compile.
Related
Section titled “Related”- World Agent — the agent as participant
- Submit boundary — the write-path check that applies capability before
WorldWriteconstruction - MCP system — how tools carry capability requirements through registration
- Python sidecar security — sandbox execution model and capability inheritance
- Scheduling system — category-based scope grants for autonomous tasks
- ADR-008: Capability-Based Permission Model
- ADR-016: World Runtime on LangGraph
Was this page helpful?
Thanks for your feedback!