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