@cello-protocol/daemon 0.0.189 → 0.0.191

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,7 +31,7 @@ var _a;
31
31
  import { createHash } from "node:crypto";
32
32
  import * as lp from "it-length-prefixed";
33
33
  import { decode } from "cbor-x";
34
- import { encodeCbor, decodeSealPayload, encodeStructure1, decodeStructure1 } from "@cello-protocol/protocol-types";
34
+ import { encodeCbor, decodeSealPayload, encodeStructure1, decodeStructure1, computeGenesisPrevRoot } from "@cello-protocol/protocol-types";
35
35
  import { verify } from "@cello-protocol/crypto";
36
36
  import { extractErrorMessage } from "./error-message.js";
37
37
  import { evaluateRelayAck } from "./relay-receipt-store.js";
@@ -197,6 +197,22 @@ function buildWitnessAlertTbs(sessionId, reason, observedAt, submitterIsCounterp
197
197
  const body = encodeCbor([RELAY_WITNESS_DOMAIN, sessionId, reason, observedAt, submitterIsCounterparty]);
198
198
  return new Uint8Array(createHash("sha256").update(body).digest());
199
199
  }
200
+ /**
201
+ * The genesis prev_root derivable from a relay assignment — 033-ACKEMIT.
202
+ *
203
+ * The carry holds both participant keys and the session timestamp, which with the session id are
204
+ * exactly `computeGenesisPrevRoot`'s inputs. Returns `undefined` when there is no assignment, and
205
+ * the caller then has no seed: absence is reported at the submit, never papered over.
206
+ *
207
+ * ⚠️ NOT 32 ZERO BYTES, and not any other constant. A value identical across every session is one
208
+ * an attacker can present for any session, which would make the first message's acknowledgement
209
+ * unfalsifiable exactly where it is most exposed.
210
+ */
211
+ function genesisFromAssignment(sessionIdHex, assignment) {
212
+ if (!assignment)
213
+ return undefined;
214
+ return computeGenesisPrevRoot(assignment.participantA, assignment.participantB, Uint8Array.from(Buffer.from(sessionIdHex, "hex")), assignment.sessionTimestamp);
215
+ }
200
216
  function toU8(v) {
201
217
  if (v instanceof Uint8Array)
202
218
  return v;
@@ -266,10 +282,25 @@ export class AgentRelayClient {
266
282
  #connecting = null;
267
283
  #closed = false;
268
284
  /**
269
- * PER-SESSION highest relay-assigned sequence (session_id hex → seq). The relay's
270
- * `seq_counter` is per session, and it rejects `last_seen_seq > seq_counter`, so each
271
- * session's submit MUST carry that session's own high-water mark NOT an agent-global
272
- * one (which would make a newer session's first submit look ahead and get rejected).
285
+ * PER-SESSION acknowledgement state (session_id hex → the position AND the content at it).
286
+ *
287
+ * `seq` is the highest relay-assigned sequence. The relay's `seq_counter` is per session, and it
288
+ * rejects `last_seen_seq > seq_counter`, so each session's submit MUST carry that session's own
289
+ * high-water mark — NOT an agent-global one (which would make a newer session's first submit look
290
+ * ahead and get rejected).
291
+ *
292
+ * ⚠️ `hash` IS THE SAME FACT AS `seq`, WHICH IS WHY THEY LIVE IN ONE ENTRY — 033-ACKEMIT.
293
+ *
294
+ * `last_seen_seq` is a NUMBER: "I saw position 7" attests to a POSITION and never to CONTENT, so
295
+ * a signed acknowledgement was an unbacked number that only the relay's separate receipt gave any
296
+ * meaning to. `hash` is the content hash of the message AT that position, and the two are written
297
+ * together in `#bumpLastSeen` from ONE decode of ONE leaf. They cannot be assigned apart, so they
298
+ * cannot come to mean different messages — which is the defect this unit exists to remove, not a
299
+ * disagreement to reconcile later.
300
+ *
301
+ * Seeded at `registerSession` with `{ seq: 0, hash: genesisPrevRoot }`: the first message of a
302
+ * session has seen nothing, and that case is a DEFINED 32-byte value — the agreed starting point
303
+ * of this two-party chain — never a missing field and never a fallback to v1.
273
304
  */
274
305
  #lastSeen = new Map();
275
306
  /** The one outstanding submit's resolver (global FIFO — ack carries no session_id). */
@@ -320,17 +351,43 @@ export class AgentRelayClient {
320
351
  * (idempotent). Storing the node per session lets a pure-receiver session re-establish
321
352
  * the shared stream if the node that originally dialed is torn down.
322
353
  */
323
- registerSession(sessionIdHex, node, onLeafDeliver, assignment) {
354
+ registerSession(sessionIdHex, node, onLeafDeliver, assignment,
355
+ /**
356
+ * 033-ACKEMIT — the session's genesis prev_root: what the FIRST message of this session
357
+ * acknowledges, before anything has been received.
358
+ *
359
+ * Supplied by the caller because `session-node-manager` is where the session record lives. When
360
+ * it is absent an ASSIGNMENT can still produce it (both participant keys and the session
361
+ * timestamp are on the carry), and that covers re-registration of a session whose row predates
362
+ * the column. When NEITHER is available the session has no seed, and its first submit claims
363
+ * position 0 with no hash — which asserts nothing about content and is therefore honest, rather
364
+ * than a claim about a position it cannot back.
365
+ */
366
+ genesisPrevRoot) {
324
367
  const existing = this.#sessions.get(sessionIdHex);
368
+ const carriedAssignment = assignment ?? existing?.assignment;
325
369
  this.#sessions.set(sessionIdHex, {
326
370
  node,
327
371
  onLeafDeliver: onLeafDeliver ?? (() => { }),
328
372
  // Carry the assignment forward across re-registration; never lose a recorded flag on re-register.
329
- assignment: assignment ?? existing?.assignment,
373
+ assignment: carriedAssignment,
330
374
  recorded: existing?.recorded ?? false,
331
375
  recordRejected: existing?.recordRejected ?? false,
332
376
  recordTimedOut: existing?.recordTimedOut ?? false,
333
377
  });
378
+ /**
379
+ * SEED THE ACKNOWLEDGEMENT, and only when there is nothing to lose.
380
+ *
381
+ * A session that has already received a leaf holds a REAL `{ seq, hash }`; overwriting it with
382
+ * the genesis on a re-registration would walk the acknowledgement backwards to "I have seen
383
+ * nothing" for a conversation that is well underway — and the relay would then answer the next
384
+ * submit from a position we had already passed.
385
+ */
386
+ if (!this.#lastSeen.has(sessionIdHex)) {
387
+ const seed = genesisPrevRoot ?? genesisFromAssignment(sessionIdHex, carriedAssignment);
388
+ if (seed)
389
+ this.#lastSeen.set(sessionIdHex, { seq: 0, hash: seed });
390
+ }
334
391
  // Eagerly present the assignment so the relay records the session (binds peer IDs, creates the
335
392
  // session entry) BEFORE the first hash_submit or the counterparty's leaves arrive — the relay
336
393
  // rejects frames for a session it has not recorded. Best-effort + serialized on the submit chain
@@ -495,12 +552,25 @@ export class AgentRelayClient {
495
552
  catch { /* best-effort */ }
496
553
  }
497
554
  }
498
- #bumpLastSeen(sessionIdHex, seq) {
555
+ /**
556
+ * Advance this session's acknowledgement to a counterparty leaf: the POSITION and the CONTENT
557
+ * AT IT, written together (033-ACKEMIT).
558
+ *
559
+ * `contentHash` comes from the same `decodeStructure1` of the same leaf that produced `seq`, so
560
+ * the pair describes one message by construction. There is no path that advances one without the
561
+ * other, and that is deliberate: a `last_seen_seq` and a `last_seen_hash` that could drift apart
562
+ * would let this daemon sign an acknowledgement of a message it never saw.
563
+ *
564
+ * Monotonic on `seq` — a re-delivery of an earlier leaf must not walk the acknowledgement
565
+ * backwards, and it must not swap the hash under an unchanged position either.
566
+ */
567
+ #bumpLastSeen(sessionIdHex, seq, contentHash) {
499
568
  if (seq < 0)
500
569
  return;
501
- const prev = this.#lastSeen.get(sessionIdHex) ?? 0;
502
- if (seq > prev)
503
- this.#lastSeen.set(sessionIdHex, seq);
570
+ const prev = this.#lastSeen.get(sessionIdHex);
571
+ if (prev && seq <= prev.seq)
572
+ return;
573
+ this.#lastSeen.set(sessionIdHex, { seq, hash: contentHash });
504
574
  }
505
575
  /** True if this Structure-1 leaf was authored by US (sender_pubkey === our K_local). */
506
576
  #isOwnLeaf(structure1Cbor) {
@@ -587,15 +657,44 @@ export class AgentRelayClient {
587
657
  // submit's paired in-flight values. Best-effort + separate from the receipt write.
588
658
  const structure2Cbor = frame["structure2_cbor"] instanceof Uint8Array ? frame["structure2_cbor"] : undefined;
589
659
  if (this.#sealLeafStore && structure2Cbor && structure1Cbor && this.#pendingLeafKind !== null) {
660
+ /**
661
+ * ⚠️ **THE AUTHOR COMES FROM THE SIGNED BYTES, AND THE RECEIPT ONLY ATTACHES TO OUR OWN
662
+ * LEAF — 034-CARRYLEAF review F3.**
663
+ *
664
+ * This wrote `senderPubkeyHex: this.senderPubkeyHex` unconditionally, which was true for
665
+ * as long as the only thing a client could submit was its own leaf. `witnessReceivedLeaf`
666
+ * ended that: on a counter-submit `structure1Cbor` holds the COUNTERPARTY's bytes, so
667
+ * this row labelled their leaf as ours — and attached a relay receipt to it, which is the
668
+ * one thing that must never happen to a leaf we did not author, because a receipt is what
669
+ * pins OUR leaves to a sequence we could otherwise renumber.
670
+ *
671
+ * It was inert on the wire by luck: `sender_pubkey_hex` is not transmitted and the
672
+ * directory re-derives the author from `structure2_cbor`. It was never inert locally —
673
+ * the store is `INSERT OR IGNORE` on `(agent, session, sequence)`, and the ack arrives
674
+ * BEFORE the `leaf_deliver` echo, so this row won and the correct one was silently
675
+ * dropped.
676
+ *
677
+ * Same rule as everywhere else on this path: the identity comes from inside the bytes
678
+ * the author signed, never from an ambient value that happens to be right today.
679
+ */
680
+ const authored = decodeStructure1(structure1Cbor);
681
+ const authorHex = authored.ok
682
+ ? Buffer.from(authored.fields.senderPubkey).toString("hex")
683
+ : this.senderPubkeyHex;
684
+ const ourOwnLeaf = authorHex === this.senderPubkeyHex;
590
685
  this.#sealLeafStore.store(this.senderPubkeyHex, ev.receipt.sessionIdHex, {
591
686
  sequenceNumber: seq,
592
687
  leafKind: this.#pendingLeafKind,
593
- senderPubkeyHex: this.senderPubkeyHex,
688
+ senderPubkeyHex: authorHex,
594
689
  structure2Cbor,
595
690
  structure1Cbor,
596
- relayId: ev.receipt.relayId,
597
- relayTimestamp: ev.receipt.timestamp,
598
- relaySignatureHex: ev.receipt.signatureHex,
691
+ ...(ourOwnLeaf
692
+ ? {
693
+ relayId: ev.receipt.relayId,
694
+ relayTimestamp: ev.receipt.timestamp,
695
+ relaySignatureHex: ev.receipt.signatureHex,
696
+ }
697
+ : {}),
599
698
  }, Date.now());
600
699
  }
601
700
  }
@@ -687,15 +786,100 @@ export class AgentRelayClient {
687
786
  const seq = typeof frame["sequence_number"] === "number" ? frame["sequence_number"] : -1;
688
787
  const sidHex = Buffer.from(toU8(frame["session_id"])).toString("hex");
689
788
  const s1 = toU8(frame["structure1_cbor"]);
690
- // Advance last_seen_seq ONLY for a COUNTERPARTY leaf. The relay also echoes our OWN
789
+ /**
790
+ * ONE DECODE, TWO CONSUMERS — 033-ACKEMIT. The acknowledgement bump and the seal-leaf capture
791
+ * below both need the sender's own signed fields, and they must agree about which leaf they
792
+ * are looking at. Decoding twice would let a future edit change one read and not the other.
793
+ */
794
+ const deliveredS1 = decodeStructure1(s1);
795
+ // Advance last_seen ONLY for a COUNTERPARTY leaf. The relay also echoes our OWN
691
796
  // leaf back as a leaf_deliver — that must NOT advance it (same reason as the ack above).
692
797
  const authoredByUs = this.#isOwnLeaf(s1);
693
- if (seq >= 0 && !authoredByUs)
694
- this.#bumpLastSeen(sidHex, seq);
798
+ /**
799
+ * The POSITION and the CONTENT AT IT, from the counterparty's own signed bytes.
800
+ *
801
+ * ⚠️ THE HASH COMES FROM INSIDE `structure1_cbor`, NEVER FROM AN ENVELOPE FIELD. The frame
802
+ * also carries `structure2_cbor`, which the RELAY built — taking the hash from there would key
803
+ * our acknowledgement on a value the witness supplies, and a tampering relay could then make
804
+ * us sign an acknowledgement of content the counterparty never sent. Index 1 of Structure 1 is
805
+ * inside the bytes the counterparty signed, so it is the one copy neither we nor the relay can
806
+ * move.
807
+ *
808
+ * A leaf whose layout this build cannot name advances NOTHING — position included. The
809
+ * previous code advanced `seq` from the envelope regardless, so an unreadable leaf could move
810
+ * the acknowledgement forward while leaving the hash behind it; refusing to advance keeps the
811
+ * pair describing one real message, and the relay's own decoder already gates what reaches
812
+ * here.
813
+ */
814
+ if (seq >= 0 && !authoredByUs) {
815
+ if (deliveredS1.ok) {
816
+ this.#bumpLastSeen(sidHex, seq, deliveredS1.fields.contentHash);
817
+ }
818
+ else {
819
+ this.#logger.warn("relay.leaf_deliver.unreadable", {
820
+ seq,
821
+ session: sidHex,
822
+ structure1Reason: deliveredS1.reason,
823
+ impact: "this delivered leaf could not be read, so this session's acknowledgement was NOT " +
824
+ "advanced to it. The next message this agent sends will acknowledge the last leaf it " +
825
+ "could read, which is honest — it never claims to have seen something it could not.",
826
+ });
827
+ }
828
+ }
695
829
  // Record the COUNTERPARTY's delivered leaf in the seal-leaf log (no relay
696
830
  // receipt — the relay does not ack-sign a delivery to the recipient). It is pinned at seal by the
697
831
  // absent party's sender_signature (unforgeable) + sequence contiguity against our receipt-pinned own
698
832
  // leaves. Our OWN echoed leaf is skipped here (it is recorded WITH its receipt on the ack path).
833
+ /**
834
+ * ─── OUR OWN LEAF, WITNESSED BY SOMEBODY ELSE — 034-CARRYLEAF review F2 ──────────────────
835
+ *
836
+ * ⚠️ **WITHOUT THIS, MAKING THE COUNTERPARTY ABLE TO WITNESS OUR LEAF COST US THE RECEIPT.**
837
+ *
838
+ * When the counterparty counter-submits a leaf WE authored, the relay assigns it a position
839
+ * and delivers it to us — and `authoredByUs` is true, so the capture below skipped it. We
840
+ * never submitted it ourselves, so no ack ever arrived and the ack path never wrote a row
841
+ * either. The result was a permanent hole at that position in our own carry: the unilateral
842
+ * seal refused it as `seal_carry_noncontiguous`, and the bilateral one refused to co-sign a
843
+ * root it could not judge. **An honest sender whose relay hiccuped once lost the receipt for
844
+ * the entire conversation** — for a message sitting in their own transcript.
845
+ *
846
+ * So the leaf is stored, **with NO relay receipt**, because we hold none: nobody acked it to
847
+ * us. That asymmetry is exactly the one the seal design already relies on. A bilateral seal
848
+ * needs a contiguous chain and gets one. A UNILATERAL seal additionally requires every one of
849
+ * our OWN leaves to carry a receipt — so a party who never witnesses their own messages still
850
+ * cannot seal alone on them (`unilateral_own_leaf_unwitnessed`), which is precisely what
851
+ * `DOD-M15-WITHHOLD-SEAL-1` intends.
852
+ *
853
+ * `INSERT OR IGNORE` keeps whichever row lands first, and a real receipt-bearing row for the
854
+ * same position can only come from our own ack — which cannot exist here, or we would have
855
+ * submitted it ourselves.
856
+ */
857
+ if (this.#sealLeafStore && seq >= 0 && authoredByUs && deliveredS1.ok) {
858
+ const s2Own = frame["structure2_cbor"];
859
+ if (s2Own instanceof Uint8Array && s1.length > 0) {
860
+ try {
861
+ const wrote = this.#sealLeafStore.store(this.senderPubkeyHex, sidHex, {
862
+ sequenceNumber: seq,
863
+ leafKind: typeof frame["leaf_kind"] === "number" ? frame["leaf_kind"] : LEAF_KIND_MSG,
864
+ senderPubkeyHex: this.senderPubkeyHex,
865
+ structure2Cbor: s2Own,
866
+ structure1Cbor: s1,
867
+ }, Date.now());
868
+ if (wrote) {
869
+ this.#logger.info("relay.seal_leaf.own.witnessed_by_counterparty", {
870
+ seq,
871
+ session: sidHex,
872
+ impact: "a message THIS agent wrote was witnessed by the counterparty rather than by us — " +
873
+ "our own submit did not land. The leaf is kept so this conversation can still be " +
874
+ "sealed together; sealing it alone would still need a receipt we do not hold.",
875
+ });
876
+ }
877
+ }
878
+ catch (err) {
879
+ this.#logger.error("relay.seal_leaf.own.store_failed", { seq, session: sidHex, error: extractErrorMessage(err) });
880
+ }
881
+ }
882
+ }
699
883
  if (this.#sealLeafStore && seq >= 0 && !authoredByUs) {
700
884
  const s2 = frame["structure2_cbor"];
701
885
  const structure2Cbor = s2 instanceof Uint8Array ? s2 : undefined;
@@ -705,9 +889,8 @@ export class AgentRelayClient {
705
889
  // #isOwnLeaf and #captureReceipt and was simply missed by the order's reader list, which is
706
890
  // exactly the "next layout change has to find them again" problem the shared decoder exists
707
891
  // to end. Behaviour for every relay-accepted leaf is unchanged; the fail-open closes.
708
- const s1Decoded = decodeStructure1(s1);
709
- const senderHex = s1Decoded.ok
710
- ? Buffer.from(s1Decoded.fields.senderPubkey).toString("hex")
892
+ const senderHex = deliveredS1.ok
893
+ ? Buffer.from(deliveredS1.fields.senderPubkey).toString("hex")
711
894
  : undefined;
712
895
  if (structure2Cbor && s1.length > 0 && senderHex) {
713
896
  try {
@@ -1220,6 +1403,28 @@ export class AgentRelayClient {
1220
1403
  */
1221
1404
  return this.submitLeaf(node, sessionId, contentHash, leafKind, null);
1222
1405
  }
1406
+ /**
1407
+ * ─── WITNESS A LEAF THIS AGENT RECEIVED BUT DID NOT AUTHOR — 034-CARRYLEAF ────────────────────
1408
+ *
1409
+ * **This is what closes `DOD-M15-WITHHOLD-SEAL-1`.** Until it existed, `submitMessageHash` had
1410
+ * one production caller on the SEND path, so nothing ever witnessed a message that was RECEIVED.
1411
+ * A counterparty who delivered a message directly and never submitted its hash left the relay's
1412
+ * account of the conversation one message short — permanently — and a unilateral seal then agreed
1413
+ * with the witness. Every leaf validly signed, nothing false, the last thing said simply absent.
1414
+ *
1415
+ * **The teeth are the author's own signature.** It arrived on the content frame beside the bytes
1416
+ * it signs, this daemon verified it before ingesting anything, and it cannot be forged here. The
1417
+ * relay verifies it again against the session's assignment before sequencing — so what this hands
1418
+ * over is a claim the author made and cannot disown.
1419
+ *
1420
+ * ⚠️ **THE BYTES ARE PASSED THROUGH, NEVER REBUILT.** A signature is over the encoded bytes, and
1421
+ * the one measured cost of forgetting that on this exact structure was a daemon-local encoder
1422
+ * emitting a timestamp as float64 where the published one promotes to uint64 — same value,
1423
+ * different signed bytes, refused by everyone.
1424
+ */
1425
+ async witnessReceivedLeaf(node, sessionId, contentHash, leafKind, carried) {
1426
+ return this.submitLeaf(node, sessionId, contentHash, leafKind, null, carried);
1427
+ }
1223
1428
  /**
1224
1429
  * Submit a leaf hash of a given kind (0x00 message / 0x02 control) to the relay. The SEAL
1225
1430
  * ctrl leaf rides this path: two distinct-sender ctrl leaves in the relay's
@@ -1263,7 +1468,15 @@ export class AgentRelayClient {
1263
1468
  * before any test runs. Every caller must now say what this leaf carries, and `submitMessageHash`
1264
1469
  * says `null` in one visible place instead of by saying nothing at all.
1265
1470
  */
1266
- async submitLeaf(node, sessionId, contentHash, leafKind, contentBytes) {
1471
+ async submitLeaf(node, sessionId, contentHash, leafKind, contentBytes,
1472
+ /**
1473
+ * 034-CARRYLEAF — a leaf THIS AGENT DID NOT AUTHOR, carried on its author's behalf.
1474
+ *
1475
+ * Absent for every ordinary send, where this client builds and signs its own claim. Present
1476
+ * only when witnessing something received whose author never submitted it — see
1477
+ * `witnessReceivedLeaf`.
1478
+ */
1479
+ carried) {
1267
1480
  if (contentBytes !== null && leafKind !== LEAF_KIND_CTRL) {
1268
1481
  // Logged at ERROR and returned: a caller reaching this line is trying to hand the relay
1269
1482
  // operator content, and the log must carry it even if the caller swallows the result.
@@ -1331,7 +1544,7 @@ export class AgentRelayClient {
1331
1544
  }
1332
1545
  // Chain on the prior submit so only one is outstanding at a time (FIFO). The ack
1333
1546
  // carries no session_id, so concurrent submits on one stream would be ambiguous.
1334
- const run = this.#submitChain.then(() => this.#doSubmit(node, sessionId, contentHash, leafKind, contentBytes));
1547
+ const run = this.#submitChain.then(() => this.#doSubmit(node, sessionId, contentHash, leafKind, contentBytes, carried));
1335
1548
  // Keep the chain alive regardless of this submit's outcome.
1336
1549
  this.#submitChain = run.then(() => undefined, () => undefined);
1337
1550
  return run;
@@ -1379,7 +1592,7 @@ export class AgentRelayClient {
1379
1592
  * answering `retry_after_ms: 3600000`. Past this the send fails and says so.
1380
1593
  */
1381
1594
  static #RATE_LIMITED_MAX_WAIT_MS = 65_000;
1382
- async #doSubmit(node, sessionId, contentHash, leafKind, contentBytes) {
1595
+ async #doSubmit(node, sessionId, contentHash, leafKind, contentBytes, carried) {
1383
1596
  const sessionIdHex = Buffer.from(sessionId).toString("hex");
1384
1597
  // Snapshotted BEFORE the first attempt, and it is the whole safety of this loop.
1385
1598
  //
@@ -1397,7 +1610,7 @@ export class AgentRelayClient {
1397
1610
  // reported `session_not_found`, indistinguishable at the wire from the race — which is why
1398
1611
  // this must be discriminated on OUR state, not on the relay's reason string.
1399
1612
  const recordedBefore = this.#sessions.get(sessionIdHex)?.recorded === true;
1400
- let result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes);
1613
+ let result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes, carried);
1401
1614
  for (let attempt = 1; attempt < _a.#SESSION_NOT_FOUND_ATTEMPTS
1402
1615
  && !recordedBefore
1403
1616
  && !result.ok
@@ -1416,7 +1629,7 @@ export class AgentRelayClient {
1416
1629
  attempt,
1417
1630
  reason: result.reason,
1418
1631
  });
1419
- result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes);
1632
+ result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes, carried);
1420
1633
  }
1421
1634
  /**
1422
1635
  * DOD-M15-RELAYABUSE-1 review F1 — **A THROTTLE IS BACK-PRESSURE, NOT AN ERROR.** (Andre,
@@ -1451,7 +1664,7 @@ export class AgentRelayClient {
1451
1664
  await new Promise((r) => setTimeout(r, waitMs));
1452
1665
  if (this.#closed)
1453
1666
  break;
1454
- result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes);
1667
+ result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes, carried);
1455
1668
  }
1456
1669
  if (!result.ok && result.reason === "rate_limited") {
1457
1670
  // Option 2, the fallback: it did not clear within our budget, so the caller must hear it
@@ -1476,7 +1689,7 @@ export class AgentRelayClient {
1476
1689
  }
1477
1690
  return result;
1478
1691
  }
1479
- async #doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes) {
1692
+ async #doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes, carried) {
1480
1693
  if (this.#closed)
1481
1694
  return { ok: false, reason: "relay_client_closed" };
1482
1695
  if (!(await this.#ensureConnected(node)))
@@ -1512,22 +1725,85 @@ export class AgentRelayClient {
1512
1725
  const stream = this.#stream;
1513
1726
  if (!stream)
1514
1727
  return { ok: false, reason: "relay_unavailable" };
1515
- // This session's OWN high-water mark (NOT an agent-global one) — the relay's seq_counter
1516
- // is per session and rejects last_seen_seq > seq_counter.
1517
- const lastSeenForSession = this.#lastSeen.get(sessionIdHex) ?? 0;
1728
+ /**
1729
+ * ─── WHAT THIS SEND ACKNOWLEDGES 033-ACKEMIT ───────────────────────────────────────────────
1730
+ *
1731
+ * This session's OWN high-water mark (NOT an agent-global one) — the relay's seq_counter is per
1732
+ * session and rejects `last_seen_seq > seq_counter` — AND the content hash at that position,
1733
+ * read from the one entry that holds both.
1734
+ */
1735
+ const lastSeen = this.#lastSeen.get(sessionIdHex);
1736
+ /**
1737
+ * ⚠️ **THIS COMMENT USED TO SAY "REFUSED, NOT DOWNGRADED", AND THE CODE UNDER IT DID REFUSE.**
1738
+ * It is rewritten rather than deleted because a comment asserting a refusal that no longer
1739
+ * happens is how the next reader comes to believe a guard exists where there is a fallback.
1740
+ *
1741
+ * There is no seed only when this session was registered with neither a genesis nor an
1742
+ * assignment to derive one from, AND nothing has been received on it. The claim that goes out
1743
+ * then is `last_seen_seq: 0` with no hash — "I have seen nothing of yours" — which is true and
1744
+ * asserts nothing about content, so it is not the unbacked number this unit exists to stop
1745
+ * signing. A claim that NAMES a position with no hash is the defect, and the receiving daemon
1746
+ * refuses exactly that.
1747
+ */
1748
+ /**
1749
+ * ⚠️ NOT FOR A CARRIED LEAF — 034-CARRYLEAF review F8. This branch describes THIS agent having
1750
+ * nothing to acknowledge, and on a counter-submit the acknowledgement inside the bytes is the
1751
+ * AUTHOR's, already made. Logging "this submit acknowledges nothing" about it would be false,
1752
+ * and this daemon's own seed is irrelevant to a claim it did not write.
1753
+ */
1754
+ if (!lastSeen && !carried) {
1755
+ /**
1756
+ * ⚠️ **v1, AND ONLY BECAUSE THERE IS NOTHING TO ACKNOWLEDGE.** Same rule as the content
1757
+ * claim's, and the receiving half is `#verifyAcknowledgedContent` — a v1 claim is refused the
1758
+ * moment it names position 1 or beyond, and accepted when it names none.
1759
+ *
1760
+ * Reaching here means the session was registered with no genesis and no assignment to derive
1761
+ * one from, and no counterparty leaf has been delivered. `last_seen_seq: 0` with no hash says
1762
+ * "I have seen nothing of yours", which is true, and asserts nothing about content — so it is
1763
+ * not the unbacked number this unit exists to stop signing.
1764
+ *
1765
+ * It does not refuse the submit, and an earlier version did. Sessions brokered without a
1766
+ * relay assignment are real, and refusing there left them unable to be witnessed at all.
1767
+ */
1768
+ this.#logger.info("session.relay.submit.unacknowledged", {
1769
+ relayPeerId: this.#relayPeerId,
1770
+ session: sessionIdHex,
1771
+ impact: "this submit acknowledges nothing: the session has no recorded starting point and no " +
1772
+ "counterparty leaf has arrived on it. The leaf is witnessed as normal; the claim simply " +
1773
+ "makes no assertion about what this agent has received.",
1774
+ });
1775
+ }
1518
1776
  // The published encoder from protocol-types — the ONE definition of the field order, pinned by
1519
- // `structure1-canonical.json`. A second local copy lived here until 020-ACKHASH; it drifted, and
1520
- // the drift was invisible because both copies "worked": it encoded a timestamp above 2^32-1 as a
1521
- // CBOR float64 while the published encoder (and every other TBS builder in this package) promotes
1522
- // it to a uint64. Same value, different signed bytes, and only the vector said which was canonical.
1523
- const structure1 = encodeStructure1({
1777
+ // `structure1-canonical.json` (v1) and `structure1-v2-canonical.json` (v2). A second local copy
1778
+ // lived here until 020-ACKHASH; it drifted, and the drift was invisible because both copies
1779
+ // "worked": it encoded a timestamp above 2^32-1 as a CBOR float64 while the published encoder
1780
+ // (and every other TBS builder in this package) promotes it to a uint64. Same value, different
1781
+ // signed bytes, and only the vector said which was canonical.
1782
+ //
1783
+ // `lastSeenHash` is passed on EVERY send, so every claim this daemon signs is v2 and binds to
1784
+ // content. Nothing here ever passes `undefined` — see the refusal above.
1785
+ /**
1786
+ * ⚠️ **A CARRIED LEAF IS SENT VERBATIM AND SIGNED BY NOBODY HERE — 034-CARRYLEAF.**
1787
+ *
1788
+ * When this agent is witnessing something it RECEIVED, the claim already exists: its author
1789
+ * built it, signed it, and put it on the content frame. Re-encoding it would change the signed
1790
+ * bytes and the relay would refuse a leaf that is perfectly valid. Signing it ourselves would be
1791
+ * worse — it would turn their statement into ours, which is the one thing that must never happen
1792
+ * to a record whose whole value is that each party's words are their own.
1793
+ *
1794
+ * So this branch takes the bytes as they arrived, and this agent's own acknowledgement state is
1795
+ * deliberately NOT consulted: `last_seen_seq` and `last_seen_hash` inside those bytes are the
1796
+ * AUTHOR's account of what THEY had seen, and they are not ours to restate.
1797
+ */
1798
+ const structure1 = carried ? carried.structure1Cbor : encodeStructure1({
1524
1799
  contentHash,
1525
1800
  senderPubkey: this.#senderPubkey,
1526
1801
  sessionId,
1527
- lastSeenSeq: lastSeenForSession,
1802
+ lastSeenSeq: lastSeen?.seq ?? 0,
1528
1803
  timestamp: Date.now(),
1804
+ ...(lastSeen ? { lastSeenHash: lastSeen.hash } : {}),
1529
1805
  });
1530
- const signature = await this.#keyProvider.sign(structure1);
1806
+ const signature = carried ? carried.senderSignature : await this.#keyProvider.sign(structure1);
1531
1807
  const frame = encodeCbor({
1532
1808
  type: "hash_submit",
1533
1809
  session_id: sessionId,
@@ -1609,8 +1885,44 @@ export class AgentRelayClient {
1609
1885
  }
1610
1886
  }
1611
1887
  /** The highest relay-assigned sequence observed for a given session (ack or deliver). */
1612
- lastSeenSeq(sessionIdHex) {
1613
- return this.#lastSeen.get(sessionIdHex) ?? 0;
1888
+ /**
1889
+ * Advance this session's acknowledgement from a message that ARRIVED — 033-ACKEMIT review F1.
1890
+ *
1891
+ * ⚠️ **`#bumpLastSeen` used to have exactly one caller, inside the `leaf_deliver` handler, so the
1892
+ * acknowledgement tracked what the RELAY DELIVERED rather than what was RECEIVED.** On a direct
1893
+ * session that is a real difference: the content arrives peer-to-peer and the relay's copy of the
1894
+ * leaf follows separately, so until it did, this daemon signed an acknowledgement one message
1895
+ * behind what it had actually read — and on a session where delivery never came back at all, the
1896
+ * acknowledgement never moved.
1897
+ *
1898
+ * The order's own words are "the content hash of the last message this sender ACTUALLY RECEIVED".
1899
+ * This is the caller that makes that true: the receive path calls it as soon as a message has been
1900
+ * verified and ingested at a known canonical position.
1901
+ *
1902
+ * **THE POSITION IS STILL REQUIRED, and that is a real limit rather than an oversight.** The pair
1903
+ * is (position, content-at-position), and the relay refuses a `last_seen_seq` that runs ahead of
1904
+ * its counter — so a message that arrived with NO ordering record cannot be acknowledged by
1905
+ * position at all, whatever we hold of it. That case is the withheld-submit attack itself, and it
1906
+ * is closed by carrying the sender's signed leaf into the seal, not from here.
1907
+ */
1908
+ noteReceivedLeaf(sessionIdHex, relaySeq, contentHash) {
1909
+ this.#bumpLastSeen(sessionIdHex, relaySeq, contentHash);
1910
+ }
1911
+ /**
1912
+ * The POSITION and the CONTENT AT IT together — 033-ACKEMIT.
1913
+ *
1914
+ * ⚠️ **IT REPLACED `lastSeenSeq()`, WHICH IS DELETED RATHER THAN LEFT WIRED.** That accessor
1915
+ * returned the position alone, and `session-node-manager`'s unwitnessed content claim was its
1916
+ * only caller. Leaving it in place after this one took over would leave a second way to read half
1917
+ * of a pair that must be read whole: a `last_seen_seq` paired with a `last_seen_hash` for a
1918
+ * different message is worse than no acknowledgement at all, because it looks checkable and
1919
+ * fails.
1920
+ *
1921
+ * `undefined` means this session has no acknowledgement to make, which the caller must handle
1922
+ * rather than fill in.
1923
+ */
1924
+ lastSeenAck(sessionIdHex) {
1925
+ return this.#lastSeen.get(sessionIdHex);
1614
1926
  }
1615
1927
  close() {
1616
1928
  this.#closed = true;