Skip to content

feat: name what a run hands to a person, and gate it as one resource - #178

Merged
mroops0111 merged 1 commit into
masterfrom
feat/handoff-capabilities
Sep 15, 2026
Merged

mroops0111 merged 1 commit into
masterfrom
feat/handoff-capabilities

Conversation

@mroops0111

@mroops0111 mroops0111 commented Sep 14, 2026

Copy link
Copy Markdown
Owner

A proposal and a clarification were never one type, yet every layer already treated them as one. This names that concept and hangs the permission model off it.

Handoff

A handoff is a point a run reached that only a person can settle. The concept was already there in everything except the type system.

  • Same Directory: both live under domain/hitl/
  • Same Service: HITLService owns all five verbs
  • Same Queue: the Inbox lists them together
  • Same Owner Fields: owner, ownerDisplayName, and ownerKind, declared twice
  • Same Filter: ProposalFilter and ClarificationFilter were byte-identical, matching comments included

Three things the schema now pins down, because the word carries baggage.

  • Direction: always from a run to a person, never the reverse
  • Not Agent To Agent: which is what the word means in most agent frameworks
  • Parking Is Irrelevant: a proposal ends its run and a resuming clarification suspends one, so what makes both one kind is who must act next

Capability Catalog

Ten ids become eight, and the verbs close to read, write, manage, and run.

id owner maintainer guest covers
handoff.read yes yes no the Inbox queue
handoff.write yes yes no apply, reject, answer, skip, defer
skill.run yes manifest manifest a skill run, a batch, a document
workspace.write yes no no PRODUCT.md, sources, mcp servers, delete
workspace.manage yes no no members and roles, and everybody's work
history.write yes no no restore, tags
server.write admin scaffold a workspace
server.manage admin users and invites

Two rules hold the set together.

  • Verbs Describe Mutation, Not Action: a verb says only whether a call changes the resource, so there is no restore or decide or approve that every new feature would want a sibling of
  • Manage Means Reach: the part of a resource that touches other people, a wider scope rather than a stronger write, which is why workspace.manage covers both member governance and seeing another member's unsettled work

Anything every member may read has no id at all. The membership gate upstream already answered it.

Removals

Three ids went because nothing was deciding anything with them.

  • workspace.read: the check was effectiveRole !== null, identical to the membership condition it always sat behind, so it passed everyone it was shown
  • proposal.read and clarification.read: never mounted on any route, so only the Studio nav read them, which made them a front-end convention rather than a permission

Four more changed shape.

  • proposal.write and clarification.write: merge into handoff.write
  • server.admin: becomes server.manage, since admin is a noun
  • workspace.create: becomes server.write, and its check now reads serverRole directly rather than relying on a deliberately absent member

Closed Holes

Four gaps close with the rename.

  • /batch Had No Gate: any member, guest included, could start, stop, resume, or archive a batch, and it now takes skill.run against the per-unit skill the ontology declares
  • GET /agui/runs/:runId Replayed Anything: the visibility check was mounted on the /runs router and /agui is a separate prefix, so knowing a run id was enough to read somebody else's Ask transcript
  • Single-Handoff GET Had No Owner Check: which made the list filter queue hygiene rather than a boundary, and both routes now answer 404 so the reply never confirms that another person's work exists
  • /coverage Leaked Queue Ids: every stage carried its pending ids to every member, so a reader who could not open the queue could still count it

Behaviour Changes

Three changes are visible to existing users and worth a look before merging.

  • Maintainers See Autonomous Handoffs: includeServiceOwned: isOwner had this backwards, and an autonomous handoff belongs to the workspace rather than to any one person
  • Settled Handoffs Follow Privacy, Not Status: the old rule made everything non-pending public, and now an applied proposal stays public as the graph's provenance while rejected, skipped, and answered stay with whoever raised them
  • Guests Reach Build And History: both read-only, with every Run button still taking skill.run and restore still taking history.write

The second one is decided in handoffVisibleTo, one function serving all four repositories. It avoids the incoherence of a maintainer being unable to see a proposal before the decision yet able to read it in full afterwards.

Documents

Rebased onto #179, and the new surface lands on the same rule without a new capability.

  • Reading Asks Nothing: a document is a reading of the graph, so GET /views and the read route stay open, and the sidebar row shows for every role
  • Writing Is A Skill Run: POST /views takes skill.run against the form's generator skill
  • The Default Is Already Right: doc:reference and doc:tutorial declare no allowedRoles and inherit owner and maintainer, so a third generator needs no permission change

The gate resolves the form to its skill before it decides, so an unknown kind or form is answered as a bad request rather than as a refusal. Observed behaviour on a write, covered by tests.

role result
guest 403 from the gate, before anything is projected
maintainer past the gate, and the subject is what answers next
owner past the gate, same as maintainer

Studio disables the Write and Regenerate controls behind the same question, asked of the generator skills rather than of the surface. Hidden skills count there, since a generator is hidden because nobody picks it off the Actions list, not because nobody runs it.

Studio

One helper, useSurfaceReach, now answers which surfaces a viewer may open. The sidebar hiding a surface while the command palette still reached it was the same drift written twice.

Keyboard chords collapse to the surfaces the sidebar keeps a row for, which is what lets each be its own first letter.

  • Bindings: G G, G A, G B, G I, G D, G H, and G S, plus G W for workspace details
  • Why Not Wider: Ask, Actions, and Activity share a first letter, as do Build and Batch
  • Pre-Existing Bugs Fixed: several printed shortcuts had no handler at all, G B was labelled Build while jumping to Activity, and chordSecondKey carried two case 'b' arms where the second was dead

The standalone Proposals and Clarification surfaces are gone. They had no sidebar entry and the Inbox already renders the same detail components.

  • Detail Components Moved: now under components/handoff/, which is also where the Show All control and the origin badge live
  • Origin Badge Is Required Now: a maintainer sees system-raised items for the first time, so a row has to say whose it is
  • Owner-Only Controls Disabled, Not Hidden: so a reader can still tell the feature exists

Verification

Local and CI both green.

  • Gates: pnpm lint and pnpm typecheck clean
  • Suites: server 620, core 517, and studio 288, all passing
  • New Coverage: handoffVisibleTo, the four holes above, the maintainer and service-owned visibility rule, and both sides of the document write gate

Out Of Scope

Three follow-ups are deliberately left out.

  • Studio's ticket: means only a clarification today and should become handoff, across 76 call sites
  • SkillArtifactKind's clarify: a pre-existing inconsistency with clarification that HandoffKind makes more visible
  • Category-Derived allowedRoles: so a read-only ask skill never has to remember to open itself to guests, which needs a transform because Zod's .default() cannot see a sibling field

🤖 Generated with Claude Code

https://claude.ai/code/session_01NGKCWu7ACm78YVGmPAywnx

A proposal and a clarification were never one thing in the type system, yet
every layer treated them as one: the same `domain/hitl/` directory, the same
service, the same queue, the same owner fields, the same filter copied twice.
This names that concept `Handoff`, a point a run reached that only a person can
settle, and hangs the permission model off it.

The capability catalog goes from ten ids to eight, and the verb set closes to
read, write, manage, and run. A verb now says only whether a call changes the
resource, never what the call is called, so `history.write` covers restoring
rather than a `restore` verb that every new feature would want a sibling of.
`manage` means the part of a resource that reaches other people, which is a
wider scope rather than a stronger write, so `workspace.manage` covers members
and roles and also whether one person sees everybody else's work.

Three ids went because nothing was deciding anything with them. The check
behind `workspace.read` was `effectiveRole !== null`, identical to the
membership gate it always sat behind, so it passed everyone it was shown.
`proposal.read` and `clarification.read` were never mounted on a route at all,
which left the Studio nav enforcing them on its own.

Four holes close with it:

  - `/batch` had no permission gate, so any member could start, stop, resume,
    or archive a batch. It now takes `skill.run` against the per-unit skill
    the ontology declares, since a batch is that skill run many times over.
  - `GET /agui/runs/:runId` replayed any run's whole event log. The visibility
    check lived on the `/runs` router, and `/agui` is a different prefix.
  - `GET /proposals/:id` and `GET /clarifications/:id` had no owner check, so
    the list filter was queue hygiene rather than a boundary.
  - `/coverage` carried each stage's pending ids to everybody, so a reader who
    could not open the queue could still count it.

Two behaviours change on purpose. A maintainer now sees what the reactor handed
over, which `includeServiceOwned: isOwner` had backwards, and which is the whole
point of the role. And a settled handoff follows whether it is still private
rather than whether it is still pending, so an applied proposal stays public as
the graph's provenance while a rejected one stays with whoever raised it.

The Documents surface lands on the same rule. Reading a document asks nothing,
since a document is a reading of the graph and every member reads the graph.
Writing one is the form's generator skill run on a projected subject, so
`POST /views` takes `skill.run` against that skill, and the two generators
inherit the owner-and-maintainer default from their manifests without declaring
anything. The gate resolves the form before it decides, so an unknown kind or
form is still answered as a bad request rather than as a refusal.

Studio gets one answer to which surfaces a viewer may open, because the sidebar
hiding a surface while the command palette still reached it was the same drift
twice. Chords collapse to the six surfaces the sidebar keeps a row for, which
is what lets every one of them be its own first letter. Several printed
shortcuts had no handler behind them, and `G B` went somewhere other than its
label said.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGKCWu7ACm78YVGmPAywnx
@mroops0111
mroops0111 force-pushed the feat/handoff-capabilities branch from ea83674 to d1b921f Compare September 15, 2026 06:50
@mroops0111
mroops0111 merged commit d024fe2 into master Sep 15, 2026
8 checks passed
@mroops0111
mroops0111 deleted the feat/handoff-capabilities branch September 15, 2026 07:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant