Skip to content
Documentation GitHub
Platform

Permission System

Status: Implemented Reference epics: INK-838 ADRs: ADR-008, ADR-016 Crates: crates/domain/src/capability.rs, crates/application/src/permission/


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:

  1. The workspace owner always has all capabilities, resolved locally with no network dependency (works offline).
  2. PermissionGuard is unforgeable — it can only be constructed inside the application crate via CapabilityResolver or the controlled system_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 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.
ComponentLocationRole
Capability enumcrates/domain/src/capability.rs19 medium-grained capability variants
PermissionGuardcrates/application/src/permission/permission_guard.rsUnforgeable proof of resolved capabilities
CapabilityResolvercrates/application/src/permission/capability_resolver.rsSingle trust boundary for guard construction
CapabilityRepository traitcrates/application/src/permission/services.rsCloud capability fetch abstraction
PermissionErrorcrates/application/src/permission/services.rsDenied and ResolutionFailed variants
system_guard()crates/application/src/permission/mod.rsControlled factory for background-task guards
empty_guard_for_testing()crates/application/src/permission/mod.rsZero-capability guard for infrastructure tests
SupabaseCapabilityRepositorycrates/infrastructure/supabase/src/capability/mod.rsPostgREST-based cloud capability fetch
Test helperscrates/application/src/permission/test_helpers.rsowner_guard(), guard_with(), MockCapabilityRepository
Framework Layer (Tauri / MCP / HTTP bridge)
|
v
CapabilityResolver.resolve_owner() OR .resolve_non_owner(workspace, participant)
|
v
IdentityStore.get_identity() <-- OS keychain (offline, cached)
|
v
PermissionGuard { participant_id, capabilities } <-- unforgeable token
|
v
use_case.execute(&guard, ...args)
|
v
guard.require(Capability::PagesWrite)? <-- first line of execute()
DomainCapabilitiesGranularity
PagesPagesRead, PagesWrite, PagesOrganize, PagesDeleteTiered: read → write → organize → delete
SearchSearchUseSingle capability for FTS5 + semantic
HistoryHistoryReadEvent log and timeline queries
BookmarksBookmarksRead, BookmarksManageRead vs. create/delete
WorkspaceWorkspaceManageSettings, admin operations
ImportImportExecuteExternal markdown import
SyncSyncManageCloud sync state control
AttachmentsAttachmentsRead, AttachmentsWriteFile metadata and upload/delete
TypesTypesRead, TypesWriteType definition CRUD
TagsTagsRead, TagsWriteTag and tag group CRUD
PropertiesPropertiesRead, PropertiesWriteProperty 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.

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.

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 case
let 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 check
impl DeletePageUseCase {
pub fn execute(&self, guard: &PermissionGuard, ...) -> Result<...> {
guard.require(Capability::PagesDelete)?;
// ... business logic
}
}

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:

crates/application/src/permission/mod.rs
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).

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).

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.

SituationWhere it shows upAgent-facing semantics
Content absentUse case returns domain-specific NotFound”X isn’t in the workspace”
Tool not registeredMCP schema does not contain the toolTool is not callable; agent has no reason to reference it
Capability deniedUse 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.

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.

RuleEnforcement
Owner always has all capabilitiesCapabilityResolver.resolve_owner() returns Capability::all() unconditionally
Guard is unforgeablePermissionGuard::new() is pub(crate) — compile-time module privacy
Permission check is first line of use caseConvention enforced by code review and denial tests
Error messages do not leak capability namesPermissionError::Denied uses generic Display ("Permission denied")
Capability::all() is exhaustiveExhaustive match test fails to compile on new variant
Capabilities are per-workspaceCapabilityRepository.get_capabilities() takes workspace_id
Capability denial is not deviationSubmit-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.

CapabilityOwnerWorld Agent (default)World Agent (restricted autonomous)
PagesReadyesyesyes
PagesWriteyesyesyes
PagesOrganizeyesyesno
PagesDeleteyesnono
SearchUseyesyesyes
HistoryReadyesyesno
BookmarksReadyesnono
BookmarksManageyesnono
WorkspaceManageyesnono
ImportExecuteyesnono
SyncManageyesnono
AttachmentsReadyesyesyes
AttachmentsWriteyesyesno
TypesReadyesyesyes
TypesWriteyesnono
TagsReadyesyesyes
TagsWriteyesyesno
PropertiesReadyesyesyes
PropertiesWriteyesyesno

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.

VariantMeaningDisplay
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.

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).

In crates/application/src/permission/test_helpers.rs:

  • owner_guard() — guard with all capabilities (simulates workspace owner)
  • guard_with(&[Capability]) — guard with specific capabilities
  • MockCapabilityRepository — configurable mock for non-owner resolution tests

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].

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.

Was this page helpful?