1
CB 548 Lead Quorum Design
Dai Ha edited this page 2026-09-10 18:44:02 +07:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

CB-548 follow-up: lead quorum — a deterministic decision procedure

Status: design proposal, not accepted, not built. This page answers one question from the operator: "a solid/deterministic way to have leaders as main orchestrator/decision maker — I want full autonomous team with leader tries to convey architect and make decision base on quorum."

This page is deliberately not a numbered chapter. Chapters 1–16 describe things that are built. This page proposes something that is not built yet. If the proposal is accepted and shipped, its as-built form goes to chapter 9 (Implementation) and chapter 11 (Features), and this page becomes a design record.


1. What I checked and what I did not

I read this in the code (all paths under fleetd/src/main/java/dev/ltms/fleet/):

  • auth/MemberRegistry.java — the architect slot registry. reserve / bind(SlotReservation, terminal) / release / released exist and enforce one terminal per slot, one slot per terminal.
  • session/SessionManager.java — acquire(...) calls memberLifecycle.reserve(...) before spawn and bind(reservation, terminal) after spawn (the private acquired(...) helper). So the older memory note "MemberRegistry.bind is never called" is out of date: reserve-then-bind is wired.
  • auth/MemberLifecycle.java — the SlotReservation record and the lifecycle contract.
  • auth/Authz.java — the role table. SPAWN, STOP, DRAIN are primary-only. SEND is primary or architect. REPLY and ASK need ownsSession(targetSession). READ is open to all roles.
  • mcp/ConnectionIdentity.java — identity comes from the loopback peer PID and herdr's PID→pane map. A caller cannot claim another identity. Off-host callers resolve to "not a member".
  • mcp/FleetMcp.java — the tool surface (fleet_spawn/send/reply/ask/poll/ack/list/status/stop/whoami), the fleet_ask ~55s cap comment, and the "worker finished without a structured fleet_reply — transcript tail follows" scrape fallback.
  • inject/Injector.java — delivery is status-gated: a message lands only when the target is injectable (idle / blocked), at most one message per turn, FIFO per target.
  • member/HerdrPeerLauncher.java (spawnInternal) — the role charter comes from the live config (fleet.charters() read at each spawn), composed with REPLY_CHARTER, fingerprinted by CharterReceipt.
  • fleetd/fleetd.yaml — two live architect slots (fleet.architects.opus, fleet.architects.sol) and a live fleet.charters.architect text that encodes the 2026-08-14 decision (own position first, at most two exchange rounds, then the lead decides).
  • Issue #16 (CB-548) — the full body, including the deferred list: "Daemon-enforced quorum, voting, or adjudication."
  • wiki/ numbering and _Sidebar.md, to place this page.

I did not check these myself:

  • I did not spawn a live architect. So I did not watch a real two-architect exchange end to end.
  • I did not check whether fleet_ask works from an architect pane. Authz would permit it (ownsSession), but the FleetMcp handler's error text says "for workers only" and I did not trace whether an architect connection passes that resolve step.
  • I did not measure the exact ticket TTL constant in code; the ~10-minute figure and the ~55s fleet_ask window come from the task constraints and from a comment in FleetMcp.java line 73.
  • I did not check the CB-637 lead-to-lead path against this design; the design below stays inside one gateway, as #16 requires.

Correction, re-measured 2026-09-10. An earlier draft of this page reported a concrete defect here: that the live fleet.charters.architect text told the architect to call bridge_send, a tool CB-634 renamed away. That does not reproduce. Measured today in fleetd/fleetd.yaml: bridge_send appears 0 times, any bridge_* tool name appears 0 times, and fleet_send appears twice. Either it was fixed after the draft, or the draft measured wrongly. Re-measure with grep -c 'bridge_[a-z]' fleetd/fleetd.yaml — a 0 means this correction still holds and the example below is the live version of the argument; anything above 0 means a stale name is back.

What is still true, and it is the argument of this page. Nothing checks charter text against the tool surface the server actually registers. FleetConfig.validateCharters() checks two things only: that the key is a role wire name, and that the text is not blank. It never reads the text. The repo already has this exact check for a document — McpContractDocTest fails if docs/MCP-Contract.md names a fleet_* tool the server does not register — and there is no equivalent for fleet.charters. So a renamed tool can still go stale inside a charter with no test going red. That is the point in one line: the current quorum design lives in prose, and nothing tests prose. The fix is cheap and is not part of this proposal: point the McpContractDocTest idiom at charter text as well.


2. The requirement, and the recorded decision that points the other way

The operator wants three things:

  1. The lead is the single orchestrator and decision maker.
  2. The lead convenes architects before a decision.
  3. The lead decides based on quorum, and the whole thing must be deterministic.

The recorded decision of 2026-08-14 chose "charter rule + lead gate": the agreement requirement lives in the reloadable architect charter, and the lead refuses a ticket that does not carry both positions. It rejected a daemon-enforced K-of-N quorum because that "puts workflow policy in the bus". Issue #16 defers "daemon-enforced quorum, voting, or adjudication" explicitly.

That decision was right about who judges — and it is wrong if it is read as an answer to determinism, because it never claimed to be one. A charter is text in a system prompt. The lead gate is a model following an instruction. Both can fail silently, and nothing tests either — see the measured note at the end of §1: charter text is never checked against the registered tool surface. So the honest position is:

  • The 2026-08-14 decision correctly keeps judgment out of the daemon.
  • It gives zero deterministic guarantees about the procedure around that judgment.
  • The operator's new requirement is about the procedure. So the decision does not need to be reversed — it needs to be split.

3. What "deterministic" can and cannot mean here

Be precise about the word, or the design lies. There are four layers, and only some can be deterministic:

Layer Can it be deterministic? Why
The content of an architect's position No It is model output.
The lead's verdict No Also model output. The operator wants the lead to judge, so this is by design.
The procedure: who was convened, who answered, in how many rounds, by when, attributed to whom Yes These are countable events the daemon already sees.
The record: a durable, attributable, tamper-evident log of the above Yes The daemon can write it and refuse an incomplete one.

So the achievable goal is: a deterministic procedure wrapped around non-deterministic judgments. Anything that promises more — for example "the lead provably always consults before acting" — is only possible by taking the decision away from the lead and giving it to the daemon. That contradicts requirement 1, and it contradicts the operator's own "message bus, not a workflow engine" principle. This page does not propose it.

One consequence must be said plainly: the lead can still ignore the whole procedure. Nothing in this design physically stops a lead from merging without convening anyone. The design makes that visible and auditable (a decision with no ledger entry is loud in review), not impossible. The only way to make it impossible is to gate an action the daemon controls — and the daemon controls no merge. Gating fleet_spawn on a quorate ticket was considered and rejected: it couples spawn to ticket semantics, which really is workflow policy in the bus. That is the honest limit of determinism here.


4. What "quorum" means — three options, one pick

The word covers three different mechanisms. They must not be mixed up:

Option A — agreement quorum (the current charter shape). Two architects try to agree within two rounds. Agreement produces a joint position; disagreement hands both positions to the lead. "Quorum" here means "the ticket carries both positions". Enforced by prose only.

Option B — vote counting (K-of-N). Three or more architects vote; the daemon counts; 2-of-3 wins. This looks deterministic, and the counting genuinely is. But it is fake determinism where it matters: the votes themselves are model outputs, their independence is unproven (two models on one vendor share blind spots), and a third strong-model slot costs real money on every decision. Worse, it removes the lead as decision maker — the count decides. That contradicts requirement 1. Rejected.

Option C — presence quorum (the pick). Quorum is an evidence threshold, not a vote: the lead's verdict is valid only when every convened slot has a recorded terminal outcome — a position, an explicit abstain, or a daemon-verified timeout. The lead still judges alone; the quorum rule says what must be on the table before it may judge. This matches the operator's own words — the leader decides, based on quorum — and it is the only option where the deterministic part (presence, attribution, bounds) is separable from the non-deterministic part (judgment).

Under Option C, the edge cases have exact answers:

  • Tie: cannot exist. With a lead adjudicator there is no vote to tie. Two disagreeing architects is the normal "dissent" outcome: the lead's verdict is recorded together with the dissent, and the record shows which position lost.
  • Abstention: a first-class terminal outcome. The architect records abstain with a reason ("outside my evidence", "both options equal"). It satisfies presence. A decision where all convened slots abstain still closes — as degraded, see §7 — because a procedure that cannot terminate on universal abstention is not deterministic.
  • Death: if an architect's backend dies before it records a position (the 2026-09-01 outage killed a reply exactly this way), the slot's outcome becomes timeout once the deadline declared at convene time passes. The daemon verifies the deadline against its own clock, so the lead cannot record a premature timeout. The decision then closes as degraded with the surviving positions. A quorum that hangs when a participant dies is not deterministic; this one degrades and terminates.

5. The smallest daemon change: a decision ledger, not a decision engine

The proposal is one narrow primitive: fleet_decision — a daemon-held, append-only decision record. Three verbs, most simply as one tool with an action argument:

  • fleet_decision{action:"open", subject, slots:[...], deadline, maxRounds} — primary only. Opens a record, names the convened slots (they must be configured slots in MemberRegistry), fixes the deadline and the round cap. Returns a decisionId.
  • fleet_decision{action:"position", decisionId, round, kind:"position"|"abstain", content} — architect only, attributed by connection. The daemon resolves the caller's slot through MemberRegistry.slotForTerminal — exactly the same unforgeable path fleet_whoami uses — and stamps slot, round, and time itself. The caller cannot write another slot's entry, cannot write for a decision that did not convene its slot, and a round above maxRounds is refused.
  • fleet_decision{action:"close", decisionId, verdict, rationale} — primary only. The daemon refuses the close unless every convened slot has a terminal outcome: a position, an abstain, or — only after the daemon's own clock passes the declared deadline — an implied timeout. A close where at least one real position is present records quorate; a close carried only by abstains and timeouts records degraded. The daemon never reads verdict for meaning. It checks completeness, nothing else.

Each accepted entry is appended to a JSONL file under the daemon's data directory, and each entry carries a hash of the previous entry. That makes the log tamper-evident: editing a past entry breaks every hash after it. It is not tamper-proof — on this host the operator's own user can rewrite the file and recompute the chain — and the page says so rather than pretending otherwise. Against the actual threat (a model participant forging or bending the record mid-flight), the connection-resolved attribution plus append-only writes are sufficient: no participant, lead included, can write an entry in another identity's name, and nobody can silently remove one.

The lead should additionally mirror the closed verdict into the Gitea ticket for human readers. The mirror is convenience; the ledger is the record.

Why this is not "workflow policy in the bus"

The 2026-08-14 rejection targeted daemon-enforced adjudication — the daemon deciding who wins. This primitive never adjudicates, never routes, never schedules, and never blocks any message. It does exactly what the bus already does elsewhere: Authz refuses a forged reply, ConnectionIdentity refuses a claimed identity, the Injector refuses an unsafe delivery. Refusing an incomplete or mis-attributed record is the same category — a trust and bookkeeping rule, not a workflow rule. The line the operator drew stays where it was: judgment stays out of the daemon. What moves into the daemon is only the part prose was never able to hold: attribution, completeness, bounds, and durability. And #16's deferred bullet — "daemon-enforced quorum, voting, or adjudication" — bundled those two categories into one sentence; this page proposes un-bundling it, not overriding it.

The ledger also directly fixes two measured failure modes that no prose can fix:

  • Ticket TTL (~10 min from send): a long engagement's fleet_reply can outlive its ticket. With the ledger, the architect records its position before replying, in a separate short call. The reply becomes a courtesy summary; losing it loses nothing.
  • Backend death mid-turn (2026-09-01): same mechanism. The position survives in daemon state even when the reply never arrives, exactly like the PR body was the only survivor that day.

6. The decision flow, with explicit turn boundaries

The flow must respect the two hard delivery facts I read in Injector.java: a member receives a message only while injectable (idle / blocked), and an inbound message starts a turn. So no participant is ever briefed to "wait for a message" — waiting keeps it busy, and busy is undeliverable. The working shape is always: work → record → reply → end the turn; the next input arrives as a new turn. The sequence below shows one full decision with the optional second round.

sequenceDiagram
    participant L as Lead
    participant D as "fleetd (ledger)"
    participant A as "Architect opus"
    participant B as "Architect sol"

    L->>D: fleet_decision open (slots, deadline, maxRounds=2)
    D-->>L: decisionId
    L->>D: fleet_spawn (role=architect, profile=opus)
    L->>D: fleet_spawn (role=architect, profile=sol)
    Note over L: spawn BOTH first, then send both (parallel, not serial)
    L->>A: fleet_send wait:false — brief + decisionId + defaults
    L->>B: fleet_send wait:false — same brief, independently
    Note over A: turn 1 — works alone, sees nothing of B
    A->>D: fleet_decision position (round 1)
    A->>L: fleet_reply — summary
    Note over A: turn ends — pane goes idle, deliverable again
    Note over B: turn 1 — works alone
    B->>D: fleet_decision position (round 1)
    B->>L: fleet_reply — summary
    Note over B: turn ends
    Note over L: reads both round-1 positions. Agreement? close now.<br/>Disagreement? one exchange round:
    L->>A: fleet_send wait:false — B's position verbatim
    L->>B: fleet_send wait:false — A's position verbatim
    Note over A,B: turn 2 each — keep, change, or reject points on evidence
    A->>D: fleet_decision position (round 2)
    A->>L: fleet_reply
    B->>D: fleet_decision position (round 2)
    B->>L: fleet_reply
    L->>D: fleet_decision close (verdict + rationale)
    D-->>L: recorded quorate — every slot has a terminal outcome
    L->>D: fleet_stop (both architect panes)

One decision, two rounds, every turn boundary explicit. The daemon appears only as ledger and transport; it never carries an opinion.

Notes on the flow:

  • The lead relays round-2 positions verbatim. The 2026-08-14 note warned that lead-relayed agreement "becomes the lead's read"; verbatim relay avoids that, and it avoids relying on direct architect-to-architect fleet_send. That direct path is Authz-permitted today (SEND allows architects) and the live charter even instructs it — while #16 defers architect-to-architect communication. That drift between Authz, the charter, and #16 should be resolved either way; this design does not depend on which way it goes.
  • The brief must carry defaults for every foreseeable question, because fleet_ask holds only ~55 seconds and an autonomous lead may not be watching. "If X is ambiguous, assume Y and say so in your position" belongs in every architect brief.
  • The lead uses wait:false everywhere. A blocking fleet_send dies at the caller's ~60s MCP timeout, far below a real engagement.

Slot lifecycle under a decision

The engagement rides on the existing CB-548 slot machinery unchanged. I read this lifecycle in MemberRegistry and SessionManager.acquire; the diagram is the as-built shape plus the queue behaviour #16 specifies.

stateDiagram-v2
    [*] --> Idle : configured in fleet.architects
    Idle --> Reserved : reserve(role, profile) before launch
    Reserved --> Live : bind(reservation, terminal) after spawn
    Reserved --> Idle : release(reservation) on failed launch
    Live --> Idle : released(terminal) on stop or teardown
    Idle --> Idle : second engagement queues FIFO,<br/>promoted when the slot frees

Slot states: reserve holds the slot before the process exists, bind makes the connection resolve as ARCHITECT, release/released free it. A decision convenes slots; it never changes this machine.

Decision record lifecycle

stateDiagram-v2
    [*] --> Open : fleet_decision open (primary only)
    Open --> Open : position or abstain recorded<br/>(architect, round ≤ maxRounds)
    Open --> ClosedQuorate : close — all slots answered,<br/>at least one real position
    Open --> ClosedDegraded : close after deadline —<br/>only abstains and timeouts
    Open --> Abandoned : lead never closes<br/>(visible in status, never auto-closed)
    ClosedQuorate --> [*]
    ClosedDegraded --> [*]

The record has exactly three ends. Abandoned is deliberately a visible state, not a transition the daemon performs — see §7 on the dead-lead case.


7. Enforcement map — every rule, its layer, and what a bypass costs

This is the core table the operator asked for. "Deterministic" means the enforcing layer is code with no model in the loop.

Rule Enforcing layer Deterministic? If bypassed or ignored
A position is attributed to the slot that wrote it Daemon: ConnectionIdentity + MemberRegistry.slotForTerminal in fleet_decision position Yes Cannot be bypassed by a participant; identity is the connection
Only the primary opens and closes a decision Daemon: Authz row for the new action Yes Refused, like any other role violation
A close needs a terminal outcome from every convened slot Daemon: the close completeness check Yes The close is refused; the lead must wait, record the deadline pass, or abandon visibly
A timeout outcome is only valid after the declared deadline Daemon clock vs the deadline stamped at open Yes A premature timeout is refused
At most maxRounds rounds Daemon: refuses a higher round Yes Refused
The record cannot be silently edited Daemon: append-only JSONL + hash chain Yes, tamper-evident (not tamper-proof against the OS user) Any edit breaks the chain and is loud
The lead convenes architects at all before deciding Nothing — lead prose + operator audit No The decision happens with no ledger entry; only audit catches it
Architects work independently in round 1 Charter prose + the lead sending separate identical briefs No Anchoring; the record still shows two entries, but not their independence
Positions cite checked evidence, mark unchecked claims Charter prose No A weak position; the lead's judgment is the only filter
The lead's verdict is reasonable Nothing — that is the lead's job No This is requirement 1; making it deterministic would delete the role
Architects reply before the pane is reaped lifecycle.idleTtlSeconds (1800) is a hard clock It is deterministic against you — the ledger removes the harm Position already in the ledger; only the courtesy reply is lost

Read the two "No" rows in the middle honestly: prose keeps a real job in this design — shaping the quality of positions. The daemon change does not replace the charter; it takes over only the four things the charter could never actually hold (attribution, completeness, bounds, durability).


8. Termination — every bound and its failure mode

A deterministic procedure must end. Each bound, and what breaks if it is the one that fires:

  • Round cap (maxRounds, default 2). Daemon-refused beyond the cap. Failure mode: a genuine near-agreement gets cut at round 2 and lands on the lead as dissent. Acceptable — that is exactly the 2026-08-14 deadlock rule, now enforced instead of requested.
  • Deadline (declared at open). After it, missing slots become timeout and the close may proceed as degraded. Failure mode: a deadline set too short converts a slow-but-alive architect into a timeout, and its late position is refused. The lead should set deadlines from the engagement size, and a refused-late position is still visible in the daemon log.
  • Architect death (backend outage, reaped pane). Covered by the deadline; the round-1 position, if already recorded, survives. Failure mode: death before any position leaves the decision resting on one architect — recorded as degraded, so the thinner evidence base is visible forever, not hidden.
  • Lead death. The record stays Open. The daemon does not auto-close it — an auto-verdict would be the daemon adjudicating. Failure mode: an abandoned decision needs an operator (or the next lead session) to notice; open decisions must therefore be listed in fleet_list or fleet_status output so they are impossible to miss. This is the one place the procedure terminates only by visibility, not by clock.
  • Both slots busy with another lead's engagement. #16 makes slot admission FIFO with bounded queue waits. The decision's deadline still runs. Failure mode: contention can eat the whole deadline; the lead sees a degraded close and should reopen with a fresh deadline rather than decide on nothing.

9. What "fully autonomous" costs

Per decision, with the default two rounds and two architects (opus + sol, both strong, both costly):

  • 2 fleet_spawn (fresh clean-room sessions — #16 forbids resume, so each pays a cold start),
  • 2 architect turns in round 1, 2 in round 2 (round 2 is skippable on agreement),
  • 4–5 short lead turns (open, 2 briefs, read + relay, close + stop),
  • 2 fleet_decision position calls per architect (cheap; the turns are the cost).

So the marginal cost of the quorum over a single-advisor consult is one extra strong-model engagement plus at most one exchange round. That is the price of requirement 2, and no design choice on this page changes it — only skipping round 2 on agreement does.

Where a human still stands, and why:

  1. Auditing that decisions go through the ledger at all. §7's honest "No" row: lead compliance is not enforceable without dethroning the lead. A periodic human read of the ledger (or a cheap script that diffs merged tickets against decision subjects) is the backstop.
  2. Abandoned decisions after a lead death (§8) — visible, not self-healing.
  3. Spend control. Every convening burns two strong-model engagements; nothing in the daemon knows the operator's budget. The autonomy is bounded by money before it is bounded by design.

None of these puts a human inside the decision loop. All three put a human on a review loop around it. That is the honest reading of "fully autonomous": autonomous per decision, audited per week.


10. Verdict on the recorded decision and on #16

  • The 2026-08-14 decision stands for judgment and falls for procedure. Keeping adjudication out of the daemon was and is right. But "charter rule + lead gate" was recorded as if it also answered enforcement, and it does not: it is prose enforced by prose. Nothing checks a charter's text against the tool surface, so this layer can rot without any test going red (§1).
  • #16's deferred bullet conflates three things. "Daemon-enforced quorum, voting, or adjudication" treats presence-checking, vote-counting, and verdict-making as one item. Voting and adjudication should stay deferred forever. Presence, attribution, bounds, and the durable record are a different category — the same category as Authz — and un-deferring only them is the proposal of this page.
  • One #16 claim is now inconsistent with the shipped code: architect-to-architect communication is listed as deferred, but Authz.SEND permits architects and the live charter instructs peer exchange. Pick one: either tighten Authz and keep lead-relayed rounds (this page's flow works either way), or strike the deferred bullet. Today the three sources disagree.

11. The cheapest experiment before building anything

Run one real convening with no daemon change: two architect spawns, the current charter, the flow of §6, and the lead writing the "ledger" by hand into a Gitea ticket comment. Measure four things: whether both architects survive their engagement (TTL, backend), whether the stale every tool the charter names still exists, whether round 2 changes any position, and whether the hand-written record is complete. That one run costs two strong-model engagements and settles the riskiest assumption of this page — that the flow works and only the record is fragile. If the hand-written record comes back complete and attributable, the ledger can wait; if it comes back partial (the 2026-09-01 pattern), the ledger is justified by measurement, not by argument.

Independent of the outcome, add the missing guard: a test that reads fleet.charters text and fails on any fleet_* or bridge_* tool name the server does not register, in the shape McpContractDocTest already uses for docs/MCP-Contract.md. That is the check whose absence let this page's own example go stale between drafts.