@cello-protocol/daemon 0.0.192 → 0.0.193

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.
@@ -1143,6 +1143,8 @@ export class SessionNodeManager {
1143
1143
  /** The reason the last reservation attempt was refused, per agent — captured at the rejection so
1144
1144
  * the retry and give-up can name a CAUSE instead of only their own exit point. */
1145
1145
  #srLastRejectionReason = new Map();
1146
+ /** 032-RELAYSPREAD: when this agent's receiver was last re-spread, so it never rides the 30s grid. */
1147
+ #srLastRespreadAt = new Map();
1146
1148
  #srReservationRetry = new Map();
1147
1149
  #reservationWatchdog = null;
1148
1150
  /** DOD-PARK-DRAIN-1: how often the backstop drain rides the watchdog grid — see #parkedDrainBackstopTick. */
@@ -6146,6 +6148,7 @@ export class SessionNodeManager {
6146
6148
  this.#standingReceivers.clear();
6147
6149
  this.#srReservationRetry.clear();
6148
6150
  this.#srLastRejectionReason.clear();
6151
+ this.#srLastRespreadAt.clear();
6149
6152
  // Release the SQLite handle so the DB file is no longer held open after shutdown
6150
6153
  // (review L5). Queries guard on `#db === null` and degrade to empty/null.
6151
6154
  if (this.#db) {
@@ -8055,7 +8058,9 @@ export class SessionNodeManager {
8055
8058
  *
8056
8059
  * So the session key encrypts the copy that goes ON THE WIRE, below, and nothing else.
8057
8060
  */
8058
- this.#trackAwaitingAck(agentName, sessionId, content, contentHash, correlationId, orderingS1, orderingS2, contentHashAlg);
8061
+ // 034-CARRYLEAF: the SIGNED claim and its domain ride the awaiting entry, so a message that
8062
+ // ends up re-parked after a restart still reaches its recipient in a shape they can witness.
8063
+ this.#trackAwaitingAck(agentName, sessionId, content, contentHash, correlationId, frameS1 ?? orderingS1, orderingS2, contentHashAlg, frameSig, leafKind);
8059
8064
  /**
8060
8065
  * THE WIRE COPY. `content_hash` above was computed over the PLAINTEXT and stays that way: the
8061
8066
  * transcript, the seal and the salted hash all depend on it meaning what it means today, and
@@ -11846,7 +11851,7 @@ export class SessionNodeManager {
11846
11851
  * route reads this map, and a v2 envelope omits the field entirely whenever the value is `sha256`,
11847
11852
  * which is every value in play today. That re-opened the exact finding the fix closed.
11848
11853
  */
11849
- #trackAwaitingAck(agentName, sessionId, content, contentHash, correlationId, structure1Cbor, structure2Cbor, contentHashAlg) {
11854
+ #trackAwaitingAck(agentName, sessionId, content, contentHash, correlationId, structure1Cbor, structure2Cbor, contentHashAlg, structure1Signature, leafKind) {
11850
11855
  const hashHex = Buffer.from(contentHash).toString("hex");
11851
11856
  const ackKey = this.#k(agentName, sessionId);
11852
11857
  let bySession = this.#awaitingAck.get(ackKey);
@@ -11868,7 +11873,7 @@ export class SessionNodeManager {
11868
11873
  // B2b-1 review F2: the algorithm rides WITH the entry. The TTF-expiry park route reads this map
11869
11874
  // minutes later, in-process, and without it that copy names nothing (= sha256) while the direct
11870
11875
  // frame named something else — the same message, two claims about what it is, no restart needed.
11871
- bySession.set(hashHex, { timer, content, correlationId, structure1Cbor, structure2Cbor, contentHashAlg });
11876
+ bySession.set(hashHex, { timer, content, correlationId, structure1Cbor, structure2Cbor, contentHashAlg, structure1Signature, leafKind });
11872
11877
  }
11873
11878
  /**
11874
11879
  * Resolve an awaiting-ACK entry on a `persisted` delivery ACK (AC-001/AC-002): cancel
@@ -11924,7 +11929,7 @@ export class SessionNodeManager {
11924
11929
  // M12-P12 (review pass 2): the ordering record travels on THIS path too. It is in hand — the
11925
11930
  // very next statement hands it to #parkContent — and a TTF row written without it re-parks in
11926
11931
  // arrival order, which is the divergent-leaf-index failure the durable columns exist to stop.
11927
- this.#onAwaitingTtf?.(agentName, sessionId, hashHex, entry.content, entry.structure1Cbor, entry.structure2Cbor, entry.contentHashAlg);
11932
+ this.#onAwaitingTtf?.(agentName, sessionId, hashHex, entry.content, entry.structure1Cbor, entry.structure2Cbor, entry.contentHashAlg, entry.structure1Signature, entry.leafKind);
11928
11933
  }
11929
11934
  catch (err) {
11930
11935
  this.#logger.error("content.park.backstop.failed", {
@@ -12253,6 +12258,28 @@ export class SessionNodeManager {
12253
12258
  * content on the direct path, and the ingest below is what decides whether the other route
12254
12259
  * succeeded. Reading it after would race the clear.
12255
12260
  */
12261
+ /**
12262
+ * ⚠️ **REFUSING AN UNNOTARIZABLE MAILBOX MESSAGE WAS TRIED HERE AND REVERTED — recorded so the
12263
+ * next attempt starts from what actually blocks it, not from the compatibility argument that
12264
+ * does not.**
12265
+ *
12266
+ * The mailbox is the remaining route for the withholding attack: a counterparty who parks a
12267
+ * message with no ordering record AND no signature over its ordering claim delivers something
12268
+ * readable that can never enter a receipt. The obvious fix is to refuse it here.
12269
+ *
12270
+ * **It cannot ship yet, and the reason is our OWN path, not an older peer's.** `SEC-1` AC5 is
12271
+ * explicit: the crash-backstop shape — signed by the sender, no ordering record — is legal and
12272
+ * must be accepted. That envelope is produced when content is queued before anything witnessed
12273
+ * it, and from the recipient's side it is INDISTINGUISHABLE from an attacker's stripped one. So
12274
+ * this refusal rejects our own crash recovery along with the attack.
12275
+ *
12276
+ * **What closes it:** make the crash backstop sign an ordering claim at enqueue time, the way
12277
+ * the live park path now does (`#signOwnContentClaim` already produces exactly this artifact).
12278
+ * Then "no ordering record and no signed claim" is a shape only a modified client emits, and
12279
+ * refusing it costs nothing real. The retry queue already carries the two columns for it —
12280
+ * `structure1_sig` and `leaf_kind` — which were added for this and are populated on the live
12281
+ * path today.
12282
+ */
12256
12283
  const refusedForAuthorship = this.#refusedOnDirectPath.get(memoKey)?.has(contentHashHex) === true;
12257
12284
  const result = await this.ingestReceivedContent(agentName, sessionId, env.content, contentHash, correlationId, recoveredSeq ?? undefined,
12258
12285
  // The envelope's own claim, verbatim — `undefined` on a v2 envelope, which resolves to
@@ -15807,28 +15834,31 @@ export class SessionNodeManager {
15807
15834
  * We hold their signature over their own bytes. So we hand it to the relay ourselves.
15808
15835
  */
15809
15836
  /**
15810
- * ⚠️ **THE KIND COMES OFF THE FRAME, AND WITHOUT IT WE DECLINE TO WITNESS — review F5.**
15837
+ * ─── THE KIND COMES OFF THE FRAME, AND A FRAME WITHOUT ONE IS REFUSED ─────────────────
15838
+ *
15839
+ * A leaf kind selects a HASH DOMAIN — documents and rejection envelopes ride this same
15840
+ * frame — so witnessing under a guessed domain would put a wrong statement in the
15841
+ * canonical record.
15811
15842
  *
15812
- * A leaf kind selects a HASH DOMAIN, and documents and rejection envelopes ride this same
15813
- * frame. Hardcoding `msg` meant a document witnessed on its author's behalf entered the
15814
- * relay's canonical log described as something it is not. A peer too old to send the field
15815
- * is left alone rather than guessed at: witnessing their leaf under the wrong domain would
15816
- * be a worse outcome than not witnessing it, because it puts a wrong statement in the
15817
- * record instead of leaving a gap the seal can name.
15843
+ * ⚠️ **THIS USED TO DECLINE TO WITNESS AND DELIVER THE MESSAGE ANYWAY, "because a peer
15844
+ * too old to send the field should be left alone". THAT SENTENCE WAS INHERITED, NOT
15845
+ * DERIVED, AND IT LEFT THE WHOLE ATTACK OPEN.** CELLO is alpha with no users; there is no
15846
+ * older peer to protect. What the leniency actually bought was an opt-out: emit the shape
15847
+ * a 2026-09-04 build emitted, and your message is delivered AND cannot be witnessed
15848
+ * which is precisely the withholding this line exists to stop, reachable by anyone
15849
+ * willing to modify their client.
15850
+ *
15851
+ * So it is refused. Missing, malformed and mismatched take one path (§5), and a peer that
15852
+ * cannot say which domain its own leaf belongs to has supplied an unusable proof.
15818
15853
  */
15819
15854
  const framedKind = frame["leaf_kind"];
15820
- if (typeof framedKind === "number") {
15821
- void this.#witnessReceivedLeaf(agentName, sessionId, contentHash, s1Cbor, senderSig, framedKind, correlationId);
15822
- }
15823
- else {
15824
- this.#logger.info("session.content.witness_received.kind_unknown", {
15825
- agentName, sessionId, correlationId,
15826
- impact: "a message arrived that its sender never had witnessed, and the frame did not say " +
15827
- "which leaf domain it belongs to — their build predates the field. It was NOT " +
15828
- "witnessed on their behalf, because witnessing it under a guessed domain would put " +
15829
- "a wrong statement in the record rather than leave a gap the seal can name.",
15830
- });
15855
+ if (typeof framedKind !== "number") {
15856
+ this.#refuseUnprovenAuthorship(agentName, sessionId, "authorship_proof_unusable", contentHash, {
15857
+ detail: "leaf_kind_absent",
15858
+ }, correlationId);
15859
+ return;
15831
15860
  }
15861
+ void this.#witnessReceivedLeaf(agentName, sessionId, contentHash, s1Cbor, senderSig, framedKind, correlationId);
15832
15862
  }
15833
15863
  void this.#sendDeliveryAck(agentName, sessionId, contentHash, correlationId);
15834
15864
  }
@@ -16564,8 +16594,68 @@ export class SessionNodeManager {
16564
16594
  * is that it never STOPS BEING REACHABLE while that is true — the surviving relays carry it,
16565
16595
  * the loss is named in the log with its cause, and the lost relay's inbound carve-out is
16566
16596
  * revoked above. That is availability, not restoration in place.
16597
+ *
16598
+ * WHICH LEAVES A RATCHET, and `#respreadIfDecayed` below is what stops it: relays are only
16599
+ * ever lost between rebuilds, never regained, so an agent nobody talks to walks itself back
16600
+ * down to one relay — the exact state this unit exists to get it out of.
16567
16601
  */
16568
16602
  }
16603
+ for (const agentName of this.#standingReceivers.keys()) {
16604
+ if (this.#agentsWantingReceiver.has(agentName))
16605
+ this.#respreadIfDecayed(agentName);
16606
+ }
16607
+ }
16608
+ /**
16609
+ * 032-RELAYSPREAD — **AN IDLE AGENT MUST NOT RATCHET ITSELF BACK DOWN TO ONE RELAY.**
16610
+ *
16611
+ * Spreading happens when a receiver is BUILT, and between builds the count only falls: a lost
16612
+ * circuit cannot be retaken by a running node (a circuit listener is fixed at node creation), and
16613
+ * a relay the directory announces later is skipped while any circuit is held. An agent in
16614
+ * conversation re-spreads constantly — the receiver is handed into each session and a fresh one
16615
+ * is built behind it — so this is about the agent nobody has talked to for a day. It loses relays
16616
+ * one at a time, nothing pulls it back up, and it ends up exactly where this unit found it:
16617
+ * reachable through one relay, one relay away from being reachable through none.
16618
+ *
16619
+ * **THE COST OF FIXING IT IS A NEW PEER ID**, which is why it is fenced three ways rather than
16620
+ * simply rebuilding on sight:
16621
+ * - **ONLY WHEN IDLE.** A rebuild replaces the receiver's transport identity, and a counterparty
16622
+ * may be holding the old one from a `session_offer_accept`. With a live session for this agent
16623
+ * we leave it alone — a degraded spread costs redundancy, a changed peer id mid-conversation
16624
+ * costs the conversation.
16625
+ * - **ONLY WHEN THERE IS SOMETHING TO GAIN.** Holding every relay that was offered is not decay.
16626
+ * - **ON ITS OWN SLOW CLOCK**, never the watchdog's 30-second grid. A reservation is scarce —
16627
+ * the relay holds it for its full TTL even after we disconnect — so this reuses the
16628
+ * reservation retry interval rather than inventing a faster one.
16629
+ */
16630
+ #respreadIfDecayed(agentName) {
16631
+ if (this.#shuttingDown)
16632
+ return;
16633
+ const sr = this.#standingReceivers.get(agentName);
16634
+ if (!sr || sr.relayPeerIds.length === 0)
16635
+ return; // zero held is the loud path
16636
+ for (const entry of this.#activeNodes.values()) {
16637
+ if (entry.agentName === agentName)
16638
+ return; // in conversation — hands off
16639
+ }
16640
+ const offered = this.#reservationCircuitAddrs(agentName).addrs.length;
16641
+ if (sr.relayPeerIds.length >= offered)
16642
+ return; // nothing to gain
16643
+ const now = Date.now();
16644
+ const last = this.#srLastRespreadAt.get(agentName) ?? 0;
16645
+ if (now - last < this.#srReservationRetryMs)
16646
+ return;
16647
+ this.#srLastRespreadAt.set(agentName, now);
16648
+ this.#logger.info("session.standing_receiver.respread", {
16649
+ agentName,
16650
+ reservationsHeld: sr.relayPeerIds.length,
16651
+ relaysOffered: offered,
16652
+ impact: "this agent is idle and holds fewer relay reservations than it was offered, so its " +
16653
+ "receiver is being rebuilt to take the rest. Without this it can only lose relays between " +
16654
+ "rebuilds, and an agent nobody talks to drifts back down to a single relay — one relay " +
16655
+ "away from being unreachable behind NAT, which is the state this whole mechanism exists " +
16656
+ "to keep it out of.",
16657
+ });
16658
+ void this.#rebuildStandingReceiver(agentName);
16569
16659
  }
16570
16660
  /**
16571
16661
  * DOD-PARK-DRAIN-1: the backstop sweep — every agent holding a standing receiver gets a drain
@@ -17178,6 +17268,11 @@ export class SessionNodeManager {
17178
17268
  // reservations that genuinely completed, so a directory that merely NAMES a relay cannot dial
17179
17269
  // in behind it, however many relays it names.
17180
17270
  gater.setReservedRelayPeers(heldRelayPeerIds);
17271
+ // The re-spread clock starts HERE, at the build, not at the epoch. Otherwise the first decay
17272
+ // re-spreads instantly — undoing the "a lost relay does not rebuild the receiver" rule seconds
17273
+ // after it fires, and changing the peer id of an agent that just lost one relay of three. The
17274
+ // ratchet this guards against runs over hours; nothing about it needs answering in a second.
17275
+ this.#srLastRespreadAt.set(agentName, Date.now());
17181
17276
  this.#standingReceivers.set(agentName, {
17182
17277
  node,
17183
17278
  gater,