@cello-protocol/daemon 0.0.183 → 0.0.185

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.
Files changed (73) hide show
  1. package/dist/close-session-handler.d.ts.map +1 -1
  2. package/dist/close-session-handler.js +90 -16
  3. package/dist/close-session-handler.js.map +1 -1
  4. package/dist/daemon.d.ts.map +1 -1
  5. package/dist/daemon.js +365 -44
  6. package/dist/daemon.js.map +1 -1
  7. package/dist/frame-values.d.ts.map +1 -1
  8. package/dist/frame-values.js +14 -0
  9. package/dist/frame-values.js.map +1 -1
  10. package/dist/inclusion-proof-handlers.d.ts +43 -0
  11. package/dist/inclusion-proof-handlers.d.ts.map +1 -0
  12. package/dist/inclusion-proof-handlers.js +565 -0
  13. package/dist/inclusion-proof-handlers.js.map +1 -0
  14. package/dist/inclusion-proof.d.ts +151 -0
  15. package/dist/inclusion-proof.d.ts.map +1 -0
  16. package/dist/inclusion-proof.js +228 -0
  17. package/dist/inclusion-proof.js.map +1 -0
  18. package/dist/network-directory-node.d.ts +16 -0
  19. package/dist/network-directory-node.d.ts.map +1 -1
  20. package/dist/network-directory-node.js +40 -0
  21. package/dist/network-directory-node.js.map +1 -1
  22. package/dist/notification-handlers.d.ts.map +1 -1
  23. package/dist/notification-handlers.js +71 -2
  24. package/dist/notification-handlers.js.map +1 -1
  25. package/dist/outbound-sessions.d.ts +6 -0
  26. package/dist/outbound-sessions.d.ts.map +1 -1
  27. package/dist/outbound-sessions.js +31 -7
  28. package/dist/outbound-sessions.js.map +1 -1
  29. package/dist/seal-coordinator.d.ts +51 -1
  30. package/dist/seal-coordinator.d.ts.map +1 -1
  31. package/dist/seal-coordinator.js +248 -1
  32. package/dist/seal-coordinator.js.map +1 -1
  33. package/dist/seal-escalation.d.ts.map +1 -1
  34. package/dist/seal-escalation.js +28 -0
  35. package/dist/seal-escalation.js.map +1 -1
  36. package/dist/seal-failure-store.d.ts.map +1 -1
  37. package/dist/seal-failure-store.js +5 -1
  38. package/dist/seal-failure-store.js.map +1 -1
  39. package/dist/sealed-leaf-set.d.ts +92 -0
  40. package/dist/sealed-leaf-set.d.ts.map +1 -0
  41. package/dist/sealed-leaf-set.js +129 -0
  42. package/dist/sealed-leaf-set.js.map +1 -0
  43. package/dist/session-ceremony.d.ts +21 -1
  44. package/dist/session-ceremony.d.ts.map +1 -1
  45. package/dist/session-ceremony.js +83 -7
  46. package/dist/session-ceremony.js.map +1 -1
  47. package/dist/session-content-handlers.d.ts +42 -0
  48. package/dist/session-content-handlers.d.ts.map +1 -1
  49. package/dist/session-content-handlers.js +193 -4
  50. package/dist/session-content-handlers.js.map +1 -1
  51. package/dist/session-node-manager.d.ts +142 -1
  52. package/dist/session-node-manager.d.ts.map +1 -1
  53. package/dist/session-node-manager.js +496 -15
  54. package/dist/session-node-manager.js.map +1 -1
  55. package/dist/session-relay-client.d.ts +47 -1
  56. package/dist/session-relay-client.d.ts.map +1 -1
  57. package/dist/session-relay-client.js +145 -0
  58. package/dist/session-relay-client.js.map +1 -1
  59. package/dist/signal-submission.d.ts +11 -1
  60. package/dist/signal-submission.d.ts.map +1 -1
  61. package/dist/signal-submission.js.map +1 -1
  62. package/dist/submission-retry.d.ts +208 -0
  63. package/dist/submission-retry.d.ts.map +1 -0
  64. package/dist/submission-retry.js +505 -0
  65. package/dist/submission-retry.js.map +1 -0
  66. package/dist/types.d.ts +12 -0
  67. package/dist/types.d.ts.map +1 -1
  68. package/dist/types.js.map +1 -1
  69. package/dist/vocabulary.d.ts +13 -3
  70. package/dist/vocabulary.d.ts.map +1 -1
  71. package/dist/vocabulary.js +14 -3
  72. package/dist/vocabulary.js.map +1 -1
  73. package/package.json +5 -5
@@ -55,6 +55,7 @@ import { AgentRelayClient, LEAF_KIND_CTRL, isTerminalRelayRefusal, extractErrorM
55
55
  import { terminalRelayRefusal } from "./session-terminal-refusal.js";
56
56
  import { RelayReceiptStore } from "./relay-receipt-store.js";
57
57
  import { SessionSealLeafStore } from "./session-seal-leaf-store.js";
58
+ import { certifiedLeafSetFrom } from "./sealed-leaf-set.js";
58
59
  import { addColumnIfMissing } from "./column-birth.js";
59
60
  import { GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT, } from "@cello-protocol/gateway";
60
61
  /** SEC-1 / review M4: cap on the refused-parked-entry memo (remote-fed → must be bounded). */
@@ -365,6 +366,34 @@ export const LEAF_FETCH_GRACE_MS = 2_000;
365
366
  function relayPeerIdOf(circuitAddr) {
366
367
  return /\/p2p\/([^/]+)\/p2p-circuit/.exec(circuitAddr)?.[1] ?? null;
367
368
  }
369
+ /**
370
+ * The Merkle leaf inputs for a seal carry: each leaf's `content_hash`, read out of the bytes its
371
+ * SENDER SIGNED (`structure1_cbor`), never out of an envelope field somebody else filled in.
372
+ *
373
+ * `null` when any leaf is unreadable — the caller must then answer "I cannot judge", never "we
374
+ * disagree". A decode failure is this daemon's limitation, not evidence against anyone.
375
+ *
376
+ * Canonical Structure 1 is
377
+ * `[protocol_version, content_hash, sender_pubkey, session_id, last_seen_seq, timestamp]`, and the
378
+ * content hash is used AS the leaf hash (RFC 6962 §2.1 "hash" leaves are taken as-is), which is the
379
+ * domain the certified root lives in.
380
+ */
381
+ function carryContentHashInputs(carry) {
382
+ const inputs = [];
383
+ for (const leaf of carry) {
384
+ let contentHash;
385
+ try {
386
+ contentHash = decode(leaf.structure1Cbor)[1];
387
+ }
388
+ catch {
389
+ return null;
390
+ }
391
+ if (!(contentHash instanceof Uint8Array) || contentHash.length !== 32)
392
+ return null;
393
+ inputs.push({ kind: "hash", data: contentHash });
394
+ }
395
+ return inputs;
396
+ }
368
397
  export class SessionNodeManager {
369
398
  #factory;
370
399
  #logger;
@@ -656,6 +685,129 @@ export class SessionNodeManager {
656
685
  correlationId,
657
686
  });
658
687
  }
688
+ /**
689
+ * DOD-M15-CORROBORATE-1: witness alerts this agent's relays have reported, oldest first, capped.
690
+ *
691
+ * ⚠️ **IN MEMORY, AND A DAEMON RESTART LOSES THEM** — review F4. This comment used to say a relay
692
+ * that still holds the observation re-delivers it on the next connection. It does not:
693
+ * `drainWitnessAlerts` splices, so once an alert has been delivered the relay no longer holds it.
694
+ * Nothing here is a durable record of anything; the relay operator's own log is, and so is the
695
+ * signature on the alert, which the operator can keep. Said plainly rather than left as an implied
696
+ * guarantee.
697
+ *
698
+ * Nothing clears the list before that restart, and that is deliberate: an alert an operator can
699
+ * silence is one an attacker can wait out.
700
+ */
701
+ #witnessAlerts = new Map();
702
+ /**
703
+ * ⚠️ **THE CAP KEEPS THE FIRST, NOT THE LAST** — review F1, and the direction is the whole point.
704
+ *
705
+ * The relay's own queue drops the NEWEST when full, precisely so a flood cannot push the first
706
+ * real observation out. This list did the opposite (`slice(-20)`), which handed the mute button
707
+ * straight back one layer up: at the relay's 120-submits-per-minute limit, about ten seconds of
708
+ * fabricated alerts evicted the genuine one before any operator read it. Repeats of one event also
709
+ * collapse — see the dedupe in `recordRelayWitnessAlert` — so a flood cannot even fill it.
710
+ */
711
+ static #WITNESS_ALERT_CAP = 20;
712
+ /**
713
+ * Review F7: relays that sent a witness alert this build could not read or verify, per agent.
714
+ * Peer id → cause + count. NO session and NO party: it reports that our witness layer is not
715
+ * working, never anything about a participant.
716
+ */
717
+ #witnessUnreadable = new Map();
718
+ /**
719
+ * Agents whose alert list hit the cap — fallback-finder LOW 2. Without this the inbox renders a
720
+ * full list that looks complete, and "twenty alerts" is indistinguishable from "twenty of some
721
+ * larger number". A marker the operator can see costs one boolean.
722
+ */
723
+ #witnessTruncated = new Set();
724
+ /**
725
+ * Record what one relay says it saw on one of this agent's sessions, for the operator to read.
726
+ *
727
+ * ⚠️ **IT DOES NOT FREEZE THE SESSION, AND THAT IS THE DESIGN.** A client freezing on its OWN
728
+ * verification is safe: it limits only what that client trusts. Freezing on a REMOTE party's
729
+ * say-so hands any single relay the power to end any conversation it carries, and to write an
730
+ * accusatory record about a counterparty who did nothing. The identity freeze stays where it is —
731
+ * on this daemon's own check of an inbound frame — and this surfaces a second, independent
732
+ * observation next to it. One witness reports; it does not rule.
733
+ */
734
+ recordRelayWitnessAlert(agentName, alert) {
735
+ const list = this.#witnessAlerts.get(agentName) ?? [];
736
+ /**
737
+ * ONE ROW PER (WITNESS, SESSION) — review F1's second half. A relay reports every refused
738
+ * submission, and a determined submitter can produce a great many; twenty rows saying the same
739
+ * thing is not twenty facts, it is one fact and nineteen ways to push another one off the list.
740
+ * The repeat updates the count and the last-seen time and leaves the row where it is.
741
+ */
742
+ /**
743
+ * Keyed on the relay's PEER ID, not on `relayId` — fallback-finder LOW 1. `relayId` is absent
744
+ * for any relay that could not sign, so two DIFFERENT such relays reporting on one session
745
+ * collapsed into a single row and the operator read one witness where there were two. The peer
746
+ * id is the transport identity this client is actually talking to and is always known.
747
+ */
748
+ const key = `${alert.witnessPeerId}::${alert.sessionIdHex}`;
749
+ const existing = list.find((n) => n.key === key);
750
+ if (existing) {
751
+ existing.occurrences += 1;
752
+ existing.lastObservedAt = alert.observedAt;
753
+ // A later repeat that IS verifiable upgrades the row: the operator should end up holding the
754
+ // strongest form of the claim they were sent, never the weakest one that arrived first.
755
+ existing.alert = alert.verifiable ? alert : existing.alert;
756
+ // `firstObservedAt` is deliberately untouched — see its note on the type.
757
+ }
758
+ else if (list.length >= SessionNodeManager.#WITNESS_ALERT_CAP) {
759
+ // Keep the first. See the cap's own note for why this direction is load-bearing.
760
+ this.#witnessTruncated.add(agentName);
761
+ this.#logger.warn("session.witness.alert.list_full", {
762
+ agentName, held: list.length,
763
+ impact: "this alert was not recorded; the earlier ones are kept and still shown, and the " +
764
+ "inbox now says the list is incomplete rather than looking whole",
765
+ });
766
+ return;
767
+ }
768
+ else {
769
+ list.push({ key, alert, occurrences: 1, firstObservedAt: alert.observedAt, lastObservedAt: alert.observedAt });
770
+ }
771
+ this.#witnessAlerts.set(agentName, list);
772
+ this.#logger.error("session.witness.alert.recorded", {
773
+ agentName,
774
+ sessionId: alert.sessionIdHex,
775
+ relayId: alert.relayId ?? "(unnamed)",
776
+ submitterIsCounterparty: alert.submitterIsCounterparty,
777
+ verifiable: alert.verifiable,
778
+ impact: "surfaced to the operator on the next cello_inbox; the session is NOT frozen by it",
779
+ });
780
+ }
781
+ /** The witness alerts an agent has been told about, oldest first, one row per witness+session. */
782
+ getWitnessAlerts(agentName) {
783
+ return this.#witnessAlerts.get(agentName) ?? [];
784
+ }
785
+ /** Whether this agent's alert list hit its cap, so the inbox can say the list is incomplete. */
786
+ witnessAlertsTruncated(agentName) {
787
+ return this.#witnessTruncated.has(agentName);
788
+ }
789
+ /**
790
+ * Review F7: a relay sent a witness alert this build could not read or could not verify.
791
+ *
792
+ * Recorded so a version skew that silently kills the witness layer is visible to the operator
793
+ * instead of living only in a log file. Carries no session and no party by construction.
794
+ */
795
+ recordRelayWitnessUnreadable(agentName, relayPeerId, why) {
796
+ const byRelay = this.#witnessUnreadable.get(agentName) ?? new Map();
797
+ const prior = byRelay.get(relayPeerId);
798
+ byRelay.set(relayPeerId, { why, count: (prior?.count ?? 0) + 1 });
799
+ this.#witnessUnreadable.set(agentName, byRelay);
800
+ this.#logger.error("session.witness.unreadable.recorded", {
801
+ agentName, relayPeerId, why,
802
+ impact: "this agent's witness layer is not working against that relay — no observation it " +
803
+ "sends can be read, and nothing has been concluded about any participant",
804
+ });
805
+ }
806
+ /** Relays whose witness alerts this build could not read, for the agent's inbox. */
807
+ getWitnessUnreadable(agentName) {
808
+ return [...(this.#witnessUnreadable.get(agentName) ?? new Map()).entries()]
809
+ .map(([relayPeerId, v]) => ({ relayPeerId, why: v.why, count: v.count }));
810
+ }
659
811
  /**
660
812
  * DOD-M15-RELAYSLOTS-1: the last relay refusal per agent, with the advice that goes with it.
661
813
  * Written where the refusal is actually known; read by whatever tells the operator.
@@ -1801,6 +1953,52 @@ export class SessionNodeManager {
1801
1953
  -- DOD-LOOP-1: composite key so each agent's end has its own append-ordered tree.
1802
1954
  PRIMARY KEY (agent_id, session_id, leaf_index)
1803
1955
  )
1956
+ `);
1957
+ // DOD-M15-INCLUSION-1: the leaf set the DIRECTORY certified — a different tree from the one
1958
+ // above, and the distinction is the whole reason this table exists.
1959
+ //
1960
+ // `session_tree_leaves` holds this agent's CONTENT leaves. The certified root covers every leaf
1961
+ // the relay ordered, CONTROL leaves included, and nothing appends a ctrl leaf to the local tree
1962
+ // (`submitSealLeaf` computes its root without mutating it). So an audit path built from
1963
+ // `session_tree_leaves` lands on a root no certificate names — it proves this machine agrees
1964
+ // with itself, which is worth nothing to the third party a proof is FOR.
1965
+ //
1966
+ // Rows land only after the Merkle root over them reproduces the FROST-signed `sealed_root`
1967
+ // (`certifiedLeafSetFrom`), so what is stored here is the consortium's leaf set and not the
1968
+ // directory's word for it. Written once at seal time; read only by the inclusion-proof surface,
1969
+ // ORDER BY leaf_index.
1970
+ this.#db.exec(`
1971
+ CREATE TABLE IF NOT EXISTS session_certified_leaves (
1972
+ agent_id TEXT NOT NULL,
1973
+ session_id TEXT NOT NULL,
1974
+ leaf_index INTEGER NOT NULL,
1975
+ content_hash_hex TEXT NOT NULL,
1976
+ recorded_at INTEGER NOT NULL,
1977
+ -- DOD-LOOP-1: composite key so each agent's end of a loopback session keeps its own set.
1978
+ PRIMARY KEY (agent_id, session_id, leaf_index)
1979
+ )
1980
+ `);
1981
+ // WHY a session has no certified leaf set — fallback-finder finding 1, and the reason it is a
1982
+ // TABLE rather than a log line.
1983
+ //
1984
+ // `getCertifiedLeafSet` returns null for four different situations: no seal frame ever carried
1985
+ // the leaves, the directory shipped a set that does not reproduce the root it signed, a leaf was
1986
+ // malformed, or the write failed. The worst of those — a directory contradicting its own FROST
1987
+ // signature — is the strongest misbehaviour signal this client can produce, and it was going to
1988
+ // one ERROR line while the operator was told the most benign of the four: "normal for the party
1989
+ // that was absent at seal time." A detection whose only consumer is a log is not a control.
1990
+ //
1991
+ // One row per session, replaced on every attempt, so the state is the LAST thing that happened
1992
+ // rather than a history. Read only by the inclusion-proof surface, to name the cause.
1993
+ this.#db.exec(`
1994
+ CREATE TABLE IF NOT EXISTS session_certified_leaves_state (
1995
+ agent_id TEXT NOT NULL,
1996
+ session_id TEXT NOT NULL,
1997
+ state TEXT NOT NULL,
1998
+ detail TEXT,
1999
+ recorded_at INTEGER NOT NULL,
2000
+ PRIMARY KEY (agent_id, session_id)
2001
+ )
1804
2002
  `);
1805
2003
  // DOD-M12B-STRAND-1 — content we RECEIVED and VERIFIED but cannot append yet.
1806
2004
  //
@@ -2264,6 +2462,35 @@ export class SessionNodeManager {
2264
2462
  const contiguousFromOne = sequences.every((n, i) => n === i + 1);
2265
2463
  const ctrlSenders = new Set(carry.filter((l) => l.leafKind === LEAF_KIND_CTRL).map((l) => l.senderPubkeyHex));
2266
2464
  const selfEvidentlyComplete = contiguousFromOne && ctrlSenders.size === 2;
2465
+ /**
2466
+ * 🚨 THE CERTIFICATE MAY COVER EXACTLY WHAT THIS SIDE HOLDS — ASK THAT FIRST.
2467
+ *
2468
+ * `DOD-M15-UNILATERAL-1`. The completeness predicate below describes a BILATERAL leaf set: two
2469
+ * SEAL ctrl leaves, from two distinct senders. **A solo seal can never satisfy it**, because the
2470
+ * counterparty is gone and never posts one — that is the entire premise. So on the solo path
2471
+ * this returned `cannot_judge` every time, `session-ceremony.ts` refuses to co-sign on anything
2472
+ * that is not `match`, and **the sealing party refused to co-sign its own unilateral seal.** The
2473
+ * FROST ceremony never reached threshold, the directory never completed, and the close came back
2474
+ * `seal_unilateral_timeout` — the label that names our own wait. Measured against the real
2475
+ * binaries: `j-unilateral` failed on exactly this, with the directory having already verified the
2476
+ * chain and recorded the counterparty ABSENT.
2477
+ *
2478
+ * Completeness was only ever needed to tell TWO KINDS OF DISAGREEMENT apart — "the roots differ
2479
+ * because my carry is behind" (cannot judge) from "the roots differ because the directory
2480
+ * certified something else" (mismatch). It answers nothing when the roots AGREE: a certificate
2481
+ * whose root and leaf count are exactly what this daemon holds is, by construction, over this
2482
+ * daemon's own leaves. Nothing is taken on trust — both values are recomputed here from the
2483
+ * carry, and an adversary who could satisfy them would have to have produced this leaf set.
2484
+ *
2485
+ * Deliberately BOTH values. A count that disagreed while the root matched would be a certificate
2486
+ * contradicting itself, and this is not the place to wave that through.
2487
+ */
2488
+ const carryInputs = carryContentHashInputs(carry);
2489
+ if (carryInputs !== null &&
2490
+ carry.length === certifiedLeafCount &&
2491
+ Buffer.compare(Buffer.from(merkleRoot(buildMerkleTree(carryInputs))), Buffer.from(certifiedRoot)) === 0) {
2492
+ return { verdict: "match" };
2493
+ }
2267
2494
  if (!selfEvidentlyComplete) {
2268
2495
  return {
2269
2496
  verdict: "cannot_judge",
@@ -2281,26 +2508,62 @@ export class SessionNodeManager {
2281
2508
  detail: `leaf_count_disagrees: this daemon holds a provably complete ${carry.length}-leaf set, the certificate claims ${certifiedLeafCount}`,
2282
2509
  };
2283
2510
  }
2284
- const inputs = [];
2511
+ if (carryInputs === null) {
2512
+ // A leaf this daemon cannot decode is a leaf it cannot judge. Never an accusation.
2513
+ return { verdict: "cannot_judge", reason: "structure1_content_hash_unreadable" };
2514
+ }
2515
+ const ownRoot = merkleRoot(buildMerkleTree(carryInputs));
2516
+ const ownRootHex = Buffer.from(ownRoot).toString("hex");
2517
+ return Buffer.compare(Buffer.from(ownRoot), Buffer.from(certifiedRoot)) === 0
2518
+ ? { verdict: "match" }
2519
+ : { verdict: "mismatch", ownRootHex, detail: "root_disagrees: same leaf count, different leaves or different order" };
2520
+ }
2521
+ /**
2522
+ * WHERE THE MUTUALLY-SIGNED PREFIX ENDS, DERIVED FROM THIS DAEMON'S OWN LEAVES —
2523
+ * `DOD-M15-UNILATERAL-1`, review F2.
2524
+ *
2525
+ * ⚠️ **THE FIRST VERSION COMPUTED THIS FROM THE CERTIFICATE'S OWN PARTICIPANT LIST, AND CALLED
2526
+ * THAT "recomputed, cannot be steered".** It could be steered. On the SOLO path the certificate's
2527
+ * TBS binds no legibility at all, and the client verifies only the *live* party's frontier — so
2528
+ * the absent party's `content_frontier_seq` and every `last_authored_seq` arrived unchecked. One
2529
+ * directory node could publish the absent party's frontier as 3 and the receipt would say
2530
+ * "mutually signed through 3" over a transcript that party never signed for. That is the precise
2531
+ * conflation this field exists to prevent, reintroduced by the field itself.
2532
+ *
2533
+ * The carry answers it without trusting anybody. This daemon holds the counterparty's own leaves,
2534
+ * each carrying, inside the bytes THEY signed, both the sequence they authored and the
2535
+ * `last_seen_seq` they acknowledged. So a party's commitment reaches
2536
+ * `max(highest sequence they authored, highest sequence they acknowledged)`, and the transcript is
2537
+ * mutually signed only as far as the LEAST-committed party reaches.
2538
+ *
2539
+ * Fewer than two distinct authors ⇒ `0`: nobody countersigned anything, which is the honest floor
2540
+ * for a conversation where the other side only ever received. `null` when the carry is empty or
2541
+ * unreadable — the caller must then publish NO boundary rather than fall back to a number
2542
+ * somebody else supplied.
2543
+ */
2544
+ countersignedThroughSeqFromCarry(agentPubkeyHex, sessionIdHex) {
2545
+ const carry = this.getSealCarry(agentPubkeyHex, sessionIdHex);
2546
+ if (carry.length === 0)
2547
+ return null;
2548
+ const reach = new Map();
2285
2549
  for (const leaf of carry) {
2286
- let contentHash;
2550
+ let signedLastSeen = 0;
2287
2551
  try {
2288
- // Canonical Structure 1 is [version, content_hash, sender_pubkey, session_id, last_seen_seq, timestamp].
2289
- contentHash = decode(leaf.structure1Cbor)[1];
2552
+ // Structure 1 = [version, content_hash, sender_pubkey, session_id, last_seen_seq, timestamp].
2553
+ const raw = decode(leaf.structure1Cbor)[4];
2554
+ const n = typeof raw === "bigint" ? Number(raw) : raw;
2555
+ if (typeof n === "number" && Number.isFinite(n))
2556
+ signedLastSeen = n;
2290
2557
  }
2291
2558
  catch {
2292
- return { verdict: "cannot_judge", reason: "structure1_decode_failed" };
2293
- }
2294
- if (!(contentHash instanceof Uint8Array) || contentHash.length !== 32) {
2295
- return { verdict: "cannot_judge", reason: "structure1_content_hash_missing" };
2559
+ return null; // unreadable: publish no boundary rather than a half-derived one
2296
2560
  }
2297
- inputs.push({ kind: "hash", data: contentHash });
2561
+ const prior = reach.get(leaf.senderPubkeyHex) ?? 0;
2562
+ reach.set(leaf.senderPubkeyHex, Math.max(prior, leaf.sequenceNumber, signedLastSeen));
2298
2563
  }
2299
- const ownRoot = merkleRoot(buildMerkleTree(inputs));
2300
- const ownRootHex = Buffer.from(ownRoot).toString("hex");
2301
- return Buffer.compare(Buffer.from(ownRoot), Buffer.from(certifiedRoot)) === 0
2302
- ? { verdict: "match" }
2303
- : { verdict: "mismatch", ownRootHex, detail: "root_disagrees: same leaf count, different leaves or different order" };
2564
+ if (reach.size < 2)
2565
+ return 0;
2566
+ return Math.min(...reach.values());
2304
2567
  }
2305
2568
  getSealCarry(agentPubkeyHex, sessionIdHex) {
2306
2569
  if (!this.#sealLeafStore && this.#db) {
@@ -3740,6 +4003,10 @@ export class SessionNodeManager {
3740
4003
  sealLeafStore: this.#sealLeafStore ?? undefined,
3741
4004
  // DOD-M15-RELAYSLOTS-1: read at each auth, never snapshotted — the token expires hourly.
3742
4005
  onlineToken: () => this.getDirectoryOnlineToken(agentName),
4006
+ // DOD-M15-CORROBORATE-1: a relay's witness alert reaches the operator's inbox from here.
4007
+ // The DETACHED clients get the same callback from the builder in daemon.ts.
4008
+ onWitnessAlert: (alert) => { this.recordRelayWitnessAlert(agentName, alert); },
4009
+ onWitnessUnreadable: (relayPeerId, why) => { this.recordRelayWitnessUnreadable(agentName, relayPeerId, why); },
3743
4010
  });
3744
4011
  this.#relayClients.set(clientKey, client);
3745
4012
  }
@@ -5535,6 +5802,162 @@ export class SessionNodeManager {
5535
5802
  }
5536
5803
  return { sealed_root: row.sealed_root_hex, legibility };
5537
5804
  }
5805
+ /**
5806
+ * DOD-M15-INCLUSION-1: keep the leaf set the certificate is signed over, so one message can later
5807
+ * be proved to sit under it.
5808
+ *
5809
+ * REFUSES unless the hashes reproduce `sealedRootHex` — `certifiedLeafSetFrom` does that check and
5810
+ * this method never bypasses it. That is what separates "the leaves the directory sent" from "the
5811
+ * leaves the consortium signed", and only the second is worth storing: a proof built on the first
5812
+ * would inherit whatever the directory chose to say.
5813
+ *
5814
+ * Idempotent (INSERT OR REPLACE keyed on leaf_index) so a re-delivered seal frame, or a unilateral
5815
+ * seal later upgraded to bilateral, rewrites the same rows instead of failing or doubling them.
5816
+ *
5817
+ * @returns whether the set was accepted and stored.
5818
+ */
5819
+ recordCertifiedLeafSet(agentName, sessionId, signedLeaves, sealedRootHex, correlationId) {
5820
+ if (!this.#db)
5821
+ return false;
5822
+ const resolved = certifiedLeafSetFrom(signedLeaves, sealedRootHex);
5823
+ if (!resolved.ok) {
5824
+ // The CAUSE is written where the proof surface can read it — fallback-finder finding 1. Without
5825
+ // this row, `sealed_leaves_root_disagrees` (a directory contradicting its own FROST signature)
5826
+ // and "this side was simply absent" are the same `null` downstream, and the operator is told
5827
+ // the second.
5828
+ this.#noteCertifiedLeafState(agentName, sessionId, resolved.reason, resolved.detail);
5829
+ // LOUD, and it names which of the two it is. `sealed_leaves_root_disagrees` in particular is
5830
+ // the directory shipping a leaf set that is not the one it signed — the receipt still stands
5831
+ // (its own signature is checked elsewhere), but nothing in this session can be proved at
5832
+ // message granularity until a set that reproduces the root arrives.
5833
+ this.#logger.error("seal.certified_leaves.refused", {
5834
+ agentName,
5835
+ sessionId,
5836
+ reason: resolved.reason,
5837
+ detail: resolved.detail,
5838
+ correlationId,
5839
+ impact: "the leaf set shipped with this seal is not the one the certificate is signed over, so no " +
5840
+ "inclusion proof can be issued for this session; the sealed receipt itself is unaffected",
5841
+ guidance: "cello_get_inclusion_proof will refuse this session by name (certified_leaves_unavailable). " +
5842
+ "Nothing local repairs it — the set has to arrive with a seal frame that reproduces the " +
5843
+ "signed root.",
5844
+ });
5845
+ return false;
5846
+ }
5847
+ const now = Date.now();
5848
+ try {
5849
+ const agentId = this.#requireAgentId(agentName);
5850
+ /**
5851
+ * DELETE THEN INSERT, INSIDE A TRANSACTION — fallback-finder finding 5.
5852
+ *
5853
+ * `INSERT OR REPLACE` alone is idempotent only for a set of the SAME length: a shorter
5854
+ * re-delivery overwrites 0..k-1 and leaves stale rows at k..n-1, and an un-transacted loop that
5855
+ * throws halfway leaves a truncated set that `getCertifiedLeafSet` still returns (it tests
5856
+ * `rows.length > 0`, not completeness). Both produce a set that no longer hashes to the
5857
+ * certified root — caught on read, but reported to the operator as *"the local copy has
5858
+ * changed since the seal"*, which points at tampering for a write that never finished.
5859
+ */
5860
+ // `BEGIN` / `COMMIT` / `ROLLBACK` via exec — this file's and `db-identity-store.ts`'s idiom.
5861
+ // `DaemonDatabase` has no `transaction()` helper (node:sqlite's handle does not provide one),
5862
+ // and reaching for better-sqlite3's would compile against the adapter and fail on the other.
5863
+ this.#db.exec("BEGIN");
5864
+ try {
5865
+ this.#db.prepare("DELETE FROM session_certified_leaves WHERE agent_id = ? AND session_id = ?")
5866
+ .run(agentId, sessionId);
5867
+ const stmt = this.#db.prepare(`INSERT INTO session_certified_leaves
5868
+ (agent_id, session_id, leaf_index, content_hash_hex, recorded_at)
5869
+ VALUES (?, ?, ?, ?, ?)`);
5870
+ for (let i = 0; i < resolved.leafHashes.length; i++) {
5871
+ stmt.run(agentId, sessionId, i, resolved.leafHashes[i], now);
5872
+ }
5873
+ this.#db.exec("COMMIT");
5874
+ }
5875
+ catch (err) {
5876
+ try {
5877
+ this.#db.exec("ROLLBACK");
5878
+ }
5879
+ catch { /* the failing statement may have aborted it already */ }
5880
+ throw err;
5881
+ }
5882
+ }
5883
+ catch (err) {
5884
+ this.#noteCertifiedLeafState(agentName, sessionId, "persist_failed", extractErrorMessage(err));
5885
+ this.#logger.error("seal.certified_leaves.persist.failed", {
5886
+ agentName,
5887
+ sessionId,
5888
+ reason: extractErrorMessage(err),
5889
+ correlationId,
5890
+ impact: "this session's certified leaf set was verified but not written, so cello_get_inclusion_proof " +
5891
+ "will refuse it by name until a later seal frame re-delivers the set",
5892
+ });
5893
+ return false;
5894
+ }
5895
+ this.#noteCertifiedLeafState(agentName, sessionId, "stored", null);
5896
+ this.#logger.info("seal.certified_leaves.recorded", {
5897
+ agentName,
5898
+ sessionId,
5899
+ leafCount: resolved.leafHashes.length,
5900
+ sealedRoot: sealedRootHex,
5901
+ correlationId,
5902
+ });
5903
+ return true;
5904
+ }
5905
+ /**
5906
+ * Record WHY this session does or does not have a certified leaf set.
5907
+ *
5908
+ * Public for the one case the manager cannot see: a seal frame that carried no signed leaves at
5909
+ * all never reaches `recordCertifiedLeafSet`, and that absence is a permanent fact about the
5910
+ * session for the party that observed it.
5911
+ */
5912
+ noteCertifiedLeafSetUnavailable(agentName, sessionId, state, detail) {
5913
+ this.#noteCertifiedLeafState(agentName, sessionId, state, detail);
5914
+ }
5915
+ /**
5916
+ * The last thing that happened to this session's certified leaf set, or null if nothing has.
5917
+ *
5918
+ * Null here and a null from `getCertifiedLeafSet` together mean "no seal has been processed on
5919
+ * this side yet" — which is a different sentence again from any of the recorded states.
5920
+ */
5921
+ getCertifiedLeafSetState(agentName, sessionId) {
5922
+ if (!this.#db)
5923
+ return null;
5924
+ const row = this.#db
5925
+ .prepare("SELECT state, detail FROM session_certified_leaves_state WHERE agent_id = ? AND session_id = ?")
5926
+ .get(this.#requireAgentId(agentName), sessionId);
5927
+ return row ? { state: row.state, detail: row.detail } : null;
5928
+ }
5929
+ /** Best-effort: a failure to record WHY must never be the thing that breaks a seal. */
5930
+ #noteCertifiedLeafState(agentName, sessionId, state, detail) {
5931
+ if (!this.#db)
5932
+ return;
5933
+ try {
5934
+ this.#db
5935
+ .prepare(`INSERT OR REPLACE INTO session_certified_leaves_state
5936
+ (agent_id, session_id, state, detail, recorded_at)
5937
+ VALUES (?, ?, ?, ?, ?)`)
5938
+ .run(this.#requireAgentId(agentName), sessionId, state, detail, Date.now());
5939
+ }
5940
+ catch (err) {
5941
+ this.#logger.warn("seal.certified_leaves.state.write.failed", {
5942
+ agentName, sessionId, state, reason: extractErrorMessage(err),
5943
+ impact: "the inclusion-proof surface will not be able to name WHY this session has no certified leaf set; it still refuses rather than proving anything",
5944
+ });
5945
+ }
5946
+ }
5947
+ /**
5948
+ * The certified leaf set, in order, or null when none was stored for this session.
5949
+ *
5950
+ * Null is a REFUSAL upstream, never a fallback to the local tree: the two cover different leaves
5951
+ * and substituting one for the other is how a proof comes to land on a root nobody signed.
5952
+ */
5953
+ getCertifiedLeafSet(agentName, sessionId) {
5954
+ if (!this.#db)
5955
+ return null;
5956
+ const rows = this.#db
5957
+ .prepare("SELECT content_hash_hex FROM session_certified_leaves WHERE agent_id = ? AND session_id = ? ORDER BY leaf_index ASC")
5958
+ .all(this.#requireAgentId(agentName), sessionId);
5959
+ return rows.length > 0 ? rows.map((r) => r.content_hash_hex) : null;
5960
+ }
5538
5961
  /**
5539
5962
  * M7-SESSION-001: Mark a session as interrupted with message count and timestamp.
5540
5963
  * Called when a relay session_interrupted frame arrives or a relay stream closes.
@@ -8739,7 +9162,22 @@ export class SessionNodeManager {
8739
9162
  "because sealing on a record the counterparty cannot match produces a receipt only one " +
8740
9163
  "side can verify.",
8741
9164
  });
8742
- return { placed: true, leafIndex };
9165
+ /**
9166
+ * ─── `016-RELAYLOSS`: CARRIED TO THE CALLER, because the log is not a consumer ────────────
9167
+ *
9168
+ * Measured with a real relay black-holed mid-conversation: the send stalls for the submit
9169
+ * timeout, then returns `{ok: true, sequence_number: N, delivered: true}` — BYTE-IDENTICAL to
9170
+ * the witnessed send that preceded it. The error above is correct, complete, and read by
9171
+ * nobody the operator can ask.
9172
+ *
9173
+ * Its two siblings twenty lines below already do this: `diverged` and the held case both
9174
+ * refuse to return an ordinary success, on the stated grounds that doing so "reports a
9175
+ * healthy send on a conversation that has silently lost the one thing the protocol exists to
9176
+ * produce." This branch is where that loss OCCURS — `position_behind_frontier`'s own comment
9177
+ * says the seal "was already lost at the unwitnessed append, not here" — so it is the branch
9178
+ * that most needed to say so, and it was the one that said nothing.
9179
+ */
9180
+ return { placed: true, leafIndex, unwitnessed: true };
8743
9181
  }
8744
9182
  if (assignedSeq === nextExpected) {
8745
9183
  const { leafIndex } = this.appendSessionLeaf(agentName, sessionId, kind, contentHashHex, correlationId);
@@ -10795,6 +11233,49 @@ export class SessionNodeManager {
10795
11233
  getSessionContentSalt(agentName, sessionId) {
10796
11234
  return this.#getSessionSalt(agentName, sessionId);
10797
11235
  }
11236
+ /**
11237
+ * THE SALT, OR WHY THERE ISN'T ONE — `DOD-M15-INCLUSION-1`, fallback-finder finding 2.
11238
+ *
11239
+ * `getSessionContentSalt` above answers `null` for THREE different situations, and a caller that
11240
+ * turns that null into a sentence for an operator gets two of them wrong:
11241
+ *
11242
+ * `none` — no salt was ever agreed. The session really is unsalted.
11243
+ * `unreadable` — a salt row EXISTS and could not be used: the wrong width (corruption on this
11244
+ * operator's own disk), or the read threw.
11245
+ *
11246
+ * The distinction is not cosmetic. `unreadable` means the session's leaves WERE hashed under a
11247
+ * salt, so telling its operator *"this session's content hashes are UNSALTED … start a session
11248
+ * while you are both connected"* is an affirmatively false statement about a security property
11249
+ * their session has, and it points them at their counterparty over damage to their own database.
11250
+ * That is the same defect `#getSessionSalt`'s own F8 note was written to end, re-committed one
11251
+ * surface out.
11252
+ *
11253
+ * ⚠️ IT DELEGATES — there is no second read here. Calling `#getSessionSalt` first means the salt
11254
+ * this reports is the salt the hashing path uses, including its cache and its wrong-width refusal.
11255
+ * A parallel query would be free to disagree with it, which is the whole failure this returns a
11256
+ * reason to prevent.
11257
+ */
11258
+ getSessionContentSaltState(agentName, sessionId) {
11259
+ const salt = this.#getSessionSalt(agentName, sessionId);
11260
+ if (salt)
11261
+ return { salt };
11262
+ if (!this.#db)
11263
+ return { salt: null, reason: "unreadable" };
11264
+ try {
11265
+ const row = this.#db
11266
+ .prepare("SELECT length(content_salt) AS n FROM sessions WHERE agent_id = ? AND session_id = ?")
11267
+ .get(this.#requireAgentId(agentName), sessionId);
11268
+ // A row with a non-empty blob that `#getSessionSalt` still refused is the corruption case: the
11269
+ // bytes are there and they are not a salt. NULL or zero-length is a genuine absence.
11270
+ const stored = row?.n ?? 0;
11271
+ return { salt: null, reason: stored > 0 ? "unreadable" : "none" };
11272
+ }
11273
+ catch {
11274
+ // The read that would tell us which case it is has itself failed, so "no salt was agreed" is
11275
+ // exactly the thing we cannot assert.
11276
+ return { salt: null, reason: "unreadable" };
11277
+ }
11278
+ }
10798
11279
  /**
10799
11280
  * This session's agreed salt, or null. Reads the durable row through a cache, because Decision #8
10800
11281
  * persists it for exactly one reason: *"a restart silently splits the transcript"* if the lookup