ADR-0062: Restricted items left out of every read¶
Status: Accepted Date: 2026-10-12 Deciders: Alex Nodeland
Amends ADR-0056: a hidden item is missing, where a refused one was forbidden.
Context¶
Lattice RFC-0001 lets an application restrict a thread or artifact to some of a workspace's members, and gives artifactr a second hook, hidden, that returns what one actor may not see. Its phase 5 asks that every read path leave those items out: listings, single reads, the log, the WebSocket's replay and live frames, MCP, and the agent's tools in a turn for another user. ADR-0056 already sends every surface through a Workspace method, so the question is where in those methods to filter, and what a hidden item looks like to whoever can't see it.
Decision¶
Workspaces(hidden=, hidden_refresh=)holds the hook, besideauthorize. It answers withartifactr.core.Visibility, or any object with itsthreadsandartifactssets (theHidesprotocol), so an access library's own answer fits without importing artifactr. Without a hook, nothing is hidden.- A hidden item is missing. A method whose access names a hidden thread or artifact raises
NotFoundbeforeauthorizeis asked, as for one that doesn't exist, so ids can't be probed by telling 403 from 404. That covers single reads, commands and claims.runandproposal, whose access names no item, are missing when their thread or artifact is hidden; answering a proposal, answering a paused run, and feedback on a turn or one of its messages are asked again of the artifact or thread they act on, and are refused, or missing as the proposal or run itself, naming no id the caller didn't give. A focus may name no hidden artifact, and keeps the hidden ones it had, which its setter can't have chosen to drop. A hidden thread's runs and history are empty, as a missing thread's are.tests/workspace/test_hidden.pycalls each of these with a hidden id and a made-up one, and checks they answer alike. - Listings leave hidden items out: artifacts, threads, proposals and runs. A thread's focus leaves out hidden artifacts.
- The log drops what is about a hidden item. An artifact or proposal event committed from a hidden thread is kept, without its thread and run, so a visible artifact's history stays whole. Nothing kept names a hidden thread: a hidden thread's agent, as an envelope's actor, a proposal's author or an artifact's last editor, is named with an empty thread and no run; an envelope kept from a hidden thread has no
traceparent, and a revision its agent made notrace_id. - A page of the log is full whatever was hidden:
readwithlimitorlastreads on from storage until the page is full or the window ends, so a short page still means the end. TheStorageport doesn't change. - A handle asks
hiddenat most once everyhidden_refresh, 30 seconds by default, so a request asks it once and an open subscription asks again as it goes. A run the WebSocket forwards live stops once its thread is hidden. The WebSocket replays the log a page at a time up to the head, then follows it, so its replay ends at the head even when the last envelopes before it are hidden. - An MCP subscription reads each artifact it names, so one hidden, or that doesn't exist, is refused as a read of it is.
- The agent reads as its turn's actor, whose
on_behalf_ofthe application's hook looks through, so a turn for one user can't surface what that user may not see.
Options considered¶
| Option | For | Against |
|---|---|---|
Filter in the Workspace methods (chosen) |
Every surface goes through them already; one place to test | Each read asks the hook once more |
| Filter in each surface | Each surface sees exactly what it sends | Four places to keep in step, and the agent's tools would be a fifth |
| Filter in storage, with the hidden sets as a parameter | Pages filled in one query | A change to the Storage port and both adapters, for sets that are small |
| A hidden item is forbidden | Nothing to add to the check | 403 for a hidden item and 404 for a missing one tell them apart |
Consequences¶
- Easier: a surface added later inherits the filter, and
tests/workspace/test_hidden.pylists everyWorkspacemethod with what it hides, as the permission matrix lists what each asks. - Harder: an open stream learns of a new restriction up to
hidden_refreshlater; commands are refused at once, since each asks. - Harder: a client subscribed over MCP to an artifact before it was restricted is still told when it changes, though reading it is
NotFound; a new subscription asks, as a read does. - Harder: gaps in the log's
seq, andhead_seq, tell a reader that something it can't see happened, though never what. - Harder: creating an artifact or thread with a restricted id answers
NotFound, where an id in use answers a conflict; ids are random, so this tells nothing that can be aimed.