diff --git a/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java b/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java
index 82dd8a8..320509d 100644
--- a/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java
+++ b/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java
@@ -7,6 +7,7 @@ import dev.ltms.bridged.herdr.HerdrException;
import dev.ltms.bridged.herdr.Tab;
import dev.ltms.bridged.herdr.Workspace;
import dev.ltms.bridged.herdr.WorkspaceControl;
+import dev.ltms.bridged.peer.CharterReceipt;
import dev.ltms.bridged.peer.MemberRole;
import dev.ltms.bridged.peer.PeerHandle;
import dev.ltms.bridged.peer.PeerLauncher;
@@ -269,8 +270,11 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
// --- spawn ---------------------------------------------------------------------------------
- /** A started peer plus the launch's agent-session id (the resume handle, or null). */
- private record Spawned(Agent agent, String agentSessionId) {
+ /**
+ * A started peer plus the launch's agent-session id (the resume handle, or null) and the
+ * charter receipt (CB-571) the base composed for it.
+ */
+ private record Spawned(Agent agent, String agentSessionId, CharterReceipt receipt) {
}
/**
@@ -305,12 +309,41 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
String replyCharter = cfg.hasMcp() ? REPLY_CHARTER : null;
String charter = roleCharter == null ? replyCharter
: replyCharter == null ? roleCharter : roleCharter + "\n\n" + replyCharter;
- Launch launch = buildLaunch(cfg, new LaunchSpec(sessionName, resumeSessionId, role, charter));
- String cwd = resolveCwd(requestedCwd, cfg, callerCwd);
- Agent agent = cfg.tabPlacement()
- ? spawnInTab(cfg, launch.env(), launch.argv(), cwd, role, liveFleet)
- : spawnAsPane(cfg, launch.env(), launch.argv(), cwd);
- return new Spawned(agent, launch.agentSessionId());
+ // CB-571: fingerprint the exact composed charter bytes once, here in the base, before the
+ // string leaves for an adapter — so Claude and OpenCode derive the same digest. A failed
+ // start has no bridge_spawn result and no roster row, so the failure log below is the only
+ // surface the byte count can appear on. The charter text itself is never logged.
+ CharterReceipt receipt = CharterReceipt.compose(role, cfg.profile(), roleCharter, charter);
+ try {
+ Launch launch = buildLaunch(cfg, new LaunchSpec(sessionName, resumeSessionId, role, charter));
+ String cwd = resolveCwd(requestedCwd, cfg, callerCwd);
+ Agent agent = cfg.tabPlacement()
+ ? spawnInTab(cfg, launch.env(), launch.argv(), cwd, role, liveFleet)
+ : spawnAsPane(cfg, launch.env(), launch.argv(), cwd, charter);
+ logCharterReceipt(receipt, true);
+ return new Spawned(agent, launch.agentSessionId(), receipt);
+ } catch (RuntimeException e) {
+ logCharterReceipt(receipt, false);
+ throw e;
+ }
+ }
+
+ /**
+ * The one place the charter's size and digest appear in the logs. {@code success} true after a
+ * start, false from the failure path of {@link #spawnInternal} where no handle or roster row
+ * exists to carry the receipt. Always metadata only — never the charter text.
+ */
+ private static void logCharterReceipt(CharterReceipt receipt, boolean success) {
+ String role = receipt.role() == null ? "" : receipt.role().wireName();
+ if (success) {
+ log.info("spawned role={} profile={} charterSource={} charterSha256={} charterBytes={}",
+ role, receipt.profile(), receipt.charterSource(),
+ receipt.charterSha256(), receipt.charterBytes());
+ } else {
+ log.warn("spawn failed; charter role={} profile={} charterSource={} charterSha256={} charterBytes={}",
+ role, receipt.profile(), receipt.charterSource(),
+ receipt.charterSha256(), receipt.charterBytes());
+ }
}
/**
@@ -349,7 +382,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
String id = UUID.randomUUID().toString();
paneByAgentId.put(id, paneId);
return new WorkerHandle(id, agent.terminalId(), requireProfile(req.profileName()).profile(),
- req.sessionName(), spawned.agentSessionId());
+ req.sessionName(), spawned.agentSessionId(), spawned.receipt());
}
@Override
@@ -433,11 +466,19 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
}
}
- /** Legacy placement: split the currently-focused tab; the peer still starts in {@code cwd}. */
+ /**
+ * Legacy placement: split the currently-focused tab; the peer still starts in {@code cwd}.
+ *
+ *
CB-571: this is the one legacy log that printed the full argv, and the charter travels
+ * inside argv — so the charter text went to the daemon log on every pane-placement spawn. The
+ * {@code spawnInTab} path never logs argv, so only this site is fixed. {@code charter} is the
+ * composed charter, if any; its argv element is replaced by its digest so the log still shows
+ * which args were passed without exposing the charter prose.
+ */
private Agent spawnAsPane(BridgedConfig.Profile cfg, Map workerEnv,
- List argv, String cwd) {
+ List argv, String cwd, String charter) {
log.info("spawning {} (pane placement) profile={} cwd={} argv={}",
- namePrefix, cfg.profile(), cwd, argv);
+ namePrefix, cfg.profile(), cwd, redactCharter(argv, charter));
String paneId = spaces.splitPane(cwd, workerEnv);
if (paneId == null) {
throw new IllegalStateException("pane.split returned no pane — cannot start a peer");
@@ -447,6 +488,21 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
return peer;
}
+ /**
+ * A copy of {@code argv} with an element equal to {@code charter} replaced by its digest, so
+ * the pane log never prints the charter prose. The charter is handed to an adapter as one argv
+ * element, so exact-equality is the right match; every other argument passes through unchanged.
+ */
+ private static List redactCharter(List argv, String charter) {
+ if (charter == null || charter.isBlank() || argv == null || argv.isEmpty()) {
+ return argv;
+ }
+ String digest = CharterReceipt.digestOf(charter);
+ return argv.stream()
+ .map(a -> a.equals(charter) ? "" : a)
+ .toList();
+ }
+
/** A started peer together with the sequence its unique name/label used. */
private record Started(Agent agent, long seq) {
}
@@ -651,11 +707,18 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
/**
* A concrete {@link PeerHandle} wrapping herdr agent coordinates, the profile that spawned it,
- * and the session identity the launch resolved (CB-547a): the bridge's logical name and the
- * peer's own session id, both null when the spawn carried no identity.
+ * the session identity the launch resolved (CB-547a): the bridge's logical name and the peer's
+ * own session id, both null when the spawn carried no identity — and the charter receipt
+ * (CB-571) the base computed for this launch.
*/
private record WorkerHandle(String id, String terminalId, String profile,
- String sessionName, String agentSessionId) implements PeerHandle {
+ String sessionName, String agentSessionId,
+ CharterReceipt receipt) implements PeerHandle {
+
+ @Override
+ public CharterReceipt charterReceipt() {
+ return receipt;
+ }
}
// --- shared helpers ------------------------------------------------------------------------
diff --git a/bridged/src/main/java/dev/ltms/bridged/member/OpenCodeLauncher.java b/bridged/src/main/java/dev/ltms/bridged/member/OpenCodeLauncher.java
index b830672..8eb300e 100644
--- a/bridged/src/main/java/dev/ltms/bridged/member/OpenCodeLauncher.java
+++ b/bridged/src/main/java/dev/ltms/bridged/member/OpenCodeLauncher.java
@@ -7,6 +7,7 @@ import dev.ltms.bridged.herdr.Agent;
import dev.ltms.bridged.herdr.AgentControl;
import dev.ltms.bridged.herdr.WorkspaceControl;
import dev.ltms.bridged.peer.Capability;
+import dev.ltms.bridged.peer.CharterReceipt;
import dev.ltms.bridged.peer.PeerHandle;
import dev.ltms.bridged.peer.SpawnRequest;
@@ -406,6 +407,11 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
// appeared).
return discovery.sessionIdForDirectory(cwd);
}
+
+ @Override
+ public CharterReceipt charterReceipt() {
+ return delegate.charterReceipt();
+ }
}
// --- Agent-returning convenience spawns (used by callers/tests that want the herdr Agent) ---
diff --git a/bridged/src/main/java/dev/ltms/bridged/peer/CharterReceipt.java b/bridged/src/main/java/dev/ltms/bridged/peer/CharterReceipt.java
new file mode 100644
index 0000000..79bf78b
--- /dev/null
+++ b/bridged/src/main/java/dev/ltms/bridged/peer/CharterReceipt.java
@@ -0,0 +1,84 @@
+package dev.ltms.bridged.peer;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+
+/**
+ * CB-571: a fingerprint of the exact charter bytes handed to a spawned member.
+ *
+ * Lets an operator prove which charter a member actually got, without ever logging the
+ * charter text. The digest covers the exact composed UTF-8 string {@code HerdrPeerLauncher} passes
+ * to its adapter as {@code LaunchSpec.charter()}, so every adapter that receives the same string —
+ * Claude inlining it, OpenCode writing it to a file — produces the same digest for the same config.
+ * Two spawns of the same role from the same config agree; editing the charter changes the digest.
+ *
+ *
Deliberately places no charter prose. A charter is operator-authored text that may name
+ * internal projects or unreleased plans, and logs get tailed, shipped, and pasted into tickets.
+ * The {@code charterSource} key is what the operator wants to confirm, and it carries no content.
+ */
+public record CharterReceipt(
+ MemberRole role,
+ String profile,
+ String charterSource,
+ String charterSha256,
+ int charterBytes) {
+
+ /** Source reported when the role has no configured charter, so the field is never omitted. */
+ public static final String NO_SOURCE = "none";
+
+ /**
+ * The config key that supplied the role's charter text, e.g. {@code fleet.charters.architect}.
+ */
+ public static String sourceKey(MemberRole role) {
+ return "fleet.charters." + (role == null ? "?" : role.wireName());
+ }
+
+ /**
+ * Fingerprint the composed charter for {@code role} on {@code profile}. {@code configured} is
+ * the role's charter text as read from config ({@code null} when none is configured);
+ * {@code composed} is the exact string the launcher will pass to the adapter — the reply
+ * charter may be appended to {@code configured}, or stand alone when no role charter exists.
+ *
+ *
No composed charter at all is reported as an explicit absence — a {@code null} digest and
+ * a zero byte count — never a digest of the empty string, which would hide the fact that no
+ * text was supplied. {@code configured} being {@code null} while {@code composed} is the reply
+ * charter alone is a normal case, and the source says so.
+ */
+ public static CharterReceipt compose(MemberRole role, String profile,
+ String configured, String composed) {
+ String source = (configured == null || configured.isBlank())
+ ? NO_SOURCE : sourceKey(role);
+ if (composed == null) {
+ return new CharterReceipt(role, profile, source, null, 0);
+ }
+ byte[] bytes = composed.getBytes(StandardCharsets.UTF_8);
+ return new CharterReceipt(role, profile, source, digestOf(composed), bytes.length);
+ }
+
+ /** Whether the composed charter was absent (no text was given to the member). */
+ public boolean absent() {
+ return charterSha256 == null;
+ }
+
+ /**
+ * The stable SHA-256 hex digest of {@code text}, or {@code null} for null/blank text. Used both
+ * for the receipt's fingerprint and to redact a charter argument in a spawn log.
+ */
+ public static String digestOf(String text) {
+ if (text == null || text.isBlank()) {
+ return null;
+ }
+ return sha256Hex(text.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static String sha256Hex(byte[] bytes) {
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ return HexFormat.of().formatHex(md.digest(bytes));
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 is unavailable", e);
+ }
+ }
+}
diff --git a/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java b/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java
index b5ed481..8d4827c 100644
--- a/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java
+++ b/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java
@@ -67,4 +67,18 @@ public interface PeerHandle {
default String agentSessionId() {
return null;
}
+
+ /**
+ * The charter receipt (CB-571) for this peer's launch — the fingerprint of the exact charter
+ * bytes it was started with. {@code null} when the launcher records none (a non-instrumented
+ * adapter, or a launcher before this field); the session registry stores it so the spawn result
+ * and the roster row can show an operator which charter a member actually got.
+ *
+ *
Deliberately not a {@code default}: a decorator that forgets to override this silently
+ * answers {@code null} for a question it has no basis to answer, and the gap surfaces only as
+ * a missing roster field, not a compile error. Every implementation must answer explicitly.
+ *
+ * @return the fingerprint, or {@code null} when the launcher carries none
+ */
+ CharterReceipt charterReceipt();
}
diff --git a/bridged/src/main/java/dev/ltms/bridged/session/MemberSession.java b/bridged/src/main/java/dev/ltms/bridged/session/MemberSession.java
index 8c80bdb..9726e06 100644
--- a/bridged/src/main/java/dev/ltms/bridged/session/MemberSession.java
+++ b/bridged/src/main/java/dev/ltms/bridged/session/MemberSession.java
@@ -1,5 +1,6 @@
package dev.ltms.bridged.session;
+import dev.ltms.bridged.peer.CharterReceipt;
import dev.ltms.bridged.peer.MemberRole;
/**
@@ -23,6 +24,8 @@ import dev.ltms.bridged.peer.MemberRole;
* @param lastActivityAtNanos {@link System#nanoTime()} of the most recent lifecycle event
* @param turnCount number of delegated turns that have been delivered to this session
* @param state current lifecycle state in the one-shot FSM
+ * @param charterReceipt the fingerprint (CB-571) of the charter bytes this member was started
+ * with; {@code null} for a session whose launcher recorded none
*/
public record MemberSession(
String paneId,
@@ -36,7 +39,8 @@ public record MemberSession(
int turnCount,
State state,
String worktree,
- String branch) {
+ String branch,
+ CharterReceipt charterReceipt) {
/** One-shot worker lifecycle states. */
public enum State {
@@ -48,21 +52,34 @@ public record MemberSession(
RELEASED
}
+ /**
+ * Backward-compatible shape: a session with no charter receipt (a test or a launcher before
+ * CB-571). A separate constructor rather than a new parameter on the canonical one, so existing
+ * call sites that have nothing to record keep compiling unchanged.
+ */
+ public MemberSession(String paneId, String terminalId, String profile, MemberRole role,
+ String cwd, String ownerTerminal, long spawnedAtNanos,
+ long lastActivityAtNanos, int turnCount, State state,
+ String worktree, String branch) {
+ this(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos,
+ lastActivityAtNanos, turnCount, state, worktree, branch, null);
+ }
+
/** Return a copy of this session in {@code state}. */
public MemberSession withState(State state) {
return new MemberSession(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos,
- lastActivityAtNanos, turnCount, state, worktree, branch);
+ lastActivityAtNanos, turnCount, state, worktree, branch, charterReceipt);
}
/** Return a copy with {@code lastActivityAtNanos} updated to {@code nowNanos}. */
public MemberSession withActivity(long nowNanos) {
return new MemberSession(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos,
- nowNanos, turnCount, state, worktree, branch);
+ nowNanos, turnCount, state, worktree, branch, charterReceipt);
}
/** Return a copy with the turn count incremented and activity timestamped at {@code nowNanos}. */
public MemberSession bumpTurn(long nowNanos) {
return new MemberSession(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos,
- nowNanos, turnCount + 1, state, worktree, branch);
+ nowNanos, turnCount + 1, state, worktree, branch, charterReceipt);
}
}
diff --git a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java
index 4f5bc72..0a7563c 100644
--- a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java
+++ b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java
@@ -163,7 +163,8 @@ public final class SessionManager implements TurnListener {
0,
MemberSession.State.SPAWNING,
null,
- null);
+ null,
+ handle.charterReceipt());
registry.put(handle.id(), session);
memberLifecycle.acquired(session.role(), session.profile(), session.terminalId());
log.debug("acquired session id={} terminal={} profile={} owner={}",
@@ -355,7 +356,8 @@ public final class SessionManager implements TurnListener {
0,
MemberSession.State.SPAWNING,
path,
- branch);
+ branch,
+ handle.charterReceipt());
registry.put(handle.id(), session);
memberLifecycle.acquired(session.role(), session.profile(), session.terminalId());
log.debug("acquired worktree session id={} terminal={} profile={} branch={} path={}",
@@ -421,6 +423,16 @@ public final class SessionManager implements TurnListener {
if (session.ownerTerminal() != null) {
m.put("owner", session.ownerTerminal());
}
+ // CB-571: which charter this member was started with — never the charter text itself. The
+ // digest lets a lead tell at a glance whether all members got the same charter; the source
+ // records whether a role charter was configured ("fleet.charters.") or only the reply
+ // charter was composed ("none").
+ if (session.charterReceipt() != null) {
+ m.put("charterSource", session.charterReceipt().charterSource());
+ if (session.charterReceipt().charterSha256() != null) {
+ m.put("charterSha256", session.charterReceipt().charterSha256());
+ }
+ }
m.put("liveStatus", live == null ? "unknown" : live.status().name().toLowerCase());
return m;
}
diff --git a/bridged/src/test/java/dev/ltms/bridged/member/CompositePeerLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/member/CompositePeerLauncherTest.java
index f73ccf8..b1f1994 100644
--- a/bridged/src/test/java/dev/ltms/bridged/member/CompositePeerLauncherTest.java
+++ b/bridged/src/test/java/dev/ltms/bridged/member/CompositePeerLauncherTest.java
@@ -10,6 +10,7 @@ import dev.ltms.bridged.herdr.AgentControl;
import dev.ltms.bridged.herdr.FakeHerdr;
import dev.ltms.bridged.herdr.WorkspaceControl;
import dev.ltms.bridged.peer.Capability;
+import dev.ltms.bridged.peer.CharterReceipt;
import dev.ltms.bridged.peer.MemberRole;
import dev.ltms.bridged.peer.PeerHandle;
import dev.ltms.bridged.peer.PeerLauncher;
@@ -93,6 +94,7 @@ class CompositePeerLauncherTest {
@Override public String id() { return "pane-" + p; }
@Override public String terminalId() { return "term-" + p; }
@Override public String profile() { return p; }
+ @Override public CharterReceipt charterReceipt() { return null; }
};
}
diff --git a/bridged/src/test/java/dev/ltms/bridged/member/HerdrPeerLauncherCharterTest.java b/bridged/src/test/java/dev/ltms/bridged/member/HerdrPeerLauncherCharterTest.java
index 0da9740..c657744 100644
--- a/bridged/src/test/java/dev/ltms/bridged/member/HerdrPeerLauncherCharterTest.java
+++ b/bridged/src/test/java/dev/ltms/bridged/member/HerdrPeerLauncherCharterTest.java
@@ -1,13 +1,19 @@
package dev.ltms.bridged.member;
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
import dev.ltms.bridged.config.BridgedConfig;
import dev.ltms.bridged.herdr.AgentControl;
import dev.ltms.bridged.herdr.FakeHerdr;
import dev.ltms.bridged.herdr.WorkspaceControl;
import dev.ltms.bridged.peer.Capability;
+import dev.ltms.bridged.peer.CharterReceipt;
import dev.ltms.bridged.peer.MemberRole;
import dev.ltms.bridged.peer.SpawnRequest;
import org.junit.jupiter.api.Test;
+import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
@@ -17,6 +23,8 @@ import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
class HerdrPeerLauncherCharterTest {
@@ -38,10 +46,72 @@ class HerdrPeerLauncherCharterTest {
"a role charter does not depend on an MCP mount");
}
+ @Test
+ void panePlacementSpawnLogNeverContainsTheCharterText() {
+ // A pane-placement spawn used to log the whole argv (CB-571), and the charter travels
+ // inside argv — so the charter text leaked to the daemon log. Prove the legacy pane path
+ // now redacts it to its digest.
+ String secret = "TOP SECRET charter marker 99x"; // distinctive, so a leak is unambiguous
+ AtomicReference fleet = new AtomicReference<>(fleet(Map.of("dev", secret)));
+ CharterArgLauncher launcher = new CharterArgLauncher(fleet::get);
+
+ Logger logger = (Logger) LoggerFactory.getLogger(HerdrPeerLauncher.class);
+ Level previous = logger.getLevel();
+ ListAppender appender = new ListAppender<>();
+ appender.start();
+ logger.addAppender(appender);
+ logger.setLevel(Level.INFO); // the test logback sets dev.ltms.bridged to WARN; a leak lives at INFO
+ try {
+ launcher.spawn(new SpawnRequest("mcp", null, null, null, null, MemberRole.DEV));
+
+ String all = String.join("\n", appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList());
+ assertFalse(all.contains(secret),
+ "the pane-placement spawn log must not contain the charter text; got:\n" + all);
+ // The "mcp" profile composes role + reply charter; the digest must match that composed
+ // string (the exact bytes the adapter receives), proving the redaction hashes and
+ // removes the real, full charter — not some placeholder.
+ String composed = secret + "\n\n" + HerdrPeerLauncher.REPLY_CHARTER;
+ assertTrue(all.contains(""),
+ "the charter argv argument should be replaced by its digest; got:\n" + all);
+ } finally {
+ logger.setLevel(previous);
+ logger.detachAppender(appender);
+ }
+ }
+
private static BridgedConfig.Fleet fleet(Map charters) {
return new BridgedConfig.Fleet(Map.of(), Map.of(), Map.of(), Map.of(), charters, null);
}
+ private static BridgedConfig.Profile profile(String name, String mcpUrl) {
+ return new BridgedConfig.Profile(name, "http://gx00.gw:8000", null, null,
+ "BRIDGED_WORKER_TOKEN", List.of("test"), "pane", null, null, mcpUrl, null, null);
+ }
+
+ /**
+ * A launcher whose {@code buildLaunch} hands the composed charter to herdr as one argv element
+ * (what the claude-cod adapter does), so a pane-placement spawn log would print it unless the
+ * base redacts it.
+ */
+ private static final class CharterArgLauncher extends HerdrPeerLauncher {
+
+ CharterArgLauncher(Supplier fleet) {
+ super("test", new AgentControl(new FakeHerdr()), new WorkspaceControl(new FakeHerdr()),
+ Map.of("mcp", profile("mcp", "http://bridge")),
+ "mcp", _ -> null, 0, () -> 0L, () -> { }, fleet);
+ }
+
+ @Override
+ protected Launch buildLaunch(BridgedConfig.Profile cfg, LaunchSpec spec) {
+ return new Launch(Map.of(), List.of("test", spec.charter() == null ? "none" : spec.charter()));
+ }
+
+ @Override
+ public Set capabilities() {
+ return Set.of();
+ }
+ }
+
private static final class CapturingLauncher extends HerdrPeerLauncher {
private final List specs = new ArrayList<>();
@@ -62,10 +132,5 @@ class HerdrPeerLauncherCharterTest {
public Set capabilities() {
return Set.of();
}
-
- private static BridgedConfig.Profile profile(String name, String mcpUrl) {
- return new BridgedConfig.Profile(name, "http://gx00.gw:8000", null, null,
- "BRIDGED_WORKER_TOKEN", List.of("test"), "pane", null, null, mcpUrl, null, null);
- }
}
}
diff --git a/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java
index b4dee40..5c1600e 100644
--- a/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java
+++ b/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java
@@ -7,6 +7,7 @@ import dev.ltms.bridged.herdr.AgentControl;
import dev.ltms.bridged.herdr.FakeHerdr;
import dev.ltms.bridged.herdr.WorkspaceControl;
import dev.ltms.bridged.peer.Capability;
+import dev.ltms.bridged.peer.CharterReceipt;
import dev.ltms.bridged.peer.PeerHandle;
import dev.ltms.bridged.peer.PeerUnreachableException;
import dev.ltms.bridged.peer.SpawnRequest;
@@ -322,6 +323,26 @@ class OpenCodeLauncherTest {
assertFalse(herdr.called("agent.get"), "no polling when the gate is disabled");
}
+ @Test
+ void handleCarriesTheRealCharterReceiptNotTheInterfaceDefault(@TempDir Path root) {
+ // The base's WorkerHandle computes a real CharterReceipt (CB-571), but the opencode adapter
+ // wraps it in SessionAwareHandle for lazy session discovery. Before this fix that decorator
+ // did not override charterReceipt(), so it silently inherited PeerHandle's `null` default
+ // and the real receipt sitting on its delegate was lost.
+ FakeHerdr herdr = new FakeHerdr();
+ BridgedConfig.Fleet fleet = new BridgedConfig.Fleet(Map.of(), Map.of(), Map.of(), Map.of(),
+ Map.of("dev", "role rule"), null);
+ PeerHandle handle = service(herdr, root,
+ opencodeCfg("google/gemini-2.5-pro", "http://127.0.0.1:8765/mcp", null), () -> fleet)
+ .spawn(new SpawnRequest(null, null, null));
+
+ assertNotNull(handle.charterReceipt(),
+ "an opencode spawn's charterReceipt() must not silently be null");
+ String composed = "role rule\n\n" + HerdrPeerLauncher.REPLY_CHARTER;
+ assertEquals(CharterReceipt.digestOf(composed), handle.charterReceipt().charterSha256(),
+ "the receipt on the wrapped handle must match the exact composed charter bytes");
+ }
+
// --- CB-508: pinned OpenAI-compatible endpoint (e.g. a local vLLM) ---------------------------
/** A profile with a baseUrl but no model provider prefix cannot be resolved — fail loudly. */
diff --git a/bridged/src/test/java/dev/ltms/bridged/peer/CharterReceiptTest.java b/bridged/src/test/java/dev/ltms/bridged/peer/CharterReceiptTest.java
new file mode 100644
index 0000000..5d7e976
--- /dev/null
+++ b/bridged/src/test/java/dev/ltms/bridged/peer/CharterReceiptTest.java
@@ -0,0 +1,57 @@
+package dev.ltms.bridged.peer;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CharterReceiptTest {
+
+ @Test
+ void recordsNoCharterConfiguredDistinctFromCharterDelivered() {
+ // No role charter configured — only the reply charter is composed. Source is "none", but
+ // text was still delivered, so absent() is false and the digest is present.
+ CharterReceipt viaReply = CharterReceipt.compose(MemberRole.DEV, "s", null, "reply charter");
+ // A role charter was configured AND delivered.
+ CharterReceipt delivered = CharterReceipt.compose(MemberRole.DEV, "s", "role charter",
+ "role charter\n\nreply charter");
+
+ // The two cases must not collapse: the no-role-charter case reports "none", the delivered
+ // case reports the config key, and their digests differ.
+ assertEquals(CharterReceipt.NO_SOURCE, viaReply.charterSource());
+ assertEquals("fleet.charters.dev", delivered.charterSource());
+ assertNotEquals(viaReply.charterSource(), delivered.charterSource());
+ assertNotEquals(viaReply.charterSha256(), delivered.charterSha256());
+
+ // Both actually delivered text — the distinction is the source and digest, not absence.
+ assertFalse(viaReply.absent());
+ assertFalse(delivered.absent());
+ }
+
+ @Test
+ void recordsExplicitAbsenceWhenNoCharterIsComposed() {
+ CharterReceipt none = CharterReceipt.compose(MemberRole.REVIEWER, "s", null, null);
+ assertTrue(none.absent());
+ assertNull(none.charterSha256());
+ assertEquals(0, none.charterBytes());
+ assertEquals(CharterReceipt.NO_SOURCE, none.charterSource(),
+ "no configured charter and nothing composed still reports a source, never a gap");
+ }
+
+ @Test
+ void digestIsStableForSameTextAndDiffersForDifferentText() {
+ assertEquals(CharterReceipt.digestOf("charter-aaa"), CharterReceipt.digestOf("charter-aaa"),
+ "the same text must always produce the same digest");
+ assertNotEquals(CharterReceipt.digestOf("charter-aaa"), CharterReceipt.digestOf("charter-bbb"),
+ "different text must produce a different digest");
+ assertNull(CharterReceipt.digestOf(""), "blank text carries no digest");
+
+ // The record's fingerprint matches the standalone digest for the same composed string.
+ CharterReceipt r = CharterReceipt.compose(MemberRole.DEV, "s", "role", "the composed text");
+ assertEquals(CharterReceipt.digestOf("the composed text"), r.charterSha256());
+ assertFalse(r.absent());
+ }
+}
diff --git a/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java b/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java
index 422831a..d8d9009 100644
--- a/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java
+++ b/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java
@@ -11,6 +11,8 @@ import dev.ltms.bridged.herdr.FakeHerdr;
import dev.ltms.bridged.herdr.WorkspaceControl;
import dev.ltms.bridged.member.ClaudeCodeLauncher;
import dev.ltms.bridged.msg.TestTurnTokens;
+import dev.ltms.bridged.peer.CharterReceipt;
+import dev.ltms.bridged.peer.MemberRole;
import dev.ltms.bridged.peer.PeerUnreachableException;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
@@ -91,6 +93,24 @@ class SessionManagerTest {
assertEquals(2, sessions.roster().size(), "both sessions are registered");
}
+ @Test
+ void rosterViewExposesTheCharterReceiptButNeverTheCharterText() {
+ // The roster (bridge_list and GET /members both render through rosterView) must let a lead
+ // see which charter a member got, without ever carrying the charter prose itself (CB-571).
+ MemberSession s = new MemberSession("p1", "term1", "prof", MemberRole.DEV, "/cwd", null,
+ 0, 0, 0, MemberSession.State.READY, null, null,
+ CharterReceipt.compose(MemberRole.DEV, "prof", "role charter", "role charter\n\nreply"));
+
+ Map view = SessionManager.rosterView(s, null);
+
+ assertEquals("fleet.charters.dev", view.get("charterSource"),
+ "the config key that supplied the role charter is reported");
+ assertEquals(CharterReceipt.digestOf("role charter\n\nreply"), view.get("charterSha256"),
+ "the digest of the exact composed charter bytes is reported");
+ assertFalse(view.values().toString().contains("role charter"),
+ "the roster row must not embed the charter text itself");
+ }
+
@Test
void aNullTerminalFromThePrimaryIsANoOpEvenWithSessionsRegistered() {
// The primary resolves to a Principal with no terminal, and BridgeMcp's context extractor