A lead-side rule has no delivery surface: fleet.charters reaches only spawned members, and it is per-daemon — so an operator policy aimed at "the fleets" cannot reach a single lead #591

Open
opened 2026-09-19 09:52:16 +02:00 by ltms · 3 comments
Owner

What the operator asked for

2026-09-19, in their own words:

I just tell fleet01 leader that in case of some decision need to be made -> it should consults
with one or more architect member and they are authorized to agreed on one decision to unblock
and moving on, don't need my input

and immediately after:

we need a way to set that rules for spawn fleets

So the policy has two halves, and they are aimed at two different kinds of session:

Half Who must obey it Meaning
A — lead side the lead (primary) when a decision blocks you, do not wait for the operator. Consult one or more architect members and act on what they agree.
B — architect side a spawned architect member you are authorized to settle the point, not only to advise.

Half B already has a config surface. Half A has none. That is this issue.

What exists today — measured 2026-09-19 on the Mac daemon

fleet.charters.<role> is real, live, and does what it says.

  • The live config holds exactly one entry, architect, at fleetd/fleetd.yaml:292.

  • It is read at spawn from the live config, not from a startup snapshot —
    HerdrPeerLauncher.java:499:

    FleetConfig.Fleet liveFleet = fleet == null ? null : fleet.get();
    String roleCharter = liveFleet == null ? null : liveFleet.charterFor(role);
    String replyCharter = cfg.hasMcp() ? REPLY_CHARTER : null;
    String charter = roleCharter == null ? replyCharter
            : replyCharter == null ? roleCharter : roleCharter + "\n\n" + replyCharter;
    
  • It is backend-neutral, because that code sits in the shared base class. Both
    ClaudeCodeLauncher.java:49 and OpenCodeLauncher.java:59 are
    extends HerdrPeerLauncher, so a Claude member and an OpenCode member get the same text.

  • It is hot — ConfigRef.java:609 lists charters among the keys a reload really applies.
    No restart, no redeploy.

  • It is validated. FleetConfig.java:2702-2709 refuses a charter whose key is not a role
    wire name or whose value is blank, and Fleetd.java:175-178 refuses one that names an MCP
    tool this server does not register.

The existing architect charter already carries the bounded-rounds deadlock rule and ends with
"the lead decides". Under the new policy that last clause is now the end of the escalation
chain rather than a step before the operator. So half B needs a small wording change, not a new
mechanism.

Gap 1 — a charter cannot reach a lead, by construction

charterFor is keyed by MemberRole (FleetConfig.java:1272):

public String charterFor(MemberRole role) {
    return role == null ? null : charters.get(role.wireName());
}

Its only caller is spawnInternal. A lead is not spawned by fleetd — it is a human-started
session that fleetd recognises by its tab label (fleet.leaders.*.tab). There is no launch for
fleetd to attach text to, so there is no path by which any fleetd.yaml key can deliver an
instruction to a lead.

Today the only place half A can live is CLAUDE.md. That is per-repo, not per-fleet: it
travels with a git checkout, so two fleets working the same repo necessarily share it, and one
fleet working two repos necessarily cannot have one policy. Neither grouping is the one the
operator asked for.

Gap 2 — charters are per-daemon, and nothing keeps two hosts in step

The Mac and fleet01 each hold their own fleetd.yaml. Both files are gitignored, so neither is
in version control, and nothing compares them. Setting a rule "for the fleets" is N manual edits
with no check that the N results match — and a fleet whose edit was missed keeps obeying the old
rule silently, which is the failure mode that is hardest to notice.

Acceptance criteria

Write these as properties under a change, not as the presence of a construct.

  1. One edit changes both halves. Change the policy text in a single place, then start a
    fresh lead on each of two daemons and spawn an architect on each. All four sessions must read
    the new text. A test must fail if any one of the four still reads the old text.
  2. A lead actually receives it. Mutate the delivered lead-side text and assert the value a
    lead reads differs. A test that only asserts the key parses, or that the text is non-blank, does
    not satisfy this — it stays green when delivery is removed. See #506 for that family.
  3. Drift is visible. With two daemons configured differently, some surface a lead can read
    (fleet_list, /healthz, or a startup log line) must say the policy text differs between
    them. Silent divergence is the defect, so "it works when both are correct" is not a pass.
  4. The refusal still holds. An invalid policy must be refused at load with the same
    behaviour FleetConfig.java:2702 gives charters today — a bad value must not start the daemon.

Notes for whoever picks this up

  • Do not reach for a daemon-enforced quorum. That was considered and rejected on 2026-08-14:
    it puts workflow policy into what is meant to be a message bus. The charter-plus-lead-gate shape
    is the decided one.
  • A lead's instruction surface is the thing being changed here, so whatever lands must also answer
    how CLAUDE.md and the new surface avoid contradicting each other. Two sources of truth for one
    rule is worse than the gap.
  • Related: #392 (the notification sink does not exist) and the companion issue on emitting an
    event when architects settle a decision without the operator.
## What the operator asked for 2026-09-19, in their own words: > I just tell fleet01 leader that in case of some decision need to be made -> it should consults > with one or more architect member and they are authorized to agreed on one decision to unblock > and moving on, don't need my input and immediately after: > we need a way to set that rules for spawn fleets So the policy has **two halves**, and they are aimed at two different kinds of session: | Half | Who must obey it | Meaning | |---|---|---| | **A — lead side** | the lead (primary) | when a decision blocks you, do not wait for the operator. Consult one or more architect members and act on what they agree. | | **B — architect side** | a spawned architect member | you are authorized to settle the point, not only to advise. | Half B already has a config surface. **Half A has none.** That is this issue. ## What exists today — measured 2026-09-19 on the Mac daemon `fleet.charters.<role>` is real, live, and does what it says. - The live config holds exactly one entry, `architect`, at `fleetd/fleetd.yaml:292`. - It is read **at spawn from the live config**, not from a startup snapshot — `HerdrPeerLauncher.java:499`: ```java FleetConfig.Fleet liveFleet = fleet == null ? null : fleet.get(); String roleCharter = liveFleet == null ? null : liveFleet.charterFor(role); String replyCharter = cfg.hasMcp() ? REPLY_CHARTER : null; String charter = roleCharter == null ? replyCharter : replyCharter == null ? roleCharter : roleCharter + "\n\n" + replyCharter; ``` - It is **backend-neutral**, because that code sits in the shared base class. Both `ClaudeCodeLauncher.java:49` and `OpenCodeLauncher.java:59` are `extends HerdrPeerLauncher`, so a Claude member and an OpenCode member get the same text. - It is **hot** — `ConfigRef.java:609` lists `charters` among the keys a reload really applies. No restart, no redeploy. - It is **validated**. `FleetConfig.java:2702-2709` refuses a charter whose key is not a role wire name or whose value is blank, and `Fleetd.java:175-178` refuses one that names an MCP tool this server does not register. The existing `architect` charter already carries the bounded-rounds deadlock rule and ends with "the lead decides". Under the new policy that last clause is now the *end* of the escalation chain rather than a step before the operator. So half B needs a small wording change, not a new mechanism. ## Gap 1 — a charter cannot reach a lead, by construction `charterFor` is keyed by `MemberRole` (`FleetConfig.java:1272`): ```java public String charterFor(MemberRole role) { return role == null ? null : charters.get(role.wireName()); } ``` Its only caller is `spawnInternal`. **A lead is not spawned by fleetd** — it is a human-started session that fleetd recognises by its tab label (`fleet.leaders.*.tab`). There is no launch for fleetd to attach text to, so there is no path by which any `fleetd.yaml` key can deliver an instruction to a lead. Today the only place half A can live is `CLAUDE.md`. That is **per-repo**, not per-fleet: it travels with a git checkout, so two fleets working the same repo necessarily share it, and one fleet working two repos necessarily cannot have one policy. Neither grouping is the one the operator asked for. ## Gap 2 — charters are per-daemon, and nothing keeps two hosts in step The Mac and fleet01 each hold their own `fleetd.yaml`. Both files are gitignored, so neither is in version control, and nothing compares them. Setting a rule "for the fleets" is N manual edits with no check that the N results match — and a fleet whose edit was missed keeps obeying the old rule silently, which is the failure mode that is hardest to notice. ## Acceptance criteria Write these as properties under a change, not as the presence of a construct. 1. **One edit changes both halves.** Change the policy text in a single place, then start a fresh lead on each of two daemons and spawn an architect on each. All four sessions must read the new text. A test must fail if any one of the four still reads the old text. 2. **A lead actually receives it.** Mutate the delivered lead-side text and assert the value a lead reads differs. A test that only asserts the key parses, or that the text is non-blank, does not satisfy this — it stays green when delivery is removed. See #506 for that family. 3. **Drift is visible.** With two daemons configured differently, some surface a lead can read (`fleet_list`, `/healthz`, or a startup log line) must say the policy text differs between them. Silent divergence is the defect, so "it works when both are correct" is not a pass. 4. **The refusal still holds.** An invalid policy must be refused at load with the same behaviour `FleetConfig.java:2702` gives charters today — a bad value must not start the daemon. ## Notes for whoever picks this up - Do **not** reach for a daemon-enforced quorum. That was considered and rejected on 2026-08-14: it puts workflow policy into what is meant to be a message bus. The charter-plus-lead-gate shape is the decided one. - A lead's instruction surface is the thing being changed here, so whatever lands must also answer how `CLAUDE.md` and the new surface avoid contradicting each other. Two sources of truth for one rule is worse than the gap. - Related: #392 (the notification sink does not exist) and the companion issue on emitting an event when architects settle a decision without the operator.
Author
Owner

Correction — one claim in the description is wrong

The description says charters are applied by hand on each host "with nothing comparing the
results". That is false, and the fleet01 lead caught it. I have confirmed it in the code.

CharterReceipt (CB-571) already fingerprints the exact charter bytes a member received, and
fleet_list reports it per member as charterSha256 alongside charterSource. From this host's
fleet_list a few minutes ago, on three dev members:

"charterSource":"none",
"charterSha256":"f3e28ddacac1606eac862ffac84b08fcf942ca24d855ccf98724219bb0234e9b"

Two properties make it usable as a cross-host instrument, both read from
CharterReceipt.compose:

  • the digest covers the composed charter string — the role charter plus REPLY_CHARTER, or
    the reply charter alone when no role charter is configured;
  • the profile is a field on the receipt but is NOT part of the digest, so two members on
    different profiles and different hosts still produce the same hash for the same text.

So the correct statement is not "nothing compares the results" but "nobody is comparing
them"
— a different and much cheaper problem.

A precision that matters when comparing two hosts

charterSource: "none" means no charter is configured for that role, so the digest is the
reply charter alone. The hash above is therefore not comparable with a hash from an
architect member, which carries the role charter too. Compare like with like: same role, and
check charterSource before reading the digest.

Concrete example from today. fleet01's architect, still on the pre-change charter because their
safety classifier blocked the fleetd.yaml write, reports
15e2a7daa35db4af015718244c6ecb32d35da9b18c88444d81f346ffcc0ee37b. That is the pre-change
baseline for fleet.charters.architect plus the reply charter.

Adopted between the two fleets

The fleet01 lead proposed, and I have accepted, that we exchange charterSha256 whenever either
host touches fleet.charters.* — the same protocol we already use for the CLAUDE.md block sha,
and for the same reason. It costs one line in a coordination message.

What this does NOT fix, which is what the ticket is actually about

The member charter has an instrument; the lead contract has neither a config surface nor a
hash.
Gap 1 in the description stands unchanged, and it is the more important half. Gap 2 is
softened from "undetectable" to "detectable, if someone looks".

One more lead-facing surface, found while measuring #595

leadRollover.bootstrapText (FleetConfig.java:1401) is operator-authored text in fleetd.yaml
that fleetd types into a lead's pane. It is configured on this host. So the daemon can already
deliver text to a lead — the limit is timing, not capability: it fires only during a roll. That
may be the seam a lead-side surface should reuse rather than invent. Noted here because it
weakens the description's "there is no path by which any fleetd.yaml key can deliver an
instruction to a lead" — accurate for charters, too strong as written.

An architect is looking at the design now.

## Correction — one claim in the description is wrong The description says charters are applied by hand on each host "with nothing comparing the results". **That is false, and the fleet01 lead caught it.** I have confirmed it in the code. `CharterReceipt` (CB-571) already fingerprints the exact charter bytes a member received, and `fleet_list` reports it per member as `charterSha256` alongside `charterSource`. From this host's `fleet_list` a few minutes ago, on three `dev` members: ``` "charterSource":"none", "charterSha256":"f3e28ddacac1606eac862ffac84b08fcf942ca24d855ccf98724219bb0234e9b" ``` Two properties make it usable as a cross-host instrument, both read from `CharterReceipt.compose`: - the digest covers the **composed** charter string — the role charter plus `REPLY_CHARTER`, or the reply charter alone when no role charter is configured; - **the profile is a field on the receipt but is NOT part of the digest**, so two members on different profiles and different hosts still produce the same hash for the same text. So the correct statement is not "nothing compares the results" but **"nobody is comparing them"** — a different and much cheaper problem. ### A precision that matters when comparing two hosts `charterSource: "none"` means no charter is configured for that role, so the digest is the **reply charter alone**. The hash above is therefore not comparable with a hash from an `architect` member, which carries the role charter too. Compare like with like: same role, and check `charterSource` before reading the digest. Concrete example from today. fleet01's architect, still on the pre-change charter because their safety classifier blocked the `fleetd.yaml` write, reports `15e2a7daa35db4af015718244c6ecb32d35da9b18c88444d81f346ffcc0ee37b`. That is the pre-change baseline for `fleet.charters.architect` plus the reply charter. ### Adopted between the two fleets The fleet01 lead proposed, and I have accepted, that we exchange `charterSha256` whenever either host touches `fleet.charters.*` — the same protocol we already use for the `CLAUDE.md` block sha, and for the same reason. It costs one line in a coordination message. ### What this does NOT fix, which is what the ticket is actually about The member charter has an instrument; **the lead contract has neither a config surface nor a hash.** Gap 1 in the description stands unchanged, and it is the more important half. Gap 2 is softened from "undetectable" to "detectable, if someone looks". ### One more lead-facing surface, found while measuring #595 `leadRollover.bootstrapText` (`FleetConfig.java:1401`) is operator-authored text in `fleetd.yaml` that fleetd **types into a lead's pane**. It is configured on this host. So the daemon can already deliver text to a lead — the limit is timing, not capability: it fires only during a roll. That may be the seam a lead-side surface should reuse rather than invent. Noted here because it weakens the description's "there is no path by which any `fleetd.yaml` key can deliver an instruction to a lead" — accurate for charters, too strong as written. An architect is looking at the design now.
Author
Owner

The instrument got used across two hosts today, and it found a divergence nobody knew about

Follow-up to the correction above. The fleet01 lead and I ran the charterSha256 comparison for
real. It worked, and the first thing it found was not today's change.

Measured, both hosts, 2026-09-19

mac fleet01
architect charter, before today 936 bytes, sha 0f895e51… 422 bytes
architect charter, after today 1377 bytes, sha 3b254309… unchanged — their classifier blocked the write
composed (role + reply), as fleet_list reports 1bbd5f7d…, 2178 bytes 15e2a7da…, 1223 bytes

Both daemons log the arithmetic and it checks out on each: 1377 + 2 + 799 = 2178 here,
422 + 2 + 799 = 1223 there. REPLY_CHARTER is 799 bytes on both hosts, so a composed-hash
mismatch between us can only be the role-charter text.

The two fleets' architect charters had already diverged by ~500 bytes before today's edit, and
neither lead knew.
They have been briefing architects from different contracts for an unknown
length of time. This is the failure the ticket predicts, found by accident on the first use of
the instrument.

It is a divergence, not a subset. My five pre-change paragraphs are 141, 257, 119, 202 and
209 bytes; no prefix and no pair of them sums to their 422, so their text is differently worded
rather than a shorter selection of mine.

Three properties of CharterReceipt that belong in this ticket

1. charterBytes is as diagnostic as charterSha256, and cheaper. The fleet01 lead's
words: "the hash says 'different', the byte count says 'different by how much', and the second
is what turns a mismatch into a diagnosis."
They satisfied my positive control without spawning
anything, because the byte count was already in their spawn log. Any surface that reports the
hash must report the byte count beside it.

2. The profile is deliberately NOT in the digest, and that must not be "fixed".
CharterReceipt.compose takes the profile as a field but hashes only the composed text. That is
what makes the comparison work across hosts whose fleets differ: my daemon refuses an architect
on gx ("no architect slot for profile 'gx' — fleet.architects carries profiles: opus, sol")
while fleet01 spawns one there without complaint. Two fleets that cannot run each other's spawn
commands can still compare each other's charters.
Folding the profile into the digest is an
obvious-looking improvement that would destroy exactly the cross-host comparison this ticket
wants. Whoever implements here should add a test that pins it.

3. The digest covers the COMPOSED string, so it cannot isolate the role charter on its own.
It works today only because REPLY_CHARTER happens to be byte-identical on both hosts — we
measured that, we did not assume it. If two daemons ever run different versions, the protocol
silently starts reporting charter differences that are really reply-charter differences. The
positive control (spawn a role with no configured charter; the digest is then the reply charter
alone — f3e28dda…, 799 bytes) is what separates the two, and it should be written down as part
of the protocol rather than rediscovered.

Protocol now in use between the two fleets

Exchange charterSha256 and charterBytes whenever either host touches
fleet.charters.*, and run the reply-charter control before believing any mismatch. Same shape
as the CLAUDE.md block-sha exchange.

None of this closes Gap 1. The lead contract still has no config surface and no fingerprint, and
that remains what the ticket is for.

## The instrument got used across two hosts today, and it found a divergence nobody knew about Follow-up to the correction above. The fleet01 lead and I ran the `charterSha256` comparison for real. It worked, and the first thing it found was not today's change. ### Measured, both hosts, 2026-09-19 | | mac | fleet01 | |---|---|---| | architect charter, before today | **936 bytes**, sha `0f895e51…` | **422 bytes** | | architect charter, after today | **1377 bytes**, sha `3b254309…` | unchanged — their classifier blocked the write | | composed (role + reply), as `fleet_list` reports | `1bbd5f7d…`, 2178 bytes | `15e2a7da…`, 1223 bytes | Both daemons log the arithmetic and it checks out on each: 1377 + 2 + 799 = 2178 here, 422 + 2 + 799 = 1223 there. **`REPLY_CHARTER` is 799 bytes on both hosts**, so a composed-hash mismatch between us can only be the role-charter text. **The two fleets' architect charters had already diverged by ~500 bytes before today's edit, and neither lead knew.** They have been briefing architects from different contracts for an unknown length of time. This is the failure the ticket predicts, found by accident on the first use of the instrument. It is a **divergence, not a subset**. My five pre-change paragraphs are 141, 257, 119, 202 and 209 bytes; no prefix and no pair of them sums to their 422, so their text is differently worded rather than a shorter selection of mine. ### Three properties of `CharterReceipt` that belong in this ticket **1. `charterBytes` is as diagnostic as `charterSha256`, and cheaper.** The fleet01 lead's words: *"the hash says 'different', the byte count says 'different by how much', and the second is what turns a mismatch into a diagnosis."* They satisfied my positive control without spawning anything, because the byte count was already in their spawn log. Any surface that reports the hash must report the byte count beside it. **2. The profile is deliberately NOT in the digest, and that must not be "fixed".** `CharterReceipt.compose` takes the profile as a field but hashes only the composed text. That is what makes the comparison work across hosts whose fleets differ: my daemon refuses an architect on `gx` (*"no architect slot for profile 'gx' — fleet.architects carries profiles: opus, sol"*) while fleet01 spawns one there without complaint. **Two fleets that cannot run each other's spawn commands can still compare each other's charters.** Folding the profile into the digest is an obvious-looking improvement that would destroy exactly the cross-host comparison this ticket wants. Whoever implements here should add a test that pins it. **3. The digest covers the COMPOSED string, so it cannot isolate the role charter on its own.** It works today only because `REPLY_CHARTER` happens to be byte-identical on both hosts — we measured that, we did not assume it. If two daemons ever run different versions, the protocol silently starts reporting charter differences that are really reply-charter differences. The positive control (spawn a role with no configured charter; the digest is then the reply charter alone — `f3e28dda…`, 799 bytes) is what separates the two, and it should be written down as part of the protocol rather than rediscovered. ### Protocol now in use between the two fleets Exchange `charterSha256` **and** `charterBytes` whenever either host touches `fleet.charters.*`, and run the reply-charter control before believing any mismatch. Same shape as the `CLAUDE.md` block-sha exchange. None of this closes Gap 1. The lead contract still has no config surface and no fingerprint, and that remains what the ticket is for.
Author
Owner

CORRECTION 3, and an architect design position

An architect member (profile sol, branch worker/591-dab0a9-4) was asked to design the delivery surface. It read the code and returned a position. It also corrected me. I verified the correction myself before writing it here.

The correction: "fleetd never starts a lead" is false

I wrote that a lead is always human-started and only ever recognised by tab label. That is wrong. LeadLauncher can start one.

Measured in fleetd/src/main/java/dev/ltms/fleet/lead/LeadLauncher.java today:

  • :174-181 — a lead with a tab: and no profile: is recognise-only. The log line says so: "it can be recognised but not launched. Add profile: under fleet.leaders. to have fleetd start it."
  • :324 — a lead with a profile: is launched by fleetd through AgentControl, using leadArgv(profile).

So there are two kinds of lead, not one. The correct statement is: a recognise-only lead is human-started; a lead with a profile: is started by fleetd.

What that opens, and immediately closes again

A launcher is a text surface — that is how every member gets its charter. So fleetd does have a launch-time hook for a lead. It deliberately puts nothing in it. leadArgv at :360-368:

No --append-system-prompt. That flag carries the worker reply charter, and a lead is not a worker — it reads its orchestration rules from the project's CLAUDE.md like any other primary. This is the single most important difference from the member launchers; do not "unify" it back.

That is a third lead-facing surface, alongside leadRollover.bootstrapText, and it is intentionally empty with a comment defending the emptiness. The ticket's conclusion does not change — fleet.charters.<role> still cannot reach any lead — but the reason is now "the one launch hook that exists was deliberately left empty", not "no hook exists". Those need different fixes, and the comment at :364 has to be answered by whoever writes one.

The architect's position, in short

Its recommendation, which I have not yet accepted or rejected:

  1. One daemon-owned document, not repo text. A new fleet.contract in fleetd.yaml holding one document for all roles, with role labels inside it. fleet.charters.<role> stays, narrowed to member-only turn mechanics. Its argument for the boundary: one repo can serve several fleets and one fleet can use several repos, so a repo rule cannot model either case, while fleetd.yaml already owns roles, charters and rollover text.
  2. Delivery is a pull through fleet_whoami, read through ConfigRef on every call so a config reload shows up on the next call. The generic rule in CLAUDE.md changes from "call once per session" to "call at the start of each task", which makes that the refresh boundary.
  3. Not pane push, and not rollover. It cites #594 directly: injectable() accepts BLOCKED, so a push receipt can say delivered while the text lands in an open prompt. And rollover is optional, destructive, and only fires on a roll — after a roll, bootstrapText should say "call fleet_whoami" rather than carry another copy.
  4. A lead-side receipt, digest over the exact UTF-8 text returned to that lead, excluding host, profile, name and time. fleet_list shows the daemon's current contract digest and each lead's last-delivered digest. A mismatch then means that lead has not fetched the current text. This is the lead-side twin of CharterReceipt (CB-571), and it is what makes a hot config field safe: without it, a stale running lead is invisible.
  5. No cross-host copying. Each daemon owns its own contract; the shared digest makes drift visible without making the bus a policy distribution authority. It says plainly that if "one edit changes both hosts" is a hard requirement, this design is blocked on a separate shared-config owner, and names what would settle it: evidence both hosts can read one operator-managed file or service, plus a decided rule for when that source is unavailable. It did not check either.

Limits it stated itself

Unchecked by the architect: the prompt-size cost of giving a shared contract to every member; whether Claude Code or OpenCode actually puts the MCP initialize instructions field into model context (it checked with javap that the local SDK's builder has one static instructions(String), not per-caller and not live, but did not test client behaviour); whether any common config source is reachable from both hosts; and live end-to-end rollover success.

It changed no files and ran no tests.

Open, not decided

A blank configured contract failing config load matches how a blank member charter is already refused at FleetConfig.java:2702-2709, so that part is consistent with the existing code. The fleet.contract name and whether the document should be one blob or per-role are the parts I would expect to change. I am recording the position, not adopting it.

## CORRECTION 3, and an architect design position An architect member (profile `sol`, branch `worker/591-dab0a9-4`) was asked to design the delivery surface. It read the code and returned a position. It also corrected me. I verified the correction myself before writing it here. ### The correction: "fleetd never starts a lead" is false I wrote that a lead is always human-started and only ever recognised by tab label. That is wrong. `LeadLauncher` can start one. Measured in `fleetd/src/main/java/dev/ltms/fleet/lead/LeadLauncher.java` today: - `:174-181` — a lead with a `tab:` and **no** `profile:` is recognise-only. The log line says so: *"it can be recognised but not launched. Add `profile:` under fleet.leaders.<name> to have fleetd start it."* - `:324` — a lead **with** a `profile:` is launched by fleetd through `AgentControl`, using `leadArgv(profile)`. So there are two kinds of lead, not one. The correct statement is: **a recognise-only lead is human-started; a lead with a `profile:` is started by fleetd.** ### What that opens, and immediately closes again A launcher is a text surface — that is how every member gets its charter. So fleetd *does* have a launch-time hook for a lead. It deliberately puts nothing in it. `leadArgv` at `:360-368`: > No `--append-system-prompt`. That flag carries the worker reply charter, and a lead is not a worker — it reads its orchestration rules from the project's `CLAUDE.md` like any other primary. **This is the single most important difference from the member launchers; do not "unify" it back.** That is a third lead-facing surface, alongside `leadRollover.bootstrapText`, and it is intentionally empty with a comment defending the emptiness. The ticket's conclusion does not change — `fleet.charters.<role>` still cannot reach any lead — but the reason is now "the one launch hook that exists was deliberately left empty", not "no hook exists". Those need different fixes, and the comment at `:364` has to be answered by whoever writes one. ### The architect's position, in short Its recommendation, which I have not yet accepted or rejected: 1. **One daemon-owned document, not repo text.** A new `fleet.contract` in `fleetd.yaml` holding one document for all roles, with role labels inside it. `fleet.charters.<role>` stays, narrowed to member-only turn mechanics. Its argument for the boundary: one repo can serve several fleets and one fleet can use several repos, so a repo rule cannot model either case, while `fleetd.yaml` already owns roles, charters and rollover text. 2. **Delivery is a pull through `fleet_whoami`**, read through `ConfigRef` on every call so a config reload shows up on the next call. The generic rule in `CLAUDE.md` changes from "call once per session" to "call at the start of each task", which makes that the refresh boundary. 3. **Not pane push, and not rollover.** It cites #594 directly: `injectable()` accepts `BLOCKED`, so a push receipt can say delivered while the text lands in an open prompt. And rollover is optional, destructive, and only fires on a roll — after a roll, `bootstrapText` should say "call `fleet_whoami`" rather than carry another copy. 4. **A lead-side receipt**, digest over the exact UTF-8 text returned to that lead, excluding host, profile, name and time. `fleet_list` shows the daemon's current contract digest and each lead's last-delivered digest. A mismatch then means that lead has not fetched the current text. This is the lead-side twin of `CharterReceipt` (CB-571), and it is what makes a hot config field safe: without it, a stale running lead is invisible. 5. **No cross-host copying.** Each daemon owns its own contract; the shared digest makes drift visible without making the bus a policy distribution authority. It says plainly that if "one edit changes both hosts" is a hard requirement, this design is blocked on a separate shared-config owner, and names what would settle it: evidence both hosts can read one operator-managed file or service, plus a decided rule for when that source is unavailable. It did not check either. ### Limits it stated itself Unchecked by the architect: the prompt-size cost of giving a shared contract to every member; whether Claude Code or OpenCode actually puts the MCP `initialize` instructions field into model context (it checked with `javap` that the local SDK's builder has one static `instructions(String)`, not per-caller and not live, but did not test client behaviour); whether any common config source is reachable from both hosts; and live end-to-end rollover success. It changed no files and ran no tests. ### Open, not decided A blank configured contract failing config load matches how a blank member charter is already refused at `FleetConfig.java:2702-2709`, so that part is consistent with the existing code. The `fleet.contract` name and whether the document should be one blob or per-role are the parts I would expect to change. I am recording the position, not adopting it.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: fleet/fleetd#591