From 8b986a52e0dd7f766f8e7d7538b9c8f2517835ea Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Sun, 20 Sep 2026 16:14:45 +0700 Subject: [PATCH 1/2] #604 item 1: fleet_list reports charterBytes alongside charterSha256 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CharterReceipt carries a byte count next to its digest, but the roster projection in SessionManager.rosterView only ever copied the digest across. A digest tells a lead whether two members' charters match; it cannot say how far apart they are when they don't. Report charterBytes too, nested in the same conditional as charterSha256 so the two travel together: the receipt's own contract only ever pairs a non-null digest with a real byte count, and a member with no composed charter reports charterSource alone, unchanged from before. Tests: the existing charter-receipt roster test now asserts charterBytes against the receipt's own value (not a literal), plus two new cases — no charter composed (source "none", no digest, no size) and the receipt itself absent (no charter keys at all). --- .../ltms/fleet/session/SessionManager.java | 7 +++ .../fleet/session/SessionManagerTest.java | 44 ++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java b/fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java index 1d74980..af23289 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java +++ b/fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java @@ -871,10 +871,17 @@ public final class SessionManager implements TurnListener { // 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"). + // #604: charterBytes rides along with charterSha256, not with charterSource — it is only + // meaningful as the digest's companion (a length turns "they differ" into "by how much"), + // and the receipt only ever pairs a non-null digest with a real byte count (CharterReceipt's + // own contract: no composed charter is an explicit null digest AND a zero byte count, never + // one without the other). A member with no composed charter at all reports charterSource and + // nothing else, exactly as before this change. if (session.charterReceipt() != null) { m.put("charterSource", session.charterReceipt().charterSource()); if (session.charterReceipt().charterSha256() != null) { m.put("charterSha256", session.charterReceipt().charterSha256()); + m.put("charterBytes", session.charterReceipt().charterBytes()); } } m.put("liveStatus", live == null ? "unknown" : live.status().name().toLowerCase()); diff --git a/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java b/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java index 886f1ff..60c3590 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java @@ -328,9 +328,10 @@ class SessionManagerTest { void rosterViewExposesTheCharterReceiptButNeverTheCharterText() { // The roster (fleet_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). + String composed = "role charter\n\nreply"; + CharterReceipt receipt = CharterReceipt.compose(MemberRole.DEV, "prof", "role charter", composed); 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"), null); + 0, 0, 0, MemberSession.State.READY, null, null, receipt, null); Map view = SessionManager.rosterView(s, null); @@ -338,10 +339,49 @@ class SessionManagerTest { "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"); + // #604: the byte count rides alongside the digest, and must match what the receipt itself + // carries (not a hardcoded literal) so a bug that reads the wrong field is caught. + assertEquals(receipt.charterBytes(), view.get("charterBytes"), + "the exact composed byte count is reported, read from the receipt"); + assertEquals(composed.getBytes(java.nio.charset.StandardCharsets.UTF_8).length, view.get("charterBytes"), + "the byte count is the real UTF-8 length of the composed charter"); assertFalse(view.values().toString().contains("role charter"), "the roster row must not embed the charter text itself"); } + @Test + void rosterViewOmitsCharterBytesAndDigestWhenNoCharterWasComposed() { + // #604: a member with no role charter and no reply charter (composed == null) still reports + // charterSource ("none"), but neither a digest nor a size — the digest is absent, and the + // size only ever accompanies a real digest. This must not be satisfiable by code that always + // writes charterBytes. + CharterReceipt receipt = CharterReceipt.compose(MemberRole.DEV, "prof", null, null); + MemberSession s = new MemberSession("p1", "term1", "prof", MemberRole.DEV, "/cwd", null, + 0, 0, 0, MemberSession.State.READY, null, null, receipt, null); + + Map view = SessionManager.rosterView(s, null); + + assertEquals(CharterReceipt.NO_SOURCE, view.get("charterSource"), + "no configured role or reply charter reports the explicit \"none\" source"); + assertFalse(view.containsKey("charterSha256"), "no digest is reported when no charter was composed"); + assertFalse(view.containsKey("charterBytes"), "no byte count is reported when no charter was composed"); + } + + @Test + void rosterViewOmitsCharterFieldsEntirelyWhenTheReceiptItselfIsAbsent() { + // #604 acceptance criterion 3: charterReceipt() can be null on its own (a session recorded + // before CB-571, or a launcher that never composed one) — the outer null-guard must still + // suppress charterSource, charterSha256 AND charterBytes together. + MemberSession s = new MemberSession("p1", "term1", "prof", MemberRole.DEV, "/cwd", null, + 0, 0, 0, MemberSession.State.READY, null, null, null, null); + + Map view = SessionManager.rosterView(s, null); + + assertFalse(view.containsKey("charterSource"), "no charter fields at all when the receipt is null"); + assertFalse(view.containsKey("charterSha256"), "no charter fields at all when the receipt is null"); + assertFalse(view.containsKey("charterBytes"), "no charter fields at all when the receipt is null"); + } + @Test void aNullTerminalFromThePrimaryIsANoOpEvenWithSessionsRegistered() { // The primary resolves to a Principal with no terminal, and FleetMcp's context extractor -- 2.52.0 From ad3d81941f46f3046de95136e043f7bfc2d16a25 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Sun, 20 Sep 2026 16:16:30 +0700 Subject: [PATCH 2/2] Correct the invariant claimed in the charterBytes comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment said CharterReceipt never pairs a null digest with a non-zero byte count. It can. compose() derives the digest with digestOf(), which returns null for blank text, while the byte count is getBytes().length, which does not. A whitespace-only role charter on a profile with no MCP produces exactly that pair. No behaviour change. The gate already omits both fields on that path, which is the right answer — a size with no digest would describe an artifact we cannot fingerprint. Only the stated reason was wrong, and a false invariant in a comment is worse than no comment, because the next reader will widen the gate on the strength of it. --- .../dev/ltms/fleet/session/SessionManager.java | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java b/fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java index af23289..ae06834 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java +++ b/fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java @@ -872,11 +872,17 @@ public final class SessionManager implements TurnListener { // records whether a role charter was configured ("fleet.charters.") or only the reply // charter was composed ("none"). // #604: charterBytes rides along with charterSha256, not with charterSource — it is only - // meaningful as the digest's companion (a length turns "they differ" into "by how much"), - // and the receipt only ever pairs a non-null digest with a real byte count (CharterReceipt's - // own contract: no composed charter is an explicit null digest AND a zero byte count, never - // one without the other). A member with no composed charter at all reports charterSource and - // nothing else, exactly as before this change. + // meaningful as the digest's companion (a length turns "they differ" into "by how much"). + // A member with no composed charter reports charterSource and nothing else, as before. + // + // Gating on the DIGEST rather than on the receipt is deliberate, and the two are not + // always null together. CharterReceipt.compose() derives the digest with digestOf(), which + // returns null for BLANK text, while the byte count is composed.getBytes().length, which + // does not. So a whitespace-only charter (a blank fleet.charters. on a profile with + // no MCP, so no reply charter is appended) yields a null digest beside a non-zero size. + // Reporting a size with no digest would say "they differ by N bytes" about an artifact we + // cannot fingerprint, so this gate omits both. Never widen it to the receipt-level null + // check without deciding what that case should report. if (session.charterReceipt() != null) { m.put("charterSource", session.charterReceipt().charterSource()); if (session.charterReceipt().charterSha256() != null) { -- 2.52.0