feat: goal blackboard — typed artifact store + role scoping (blackboard 1/3) - #260
Merged
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
saucam
added a commit
that referenced
this pull request
Jul 27, 2026
…ackboard 2/3) Folds slice 2 into #260. Slice 1 gave the blackboard a store and an access model; this gives it a surface any backend can mount. Deliberately an HTTP Streamable MCP server, not the in-process Claude-SDK object that backs the fleet tools. That in-process server is exactly why the orchestrator is claude-only in v1 — no other backend can mount it — and the blackboard must not inherit that limit, because the entire premise is a gemini reviewer and an openai reasoner handing work to each other (#245). - blackboard/mcp-http.ts: four tools — index / read / read_all / write. The token IS the scope: mint() binds it to one RoleBlackboard handle (one goal, one role's read/write set). Note what the schemas do NOT contain — no goal id on any tool, and no slot on write. A child cannot address another goal or a peer's findings because there is no parameter with which to try. Scope denials come back as MCP tool errors, not transport errors, so the agent sees WHY and adapts instead of retrying an opaque failure. - mcp/jsonrpc-http.ts: extracted the JSON-RPC + bearer helpers that memory's endpoint had inline. tokenFrom is the authorization boundary for every one of these mounts; two hand-maintained copies is the duplication that drifts, one gets a fix and the other quietly keeps the hole. - server.ts routes /mcp/blackboard and sets the loopback URL — loopback regardless of bind address, so the endpoint can't become reachable off-box just because the daemon binds wide. - session-manager: mints a role-scoped token per child before construction, revokes it BEFORE dropping the session at teardown (a wedged subprocess still holding the URL must not keep reading the goal), and deletes the goal's artifacts after the children are gone so a mid-write child can't recreate rows behind the delete. Attribution is keyed to role-within-goal rather than child session id: a child replaced after a restart is the same contributor. Tests: +15. Mutation testing earned its keep here — a first pass at the auth tests PASSED against an endpoint mutated to fall back to any live binding instead of requiring a matching token, because those tests ran with an empty binding map and so only proved "401 when nothing is minted". They now mint an unrelated valid token first, and all three fail against that mutation. Suite 2036 pass / 0 fail, typecheck + biome + build clean. NOT done, and the reason slice 2 is not yet end-to-end: no provider consumes the mount. It reaches ProviderInit alongside memoryMcp, but acp/codex must still surface it as an MCP server before an agent can call these tools. That is per-backend spawn-args + revoke work; splitting it out keeps this reviewable. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Signed-off-by: Yash Datta <[email protected]>
…rd 1/3) First slice of the blackboard phase (docs/collaborative-session-design.md §4). Storage and the access model; the MCP tool surface and mid-goal resume follow in slices 2 and 3. This is the piece that lets a searcher hand work to an architect without the orchestrator re-serializing it as prose — §4's whole argument for why codeoid can beat a message-relay harness. - blackboard/types.ts: six core kinds (spec/research/adr/task-list/diff/ findings) + a scoped `extra/<key>` hatch. Fixed core because access scoping is meaningless over a free-form key space — you cannot grant "read the spec" if `spec` isn't a real name — and an escape hatch because a closed enum would make every new pack a code change. Unknown kinds REJECT rather than normalize: a typo'd kind quietly becoming a new extra/ slot would look like a successful handoff while the intended reader waits forever. - blackboard/store.ts: versioned, identity-stamped, append-only. Every query scoped on account_id AND project_id — a goal id is not a permission, and without the tenant a leaked or colliding session id would expose another account's artifacts. append() runs in an IMMEDIATE transaction because children on different backends genuinely do write concurrently, and a UNIQUE (goal, kind, slot, version) makes append-only a storage guarantee rather than a convention. - blackboard/service.ts: role-scoped handles, fail-closed both directions, with the §3 default profile. Enforcement lives HERE, not in the tool wrappers: scoping in the MCP layer would leave every other caller — a future frontend, the pipeline engine, a test helper — silently unscoped. With the service owning it, slice 2 cannot expose a bypass because there isn't one. - protocol: reads/writes on CollaborationRole + validation. "Declared empty" is kept distinct from "declared nothing" (the latter falls back to the default profile); collapsing them would silently strip every default. Two properties the tests exist for: Reviewer independence (§6) is a consequence of the read set, not a request. `review` reads exactly spec+diff — not `research` (implementer reasoning by proxy) and not `findings`, not even a peer's. A panel whose members can read each other is an echo, not a panel. Slots stop a panel collapsing. §4 has EACH reviewer write a findings entry; without per-writer slots reviewer #2's write becomes version 2 of the same artifact and a reader taking "latest" sees one opinion, with no error anywhere. The write API takes no slot parameter at all — a caller-chosen slot would let one reviewer overwrite another's findings. Tests: +33. Mutation-checked — dropping account_id from the lookup fails 2 (tenant isolation), making reviewers share a slot fails 2 (panel collapse). Stacked on fix/rate-limiter-record-destruction (#259) so this diff stays pure blackboard; main does not currently typecheck without it. Suite 2021 pass / 0 fail, typecheck + biome clean. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Signed-off-by: Yash Datta <[email protected]>
…ackboard 2/3) Folds slice 2 into #260. Slice 1 gave the blackboard a store and an access model; this gives it a surface any backend can mount. Deliberately an HTTP Streamable MCP server, not the in-process Claude-SDK object that backs the fleet tools. That in-process server is exactly why the orchestrator is claude-only in v1 — no other backend can mount it — and the blackboard must not inherit that limit, because the entire premise is a gemini reviewer and an openai reasoner handing work to each other (#245). - blackboard/mcp-http.ts: four tools — index / read / read_all / write. The token IS the scope: mint() binds it to one RoleBlackboard handle (one goal, one role's read/write set). Note what the schemas do NOT contain — no goal id on any tool, and no slot on write. A child cannot address another goal or a peer's findings because there is no parameter with which to try. Scope denials come back as MCP tool errors, not transport errors, so the agent sees WHY and adapts instead of retrying an opaque failure. - mcp/jsonrpc-http.ts: extracted the JSON-RPC + bearer helpers that memory's endpoint had inline. tokenFrom is the authorization boundary for every one of these mounts; two hand-maintained copies is the duplication that drifts, one gets a fix and the other quietly keeps the hole. - server.ts routes /mcp/blackboard and sets the loopback URL — loopback regardless of bind address, so the endpoint can't become reachable off-box just because the daemon binds wide. - session-manager: mints a role-scoped token per child before construction, revokes it BEFORE dropping the session at teardown (a wedged subprocess still holding the URL must not keep reading the goal), and deletes the goal's artifacts after the children are gone so a mid-write child can't recreate rows behind the delete. Attribution is keyed to role-within-goal rather than child session id: a child replaced after a restart is the same contributor. Tests: +15. Mutation testing earned its keep here — a first pass at the auth tests PASSED against an endpoint mutated to fall back to any live binding instead of requiring a matching token, because those tests ran with an empty binding map and so only proved "401 when nothing is minted". They now mint an unrelated valid token first, and all three fail against that mutation. Suite 2036 pass / 0 fail, typecheck + biome + build clean. NOT done, and the reason slice 2 is not yet end-to-end: no provider consumes the mount. It reaches ProviderInit alongside memoryMcp, but acp/codex must still surface it as an MCP server before an agent can call these tools. That is per-backend spawn-args + revoke work; splitting it out keeps this reviewable. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Signed-off-by: Yash Datta <[email protected]>
saucam
force-pushed
the
feat/collab-blackboard-store
branch
from
July 27, 2026 02:55
abeb540 to
7e1230d
Compare
akashjavelin
approved these changes
Jul 27, 2026
…d-to-end
Completes the blackboard's usable path. Until now the endpoint existed and
minted scoped tokens, but no provider surfaced it, so no agent could call the
tools. A role-child can now actually read its inputs and publish its output.
- claude: mounted as `{type:"http"}` in the SDK's mcpServers. Deliberately the
HTTP mount and not an in-process createSdkMcpServer object — claude gets no
privileged path here, because the same surface has to work on gemini and
codex (#245). That in-process shortcut is exactly why the ORCHESTRATOR is
claude-only today, and the blackboard must not repeat it.
- acp (gemini-cli): pushed onto session/new's MCP server list.
- codex: `-c mcp_servers.codeoid_blackboard.{url,bearer_token_env_var}` with
the token passed via env, not argv — process argv is world-readable on Linux
and these tokens ARE the scope.
- registry forwards the mount to all three factories.
None of the providers mint or revoke, unlike the memory mount they sit next
to. The SessionManager owns this token because the scope it encodes belongs to
the collaboration, not to a backing session that resetToNewSession may
recreate — re-minting per backing session would leave a second live token that
teardown cannot revoke.
tool-safety: blackboard READS auto-approve (both `mcp__server__tool` and
`server__tool` conventions); `blackboard_write` deliberately does NOT. The
role's write scope decides whether a write is PERMITTED; auto-approval decides
whether it happens without anyone looking, and a write publishes into shared
state peers act on. Look-alike server names and unknown tools fall through to
prompting, matching the memory rule.
childBrief now tells the child which tools exist and what it may read/write —
an agent that isn't told about a tool never calls it, so the mount alone would
have demoed as "nothing happens". The brief resolves scope through the SAME
resolveRoleIo the fence uses (extracted for this): computing them separately
would eventually tell an agent it can read something the fence then refuses,
which is the worst failure mode to hand a model.
Tests: +4 on tool-safety classification. Suite 2040 pass / 0 fail, typecheck +
biome + build clean.
Note on CI: retargeting this PR to main did NOT trigger the workflow — it fires
on opened/synchronize/reopened, and a base change is `edited`. This push is a
synchronize, so daemon/web should finally run against the right base.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Signed-off-by: Yash Datta <[email protected]>
abhijitjavelin
approved these changes
Jul 27, 2026
Everything below passed CI and 2040 unit tests while the feature could not
complete a single handoff. Found by actually running it.
1. DEADLOCK — children spawned in interactive mode. blackboard_write needs
approval, and NOBODY ATTACHES TO A CHILD, so the first handoff parked at
waiting_approval with zero clients and stayed there. Observed live: child
status `waiting approval`, clients: 0, zero artifacts written.
Children now spawn autonomous with a bounded budget — the posture dispatch
already gives its workers, for the same reason. The owner's approval happens
once at the R3 dispatch gate, not per tool call. sendToSession also re-arms
the budget on every dispatch: a child is long-lived across the goal (unlike
a disposable spawn worker), so one initial budget is spent down across
successive dispatches and the child would wedge partway through with nobody
to approve.
2. THE ORCHESTRATOR HAD NO BLACKBOARD AT ALL. Only children got a mount. §4
has the orchestrator holding the index of artifact states and §7 has it
reading every reviewer's findings to synthesize — both impossible. Children
published artifacts the orchestrator could never see, so the coordination
loop never closed. DEFAULT_ROLE_IO.orchestrator was defined and unreachable.
It now gets its own orchestrator-scoped mount. This required making the
mount lazily resolved (a getter, not a captured value): the orchestrator's
goal id IS its session id, so its mount cannot exist until after the Session
is constructed.
3. TOKEN LEAK — destroying a child directly skipped revocation. The token lives
in the endpoint's binding map, not on the Session, so dropping the session
left a credential that still authorized reads and writes against the goal.
Revocation is now centralized in #revokeBlackboardToken and called from
every path that removes a child, including the dispatch destroyWorker path
(a child is role:"worker", so it can reach it).
Live verification, end to end, against a real claude backend in an isolated
XDG_CONFIG_HOME sandbox:
- child called blackboard_index then blackboard_write; artifact landed
(research v1, author_role=search, author_sub=agent:<goal>:search#1) and the
child returned to idle instead of wedging;
- the fence refused out-of-scope reads in the transcript ("may not read …")
with no artifacts created;
- the orchestrator called blackboard_index and correctly reported
"research v1, 365 bytes, author search" — the loop closing.
Tests: +4, all mutation-checked (reverting each fix fails its test). One
honest gap: no test distinguishes "mount minted and registered" from "mount
attached to the Session" — asserting that needs a public session accessor I
wasn't willing to add for a test alone. The teardown test covers registration.
Suite 2044 pass / 0 fail, typecheck + biome + build clean.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Signed-off-by: Yash Datta <[email protected]>
Until now a collaboration could only be created from the CLI — the web create dialog sent name/workdir/providerId/pack/packRole and had no notion of `collaboration` at all, so the browser could not start one. This is §9's front door. Extends the EXISTING dialog rather than adding a panel, because a collaboration is a session plus a goal and role bindings, not a separate object. The modal already served two flows (session, pipeline); this makes it three, with a Single agent ↔ Collaborative toggle. Pipeline stays externally-entered from /pipeline, so it isn't offered in the toggle. Role rows: name, backend, model, ×count, "can write". Design choices that mirror what the daemon enforces, so the UI can't offer something that will be rejected: - The orchestrator row is present, non-removable and claude-pinned. The daemon requires exactly one orchestrator, the session being created IS it, and claude is the only backend that mounts the fleet MCP server in v1 (#245). A removable or re-pointable row would just produce a server-side error. - "can write" defaults OFF and isn't offered for the orchestrator. Read-only is the §3 default and the daemon backs it with an identity holding no write scope — the checkbox's tooltip says exactly that, since "can write" alone reads like a soft preference. - No top-level providerId is ever sent. A collaborative session IS its orchestrator, so the daemon derives the backend from that role and REJECTS a conflicting explicit value — sending one would fail every create. - Optional fields are omitted rather than sent empty: count:1 and write:false are daemon defaults, and an empty-string model would fail provider-aware validation. Local pre-flight (goal present, names non-empty, no duplicates, an orchestrator exists) only spares a round-trip; the daemon re-validates everything and its error is surfaced verbatim, because it names the precise rule broken. The unmet condition is shown under the form — a disabled Create with no explanation is the worst version of this. Tests: +8 web tests, mutation-checked — sending a top-level providerId fails one, and sending count at its default fails another. Web 319 pass, daemon 2044 pass / 0 fail, typecheck + biome + both builds clean. Still not rendered: children appear as separate sessions with no grouping, no read-only badge, and no artifact view, though `collaborationRole` is already on the wire. That is the other half of §9 and wants its own change. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Signed-off-by: Yash Datta <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First slice of the blackboard phase (design §4). Storage + the access model; the MCP tool surface and mid-goal resume follow in slices 2 and 3.
This is the piece that lets a searcher hand work to an architect without the orchestrator re-serializing it as prose — §4's whole argument for why codeoid can beat a message-relay harness.
What's here
blackboard/types.tsextra/<key>, with a rejecting validatorblackboard/store.tsblackboard/service.tsreads/writesonCollaborationRole+ validationDesign decisions worth your eye
Fixed core + escape hatch. Access scoping is meaningless over a free-form key space — you can't grant "read the spec" if
specisn't a real name — but a closed enum makes every new pack a code change. Unknown kinds reject rather than normalize: a typo'd kind quietly becoming a newextra/slot would look like a successful handoff while the intended reader waits forever.A goal id is not a permission. Every query is scoped on
account_idandproject_id. Without the tenant, one leaked or colliding session id exposes another account's artifacts.append()is an IMMEDIATE transaction. Children on different backends genuinely write concurrently, so read-max-then-insert is a real race, not a theoretical one. AUNIQUE (goal, kind, slot, version)makes append-only a storage guarantee rather than a convention.Enforcement lives in the service, not the tool wrappers. If scoping lived in the MCP layer, every other caller — a future frontend, the pipeline engine, a test helper — would silently get unscoped access. With the service owning it, slice 2 cannot expose a bypass, because there isn't one to expose. That's the design's standing "an unenforced field is false security" applied structurally rather than repeated.
"Declared empty" ≠ "declared nothing." The latter falls back to the §3 default profile; the former grants nothing. Collapsing them would have silently stripped every default.
The two properties the tests exist for
Reviewer independence (§6) is a consequence of the read set, not a request.
reviewreads exactlyspec+diff— notresearch(the implementer's reasoning by proxy), and notfindings, not even a peer reviewer's. A panel whose members can read each other is an echo, not a panel.Slots stop a panel collapsing. §4 has each reviewer write a
findingsentry. Without per-writer slots, reviewer #2's write becomes version 2 of the same artifact and a reader taking "latest" sees one opinion — with no error anywhere. The write API takes no slot parameter at all, since a caller-chosen slot would let one reviewer overwrite another's findings.Verification
account_idfrom the lookup fails 2 tests (tenant isolation); making reviewers share a slot fails 2 (panel collapse).Deliberately not here
🤖 Generated with Claude Code