Table of Contents
- CB-548 follow-up: lead quorum — a deterministic decision procedure
- 1. What I checked and what I did not
- 2. The requirement, and the recorded decision that points the other way
- 3. What "deterministic" can and cannot mean here
- 4. What "quorum" means — three options, one pick
- 5. The smallest daemon change: a decision ledger, not a decision engine
- 6. The decision flow, with explicit turn boundaries
- 7. Enforcement map — every rule, its layer, and what a bypass costs
- 8. Termination — every bound and its failure mode
- 9. What "fully autonomous" costs
- 10. Verdict on the recorded decision and on #16
- 11. The cheapest experiment before building anything
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/releasedexist and enforce one terminal per slot, one slot per terminal.session/SessionManager.java—acquire(...)callsmemberLifecycle.reserve(...)before spawn andbind(reservation, terminal)after spawn (the privateacquired(...)helper). So the older memory note "MemberRegistry.bind is never called" is out of date: reserve-then-bind is wired.auth/MemberLifecycle.java— theSlotReservationrecord and the lifecycle contract.auth/Authz.java— the role table.SPAWN,STOP,DRAINare primary-only.SENDis primary or architect.REPLYandASKneedownsSession(targetSession).READis 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), thefleet_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 withREPLY_CHARTER, fingerprinted byCharterReceipt.fleetd/fleetd.yaml— two live architect slots (fleet.architects.opus,fleet.architects.sol) and a livefleet.charters.architecttext 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_askworks from an architect pane.Authzwould permit it (ownsSession), but theFleetMcphandler'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_askwindow come from the task constraints and from a comment inFleetMcp.javaline 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:
- The lead is the single orchestrator and decision maker.
- The lead convenes architects before a decision.
- 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
abstainwith a reason ("outside my evidence", "both options equal"). It satisfies presence. A decision where all convened slots abstain still closes — asdegraded, 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
timeoutonce 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 asdegradedwith 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 inMemberRegistry), fixes the deadline and the round cap. Returns adecisionId.fleet_decision{action:"position", decisionId, round, kind:"position"|"abstain", content}— architect only, attributed by connection. The daemon resolves the caller's slot throughMemberRegistry.slotForTerminal— exactly the same unforgeable pathfleet_whoamiuses — 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 aroundabovemaxRoundsis 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 impliedtimeout. A close where at least one real position is present recordsquorate; a close carried only by abstains and timeouts recordsdegraded. The daemon never readsverdictfor 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_replycan 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 isAuthz-permitted today (SENDallows architects) and the live charter even instructs it — while #16 defers architect-to-architect communication. That drift betweenAuthz, 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_askholds 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:falseeverywhere. A blockingfleet_senddies 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 becometimeoutand the close may proceed asdegraded. 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 infleet_listorfleet_statusoutput 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
degradedclose 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 positioncalls 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:
- 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.
- Abandoned decisions after a lead death (§8) — visible, not self-healing.
- 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.SENDpermits architects and the live charter instructs peer exchange. Pick one: either tightenAuthzand 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.
📖 fleet
Home — overview & the decision
Chapters
- Architecture — system · 2 invariants · 2 modes
- Message Server — the
fleetddesign - Approaches — transports compared, why herdr
- Setup — ⚫ superseded by 13
- Operations — ⚫ superseded by 13
- Team — orchestrating a mixed fleet
- Use Cases — the review scenario + mechanisms
- Roadmap — delivery record: what is live, what is off, what was dropped
- Implementation — as-built code map · classes · flows · state machines
- Cross-Host Messaging — broker topology · exchanges · queues per entity
- Features — what it can do · the knob that turns it on · why · the gotcha
- Claude → OpenCode — porting a workspace to a second host
- User Guide — 🟢 install · configure · run · delegate · the traps
- Fleet Manager — many fleets on one host, over REST
- REST API Reference — all 14 routes, roles, and bodies
- Security & Trust Boundary — the guard · authz · what a member inherits
Design proposals (not built)
- CB-548 Lead Quorum — a deterministic decision procedure around a lead's judgment
🟢 herdr-centric fleetd · AgentAPI = research, never built