CB-619: a spawn asking for a role its profile has no slot for is silently demoted to worker, and the roster still says otherwise #123

Closed
opened 2026-08-22 12:36:32 +02:00 by ltms · 1 comment
Owner

Found on 2026-08-22 while running the CB-617 acceptance test.

What happened

POST /members {"role":"architect","profile":"sonnet","cwd":"/Users/dai.ha/LTMS/claude-bridge"}
-> 200, state ready

The member started, mounted the bridge, and read the architect charter and the architect
agent definition. Then it answered:

1) I am a worker (bridge_whoami confirms this, not architect).
2) A worker must never merge its own work or spawn/send to other members.
3) I must call bridge_reply to end this turn.

The daemon log says why, at INFO:

member slot: no free architect slot for profile=sonnet; session remains a worker

fleet.architects lists only opus and sol. sonnet is not in that pool, so no slot
could be bound and the session stayed a worker.

Why this is a defect and not just my config mistake

Three sources of truth disagree about the same live member, and nothing warns:

Source Says
the spawn request role: architect
GET /members "role": "architect", "charterSource": "fleet.charters.architect"
the charter and agent file the member actually read architect
bridge_whoami worker

bridge_whoami is the one the member is told to trust, and the canonical CLAUDE.md block
says so plainly: "Don't infer what you can ask." So the member correctly ignored its own
charter and acted as a worker. The lead, reading GET /members, sees an architect. The lead
then sends it a design brief and gets worker behaviour, or sends it something an architect
may do and the authorization gate refuses it.

The demotion is also the quiet direction. The block notes the two mistakes are not symmetric:
a member acting above its role is refused loudly, a member acting below it just fails to do
the job. This turns an explicit operator request into the quiet failure.

What the fix has to decide

Not designed yet. The choice is between two defensible answers, and it is a judgement call:

  1. Refuse the spawn. role: architect on a profile that is in no architect pool is a
    config error, so return 400 naming the role, the profile, and the pools that do carry it.
    Loud and self-correcting. The cost is that an operator can no longer spawn a one-off
    architect on a profile that is not in the pool, which is exactly what I was doing here.
  2. Bind the slot anyway and treat fleet.architects as the automatic placement pool
    rather than a whitelist of who may hold the role. Keeps the explicit spawn working. The
    cost is that the pool no longer bounds how many architects exist.

Either way, two things must change regardless of which is chosen:

  • The log line is INFO and reads like routine bookkeeping. A silent role downgrade is at
    least WARN, and it should name what the operator asked for.
  • GET /members must not report a role the member itself does not hold. Report the bound
    role, or report both the requested and the effective one — never only the requested one.

Acceptance

  • A spawn asking for a role with no matching slot either fails with a message naming the role,
    the profile and the pools, or binds and reports the role it bound.
  • GET /members and bridge_whoami never disagree about a live member's role.
  • The test drives a real spawn through the launcher, then reads back both GET /members and
    bridge_whoami for the same session. Asserting on MemberRegistry.bind alone walks around
    the gate — see #113.

Related

Found while verifying #120 (CB-617). Not caused by it: the binding rule predates that work.
fleet.charters and fleet.architects are separate keys, so a charter can be configured for
a role a profile can never hold, which is how the two got out of step here.

Found on 2026-08-22 while running the CB-617 acceptance test. ## What happened ``` POST /members {"role":"architect","profile":"sonnet","cwd":"/Users/dai.ha/LTMS/claude-bridge"} -> 200, state ready ``` The member started, mounted the bridge, and read the architect charter and the architect agent definition. Then it answered: ``` 1) I am a worker (bridge_whoami confirms this, not architect). 2) A worker must never merge its own work or spawn/send to other members. 3) I must call bridge_reply to end this turn. ``` The daemon log says why, at INFO: ``` member slot: no free architect slot for profile=sonnet; session remains a worker ``` `fleet.architects` lists only `opus` and `sol`. `sonnet` is not in that pool, so no slot could be bound and the session stayed a worker. ## Why this is a defect and not just my config mistake Three sources of truth disagree about the same live member, and nothing warns: | Source | Says | |---|---| | the spawn request | `role: architect` | | `GET /members` | `"role": "architect"`, `"charterSource": "fleet.charters.architect"` | | the charter and agent file the member actually read | architect | | `bridge_whoami` | **worker** | `bridge_whoami` is the one the member is told to trust, and the canonical `CLAUDE.md` block says so plainly: *"Don't infer what you can ask."* So the member correctly ignored its own charter and acted as a worker. The lead, reading `GET /members`, sees an architect. The lead then sends it a design brief and gets worker behaviour, or sends it something an architect may do and the authorization gate refuses it. The demotion is also the quiet direction. The block notes the two mistakes are not symmetric: a member acting above its role is refused loudly, a member acting below it just fails to do the job. This turns an explicit operator request into the quiet failure. ## What the fix has to decide Not designed yet. The choice is between two defensible answers, and it is a judgement call: 1. **Refuse the spawn.** `role: architect` on a profile that is in no architect pool is a config error, so return 400 naming the role, the profile, and the pools that do carry it. Loud and self-correcting. The cost is that an operator can no longer spawn a one-off architect on a profile that is not in the pool, which is exactly what I was doing here. 2. **Bind the slot anyway** and treat `fleet.architects` as the *automatic placement* pool rather than a whitelist of who may hold the role. Keeps the explicit spawn working. The cost is that the pool no longer bounds how many architects exist. Either way, two things must change regardless of which is chosen: - The log line is INFO and reads like routine bookkeeping. A silent role downgrade is at least WARN, and it should name what the operator asked for. - `GET /members` must not report a role the member itself does not hold. Report the bound role, or report both the requested and the effective one — never only the requested one. ## Acceptance - A spawn asking for a role with no matching slot either fails with a message naming the role, the profile and the pools, or binds and reports the role it bound. - `GET /members` and `bridge_whoami` never disagree about a live member's role. - The test drives a real spawn through the launcher, then reads back both `GET /members` and `bridge_whoami` for the same session. Asserting on `MemberRegistry.bind` alone walks around the gate — see #113. ## Related Found while verifying #120 (CB-617). Not caused by it: the binding rule predates that work. `fleet.charters` and `fleet.architects` are separate keys, so a charter can be configured for a role a profile can never hold, which is how the two got out of step here.
Author
Owner

Fixed by PR #223, merged to main.

I picked option 1, refuse the spawn, over binding-anyway. The reason, now recorded in the code: an architect's identity is the slot it was bound to, so binding a role with no slot means inventing an identity out of nothing. The operator's fix is one config line, and the refusal names it.

The refusal message:

no architect slot for profile '<profile>' — an architect's identity IS the slot it is bound to,
so there is nothing to bind this session's identity to. fleet.architects carries profiles:
<list, or "(none configured)">; add profile '<profile>' there, or spawn architect on one of
those profiles instead

The other two required changes landed with it: the demotion log is now WARN and names the terminal, and SessionManager records the role acquired() actually returns instead of the one that was requested — which makes GET /members and fleet_list honest without touching either view.

What I checked myself:

  • mvn clean install on the branch merged with current main: 1089 tests, 0 failures, 0 errors, BUILD SUCCESS.
  • Both session-recording sites, not just one. SessionManager builds a MemberSession in two places — the worktree path and the no-worktree path — and both now record the bound role. A fix applied to only one of them would have looked complete and left half the defect in place.
  • The refusal is scoped to ARCHITECT with an explicit, non-blank profile. Dev and reviewer spawns are untouched, so no spawn that used to work is now refused. This was the risk worth checking, since the daemon is live.

A reviewer on a separate backend traced the same paths independently and confirmed the scope, the roster fix, and that the refusal leaves no pane, tab, worktree or registry entry behind.

One thing this does not fix, now filed as #226. If a slot exists but is fully bound by the time the bind happens, the member has already been launched on the architect charter, and is then recorded as dev. So it reads "you are an architect" and fleet_whoami tells it "worker". That window is narrow, it is loudly logged, and the damage is bounded — the member's architect-only calls are refused by the authorization gate, which is the recoverable direction. A pre-check cannot close it; #226 carries the reserve-then-bind fix.

Both the PR author and the reviewer reported this residual independently, before I asked about it.

Fixed by PR #223, merged to `main`. I picked **option 1, refuse the spawn**, over binding-anyway. The reason, now recorded in the code: an architect's identity *is* the slot it was bound to, so binding a role with no slot means inventing an identity out of nothing. The operator's fix is one config line, and the refusal names it. The refusal message: ``` no architect slot for profile '<profile>' — an architect's identity IS the slot it is bound to, so there is nothing to bind this session's identity to. fleet.architects carries profiles: <list, or "(none configured)">; add profile '<profile>' there, or spawn architect on one of those profiles instead ``` The other two required changes landed with it: the demotion log is now WARN and names the terminal, and `SessionManager` records the role `acquired()` actually returns instead of the one that was requested — which makes `GET /members` and `fleet_list` honest without touching either view. **What I checked myself:** - `mvn clean install` on the branch merged with current `main`: **1089 tests, 0 failures, 0 errors, BUILD SUCCESS**. - Both session-recording sites, not just one. `SessionManager` builds a `MemberSession` in two places — the worktree path and the no-worktree path — and both now record the bound role. A fix applied to only one of them would have looked complete and left half the defect in place. - The refusal is scoped to `ARCHITECT` with an explicit, non-blank profile. Dev and reviewer spawns are untouched, so no spawn that used to work is now refused. This was the risk worth checking, since the daemon is live. A reviewer on a separate backend traced the same paths independently and confirmed the scope, the roster fix, and that the refusal leaves no pane, tab, worktree or registry entry behind. **One thing this does not fix, now filed as #226.** If a slot *exists* but is fully bound by the time the bind happens, the member has already been launched on the **architect charter**, and is then recorded as `dev`. So it reads "you are an architect" and `fleet_whoami` tells it "worker". That window is narrow, it is loudly logged, and the damage is bounded — the member's architect-only calls are refused by the authorization gate, which is the recoverable direction. A pre-check cannot close it; #226 carries the reserve-then-bind fix. Both the PR author and the reviewer reported this residual independently, before I asked about it.
ltms closed this issue 2026-09-01 10:42:42 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: fleet/fleetd#123