ADR-0056: Access asked in every workspace method¶
Status: Accepted, amended by ADR-0058 and ADR-0062 Date: 2026-10-12 Deciders: Alex Nodeland
Context¶
ADR-0048 put authorization within a tenant behind one hook, authorize(tenant_id, workspace_id, actor), which the router and ArtifactrMcp each took and passed to Workspaces.open. That hook had three gaps:
- It never heard the action. It was asked once, when a workspace was opened, so a role such as "viewer" could not be kept from editing. The security guide told applications to check commands in their own routes.
- Each surface took it separately. An application could give the router one hook and the MCP server another, or none, and in-process calls, the agent's tools among them, were never asked.
- It had side effects. The template's only hook started the workspace's feedback mirror and returned
True, so a permission check did work.
lattice RFC-0001 widens the hook so it hears the action, asks it for every command and read, and lets grantr answer it. Its phase 1 is this change.
Decision¶
Access,Authorize,OnOpenandACTIONSlive inartifactr.workspace:Access(action, thread_id=None, artifact_id=None)is what an actor is about to do. It has norule; reflexr's has.Authorize = Callable[[TenantId, WorkspaceId | None, Actor, Access], Awaitable[bool]]. artifactr always names the workspace; theNoneis for reads that span a tenant, which reflexr has.OnOpen = Callable[[TenantId, WorkspaceId], Awaitable[None]].ACTIONSis the complete catalog, asartifactr:<name>. A command's action is itstype(artifactr:edit_artifact), a read's isread_and what it reads (artifactr:read_artifacts), and opening a workspace isartifactr:open. Stopping a run isartifactr:stop_run; recording a factartifactr:record; holding a threadartifactr:claim_thread; and a consumer's place in the logartifactr:save_cursorandartifactr:read_cursors.
Workspacesholds the hooks:Workspaces(storage, ..., authorize=, on_open=).Workspaces.open,artifactr_routerandArtifactrMcpno longer takeauthorize, so no surface can be given a different hook, or none.- Every public
Workspacemethod asks first, as the handle's actor, throughWorkspace.check(access), which raisesForbidden("artifactr:edit_artifact is refused in this workspace"). A handle fromas_actorasks as its own actor. A method that calls another public method (create,post_messageandartifact) asks once, through the one it calls.commitasks inside its span, so a refusal is counted as a rejection, like any other. Accessnames what the call names, without loading anything: a command'sthread_idandartifact_id, a proposed change's, a feedback target's, and a read's argument.respond_to_proposal,answer_deferredandstop_run's run lookup carry no artifact or thread yet; phase 5 of the RFC, which hides threads and artifacts, refines them.checkis public for what a surface does beside the workspace: the runner asksartifactr:stop_run, with the run's thread, before it stops a run, and the MCP server asksartifactr:read_artifactsfor each artifact a subscription names, as a read of it is asked.- Opening asks
artifactr:open, then callson_open.on_openis called on every allowed open, and its callees are idempotent, so a permission check has no side effects. The template's mirrors start there. - The runner's own work acts as the runner. A command, and the reads that find what it is about, are checked as its sender. Holding a thread, keeping the runner's cursors and planning turns use a handle acting as
SystemActor(name="runner"), which already recorded abandoned runs. A sender is never asked forartifactr:claim_threadorartifactr:save_cursor. - A cursor saved with a command or a fact (
commit(..., cursor=),record(..., cursor=)) is covered by that command's or fact's check; onlysave_cursorasksartifactr:save_cursor. - The MCP server follows a workspace's log as itself,
SystemActor(name="mcp"), not as the client whose tool call first opened it, so that client's later refusal doesn't stop other clients' notifications. - A WebSocket that may not read the log or the runs is closed with 4403 before
welcome, as one refused its workspace is. - The cores' fixed checks stay beneath the hook. An evaluator only gives feedback, only a thread's agent or the system records run facts, and an author cannot accept their own proposal. The last now looks through
on_behalf_of(ADR-0057). - No hook allows everything, so the library stays usable alone, as docplan uses it.
- A permission-matrix test (
tests/test_access.py) lists every publicWorkspacemethod with its action, and every REST route, WebSocket command, and MCP tool and resource with what its caller is asked for. It calls each with a hook that records what it is asked. A method or surface missing from the tables, an action outsideACTIONS, or an action inACTIONSnothing asks for fails it. What asks nothing on purpose (as_actor,check, listing MCP's tools and resource templates) is listed with its reason.
Options considered¶
Option A: a check inside each method (chosen)¶
| Dimension | Assessment |
|---|---|
| Complexity | Low: one check call at the top of each method |
| Coverage | Every surface, in-process calls and the agent's tools alike |
| Cost | One hook call per method call |
Pros: a surface cannot forget it; the matrix test proves each method's action by calling it. Cons: a surface that calls several methods asks several times.
Option B: a decorator naming each method's action¶
| Dimension | Assessment |
|---|---|
| Complexity | Medium: the decorator must reach each call's ids, and wrap generators and context managers |
| Coverage | As A |
Pros: the action sits beside the signature.
Cons: subscribe is an async generator and claim_thread an async context manager, so it needs three shapes, and the ids an Access carries come from arguments it would have to know by name.
Option C: a second permit hook beside the old authorize¶
Rejected by the RFC (D1): two hooks, and a surface can forget one.
Trade-off analysis¶
A check per method costs a hook call per call; with grantr's SQL adapter that is one indexed query, and a request may reuse its own answers. It buys the RFC's guarantee that nothing reaches storage unasked. The explicit call reads plainly in each method and handles the generator and the context manager without special cases, which a decorator would not.
Consequences¶
- Easier: one hook decides every command and read, wherever it comes from.
- Easier: a role such as "viewer" can be refused edits, which grantr will provide.
- Harder: every application's
authorizetakes theAccess, and is passed toWorkspacesrather than to the surfaces. This is a breaking change, released as a minor version before 1.0 (lattice ADR-0006). - Harder: a subscription, as a WebSocket or an MCP stream, is checked when it opens; a command on it is checked as it arrives, but its events keep flowing after a revocation until it closes.
- Revisit: the thread and artifact a
respond_to_proposaloranswer_deferredis about, when phase 5 needs them.
Action items¶
-
Access, the widenedAuthorize,OnOpenandACTIONS, held byWorkspaces. - A check in every
Workspacemethod, and in the runner'sstop_run. - The permission-matrix test.
- grantr answers the hook (lattice RFC-0001, phases 2 to 4).