Skip to content

feat: goal blackboard — typed artifact store + role scoping (blackboard 1/3) - #260

Merged
saucam merged 5 commits into
mainfrom
feat/collab-blackboard-store
Jul 27, 2026
Merged

feat: goal blackboard — typed artifact store + role scoping (blackboard 1/3)#260
saucam merged 5 commits into
mainfrom
feat/collab-blackboard-store

Conversation

@saucam

@saucam saucam commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

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.

⚠️ Stacked on #259 (base = fix/rate-limiter-record-destruction), because main does not currently typecheck without it. Retarget to main once #259 merges. Lockstep: highflame-ai/codeoid-ui#37.

What's here

File Role
blackboard/types.ts Six core kinds + scoped extra/<key>, with a rejecting validator
blackboard/store.ts Versioned, identity-stamped, append-only, tenant-scoped
blackboard/service.ts Role-scoped handles, fail-closed, §3 default profile
protocol reads/writes on CollaborationRole + validation

Design 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 spec isn'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 new extra/ 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_id and project_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. A UNIQUE (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. review reads exactly spec+diff — not research (the implementer's reasoning by proxy), and not findings, 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 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, since a caller-chosen slot would let one reviewer overwrite another's findings.

Verification

  • +33 tests; suite 2021 pass / 0 fail; typecheck + biome clean.
  • Mutation-checked: dropping account_id from the lookup fails 2 tests (tenant isolation); making reviewers share a slot fails 2 (panel collapse).

Deliberately not here

  • The MCP tool surface (slice 2) — mountable via stdio/registry per Support non-Claude orchestrators for collaborative sessions (mountable fleet MCP) #245, not the in-process Claude-SDK object, plus wiring onto children and the fence-level enforcement note for non-Claude backends.
  • Mid-goal restart resume (slice 3), which also picks up the child-set resume deferred from P1b.
  • Nothing is wired into a live session yet — no session can reach the blackboard until slice 2.

🤖 Generated with Claude Code

@gemini-code-assist

Copy link
Copy Markdown

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]>
saucam and others added 2 commits July 27, 2026 10:54
…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
saucam force-pushed the feat/collab-blackboard-store branch from abeb540 to 7e1230d Compare July 27, 2026 02:55
@saucam
saucam changed the base branch from fix/rate-limiter-record-destruction to main July 27, 2026 02:55
…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]>
saucam and others added 2 commits July 27, 2026 11:52
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]>
@saucam
saucam merged commit f02f9ef into main Jul 27, 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.

3 participants