Skip to content

fix(backend): resolve every scoped resource through one visibility authority - #424

Merged
willdady merged 2 commits into
mainfrom
fix/scoped-resource-visibility-authority
Aug 6, 2026
Merged

fix(backend): resolve every scoped resource through one visibility authority#424
willdady merged 2 commits into
mainfrom
fix/scoped-resource-visibility-authority

Conversation

@willdady

@willdady willdady commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Follow-up to #423, which fixed the sub-agent lookup but left the same rule reimplemented in several other places — and they disagreed with each other.

One authority for "may this Workspace use this?"

listScopedByIds is the ids-filtered form of listScoped: the Workspace's own rows, plus the Organization-scoped (Shared) rows attached to it (ADR-0007). The Chat turn's Agent, Provider, Skill, MCP and sub-Agent lookups, the save-time sub-agent check, and Kanban assignee validation all resolve through it now. Three near-identical copies of the rule and one local attachment helper are gone.

Bugs this closes

A workspace-surface create could choose its own scope. The Provider and MCP create routes spread the request body straight into the insert, and their create schemas accept organizationId/workspaceId while dropping the XOR refinement that providerSchema/mcpSchema carry. A caller with workspace access could name a different Workspace, or set organizationId and mint a Shared Provider or MCP from the Workspace surface — something only an Org Admin may do (ADR-0006, ADR-0007). Both routes now take the scope from the route, as the Agent and Skill routes already did.

A Kanban card could not be assigned to a Shared Agent. The assignee picker offers every Agent visible in the Workspace, which includes attached Shared Agents, but validation accepted only workspace-scoped ones — so assigning an Agent that can run here failed with Invalid agent assignee.

The Agent Discovery tool set resolved at the wrong scope in both directions. listAgents, getAgent and the MCP list were workspace-only, so an Operator working through an Agent could not see a Shared resource attached to their Workspace — while listModelProviders offered every Shared Provider in the Organization, attached or not, which yields an Agent that cannot run. All four now resolve at the Workspace's scope and tag each row with it.

Hardening

The scope columns are mutually exclusive by a Zod refinement on write, not by a database constraint. resolveScoped now classifies a row by the scope it actually carries rather than by elimination, the org-scoped queries require a null workspace, and the Promotion guard counts a reference as Shared only if it is org-scoped and at no Workspace — so a row holding both columns can no longer reach a Workspace that neither owns nor attached it. With the create routes fixed, that is belt and braces rather than the only line of defence.

Notes

  • No user-facing docs change: the Boards page already says an Assignee "can be a Workspace member or an Agent", and the Agent Discovery row already says "in the Workspace" — this makes the code match the prose in both cases.
  • Full suite green (backend 1486, frontend 63, schemas 100, docs contract 24), typecheck and lint clean.

🤖 Generated with Claude Code

willdady and others added 2 commits August 6, 2026 20:52
…thority

Follow-up to #423. The sub-agent fix left three paths deciding for themselves
which Agents a Workspace may use, and they disagreed.

`listScopedByIds` is now the single authority for "which of these references may
this Workspace use?" — an ids-filtered `listScoped`, used by the Chat-turn
sub-agent lookup, the save-time sub-agent check, and Kanban assignee validation.
The Chat-turn lookup no longer hand-rolls the two scope queries, and the save-time
check no longer lists every Agent in the Workspace to test two ids.

Kanban assignee validation accepted only workspace-scoped Agents while the
assignee picker offers every Agent visible in the Workspace, so assigning an
attached Shared Agent — one that can run here — failed with "Invalid agent
assignee".

The Agent Discovery tool set resolved at the wrong scope in both directions:
listAgents, getAgent and the MCP list were workspace-only, so an Operator working
through an Agent could not see a Shared resource attached to their Workspace,
while listModelProviders offered every Shared Provider in the Organization
including unattached ones, which produce an Agent that cannot run. All four now
resolve at the Workspace's scope and tag each row with it.

The read side is also hardened against a row carrying both scope columns — the
XOR is a write-time schema refinement, not a database constraint. `resolveScoped`
now classifies by the scope a row actually carries instead of by elimination,
the org-scoped queries require a null workspace, and `findNonSharedReferences`
counts a reference as Shared only if it is org-scoped and at no Workspace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review follow-ups, all one theme: the Chat turn's four remaining resource
lookups each carried their own copy of the visibility rule, and two create
routes let the request body decide a row's scope.

The Chat-turn Agent, Provider, Skill and MCP lookups now go through the
Scoped-resource read module like the sub-Agent one, deleting three
near-identical copies of the rule and the local attachment helper. Each copy
treated "has an organizationId, has no workspaceId" as the definition of Shared,
so a row carrying both columns resolved in a Workspace that neither owned nor
attached it.

Such a row was reachable: the workspace Provider and MCP create routes spread
the request body into the insert, and their create schemas accept both scope
columns while dropping the XOR refinement. A caller could name another Workspace,
or set organizationId and mint a Shared resource from the Workspace surface —
something only an Org Admin may do. Both routes now take the scope from the
route, as the Agent and Skill routes already did.

Also from review: the Promotion guard reuses the exported Shared-row predicate
rather than keeping a private copy, `listScoped` gains the optional id filter
instead of delegating to a private helper, and three tests that passed against
the old code now assert the Attachment join and the id filter that make the
new behaviour different.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@willdady
willdady merged commit ca421ab into main Aug 6, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant