Skip to content

fix(teams): grant a delegated lead read access to its own team - #1537

Open
yatul wants to merge 3 commits into
nextlevelbuilder:devfrom
yatul:fix/delegated-lead-team-workspace
Open

fix(teams): grant a delegated lead read access to its own team#1537
yatul wants to merge 3 commits into
nextlevelbuilder:devfrom
yatul:fix/delegated-lead-team-workspace

Conversation

@yatul

@yatul yatul commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Fixes #1535. Implements the direction from your triage — give the delegated lead its own team context rather than loosening send_file/message — but through a different mechanism than my first attempt.

Stacked on #1530. The team workspace path is derived from the origin chat, which is exactly what #1530 corrects; based on dev alone the lead would resolve a different directory than its own tasks use. Review only the last commit, or merge #1530 first and this becomes a three-file diff.

The mechanism

My first attempt set RunRequest.TeamWorkspace on the delegated run. That is rejected outright, and deliberately — internal/agent/loop_context.go:233:

if isArtifactDelegation {
    if req.TeamWorkspace != "" ||
        !validateDelegationArtifactWorkspace(req.DelegationID, req.DelegateInputsPath, req.DelegateOutputsPath) {
        return contextSetupResult{}, fmt.Errorf("invalid delegation artifact workspace")
    }

The invariant is right: that field does not only grant read access, it also becomes ToolWorkspace, which would displace the exchange outputs directory. Deployed, every delegation died at setup. This version does not go near the field.

The separation the lead needs already exists in the code. A lead addressed directly resolves its team at loop_context.go:285-321 and gets

ctx = tools.WithToolTeamWorkspace(ctx, wsDir)
ctx = tools.WithToolTeamRoot(ctx, teamRoot)

with no WithToolWorkspace call — a read allowance, not an override. Only the !isArtifactDelegation gate keeps a delegated lead out of it. So the grant is made inside the artifact branch instead, from the same inputs. The guard above stays exactly as strict as it was, and no new field is added to RunRequest.

Both paths are needed

The workspace alone covers only the lead's own chat leaf. The deliverable in the report sits at teams/<id>/system/review-....md — written by a member under a different chat scope. buildAllowedPrefixes adds the team root for reads and not for writes (internal/tools/filesystem.go:366-370), which is precisely the asymmetry this case wants: the lead reads the team's output, per-chat write isolation is untouched.

What is deliberately not granted

ToolTeamID is not set. It switches on the workspace interceptor's write validation, file-change broadcast and task attachment (internal/tools/workspace_interceptor.go:36,114,203) — none of which a delegated run should trigger, and none of which reading needs.

Hermeticity is unchanged: send_file and message still refuse inside an artifact run, and publication still happens through delegation outputs on completion. There is a test pinning that.

The multi-team rule

As requested, ambiguity is not resolved silently: an agent leading more than one active team gets nothing. store.GetTeamForAgent would answer in one call, but it is ORDER BY (lead_agent_id = $1) DESC LIMIT 1 — it would quietly pick a team, which is the choice this must not make on its own.

Tests

property test
the guard is crossed, outputs not displaced TestInjectContext_DelegatedLeadReadsTeamWithoutDisplacingOutputs
an agent leading no team gains nothing TestInjectContext_DelegatedNonLeadGainsNothing
resolution rule: exactly one active led team SingleLedTeamResolves, MultipleLedTeamsResolveNothing, NonLeadResolvesNothing, InactiveTeamIgnored
shared vs isolated path shape SharedTeamCollapsesToRoot
no cross-team access SeparateLeadsGetSeparateWorkspaces
store failure or no store grants nothing StoreFailureResolvesNothing, NoTeamStoreResolvesNothing
team root widens reads, not writes TestTeamRootDoesNotWidenWrites
direct sending still blocked TestSendFileStaysBlockedInDelegationArtifactRun

The first one is the one that matters: my previous attempt had eight green unit tests and still broke every delegation, because not one of them crossed the guard in injectContext. This one drives injectContext itself and asserts both halves — the run is not refused, and ToolWorkspace is still the exchange outputs directory. Reverting the change fails it.

go test ./cmd/... ./internal/tools/ ./internal/agent/... ./internal/gateway/... ./internal/http/... is green.

Verified running, not just under test

Given how the first attempt failed, unit tests alone are not evidence. Built and deployed to a live cluster:

  1. the delegation reaches the lead — the previous invalid delegation artifact workspace is gone;
  2. the lead reads teams/<id>/system/review-....md, the path from the report, in a chat scope that is not its own — reachable only through the team root;
  3. the content comes back verbatim to the caller;
  4. separately, the lead reads a team file and writes it into its own outputs/ — the sanctioned publication route the report said was unreachable — byte count matching the source.

Note on the branch

Force-pushed twice: once to replace the first approach, and once to strip 411 built internal/webui/dist assets that rode in from #1530's commit. See the note on #1530. Nothing else moved; the diff is now the three files this change actually touches, plus #1530's.

@yatul
yatul marked this pull request as draft August 27, 2026 06:27
@yatul

yatul commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Converting to draft — this approach is wrong and I found out by deploying it. Flagging before anyone spends review time on it.

Setting RunRequest.TeamWorkspace on a delegated run is explicitly rejected, and deliberately so — internal/agent/loop_context.go:233:

if isArtifactDelegation {
    if req.TeamWorkspace != "" ||
        !validateDelegationArtifactWorkspace(req.DelegationID, req.DelegateInputsPath, req.DelegateOutputsPath) {
        return contextSetupResult{}, fmt.Errorf("invalid delegation artifact workspace")
    }

and the team-workspace application below it is gated on !isArtifactDelegation twice over. In a live cluster every delegation then died at setup:

WARN delegate.async.failed to=brain error="setup context: inject context: invalid delegation artifact workspace"

The invariant makes sense: req.TeamWorkspace does not just grant read access, it also becomes ToolWorkspace, which would displace the artifact outputs directory and break the exchange. My unit tests passed precisely because they exercised the resolver and the allow-list, never this guard — my fault for not covering the wiring end to end.

The idea still seems right; the mechanism has to change. What the lead needs is the read allowance (WithToolTeamWorkspace, feeding allowedWithTeamWorkspace) without the workspace override — so it cannot travel through req.TeamWorkspace as long as that field carries both meanings.

Two shapes, and since the guard is deliberate I would rather you pick:

  1. A separate field, e.g. RunRequest.TeamReadWorkspace, applied inside the artifact branch as a read allowance only. Leaves the existing invariant and its guard untouched.
  2. Split the meaning of TeamWorkspace — keep rejecting it as a workspace override in artifact runs, but let the artifact branch adopt it for reads. Fewer fields, but weakens a guard that currently says something simple.

I lean towards (1). Happy to push it once you say which.

Sorry for the noise — should have caught this before opening.

@clark-cant clark-cant left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(teams): give a delegated lead its own team workspace

Summary: Resolves #1535 by populating the delegated lead's TeamWorkspace from its own team context, restoring read access to team files that was always available to team members but missing from the agent-link delegation path. Stacked on #1530 (already merged).

Risk level: Low — focused Go changes (+450/-16 across 14 source files) with comprehensive regression tests. The 400+ webui dist files are an unrelated UI rebuild bundled in.

Mandatory gates:

  • Duplicate / prior implementation: clear — no overlapping PR found; #1530 (prerequisite) already merged
  • Project standards: aligned — uses existing patterns (ListTeams walk, allowedWithTeamWorkspace validation, OriginChannelFromCtx helpers)
  • Strategic necessity: clear value — completes the delegated-lead capability chain (dispatch → routing → workspace access)

Findings:

  • Suggestion: The PR bundles ~400 webui dist asset files unrelated to the Go fix. Consider separating UI rebuilds into dedicated commits/PRs for cleaner history and easier bisect.
  • Suggestion: The multi-team rule (agent leading >1 team gets no workspace) is correct defensive behavior. Worth documenting this constraint in team architecture docs.

Verdict: Approve — well-designed narrow fix with proper test coverage (8 test cases covering resolution, isolation, hermeticity). No Critical or Important findings.

Posted by /ck:review-pr at 2026-08-31T20:44:00Z

yatul and others added 3 commits September 1, 2026 15:35
…ry channel

A delegated run is delivered on the internal "delegate" channel while its real
origin is preserved separately — buildAgentLinkRunRequest is explicit about it:

    // preserves the origin's authorization-bearing identity while keeping
    // delegation on its internal delivery channel.
    Channel:          "delegate",
    WorkspaceChannel: req.Channel,
    WorkspaceChatID:  req.ChatID,

The team tools did not consult that origin, so a task created by a lead reached
through delegate was stamped with the delivery channel. Nothing is registered
for it, so every notification about that task — completion, failure, blocker
escalation, ask_user — was dropped:

    unknown channel for outbound message channel=delegate

The delegatee's first answer still arrived, because it travels back as the
delegation result rather than through a channel; everything the lead said after
the delegation closed was lost. One session produced 16 such drops, including a
blocker escalation the user needed to see.

Add OriginChannelFromCtx / OriginChatIDFromCtx next to the existing workspace
scope propagation helpers, and use them where team scoping and notification
routing are decided: task records, list/search scoping, dispatch fallbacks,
event payloads, escalation tasks, ask_user and leader notifications. The
resolution is the identity when no delegation origin is present, so
non-delegated flows are unchanged.

Deliberately not touched: the channel used in authorization decisions
(checkTeamAccess, requireLead, approve/reject lead bypass). Those ask "how did
this call arrive", not "where should the answer go", and widening them is a
separate question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tion

Triage on nextlevelbuilder#1529 named two gates this PR had not met: regression coverage for
delegated lead task completion, and a guard that unrelated origins cannot
receive the notification. The existing tests only covered what create persists.

TestDelegatedLeadCompletionNotifiesOrigin completes a task raised in a delegated
context and asserts the completion event is addressed to the caller's origin.
Reverting team_event_helpers.go to the delivery channel fails it with
"addressed to delegate/system".

TestCompletionNotificationStaysWithinItsOwnOrigin puts two tasks from different
origins on one board, completes one, and asserts no completion notification
carries the other origin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes nextlevelbuilder#1535. Second attempt: the first one set RunRequest.TeamWorkspace on the
delegated run, which loop_context.go rejects outright and deliberately so — that
field also becomes ToolWorkspace, which would displace the exchange outputs
directory. Every delegation then died at setup with "invalid delegation artifact
workspace". This does not go near that field.

The separation the lead needs already exists in the code. A lead addressed
directly resolves its team at loop_context.go:285-321 and gets

    ctx = tools.WithToolTeamWorkspace(ctx, wsDir)
    ctx = tools.WithToolTeamRoot(ctx, teamRoot)

with no WithToolWorkspace call — a read allowance, not an override. Only the
!isArtifactDelegation gate keeps a delegated lead out of it. So the grant is
made inside the artifact branch instead, from the same inputs, and the guard on
req.TeamWorkspace stays exactly as strict as it was.

Both paths are needed. The workspace alone covers only the lead's own chat leaf;
the deliverable in the report sits at teams/<id>/system/review-....md, written by
a member under a different chat scope. buildAllowedPrefixes adds the team root
for reads and not for writes (filesystem.go:366-370), which is precisely the
asymmetry this case wants: the lead reads the team's output, per-chat write
isolation is untouched.

Team ID is deliberately not set. It switches on the workspace interceptor's
write validation, file-change broadcast and task attachment (workspace_
interceptor.go:36,114,203) — none of which a delegated run should trigger, and
none of which reading needs.

Ambiguity is not resolved silently, as triage asked: an agent leading more than
one active team gets nothing. store.GetTeamForAgent would answer in one call but
it is ORDER BY (lead_agent_id = $1) DESC LIMIT 1 and would quietly pick a team.

Hermeticity is unchanged: send_file and message still refuse inside an artifact
run, publication still happens through delegation outputs on completion.

Tests: TestInjectContext_DelegatedLeadReadsTeamWithoutDisplacingOutputs drives
injectContext through the guard that broke the first attempt and pins both
halves — the run is not refused, and ToolWorkspace stays the outputs directory.
Resolution rules, the shared/isolated path shapes, read-not-write on the team
root, and send_file staying blocked are covered alongside.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@yatul

yatul commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Ready for review — reworked and out of draft.

I put this in draft on 27 August because the first approach was wrong: it set RunRequest.TeamWorkspace on the delegated run, which injectContext rejects by design, and every delegation died at setup. I asked which of two shapes you preferred — a new TeamReadWorkspace field, or splitting the meaning of TeamWorkspace.

Neither turned out to be necessary, so I did not wait for the answer. The read-allowance-without-override separation already exists in the code: a lead addressed directly gets WithToolTeamWorkspace + WithToolTeamRoot and no WithToolWorkspace (loop_context.go:285-321). Only the !isArtifactDelegation gate excluded a delegated lead. The grant now happens inside the artifact branch from the same inputs, the guard on req.TeamWorkspace is untouched, and RunRequest gains no field. The PR body has the details.

Two things worth your attention:

The team root is not optional. The workspace alone covers only the lead's own chat leaf, and the deliverable in the report lives under another member's chat scope. buildAllowedPrefixes already adds the root for reads and not for writes, so this grants exactly the asymmetry the case needs.

ToolTeamID is deliberately left unset, since it would switch on the workspace interceptor's write validation and broadcast, which a delegated run has no business triggering.

I also verified it running, not only under test — deployed and drove a real delegation end to end, including the lead reading the exact path from the report and staging a file into its delegation outputs. My previous attempt passed eight unit tests and still broke everything, so I no longer treat green tests as evidence here. There is now a test that crosses the guard those eight missed.

@yatul
yatul marked this pull request as ready for review September 1, 2026 12:15
@yatul yatul changed the title fix(teams): give a delegated lead its own team workspace fix(teams): grant a delegated lead read access to its own team Sep 4, 2026
@yatul

yatul commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Flagging that the approval on this PR is stale, rather than quietly merging on it.

The review was submitted on 31 August against 019cea10. The branch head is now 91376643, and the approval carried across the force-push. Three details in the review text confirm it describes the earlier state and not the current one:

What is on the branch now takes a different route: the read-allowance-without-override separation already exists in loop_context.go:285-321, where a directly addressed lead gets WithToolTeamWorkspace + WithToolTeamRoot and no WithToolWorkspace. Only the !isArtifactDelegation gate excluded a delegated lead, so the grant is made inside the artifact branch from the same inputs. The guard on req.TeamWorkspace is untouched and RunRequest gains no field. Full reasoning is in the PR body, which I rewrote at the same time; I have also corrected the PR title, which still described the withdrawn approach.

Re-review should be cheap — the whole change is three source files plus tests, and the one that matters is TestInjectContext_DelegatedLeadReadsTeamWithoutDisplacingOutputs, which drives injectContext through the guard the first attempt tripped over.

I would rather this went in on a review of the code that is actually here.

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.

A delegated team lead cannot return the team's files: send_file and message are refused, and the artifact outputs dir is unreachable

2 participants