ADR-0058: A tenant's workspaces, listed by what each actor may open¶
Status: Accepted Date: 2026-10-12 Deciders: Alex Nodeland
Amends ADR-0056, where artifactr always names the workspace it asks authorize about.
Context¶
artifactr opens a workspace by id, but can't list the workspaces a tenant has (lattice #5). Two readers need the list:
- relayr starts a follower per workspace (stackr RFC-0002, phase 3). Without a list, a follower starts only when its workspace is first used, and a workspace that hasn't been used since a restart is not followed.
- A UI shows the workspaces a person can open. grantr can't: a check writes nothing, so grantr knows a workspace only once a grant names it (grantr ADR-0015). That ADR leaves listing every workspace to artifactr, filtered by the check.
artifactr has no registry of workspaces. No command creates a workspace; SqlStorage keeps a row per workspace from its first transaction, and a workspace counts as existing once its log has an event.
Decision¶
Storage.workspaces(tenant_id, *, after=None, limit=100)returns a page of a tenant's workspace ids, ordered by id as the storage orders strings, afterafter. It is the first storage method that spans a tenant rather than one workspace.- A workspace is listed once its log has an event. Reading a workspace, or keeping its cursors or leases, doesn't list it, so a probe of an unknown id leaves nothing behind.
SqlStoragelists theartifactr_workspacesrows withhead_seq > 0, through their primary key, with no migration. Workspaces.list(tenant_id, *, actor, after=None, limit=100)asksauthorizeforartifactr:read_workspaceswith no workspace, as reflexr's tenant-wide reads do. It then asksartifactr:openin each workspace it finds, and leaves out those refused. It opens none of them, soon_openis not called and listing has no side effects.- A page is full unless the list has ended.
listreads storage, 500 ids at a time whatever thelimit, until it haslimitworkspaces the actor may open, or storage has none left. The last id is the next page'safter, and a short page is the last. - Paging is by key, so it doesn't see behind its cursor. A workspace created mid-scan with an id before the cursor is missed by that scan; relayr's start on first use picks it up.
artifactr:read_workspacesjoinsACTIONS. It is the one action artifactr asks with no workspace;Authorizekeeps its signature.- REST and MCP list through it:
GET /workspaces?after=&limit=returns the ids as a JSON array, and thelist_workspacestool returns one per line. Both take the tenant from the caller's credentials. A refusal is 403 or a tool error, and alimitbelow 1 is 422, which the OpenAPI document states as a minimum, or a tool error.
Options considered¶
Option A: filtered by artifactr:open, page-filling (chosen)¶
| Dimension | Assessment |
|---|---|
| Complexity | Low: one loop over storage pages |
| Surfaces | Every surface lists what its caller may open, through one method |
| Cost | One hook call per workspace read |
Pros: what a caller sees is what it could open, everywhere; the page shape is a plain list. Cons: a caller who may open few of many workspaces costs a hook call for each it can't. In the worst case, one page for a user who may open few of a tenant's N workspaces scans the whole tenant: N hook calls, and N grantr queries.
Option B: list every workspace once read_workspaces allows it¶
| Dimension | Assessment |
|---|---|
| Complexity | Lowest |
| Surfaces | Leaves filtering to each application |
Pros: one hook call per page. Cons: names workspaces to callers who may not open them, and a UI must filter with grantr's batch check itself. ADR-0015 asks for the opposite.
Option C: filtered, returning a page with a cursor¶
Each storage page is filtered once, and the result carries the last id read as next, so a page may be short or empty before the end.
Pros: a bounded number of hook calls per request. Cons: a page model and an end marker on every surface, and an empty page that is not the end.
Trade-off analysis¶
The reader that matters most for correctness is a person's UI, which must not see workspaces they can't open. Filtering inside Workspaces.list gives that on every surface without a second hook. Page-filling keeps the shape a list of ids, which REST, MCP and relayr all use as-is; a short page meaning the end is a rule each caller already knows from read. The cost of a hook call per refused workspace is the price of ADR-0015's division of work, and grantr answers each check with one indexed query.
Consequences¶
- Easier: relayr can start a follower for every workspace at start-up, through a system actor that may open them all (stackr RFC-0002, phase 3).
- Easier: a UI lists exactly the workspaces its user may open.
- Harder: every
Storageimplementation addsworkspaces; the behaviour suite checks it. - Harder: an
authorizehook now hears aNoneworkspace from artifactr, forartifactr:read_workspaces. - Revisit: if listing a large tenant for a narrowly granted user becomes slow, a batch form of the hook, or a hook that lists: grantr's
workspaces(tenant, member)orresources(...)lists the workspaces a member is granted in about one query, and a tenant-wide grant falls back to the unfiltered list.
Action items¶
-
Storage.workspacesinInMemoryStorageandSqlStorage, with contract tests. -
Workspaces.list,artifactr:read_workspaces, the REST route and the MCP tool, in the permission matrix. - relayr starts its followers from the list (stackr RFC-0002, phase 3).