@cello-protocol/daemon 0.0.180 → 0.0.182
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.
- package/dist/agent-id-migration.d.ts +9 -0
- package/dist/agent-id-migration.d.ts.map +1 -1
- package/dist/agent-id-migration.js +73 -1
- package/dist/agent-id-migration.js.map +1 -1
- package/dist/agent-selection.d.ts +73 -0
- package/dist/agent-selection.d.ts.map +1 -0
- package/dist/agent-selection.js +80 -0
- package/dist/agent-selection.js.map +1 -0
- package/dist/assignment-verify.d.ts +62 -0
- package/dist/assignment-verify.d.ts.map +1 -0
- package/dist/assignment-verify.js +185 -0
- package/dist/assignment-verify.js.map +1 -0
- package/dist/backup-restore.d.ts +102 -0
- package/dist/backup-restore.d.ts.map +1 -0
- package/dist/backup-restore.js +340 -0
- package/dist/backup-restore.js.map +1 -0
- package/dist/bin/cello-daemon.js +91 -49
- package/dist/bin/cello-daemon.js.map +1 -1
- package/dist/close-commitment.d.ts +67 -0
- package/dist/close-commitment.d.ts.map +1 -0
- package/dist/close-commitment.js +78 -0
- package/dist/close-commitment.js.map +1 -0
- package/dist/close-session-handler.d.ts +20 -0
- package/dist/close-session-handler.d.ts.map +1 -1
- package/dist/close-session-handler.js +351 -58
- package/dist/close-session-handler.js.map +1 -1
- package/dist/consortium-bootstrap.d.ts +2 -0
- package/dist/consortium-bootstrap.d.ts.map +1 -1
- package/dist/consortium-bootstrap.js +65 -7
- package/dist/consortium-bootstrap.js.map +1 -1
- package/dist/content-park.d.ts.map +1 -1
- package/dist/content-park.js +141 -10
- package/dist/content-park.js.map +1 -1
- package/dist/daemon.d.ts +13 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +928 -123
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-session-suspects.d.ts.map +1 -1
- package/dist/delivery-session-suspects.js +5 -0
- package/dist/delivery-session-suspects.js.map +1 -1
- package/dist/directory-auth-posture.d.ts +87 -0
- package/dist/directory-auth-posture.d.ts.map +1 -0
- package/dist/directory-auth-posture.js +134 -0
- package/dist/directory-auth-posture.js.map +1 -0
- package/dist/directory-bootstrap.d.ts +66 -3
- package/dist/directory-bootstrap.d.ts.map +1 -1
- package/dist/directory-bootstrap.js +133 -7
- package/dist/directory-bootstrap.js.map +1 -1
- package/dist/document-delivery-transport.d.ts +22 -2
- package/dist/document-delivery-transport.d.ts.map +1 -1
- package/dist/document-delivery-transport.js +11 -3
- package/dist/document-delivery-transport.js.map +1 -1
- package/dist/document-frame-router.d.ts +14 -0
- package/dist/document-frame-router.d.ts.map +1 -1
- package/dist/document-frame-router.js +17 -0
- package/dist/document-frame-router.js.map +1 -1
- package/dist/document-gate.js +51 -8
- package/dist/document-gate.js.map +1 -1
- package/dist/document-handlers.d.ts.map +1 -1
- package/dist/document-handlers.js +50 -6
- package/dist/document-handlers.js.map +1 -1
- package/dist/document-inbound.d.ts +22 -0
- package/dist/document-inbound.d.ts.map +1 -1
- package/dist/document-inbound.js +54 -0
- package/dist/document-inbound.js.map +1 -1
- package/dist/document-layer.d.ts +13 -0
- package/dist/document-layer.d.ts.map +1 -1
- package/dist/document-layer.js +1 -0
- package/dist/document-layer.js.map +1 -1
- package/dist/document-screen.d.ts +39 -0
- package/dist/document-screen.d.ts.map +1 -1
- package/dist/document-screen.js +64 -9
- package/dist/document-screen.js.map +1 -1
- package/dist/inbound-sessions.d.ts +31 -1
- package/dist/inbound-sessions.d.ts.map +1 -1
- package/dist/inbound-sessions.js +276 -16
- package/dist/inbound-sessions.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/ipc-server.d.ts +5 -1
- package/dist/ipc-server.d.ts.map +1 -1
- package/dist/ipc-server.js +19 -3
- package/dist/ipc-server.js.map +1 -1
- package/dist/manifest-validity.d.ts +151 -0
- package/dist/manifest-validity.d.ts.map +1 -0
- package/dist/manifest-validity.js +268 -0
- package/dist/manifest-validity.js.map +1 -0
- package/dist/notification-handlers.d.ts.map +1 -1
- package/dist/notification-handlers.js +15 -0
- package/dist/notification-handlers.js.map +1 -1
- package/dist/outbound-sessions.d.ts +31 -0
- package/dist/outbound-sessions.d.ts.map +1 -1
- package/dist/outbound-sessions.js +198 -17
- package/dist/outbound-sessions.js.map +1 -1
- package/dist/park-envelope.d.ts +72 -1
- package/dist/park-envelope.d.ts.map +1 -1
- package/dist/park-envelope.js +119 -4
- package/dist/park-envelope.js.map +1 -1
- package/dist/refusal-reasons.d.ts +72 -0
- package/dist/refusal-reasons.d.ts.map +1 -0
- package/dist/refusal-reasons.js +69 -0
- package/dist/refusal-reasons.js.map +1 -0
- package/dist/registration-manager.d.ts.map +1 -1
- package/dist/registration-manager.js +20 -1
- package/dist/registration-manager.js.map +1 -1
- package/dist/restart-seal-resolver.d.ts.map +1 -1
- package/dist/restart-seal-resolver.js +6 -0
- package/dist/restart-seal-resolver.js.map +1 -1
- package/dist/retry-queue.d.ts +23 -1
- package/dist/retry-queue.d.ts.map +1 -1
- package/dist/retry-queue.js +37 -9
- package/dist/retry-queue.js.map +1 -1
- package/dist/roster-freshness.d.ts +169 -0
- package/dist/roster-freshness.d.ts.map +1 -0
- package/dist/roster-freshness.js +249 -0
- package/dist/roster-freshness.js.map +1 -0
- package/dist/seal-certificate-pull.d.ts +17 -1
- package/dist/seal-certificate-pull.d.ts.map +1 -1
- package/dist/seal-certificate-pull.js +37 -3
- package/dist/seal-certificate-pull.js.map +1 -1
- package/dist/seal-coordinator.d.ts +17 -1
- package/dist/seal-coordinator.d.ts.map +1 -1
- package/dist/seal-coordinator.js +71 -0
- package/dist/seal-coordinator.js.map +1 -1
- package/dist/seal-failure-store.d.ts +104 -0
- package/dist/seal-failure-store.d.ts.map +1 -0
- package/dist/seal-failure-store.js +126 -0
- package/dist/seal-failure-store.js.map +1 -0
- package/dist/seal-flows.d.ts.map +1 -1
- package/dist/seal-flows.js +35 -6
- package/dist/seal-flows.js.map +1 -1
- package/dist/seal-upgrade.d.ts +20 -5
- package/dist/seal-upgrade.d.ts.map +1 -1
- package/dist/seal-upgrade.js +28 -5
- package/dist/seal-upgrade.js.map +1 -1
- package/dist/session-assignment-parser.d.ts +41 -5
- package/dist/session-assignment-parser.d.ts.map +1 -1
- package/dist/session-assignment-parser.js +77 -14
- package/dist/session-assignment-parser.js.map +1 -1
- package/dist/session-ceremony.d.ts +29 -0
- package/dist/session-ceremony.d.ts.map +1 -1
- package/dist/session-ceremony.js +71 -0
- package/dist/session-ceremony.js.map +1 -1
- package/dist/session-connection-gater.d.ts +46 -7
- package/dist/session-connection-gater.d.ts.map +1 -1
- package/dist/session-connection-gater.js +163 -12
- package/dist/session-connection-gater.js.map +1 -1
- package/dist/session-content-handlers.d.ts.map +1 -1
- package/dist/session-content-handlers.js +110 -4
- package/dist/session-content-handlers.js.map +1 -1
- package/dist/session-node-manager.d.ts +407 -37
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +3051 -239
- package/dist/session-node-manager.js.map +1 -1
- package/dist/session-read-handlers.d.ts +27 -0
- package/dist/session-read-handlers.d.ts.map +1 -1
- package/dist/session-read-handlers.js +112 -6
- package/dist/session-read-handlers.js.map +1 -1
- package/dist/session-relay-client.d.ts +61 -2
- package/dist/session-relay-client.d.ts.map +1 -1
- package/dist/session-relay-client.js +170 -11
- package/dist/session-relay-client.js.map +1 -1
- package/dist/session-salt-agreement.d.ts +284 -0
- package/dist/session-salt-agreement.d.ts.map +1 -0
- package/dist/session-salt-agreement.js +371 -0
- package/dist/session-salt-agreement.js.map +1 -0
- package/dist/signal-submission.d.ts.map +1 -1
- package/dist/signal-submission.js +10 -1
- package/dist/signal-submission.js.map +1 -1
- package/dist/signaling-connect.d.ts.map +1 -1
- package/dist/signaling-connect.js +42 -5
- package/dist/signaling-connect.js.map +1 -1
- package/dist/types.d.ts +54 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +10 -0
- package/dist/vocabulary.js.map +1 -1
- package/dist/wire-content-hash.d.ts +56 -0
- package/dist/wire-content-hash.d.ts.map +1 -1
- package/dist/wire-content-hash.js +71 -0
- package/dist/wire-content-hash.js.map +1 -1
- package/package.json +5 -5
|
@@ -17,7 +17,12 @@
|
|
|
17
17
|
// The daemon DB is SQLCipher (whole-file AES-256 at rest), never `node:sqlite`. `DaemonDatabase` is
|
|
18
18
|
// the thin varargs surface; `openEncryptedDatabase` opens with a PRAGMA key and `resolveDbKey`
|
|
19
19
|
// manages the single plaintext key file.
|
|
20
|
-
|
|
20
|
+
// `wireContentHash` is gone from this file on purpose: its ONE use was the receive-path cross-check,
|
|
21
|
+
// which now goes through `contentHashFor` so the comparison runs under the algorithm the sender
|
|
22
|
+
// named. A direct call here would be a hash computed without asking what the frame said (part B1).
|
|
23
|
+
import { contentHashFor, resolveContentHashAlg, CONTENT_HASH_ALGS } from "./wire-content-hash.js";
|
|
24
|
+
import { CAPACITY_REASONS } from "./refusal-reasons.js";
|
|
25
|
+
import { onPeerSaltFrame, ownSaltFrame, SALT_ADOPTION_LABELS, SALT_FREEZE_GUIDANCE, } from "./session-salt-agreement.js";
|
|
21
26
|
import { openEncryptedDatabase, resolveDbKey, dbKeyPathFor, } from "./sqlcipher-db.js";
|
|
22
27
|
import { migrateToEncryptedIfNeeded } from "./identity-migration.js";
|
|
23
28
|
import { ensureIdentitySchema } from "./db-identity-store.js";
|
|
@@ -34,14 +39,17 @@ import { MAX_SESSION_NODES, STANDING_RECEIVER_AGENT_NAME } from "./types.js";
|
|
|
34
39
|
import { SessionConnectionGater } from "./session-connection-gater.js";
|
|
35
40
|
import { SessionTree, sessionTreeLeafKindFromDb } from "./session-tree.js";
|
|
36
41
|
import { CELLO_CONTENT_PROTOCOL_ID, NodeAutoNatService } from "@cello-protocol/transport";
|
|
37
|
-
import { verify } from "@cello-protocol/crypto";
|
|
42
|
+
import { verify, buildMerkleTree, merkleRoot, generateSaltContribution, SESSION_SALT_BYTES } from "@cello-protocol/crypto";
|
|
38
43
|
import { encodeSealPayload, MONIKER_RE, validateMoniker } from "@cello-protocol/protocol-types";
|
|
39
|
-
import { decodeParkEnvelope, authenticateParkedEntry, pubkeyMatchesHex } from "./park-envelope.js";
|
|
44
|
+
import { decodeParkEnvelope, authenticateParkedEntry, pubkeyMatchesHex, ParkEnvelopeError, PARK_ENVELOPE_REASONS } from "./park-envelope.js";
|
|
40
45
|
import { isValidMultiaddr } from "@cello-protocol/transport";
|
|
41
|
-
|
|
46
|
+
// `LEAF_KIND_MSG` is no longer imported here: `sendContent`'s `leafKind` stopped defaulting to it
|
|
47
|
+
// (B2b-1 review F4), so this file no longer names a default — every caller states its own kind.
|
|
48
|
+
import { AgentRelayClient, LEAF_KIND_CTRL, isTerminalRelayRefusal, extractErrorMessage } from "./session-relay-client.js";
|
|
42
49
|
import { terminalRelayRefusal } from "./session-terminal-refusal.js";
|
|
43
50
|
import { RelayReceiptStore } from "./relay-receipt-store.js";
|
|
44
51
|
import { SessionSealLeafStore } from "./session-seal-leaf-store.js";
|
|
52
|
+
import { addColumnIfMissing } from "./column-birth.js";
|
|
45
53
|
import { GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT, } from "@cello-protocol/gateway";
|
|
46
54
|
/** SEC-1 / review M4: cap on the refused-parked-entry memo (remote-fed → must be bounded). */
|
|
47
55
|
/**
|
|
@@ -52,6 +60,89 @@ import { GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT, } from "@cello-protocol/gatewa
|
|
|
52
60
|
*/
|
|
53
61
|
const AUTOACK_BROKER_GRACE_MS = 30_000;
|
|
54
62
|
const MAX_REFUSED_PARKED_ENTRIES = 512;
|
|
63
|
+
/**
|
|
64
|
+
* Per-session cap on remembered unreadable-algorithm frames (`DOD-M15-SEALWIRE-1` part B1).
|
|
65
|
+
*
|
|
66
|
+
* Fed by a REMOTE party — a peer on a newer build refuses every frame it sends — so it needs a
|
|
67
|
+
* bound for the same reason `MAX_REFUSED_PARKED_ENTRIES` does. Small on purpose: the entries exist
|
|
68
|
+
* only to reconcile a refusal with its park-route redelivery, which happens within seconds, and
|
|
69
|
+
* losing an old one costs a log line rather than correctness.
|
|
70
|
+
*/
|
|
71
|
+
const MAX_UNREADABLE_ALG_FRAMES = 64;
|
|
72
|
+
/**
|
|
73
|
+
* How long the first send waits for an in-flight salt agreement before giving up on it —
|
|
74
|
+
* `DOD-M15-SEALWIRE-1` B2b-2 constraint 2.
|
|
75
|
+
*
|
|
76
|
+
* The agreement is ONE round trip on a stream that is already open, so a healthy exchange finishes
|
|
77
|
+
* in milliseconds; this bound is not sized for the normal case, it is sized for how long an operator
|
|
78
|
+
* should wait before their message goes out unsalted instead of not going out.
|
|
79
|
+
*
|
|
80
|
+
* Five seconds because both errors cost real things. Too short and a merely slow counterparty makes
|
|
81
|
+
* the session permanently unsalted for no reason — the decision is irreversible, so the bound should
|
|
82
|
+
* be generous relative to the round trip. Too long and the first message of every conversation with
|
|
83
|
+
* a peer on an older build visibly hangs, which is the failure a user actually notices and blames
|
|
84
|
+
* the product for. This is only ever paid by a session that HAS an agreement outstanding: a
|
|
85
|
+
* park-only session never starts one and never waits (constraint 5).
|
|
86
|
+
*/
|
|
87
|
+
const SALT_AGREEMENT_WAIT_MS = 5_000;
|
|
88
|
+
/**
|
|
89
|
+
* WHY a session is hashing unsalted — review Finding 1, and this exists because one sentence was
|
|
90
|
+
* carrying five different situations.
|
|
91
|
+
*
|
|
92
|
+
* `#saltForHashing` returns null for five distinct upstream conditions, and the single guidance
|
|
93
|
+
* string asserted one of them: *"expected when your counterparty runs a build that predates the salt
|
|
94
|
+
* agreement… start a new session once they upgrade."* An operator whose counterparty was merely
|
|
95
|
+
* OFFLINE when they sent their first message — the most common case by far, since a parked first
|
|
96
|
+
* message unsalts the session by design — read that and went and told a fully up-to-date
|
|
97
|
+
* counterparty to upgrade.
|
|
98
|
+
*
|
|
99
|
+
* That is the `directory_unreachable` shape this project keeps re-learning: the message names the
|
|
100
|
+
* exit point and points at the wrong machine. A closed set of reasons with its own guidance per
|
|
101
|
+
* reason is the fix, and a closed set is what stops a sixth condition quietly inheriting a fifth's
|
|
102
|
+
* explanation.
|
|
103
|
+
*/
|
|
104
|
+
const UNSALTED_REASONS = {
|
|
105
|
+
/** No agreement was ever started — the counterparty has not connected. The park-only case. */
|
|
106
|
+
NO_AGREEMENT_STARTED: "no_agreement_started",
|
|
107
|
+
/** We announced and they did not answer inside the bound. */
|
|
108
|
+
AGREEMENT_TIMED_OUT: "agreement_timed_out",
|
|
109
|
+
/** They answered, terminally: they have already hashed content and can never adopt. */
|
|
110
|
+
PEER_CLOSED_ADOPTION: "peer_closed_adoption",
|
|
111
|
+
/** The session was torn down while the first send was still waiting. */
|
|
112
|
+
SESSION_TORN_DOWN: "session_torn_down",
|
|
113
|
+
/** This side already hashed, leafed, held or has in flight — adoption closed here. */
|
|
114
|
+
ADOPTION_CLOSED_LOCALLY: "adoption_closed_locally",
|
|
115
|
+
/** They answered in time and OUR OWN write failed. Nothing about their build is involved. */
|
|
116
|
+
OUR_PERSIST_FAILED: "our_persist_failed",
|
|
117
|
+
/**
|
|
118
|
+
* OUR announce never left this machine — review pass 2, F2. Reusing `AGREEMENT_TIMED_OUT` here
|
|
119
|
+
* told the operator *"your counterparty did not answer"* about a frame we never sent, which is the
|
|
120
|
+
* exact substitution this closed set was created to end, re-entering through the settle site the
|
|
121
|
+
* previous pass asked for.
|
|
122
|
+
*/
|
|
123
|
+
ANNOUNCE_FAILED: "our_announce_failed",
|
|
124
|
+
/**
|
|
125
|
+
* They answered, we stored it, and reading it back FAILED — review pass 2, F4. Distinct from
|
|
126
|
+
* `OUR_PERSIST_FAILED` because the two point at different log lines, and the guidance names one.
|
|
127
|
+
*/
|
|
128
|
+
OUR_READ_FAILED: "our_read_failed",
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* What the operator should DO about each. TOTAL by construction — a `Record` over the union, so a
|
|
132
|
+
* new reason cannot be added without something for the reader to act on. Same shape, and the same
|
|
133
|
+
* reason, as `refusal-reasons.ts`: that file exists because a free-form `reason: string` let a new
|
|
134
|
+
* code slip past every test in its own guard file.
|
|
135
|
+
*/
|
|
136
|
+
const UNSALTED_GUIDANCE = {
|
|
137
|
+
[UNSALTED_REASONS.NO_AGREEMENT_STARTED]: "Your counterparty was not connected when you sent this, so there was nobody to agree a salt with — most often they are simply offline and this message is going to their relay mailbox. Nothing is wrong with either build. The agreement only runs at session open, so this session stays unsalted even after they come online; a session started while you are both connected will be salted.",
|
|
138
|
+
[UNSALTED_REASONS.AGREEMENT_TIMED_OUT]: "Your counterparty was connected but did not answer the salt agreement in time. Almost always they are on a build that predates it, in which case this is expected and permanent for this session — start a new session once they upgrade. If you know they are on the same version, look for session.salt.persist.failed on this side and session.salt.announce.failed on either.",
|
|
139
|
+
[UNSALTED_REASONS.PEER_CLOSED_ADOPTION]: "Your counterparty declined the salt because their side of this session had already hashed messages — their conversation started before yours could agree one. Both builds are fine and both sides know. Start a new session if you want the protection.",
|
|
140
|
+
[UNSALTED_REASONS.SESSION_TORN_DOWN]: "This session was closed or reset while the message was still being prepared. This line is about the salt only; look for the close or freeze event just before it for what actually happened to the session.",
|
|
141
|
+
[UNSALTED_REASONS.ADOPTION_CLOSED_LOCALLY]: "This session had already hashed or sent messages before a salt could be agreed, so adopting one now would leave half the conversation verifiable by one rule and half by another. That is permanent for this session and both builds are fine. Start a new session if you want the protection.",
|
|
142
|
+
[UNSALTED_REASONS.OUR_PERSIST_FAILED]: "Your counterparty answered in time and THIS side failed to store the agreed salt — the fault is local, not theirs. Do not ask them to upgrade. Look for session.salt.persist.failed immediately above this line; it names the write that failed.",
|
|
143
|
+
[UNSALTED_REASONS.ANNOUNCE_FAILED]: "THIS side could not send the salt agreement to your counterparty — the frame never left this machine, so they were never asked and their build is not involved. Do not ask them to upgrade. Look for session.salt.announce.failed immediately above this line for the connection error; most often the direct link to them dropped between connecting and sending.",
|
|
144
|
+
[UNSALTED_REASONS.OUR_READ_FAILED]: "A salt was agreed for this session and THIS side could not read it back — the fault is local storage, not your counterparty. Do not ask them to upgrade. Look for session.salt.read.failed immediately above this line; a wrong-width or unreadable row is named there.",
|
|
145
|
+
};
|
|
55
146
|
/**
|
|
56
147
|
* DOD-M12B-ACK-1 — inbound `/cello/content/1.0.0` streams allowed per connection.
|
|
57
148
|
*
|
|
@@ -361,6 +452,24 @@ export class SessionNodeManager {
|
|
|
361
452
|
#leafFetchGraceMs = LEAF_FETCH_GRACE_MS;
|
|
362
453
|
#standingReceivers = new Map();
|
|
363
454
|
#standingReceiverCreating = new Set();
|
|
455
|
+
/**
|
|
456
|
+
* (agentName, sessionId) → the peer id that session's `session_offer` named as the dialer.
|
|
457
|
+
*
|
|
458
|
+
* KEYED BY SESSION, not by agent (review F1). Keyed by agent alone, two overlapping inbound
|
|
459
|
+
* sessions destroyed each other: offer P narrows to peer P, offer Q overwrites with peer Q, then
|
|
460
|
+
* assignment P arrives and MISMATCHES — refusing a legitimate session while accusing the directory
|
|
461
|
+
* of naming two dialers, which it had not. It then cleared Q's record too, so assignment Q passed
|
|
462
|
+
* unchecked. An attacker could therefore disarm the check by provoking one mismatch.
|
|
463
|
+
*
|
|
464
|
+
* The gap between offer and assignment spans a cross-region threshold ceremony, so overlapping
|
|
465
|
+
* sessions are ordinary, not exotic. `DOD-M15-OFFER-EXPIRY-1` already prescribed this fix shape —
|
|
466
|
+
* "bind the narrowing to the session id it came from" — and the session id was in hand at both
|
|
467
|
+
* ends the whole time.
|
|
468
|
+
*
|
|
469
|
+
* DOD-M15-OFFER-SIGNED-1: read back when the SIGNED assignment arrives, so an unsigned offer
|
|
470
|
+
* cannot name a peer the signed document does not.
|
|
471
|
+
*/
|
|
472
|
+
#offeredDialer = new Map();
|
|
364
473
|
// M8B F14: agents that SHOULD have a standing receiver — marked by
|
|
365
474
|
// ensureStandingReceiverForAgent (cello_start_agent / the inbound accept path) and
|
|
366
475
|
// unmarked by removeStandingReceiverForAgent (cello_set_agent_offline). Consulted by the
|
|
@@ -446,13 +555,165 @@ export class SessionNodeManager {
|
|
|
446
555
|
// safe to close — the close would be signed, refused as `leaf_count_mismatch`, and the receipt
|
|
447
556
|
// lost for good.
|
|
448
557
|
#diverged = new Set();
|
|
558
|
+
/**
|
|
559
|
+
* Rehydrate `#diverged` from `sessions.diverged_at` — `DOD-M15-DIVERGE-DURABLE-1`.
|
|
560
|
+
*
|
|
561
|
+
* The Set stays as the hot read (the seal gate consults it per close), and the column is the
|
|
562
|
+
* truth. Loaded once at boot rather than queried per read so the gate's cost does not change.
|
|
563
|
+
*/
|
|
564
|
+
#loadDivergedFromDb() {
|
|
565
|
+
if (!this.#db)
|
|
566
|
+
return;
|
|
567
|
+
const rows = this.#db
|
|
568
|
+
.prepare(`SELECT s.session_id AS sid, a.agent_name AS agent
|
|
569
|
+
FROM sessions s JOIN agents a ON a.agent_id = s.agent_id
|
|
570
|
+
WHERE s.diverged_at IS NOT NULL`)
|
|
571
|
+
.all();
|
|
572
|
+
for (const r of rows)
|
|
573
|
+
this.#diverged.add(this.#k(r.agent, r.sid));
|
|
574
|
+
if (rows.length > 0) {
|
|
575
|
+
this.#logger.info("session.diverged.restored", {
|
|
576
|
+
count: rows.length,
|
|
577
|
+
impact: "these sessions provably cannot seal bilaterally and are refused at the seal gate — before " +
|
|
578
|
+
"this was durable, a restart made them read as healthy",
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Record that this session's tree and the relay's counter have provably parted.
|
|
584
|
+
*
|
|
585
|
+
* Idempotent, and deliberately does NOT touch `updated_at`: that column drives the inbox's
|
|
586
|
+
* last-spoke ordering, and divergence is not activity.
|
|
587
|
+
*/
|
|
588
|
+
markSessionDiverged(agentName, sessionId) {
|
|
589
|
+
this.#diverged.add(this.#k(agentName, sessionId));
|
|
590
|
+
if (!this.#db)
|
|
591
|
+
return;
|
|
592
|
+
/**
|
|
593
|
+
* KEYED ON (agent_id, session_id) — review F3, and the loopback case makes it concrete.
|
|
594
|
+
*
|
|
595
|
+
* This was `WHERE session_id = ?` alone. The table's PK is composite for a documented reason
|
|
596
|
+
* (`DOD-LOOP-1`, on the CREATE TABLE above): **two of one operator's agents can hold both ends
|
|
597
|
+
* of the SAME session_id on ONE daemon**, so `sessions` holds two rows. Unkeyed, marking one
|
|
598
|
+
* side diverged marked BOTH, and the clear below wiped BOTH — so side B sealing its half
|
|
599
|
+
* erased side A's divergence, and after a restart A's seal gate read healthy and signed a close
|
|
600
|
+
* that could only be refused. The line's own defect, produced by the line's own clear.
|
|
601
|
+
*
|
|
602
|
+
* Every other per-session UPDATE in this file keys on both columns; these two were the
|
|
603
|
+
* exceptions.
|
|
604
|
+
*/
|
|
605
|
+
this.#db
|
|
606
|
+
.prepare("UPDATE sessions SET diverged_at = ? WHERE agent_id = ? AND session_id = ? AND diverged_at IS NULL")
|
|
607
|
+
.run(Date.now(), this.#requireAgentId(agentName), sessionId);
|
|
608
|
+
}
|
|
609
|
+
/** Whether this session has provably parted from the relay's ordering. */
|
|
610
|
+
isSessionDiverged(agentName, sessionId) {
|
|
611
|
+
return this.#diverged.has(this.#k(agentName, sessionId));
|
|
612
|
+
}
|
|
613
|
+
// DOD-M15-FRAME-1 (review F1): sessions frozen because a frame failed to verify against the
|
|
614
|
+
// expected counterparty. Consulted by `reviveSessionNode` — a teardown writes `interrupted`,
|
|
615
|
+
// which is the REVIVABLE status, so without this the next `cello_receive` silently rebuilt the
|
|
616
|
+
// session and re-admitted the same peer. NOT cleared by `#evictSessionCaches`: the freeze is a
|
|
617
|
+
// fact about the session, not about the node that was torn down to enforce it.
|
|
618
|
+
//
|
|
619
|
+
// DOD-M15-SEALWIRE-1 (part A): a MAP, not a Set, and the value is load-bearing. It was a Set when
|
|
620
|
+
// the identity freeze was the only freeze, so `reviveSessionNode` could hardcode its refusal —
|
|
621
|
+
// *"a message failed to verify against the expected counterparty's key"*. A salt disagreement now
|
|
622
|
+
// freezes through the same path, and that sentence would accuse a counterparty who did nothing:
|
|
623
|
+
// the ordinary cause is two builds that do not match. The refusal carries the freezing site's own
|
|
624
|
+
// words instead.
|
|
625
|
+
#frozenSessions = new Map();
|
|
626
|
+
/**
|
|
627
|
+
* DOD-M15-SEALWIRE-1 bullet 6 (part A) — the salt agreement's two pieces of per-session state.
|
|
628
|
+
*
|
|
629
|
+
* `#saltContributions` — OUR random half, MINTED ONCE PER SESSION. This being a map rather than a
|
|
630
|
+
* fresh call at each send is the whole correctness of the exchange: we re-announce on every
|
|
631
|
+
* counterparty connect, and a contribution regenerated per reconnect would have both sides
|
|
632
|
+
* deriving against a moving value with the fingerprints never settling — a session that
|
|
633
|
+
* reconnects and still disagrees, which reads as a network fault rather than a bug here.
|
|
634
|
+
*
|
|
635
|
+
* `#sessionSalts` — a CACHE over `sessions.content_salt`, which is the durable copy. Both are
|
|
636
|
+
* cleared by `#evictSessionCaches`: the contribution is worthless once a salt exists, and the
|
|
637
|
+
* salt is re-read from the row on revival, which is exactly what Decision #8 persists it for.
|
|
638
|
+
*/
|
|
639
|
+
#saltContributions = new Map();
|
|
640
|
+
#sessionSalts = new Map();
|
|
641
|
+
/**
|
|
642
|
+
* The peer half we last answered with a repair, hex — review F14, and it is what makes the repair
|
|
643
|
+
* TERMINATE. Without it, two daemons that already hold the same salt trade contributions forever
|
|
644
|
+
* once a reconnect leaves a stale copy queued on each side. See `onPeerSaltFrame`'s
|
|
645
|
+
* `alreadyRepairedAgainstPeerHalf`.
|
|
646
|
+
*/
|
|
647
|
+
#saltRepairedAgainst = new Map();
|
|
648
|
+
/**
|
|
649
|
+
* ─── B2b-2 state: what the SEND path needs that the row cannot answer ─────────────────────────
|
|
650
|
+
*
|
|
651
|
+
* `#saltPending` — an agreement that has actually gone out and not yet been answered. The first
|
|
652
|
+
* send waits on it (constraint 2). Absent means *nothing is in flight*, which is not the same as
|
|
653
|
+
* "no salt": a park-only session never starts one at all, and must not wait (constraint 5).
|
|
654
|
+
*
|
|
655
|
+
* `#hashedWithoutSalt` — this session has already computed an unsalted content hash. Decision #8
|
|
656
|
+
* closes adoption at the moment content is HASHED, and for a session's first message that is a
|
|
657
|
+
* full network round trip before any leaf, held row or in-flight entry exists. Without this flag
|
|
658
|
+
* the frontier count reads empty for exactly the window in which adopting would split the
|
|
659
|
+
* transcript.
|
|
660
|
+
*
|
|
661
|
+
* `#unsaltedAnnounced` — the fallback has been stated for this session. Decision #15 says once per
|
|
662
|
+
* session; a per-message warning is a filter waiting to be written.
|
|
663
|
+
*
|
|
664
|
+
* All three are per-session and in-memory by design, and are dropped with the rest of a session's
|
|
665
|
+
* caches on eviction — a revived session re-reads its salt from the row, re-derives its frontier
|
|
666
|
+
* from durable state, and starts a fresh agreement if it reconnects.
|
|
667
|
+
*/
|
|
668
|
+
#saltPending = new Map();
|
|
669
|
+
/**
|
|
670
|
+
* HOW THE LAST AGREEMENT ENDED, kept after `#saltPending` is cleared.
|
|
671
|
+
*
|
|
672
|
+
* ⚠️ FOUND BY FALSIFYING MY OWN FIX. `#settleSaltPending` deletes the pending entry, so a send that
|
|
673
|
+
* arrives AFTER an agreement has already failed finds nothing pending and is told
|
|
674
|
+
* `no_agreement_started` — *"your counterparty was not connected"* — when in fact they were
|
|
675
|
+
* connected and our own dial to them failed. The outcome was observable only to a send that
|
|
676
|
+
* happened to already be waiting, which is the minority case.
|
|
677
|
+
*
|
|
678
|
+
* So the verdict outlives the wait. An ABSENT entry still means what it always meant — no
|
|
679
|
+
* agreement was ever started, the park-only case — and that distinction is the whole reason this
|
|
680
|
+
* is a separate map rather than a default.
|
|
681
|
+
*/
|
|
682
|
+
#saltLastOutcome = new Map();
|
|
683
|
+
#hashedWithoutSalt = new Map();
|
|
684
|
+
#unsaltedAnnounced = new Set();
|
|
449
685
|
// DOD-M12B-ACK-1: pending linger-resets for inbound content streams the peer has not closed.
|
|
450
686
|
// Held so shutdown can drop them rather than leave timers pointing at a torn-down node.
|
|
451
687
|
#lingeringStreams = new Set();
|
|
452
688
|
// M7-UPGRADE-002: sessions whose content integrity could NOT be verified (a content_hash
|
|
453
689
|
// mismatch = tamper was observed). The auto-acknowledge gate (SI-002) refuses to auto-co-sign
|
|
454
690
|
// for a desynced session — B must never blind-sign a tail it cannot verify. Keyed by sessionId hex.
|
|
455
|
-
|
|
691
|
+
//
|
|
692
|
+
// DOD-M15-SEALWIRE-1 part B1 (review F1): a MAP, not a Set, and the value is the whole fix.
|
|
693
|
+
//
|
|
694
|
+
// It was a Set because a content_hash mismatch was the only way in, so "present" could mean
|
|
695
|
+
// "tampered". B1 added two more ways for a frame to fail verification — an algorithm we cannot
|
|
696
|
+
// read, and a salted frame we hold no salt for — and BOTH ARE ORDINARY. An honest peer on a newer
|
|
697
|
+
// build produces the first. So the gate must still fire (never sign content you could not verify)
|
|
698
|
+
// while the LABEL must not accuse anyone: `unverifiable` is not `tampered`.
|
|
699
|
+
//
|
|
700
|
+
// One structure with two labels rather than two sets, deliberately: a second set is a second thing
|
|
701
|
+
// for every gate to remember to consult, and the one that gets forgotten is the one that matters.
|
|
702
|
+
#contentDesynced = new Map();
|
|
703
|
+
/**
|
|
704
|
+
* Frames refused because they named a content-hash algorithm this build cannot read, keyed by
|
|
705
|
+
* session → the refused frame's content hash → the name it used. Review F2, corrected by F-D.
|
|
706
|
+
*
|
|
707
|
+
* ⚠️ KEYED BY THE FRAME, NOT THE SESSION, and the first version was keyed by the session. That
|
|
708
|
+
* made it fire on the NORMAL case: after one junk-alg frame, every subsequent park recovery on
|
|
709
|
+
* that session logged a WARN forever, for entirely unrelated messages — and the text asserted the
|
|
710
|
+
* two events were "the same message arriving twice by different routes", which nothing had
|
|
711
|
+
* established. A warning that fires on the benign steady state is not a signal.
|
|
712
|
+
*
|
|
713
|
+
* Hash-keyed, the claim becomes a fact and the event fires exactly once per affected message: the
|
|
714
|
+
* entry is removed the moment it is reconciled.
|
|
715
|
+
*/
|
|
716
|
+
#unreadableAlgSeen = new Map();
|
|
456
717
|
// DOD-MSG-4 (strict in-order): the RELAY is the ordering authority (Structure 2). For each
|
|
457
718
|
// message the relay witnesses, it delivers B a (content_hash -> canonical sequence) binding via
|
|
458
719
|
// the leaf_deliver stream. B records it here — keyed #k(agent,session) -> (contentHashHex -> seq)
|
|
@@ -517,6 +778,13 @@ export class SessionNodeManager {
|
|
|
517
778
|
* The router it calls never logs them.
|
|
518
779
|
*/
|
|
519
780
|
#onDocumentFrame = null;
|
|
781
|
+
/**
|
|
782
|
+
* DOD-DOC-SCREEN-CLASSIFY-1: the classify-only half of the hook above — is this a document
|
|
783
|
+
* frame, deciding nothing else. Injected together with it so the two cannot disagree about what
|
|
784
|
+
* a document frame is. Null means every frame takes the full inbound screen, exactly as before
|
|
785
|
+
* the document layer existed.
|
|
786
|
+
*/
|
|
787
|
+
#isDocumentFrame = null;
|
|
520
788
|
// A send is NOT fire-and-forget. After a content_frame is delivered over the direct session
|
|
521
789
|
// channel, the sender arms a TTF timer and waits for an unsigned, transport-authenticated
|
|
522
790
|
// `persisted` delivery ACK on the same /cello/content/1.0.0 protocol. A persisted ACK cancels the
|
|
@@ -749,7 +1017,17 @@ export class SessionNodeManager {
|
|
|
749
1017
|
* fire this from an async backstop with no live caller (the TTF-expiry path) may ignore the
|
|
750
1018
|
* result — the deposit itself and its logging are unchanged either way.
|
|
751
1019
|
*/
|
|
752
|
-
|
|
1020
|
+
/**
|
|
1021
|
+
* `contentHashAlg` is `string | undefined`, NOT optional — B2b-1 review F4's shape, applied to the
|
|
1022
|
+
* last place it was missing.
|
|
1023
|
+
*
|
|
1024
|
+
* Optional, dropping it at a call site was neither a typecheck error nor a test failure, because
|
|
1025
|
+
* absent silently means `sha256` and that is the only value in play today. Measured: the
|
|
1026
|
+
* direct-dial-fail route's mutant SURVIVED the whole daemon suite. Requiring the argument — even
|
|
1027
|
+
* when its value is `undefined` — forces each of the three callers to state what this message was
|
|
1028
|
+
* hashed under, so a new fourth caller cannot omit it by accident.
|
|
1029
|
+
*/
|
|
1030
|
+
async #parkContent(agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor, contentHashAlg) {
|
|
753
1031
|
// Fault injection FIRST, so it reproduces the real shape: the refusal happens at the same point
|
|
754
1032
|
// the live hook refuses (before any deposit), with the same event and the same `cause`.
|
|
755
1033
|
if (this.#parkFaultRemaining > 0) {
|
|
@@ -785,6 +1063,9 @@ export class SessionNodeManager {
|
|
|
785
1063
|
// on recover too (sealed INTO the ciphertext envelope — INV-3: the relay still sees only ciphertext).
|
|
786
1064
|
structure1Cbor,
|
|
787
1065
|
structure2Cbor,
|
|
1066
|
+
// B2b: the park route must name the same algorithm the direct frame did, or the recipient
|
|
1067
|
+
// verifies the same message two different ways depending on which route it took.
|
|
1068
|
+
contentHashAlg,
|
|
788
1069
|
});
|
|
789
1070
|
// DOD-LEAVEMSG-1 (reviewer HIGH fix): check the TYPED result, not just "didn't throw" — the
|
|
790
1071
|
// production hook's own failure branches (standing receiver unavailable, relay explicitly
|
|
@@ -802,12 +1083,30 @@ export class SessionNodeManager {
|
|
|
802
1083
|
return { outcome: "parked" };
|
|
803
1084
|
}
|
|
804
1085
|
catch (err) {
|
|
1086
|
+
/**
|
|
1087
|
+
* THE CODE GOES IN `cause`, THE PARAGRAPH GOES IN THE LOG — B2b-2 constraint 6.
|
|
1088
|
+
*
|
|
1089
|
+
* `cause` is documented as the machine-readable half and is handed to callers that branch on
|
|
1090
|
+
* it. Putting `err.message` there meant a producer-side refusal — this build cannot seal that
|
|
1091
|
+
* algorithm — was indistinguishable from a relay outage, so it inherited the relay's guidance:
|
|
1092
|
+
* *"queued, and will be re-sent when the relay link is back."* The relay was never asked, and
|
|
1093
|
+
* every re-park throws in the same place, so that sends the operator to the wrong subsystem
|
|
1094
|
+
* and then tells them to wait for a recovery that cannot happen.
|
|
1095
|
+
*
|
|
1096
|
+
* `instanceof`, not a string test: an untyped failure keeps the old behaviour exactly, so this
|
|
1097
|
+
* narrows what the caller can distinguish without changing anything it could not.
|
|
1098
|
+
*/
|
|
1099
|
+
const coded = err instanceof ParkEnvelopeError ? err : null;
|
|
805
1100
|
this.#logger.warn("content.park.deposit.failed", {
|
|
806
1101
|
sessionId,
|
|
807
1102
|
contentHash: contentHashHex,
|
|
1103
|
+
...(coded === null ? {} : { reason: coded.reason, detail: coded.detail }),
|
|
808
1104
|
error: err instanceof Error ? err.message : String(err),
|
|
809
1105
|
});
|
|
810
|
-
return {
|
|
1106
|
+
return {
|
|
1107
|
+
outcome: "refused",
|
|
1108
|
+
cause: coded?.reason ?? (err instanceof Error ? err.message : String(err)),
|
|
1109
|
+
};
|
|
811
1110
|
}
|
|
812
1111
|
}
|
|
813
1112
|
// ─── Initialization ──────────────────────────────────────────────────────
|
|
@@ -847,6 +1146,47 @@ export class SessionNodeManager {
|
|
|
847
1146
|
for (const ddl of [
|
|
848
1147
|
"ALTER TABLE sessions ADD COLUMN message_count INTEGER NOT NULL DEFAULT 0",
|
|
849
1148
|
"ALTER TABLE sessions ADD COLUMN interrupted_at TEXT",
|
|
1149
|
+
/**
|
|
1150
|
+
* Decisions Carried #8 — THE SESSION SALT, persisted.
|
|
1151
|
+
*
|
|
1152
|
+
* Agreed once at session open from BOTH sides' random contributions, and unchanged for the
|
|
1153
|
+
* life of the session. It is NOT a key: it decrypts nothing, and it is what lets this
|
|
1154
|
+
* operator's own transcript stay verifiable — the content hash is recomputed from stored
|
|
1155
|
+
* plaintext on the receive path and again for any later check, and salted it is underivable
|
|
1156
|
+
* without this value.
|
|
1157
|
+
*
|
|
1158
|
+
* PERSISTED because the alternative is silent corruption. ⚠️ FUTURE TENSE, deliberately
|
|
1159
|
+
* (review F10): NOTHING WRITES OR READS THIS COLUMN YET. `DOD-M15-SEALWIRE-1` will add the
|
|
1160
|
+
* contribution exchange and the lookup — "does this session already have a salt? yes → use it,
|
|
1161
|
+
* no → agree one" — and without the column that lookup would fail after a restart, mint a
|
|
1162
|
+
* fresh salt, and split the transcript at the crash: every leaf before it unverifiable, with
|
|
1163
|
+
* nothing saying so. The column lands now because it must exist before the code that needs it.
|
|
1164
|
+
*
|
|
1165
|
+
* NULL for every session opened before this column existed; those keep the unsalted hash.
|
|
1166
|
+
*/
|
|
1167
|
+
"ALTER TABLE sessions ADD COLUMN content_salt BLOB",
|
|
1168
|
+
/**
|
|
1169
|
+
* DOD-M15-FREEZE-STATUS-1 — carried here for the OTHER LANE (`CELLO_Support`), agreed in
|
|
1170
|
+
* session `e3adcaa7…`. Two lanes must not both edit this file (§2e, one file two branches), so
|
|
1171
|
+
* the columns land in one migration and every line of behaviour stays on their side. Nothing
|
|
1172
|
+
* in this lane reads or writes them.
|
|
1173
|
+
*
|
|
1174
|
+
* `frozen_at` epoch-ms when `#freezeOnIdentityFailure` fired. NULL = never frozen.
|
|
1175
|
+
* `frozen_reason` the `reason` already passed to that method. NULL iff `frozen_at` is NULL.
|
|
1176
|
+
*
|
|
1177
|
+
* ⚠️ THE WRITE MUST LAND BEFORE `destroySessionNode`, NOT AFTER — FRAME-1 review F1's
|
|
1178
|
+
* ordering, and the reason the in-memory `#frozenSessions.add` already sits before the
|
|
1179
|
+
* teardown. `destroySessionNode` writes `interrupted`, which is the REVIVABLE status, so a
|
|
1180
|
+
* durable mark landing after it lets a read race the teardown and revive the session out from
|
|
1181
|
+
* under the freeze — the disk reproducing the bug the memory mark was moved early to fix.
|
|
1182
|
+
*
|
|
1183
|
+
* Why it earns a slot rather than waiting: `#frozenSessions` is memory-only today, so a
|
|
1184
|
+
* restart UN-FREEZES a session that was frozen because a party signed with a key that was not
|
|
1185
|
+
* the counterparty's. The next read revives it and re-admits that peer, while the log still
|
|
1186
|
+
* says the session will not be revived.
|
|
1187
|
+
*/
|
|
1188
|
+
"ALTER TABLE sessions ADD COLUMN frozen_at INTEGER",
|
|
1189
|
+
"ALTER TABLE sessions ADD COLUMN frozen_reason TEXT",
|
|
850
1190
|
// MSG-001-3b (MSG-2 startup-flush): persist the session's relay endpoint so the
|
|
851
1191
|
// crash-backstop flush can deposit un-acked content after a restart, when the
|
|
852
1192
|
// in-memory entry is gone. relay_addrs is a JSON array of multiaddr strings.
|
|
@@ -893,6 +1233,18 @@ export class SessionNodeManager {
|
|
|
893
1233
|
// several times a day re-runs the whole budget against a hopeless session on every boot.
|
|
894
1234
|
"ALTER TABLE sessions ADD COLUMN restart_seal_gave_up_at INTEGER",
|
|
895
1235
|
"ALTER TABLE sessions ADD COLUMN restart_seal_gave_up_reason TEXT",
|
|
1236
|
+
// DOD-M15-DIVERGE-DURABLE-1: epoch-ms when this session's tree and the relay's counter
|
|
1237
|
+
// provably parted, so it can never seal bilaterally. NULL = not diverged.
|
|
1238
|
+
//
|
|
1239
|
+
// DURABLE, and the reason is that the read site cannot tell "not diverged" from "forgotten":
|
|
1240
|
+
// both are false and both read READY. `#diverged` was in memory, so a restart turned a
|
|
1241
|
+
// session that provably cannot seal into one the gate was happy to close.
|
|
1242
|
+
//
|
|
1243
|
+
// NOT the trade `frontier-mismatch.ts` makes on purpose. A frontier mismatch is re-detected
|
|
1244
|
+
// by the very next close, so losing it costs a recomputation. Divergence is re-detected only
|
|
1245
|
+
// by the next send that gets an ack behind the frontier — which on a finished conversation
|
|
1246
|
+
// never comes. Losing it costs a WRONG ANSWER.
|
|
1247
|
+
"ALTER TABLE sessions ADD COLUMN diverged_at INTEGER",
|
|
896
1248
|
]) {
|
|
897
1249
|
try {
|
|
898
1250
|
this.#db.exec(ddl);
|
|
@@ -1063,6 +1415,27 @@ export class SessionNodeManager {
|
|
|
1063
1415
|
direction TEXT NOT NULL, -- 'sent' | 'received'
|
|
1064
1416
|
blob BLOB NOT NULL, -- readable plaintext bytes (whole-DB SQLCipher-encrypted at rest)
|
|
1065
1417
|
created_at INTEGER NOT NULL,
|
|
1418
|
+
-- ─── DOD-M15-SEALWIRE-1 bullet 5: the row proves AUTHORSHIP, or says it cannot ──────────
|
|
1419
|
+
--
|
|
1420
|
+
-- Before this, a row was (message, direction) and attribution came entirely from local
|
|
1421
|
+
-- session state: "this arrived on the socket I believed was Bob's". That is fine while the
|
|
1422
|
+
-- transcript is only ever read by its owner, and worthless the moment it is shown to anyone
|
|
1423
|
+
-- else — which is the whole point of a notarized record.
|
|
1424
|
+
--
|
|
1425
|
+
-- sender_sig is the Structure-2 signature, and it is stored ONLY after the receiver has
|
|
1426
|
+
-- already verified it against the pubkey inside the sender's own signed bytes
|
|
1427
|
+
-- (#recordFrameOrdering). A stored signature here is a VERIFIED one, never a claimed one.
|
|
1428
|
+
--
|
|
1429
|
+
-- attribution is NOT NULL ON PURPOSE, and it is the load-bearing column. There is a soft
|
|
1430
|
+
-- path — session.content.ordering.decode_failed falls back to hash-dedup — that ingests a
|
|
1431
|
+
-- message with no verified signature, so rows legitimately without one WILL exist. A
|
|
1432
|
+
-- nullable signature column and nothing else would rebuild the defect this bullet exists to
|
|
1433
|
+
-- fix: a table that IMPLIES every row carries authorship proof, where some carry none and
|
|
1434
|
+
-- nothing distinguishes them. Forcing every writer to name which it is makes silent NULL
|
|
1435
|
+
-- impossible rather than merely discouraged.
|
|
1436
|
+
sender_pubkey TEXT, -- from INSIDE the sender's signed bytes; NULL unless verified
|
|
1437
|
+
sender_sig BLOB, -- the VERIFIED Structure-2 signature; NULL unless verified
|
|
1438
|
+
attribution TEXT NOT NULL DEFAULT 'local_session_state', -- verified_signature | self_authored | local_session_state
|
|
1066
1439
|
PRIMARY KEY (agent_id, session_id, sequence, direction)
|
|
1067
1440
|
)
|
|
1068
1441
|
`);
|
|
@@ -1114,6 +1487,32 @@ export class SessionNodeManager {
|
|
|
1114
1487
|
PRIMARY KEY (agent_id, contact_pubkey, signal_hash)
|
|
1115
1488
|
)
|
|
1116
1489
|
`);
|
|
1490
|
+
/**
|
|
1491
|
+
* DOD-M15-SEALWIRE-1 bullet 5: authorship columns on an EXISTING transcript.
|
|
1492
|
+
*
|
|
1493
|
+
* BEFORE `migrateSessionTablesToAgentId` — the rebuild copies the intersection of old and new
|
|
1494
|
+
* columns, so a column added after it would be dropped on the upgrade boot and re-added empty.
|
|
1495
|
+
* These have their second entry in that migration's pinned DDL; `DOD-M15-MIGRATION-GUARD-1`
|
|
1496
|
+
* fails the build if the two ever disagree.
|
|
1497
|
+
*
|
|
1498
|
+
* `addColumnIfMissing` rather than a bare try/catch: it swallows ONLY `duplicate column name`
|
|
1499
|
+
* and rethrows anything else, so broken DDL cannot be mistaken for "already applied".
|
|
1500
|
+
*/
|
|
1501
|
+
// Written as three LITERAL statements rather than a loop over a column array. A loop needs its
|
|
1502
|
+
// own parser in the guard (as retry_queue does); literals are read by the guard's generic one,
|
|
1503
|
+
// so these are replayed automatically and cannot fall outside it.
|
|
1504
|
+
addColumnIfMissing(this.#db, this.#logger, {
|
|
1505
|
+
table: "transcript", column: "sender_pubkey",
|
|
1506
|
+
sql: "ALTER TABLE transcript ADD COLUMN sender_pubkey TEXT",
|
|
1507
|
+
});
|
|
1508
|
+
addColumnIfMissing(this.#db, this.#logger, {
|
|
1509
|
+
table: "transcript", column: "sender_sig",
|
|
1510
|
+
sql: "ALTER TABLE transcript ADD COLUMN sender_sig BLOB",
|
|
1511
|
+
});
|
|
1512
|
+
addColumnIfMissing(this.#db, this.#logger, {
|
|
1513
|
+
table: "transcript", column: "attribution",
|
|
1514
|
+
sql: "ALTER TABLE transcript ADD COLUMN attribution TEXT NOT NULL DEFAULT 'local_session_state'",
|
|
1515
|
+
});
|
|
1117
1516
|
const contactCols = this.#db.prepare("PRAGMA table_info(contacts)").all();
|
|
1118
1517
|
if (!contactCols.some((c) => c.name === "moniker")) {
|
|
1119
1518
|
this.#db.exec("ALTER TABLE contacts ADD COLUMN moniker TEXT");
|
|
@@ -1127,6 +1526,16 @@ export class SessionNodeManager {
|
|
|
1127
1526
|
// database it already exists here and is re-keyed in the same transaction; on a fresh one it is
|
|
1128
1527
|
// absent, is skipped, and RetryQueue then creates it directly in the re-keyed shape.
|
|
1129
1528
|
migrateSessionTablesToAgentId(this.#db, this.#logger);
|
|
1529
|
+
/**
|
|
1530
|
+
* DOD-M15-DIVERGE-DURABLE-1: rehydrate the divergence set from `sessions.diverged_at`.
|
|
1531
|
+
*
|
|
1532
|
+
* AFTER `migrateSessionTablesToAgentId`, not with the column migrations that create the field.
|
|
1533
|
+
* The query joins `sessions.agent_id` to `agents`, and on a database written before REMOVE-001
|
|
1534
|
+
* that column does not exist until this migration adds it — placing the load earlier failed
|
|
1535
|
+
* with `no such column: s.agent_id` on exactly those legacy databases, which are the ones a
|
|
1536
|
+
* restart matters most for.
|
|
1537
|
+
*/
|
|
1538
|
+
this.#loadDivergedFromDb();
|
|
1130
1539
|
// DOD-TIER-1 (address-book Step 1): give `contacts` its tier metadata (tier / provenance /
|
|
1131
1540
|
// last_offered_moniker / away_message). Pure ADD COLUMN, no rebuild — so it runs AFTER the
|
|
1132
1541
|
// agent-id re-key above (it never needs to appear in that migration's pinned DDL) and BEFORE any
|
|
@@ -1269,6 +1678,101 @@ export class SessionNodeManager {
|
|
|
1269
1678
|
* directory for the OFFLINE tree rebuild. Empty when no leaves were logged (e.g. a direct-only session
|
|
1270
1679
|
* with no relay witness) — the caller then has nothing to carry and the seal stays bilateral/pending.
|
|
1271
1680
|
*/
|
|
1681
|
+
/**
|
|
1682
|
+
* REBUILD THE CERTIFIED ROOT FROM THIS DAEMON'S OWN LEAVES — `DOD-M15-SEALWIRE-1` bullet 2.
|
|
1683
|
+
*
|
|
1684
|
+
* The receipt used to prove only that the directory signed SOMETHING: the client took the sealed
|
|
1685
|
+
* root off the wire, confirmed the directory's signature over those bytes, stored it, and threw
|
|
1686
|
+
* away the root it had computed a step earlier. At co-signing time that means **your key signs a
|
|
1687
|
+
* root you never checked.**
|
|
1688
|
+
*
|
|
1689
|
+
* Bullet 1 moved the certified root into the content-hash domain, which is the domain this daemon
|
|
1690
|
+
* can actually rebuild — each carry leaf's `content_hash` is the leaf hash (RFC 6962 §2.1 "hash"
|
|
1691
|
+
* leaves are used as-is), and the carry is ordered by the relay's canonical `sequence_number`,
|
|
1692
|
+
* which is the order the directory rebuilds in.
|
|
1693
|
+
*
|
|
1694
|
+
* ─── Why this returns "cannot judge" instead of always answering ───────────────────────────
|
|
1695
|
+
*
|
|
1696
|
+
* A root comparison that is WRONG makes every session unsealable, and force-abandon — no receipt —
|
|
1697
|
+
* becomes the only exit. That failure is worse than the one being guarded, and this file already
|
|
1698
|
+
* carries two comments saying so about other gates.
|
|
1699
|
+
*
|
|
1700
|
+
* The carry is this daemon's view, and it is not guaranteed complete at the instant a certificate
|
|
1701
|
+
* arrives: the counterparty's SEAL ctrl leaf is what TRIGGERS the seal, so it may not have been
|
|
1702
|
+
* witnessed here yet. So completeness is checked FIRST, against the certificate's own leaf count.
|
|
1703
|
+
* A short carry means this daemon cannot judge — which is a different answer from "the roots
|
|
1704
|
+
* disagree", and conflating them would turn a local timing gap into an accusation.
|
|
1705
|
+
*/
|
|
1706
|
+
verifyCertifiedRoot(agentPubkeyHex, sessionIdHex, certifiedRoot, certifiedLeafCount) {
|
|
1707
|
+
const carry = this.getSealCarry(agentPubkeyHex, sessionIdHex);
|
|
1708
|
+
if (carry.length === 0)
|
|
1709
|
+
return { verdict: "cannot_judge", reason: "no_carry" };
|
|
1710
|
+
/**
|
|
1711
|
+
* COMPLETENESS IS ESTABLISHED FROM THE CARRY'S OWN EVIDENCE, NEVER FROM THE CERTIFICATE.
|
|
1712
|
+
*
|
|
1713
|
+
* Review F3, and the first cut had this exactly backwards. It gated on
|
|
1714
|
+
* `carry.length !== certifiedLeafCount`, where `certifiedLeafCount` is a field the DIRECTORY
|
|
1715
|
+
* chooses and signs — so the party being checked controlled whether it was checked. A directory
|
|
1716
|
+
* certifying a root over a different conversation had only to state a `leaf_count` that did not
|
|
1717
|
+
* match, and the client answered "cannot judge" and accepted. The signature still verified,
|
|
1718
|
+
* because the count is signed inside the same TBS.
|
|
1719
|
+
*
|
|
1720
|
+
* That is the hole §2b names in as many words: *"an attacker who wants to evade a mismatch check
|
|
1721
|
+
* simply never supplies a checkable proof. Treating 'we could not tell' as harmless is the
|
|
1722
|
+
* hole."* I defended against a false POSITIVE and left the false NEGATIVE one field away.
|
|
1723
|
+
*
|
|
1724
|
+
* The carry can answer the question by itself. A complete bilateral leaf set is:
|
|
1725
|
+
* - sequences contiguous from 1 — no gap where a leaf this daemon never saw would sit; and
|
|
1726
|
+
* - exactly two SEAL ctrl leaves, from two DISTINCT senders — which is what a bilateral seal
|
|
1727
|
+
* is, and is the condition that says the counterparty's closing leaf has landed here.
|
|
1728
|
+
* Both predicates already exist in `seal-escalation.ts`; this reuses their shape rather than
|
|
1729
|
+
* inventing a second opinion about the same question.
|
|
1730
|
+
*
|
|
1731
|
+
* When the carry IS self-evidently complete, a `leaf_count` that disagrees is no longer "I
|
|
1732
|
+
* cannot tell" — it is the certificate describing a different leaf set, which is a MISMATCH.
|
|
1733
|
+
*/
|
|
1734
|
+
const sequences = carry.map((l) => l.sequenceNumber).sort((a, b) => a - b);
|
|
1735
|
+
const contiguousFromOne = sequences.every((n, i) => n === i + 1);
|
|
1736
|
+
const ctrlSenders = new Set(carry.filter((l) => l.leafKind === LEAF_KIND_CTRL).map((l) => l.senderPubkeyHex));
|
|
1737
|
+
const selfEvidentlyComplete = contiguousFromOne && ctrlSenders.size === 2;
|
|
1738
|
+
if (!selfEvidentlyComplete) {
|
|
1739
|
+
return {
|
|
1740
|
+
verdict: "cannot_judge",
|
|
1741
|
+
reason: contiguousFromOne
|
|
1742
|
+
? `carry_incomplete: ${ctrlSenders.size} of 2 SEAL ctrl leaves witnessed here`
|
|
1743
|
+
: `carry_noncontiguous: hold ${carry.length} leaves with a gap in the relay sequence`,
|
|
1744
|
+
};
|
|
1745
|
+
}
|
|
1746
|
+
if (carry.length !== certifiedLeafCount) {
|
|
1747
|
+
// The carry proves itself complete and the certificate claims a different size, so the
|
|
1748
|
+
// certificate is over a different leaf set. Accusing is correct here.
|
|
1749
|
+
return {
|
|
1750
|
+
verdict: "mismatch",
|
|
1751
|
+
ownRootHex: null, // no root computed — the sets differ in SIZE, which is decisive on its own
|
|
1752
|
+
detail: `leaf_count_disagrees: this daemon holds a provably complete ${carry.length}-leaf set, the certificate claims ${certifiedLeafCount}`,
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
const inputs = [];
|
|
1756
|
+
for (const leaf of carry) {
|
|
1757
|
+
let contentHash;
|
|
1758
|
+
try {
|
|
1759
|
+
// Canonical Structure 1 is [version, content_hash, sender_pubkey, session_id, last_seen_seq, timestamp].
|
|
1760
|
+
contentHash = decode(leaf.structure1Cbor)[1];
|
|
1761
|
+
}
|
|
1762
|
+
catch {
|
|
1763
|
+
return { verdict: "cannot_judge", reason: "structure1_decode_failed" };
|
|
1764
|
+
}
|
|
1765
|
+
if (!(contentHash instanceof Uint8Array) || contentHash.length !== 32) {
|
|
1766
|
+
return { verdict: "cannot_judge", reason: "structure1_content_hash_missing" };
|
|
1767
|
+
}
|
|
1768
|
+
inputs.push({ kind: "hash", data: contentHash });
|
|
1769
|
+
}
|
|
1770
|
+
const ownRoot = merkleRoot(buildMerkleTree(inputs));
|
|
1771
|
+
const ownRootHex = Buffer.from(ownRoot).toString("hex");
|
|
1772
|
+
return Buffer.compare(Buffer.from(ownRoot), Buffer.from(certifiedRoot)) === 0
|
|
1773
|
+
? { verdict: "match" }
|
|
1774
|
+
: { verdict: "mismatch", ownRootHex, detail: "root_disagrees: same leaf count, different leaves or different order" };
|
|
1775
|
+
}
|
|
1272
1776
|
getSealCarry(agentPubkeyHex, sessionIdHex) {
|
|
1273
1777
|
if (!this.#sealLeafStore && this.#db) {
|
|
1274
1778
|
this.#sealLeafStore = new SessionSealLeafStore(this.#db, this.#logger);
|
|
@@ -1286,16 +1790,40 @@ export class SessionNodeManager {
|
|
|
1286
1790
|
* lost row only cost the unread count. Delivery reads the transcript now, so a swallowed received
|
|
1287
1791
|
* row is TOTAL content loss and the caller has to know.
|
|
1288
1792
|
*/
|
|
1289
|
-
recordTranscriptMessage(agentName, sessionId, sequence, direction, plaintext, correlationId
|
|
1793
|
+
recordTranscriptMessage(agentName, sessionId, sequence, direction, plaintext, correlationId,
|
|
1794
|
+
/**
|
|
1795
|
+
* DOD-M15-SEALWIRE-1 bullet 5: the VERIFIED authorship proof, when there is one.
|
|
1796
|
+
*
|
|
1797
|
+
* Optional because there legitimately is not always one — the ordering decode can fail SOFT and
|
|
1798
|
+
* the message is still ingested via hash-dedup. Optional is NOT the same as unremarked: absence
|
|
1799
|
+
* is written into the row as `attribution = 'local_session_state'`, so a reader can tell a row
|
|
1800
|
+
* whose author was proven from one whose author was assumed. That distinction is the bullet.
|
|
1801
|
+
*/
|
|
1802
|
+
authorship) {
|
|
1290
1803
|
if (!this.#db)
|
|
1291
1804
|
return false;
|
|
1292
1805
|
try {
|
|
1293
1806
|
const agentId = this.#requireAgentId(agentName);
|
|
1294
1807
|
const blob = Buffer.from(plaintext);
|
|
1295
1808
|
this.#db
|
|
1296
|
-
.prepare(`INSERT OR IGNORE INTO transcript
|
|
1297
|
-
|
|
1298
|
-
|
|
1809
|
+
.prepare(`INSERT OR IGNORE INTO transcript
|
|
1810
|
+
(agent_id, session_id, sequence, direction, blob, created_at, sender_pubkey, sender_sig, attribution)
|
|
1811
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1812
|
+
.run(agentId, sessionId, sequence, direction, blob, Date.now(), authorship ? Buffer.from(authorship.senderPubkey).toString("hex") : null, authorship ? Buffer.from(authorship.senderSig) : null,
|
|
1813
|
+
/**
|
|
1814
|
+
* THREE values, not two — caught by CELLO_Coder_1 reviewing the first version, and it was
|
|
1815
|
+
* the same defect this column exists to prevent, surviving one layer up in the enum.
|
|
1816
|
+
*
|
|
1817
|
+
* `local_session_state` covered two OPPOSITE rows: one this agent AUTHORED (provenance
|
|
1818
|
+
* fully known, merely not third-party-provable) and one RECEIVED on the soft fallback
|
|
1819
|
+
* (provenance unknown — something arrived on a socket and was trusted). A reader shown the
|
|
1820
|
+
* transcript later could not separate "he wrote this himself" from "nobody checked".
|
|
1821
|
+
* Structurally identical rows with different trustworthiness is exactly what I refused to
|
|
1822
|
+
* ship when I rejected a nullable signature column.
|
|
1823
|
+
*
|
|
1824
|
+
* No plumbing needed: `direction` already carries the answer at write time.
|
|
1825
|
+
*/
|
|
1826
|
+
authorship ? "verified_signature" : direction === "sent" ? "self_authored" : "local_session_state");
|
|
1299
1827
|
this.#logger.info("transcript.message.recorded", { sessionId, agentName, sequence, direction, correlationId });
|
|
1300
1828
|
return true;
|
|
1301
1829
|
}
|
|
@@ -1819,7 +2347,54 @@ export class SessionNodeManager {
|
|
|
1819
2347
|
// DOD-RENAME-1: a removed contact has no pending rename to resolve.
|
|
1820
2348
|
if (res.changes > 0)
|
|
1821
2349
|
this.clearRenameNotice(agentName, pubkey);
|
|
1822
|
-
|
|
2350
|
+
/**
|
|
2351
|
+
* OUTSIDE the `changes > 0` guard — review N2, and inside it the F2 fix did nothing for the
|
|
2352
|
+
* case that matters.
|
|
2353
|
+
*
|
|
2354
|
+
* The pin is written on every ACCEPTED INBOUND session. A contact row is written only on an
|
|
2355
|
+
* outbound initiate, an explicit add, a reply, or a trust-signal presentation — and an inbound
|
|
2356
|
+
* requester is deliberately NOT auto-added. So a counterparty you never replied to (away-mode
|
|
2357
|
+
* auto-ack is exactly this) has a pin and no contact row.
|
|
2358
|
+
*
|
|
2359
|
+
* Guarded, `cello_contact_remove` for them returned `{ ok: true, removed: false }`, cleared
|
|
2360
|
+
* nothing, and the identity refusal stayed permanent — the original lockout, now wearing an
|
|
2361
|
+
* `ok: true`, which is harder to notice than the original.
|
|
2362
|
+
*/
|
|
2363
|
+
const pinsCleared = this.clearPinnedCounterpartyPrimary(agentName, pubkey);
|
|
2364
|
+
return res.changes > 0 || pinsCleared > 0;
|
|
2365
|
+
}
|
|
2366
|
+
/**
|
|
2367
|
+
* Forget the pinned threshold group key for a counterparty, so the next session re-pins.
|
|
2368
|
+
*
|
|
2369
|
+
* DOD-M15-OFFER-SIGNED-1 review F2 — WITHOUT THIS THE REFUSAL WAS PERMANENT. The identity-change
|
|
2370
|
+
* check refuses a counterparty whose group key differs from the one recorded in an earlier
|
|
2371
|
+
* session, and its guidance told the operator to confirm out of band and then remove the contact
|
|
2372
|
+
* so the new identity is pinned afresh. `removeContact` deleted a row in `contacts`; the pin lives
|
|
2373
|
+
* in `sessions.counterparty_primary_pubkey`, and nothing in the daemon ever cleared it.
|
|
2374
|
+
*
|
|
2375
|
+
* So an operator who did exactly as instructed — called their counterparty, confirmed the
|
|
2376
|
+
* re-registration was genuine, removed the contact, retried — got the identical refusal, with no
|
|
2377
|
+
* way out short of editing the database. A security control that cannot be reset by the person it
|
|
2378
|
+
* protects is a lockout, and the printed remedy made it worse by reading as though it worked.
|
|
2379
|
+
*
|
|
2380
|
+
* Nulls the column rather than deleting the session rows: those rows are the transcript record,
|
|
2381
|
+
* and a re-pin is not a reason to lose them.
|
|
2382
|
+
*/
|
|
2383
|
+
clearPinnedCounterpartyPrimary(agentName, counterpartyPubkeyHex) {
|
|
2384
|
+
if (!this.#db)
|
|
2385
|
+
return 0;
|
|
2386
|
+
const res = this.#db
|
|
2387
|
+
.prepare(
|
|
2388
|
+
// NO `updated_at` BUMP — review N6. `CAP_COUNTS` counts an interrupted session only while
|
|
2389
|
+
// `updated_at` is inside the staleness window, so touching it here reset the clock on every
|
|
2390
|
+
// stale session with that counterparty, re-inflating their per-sender cap — while removing
|
|
2391
|
+
// the contact simultaneously dropped them to UNKNOWN tier, which LOWERS it. The operator
|
|
2392
|
+
// follows the printed remedy and their counterparty's next session is refused for cap,
|
|
2393
|
+
// through a reason string deliberately identical to every other refusal. A second lockout
|
|
2394
|
+
// that says nothing. Nothing needs the timestamp: every candidate row ends up NULL.
|
|
2395
|
+
"UPDATE sessions SET counterparty_primary_pubkey = NULL WHERE agent_id = ? AND counterparty_pubkey = ?")
|
|
2396
|
+
.run(this.#requireAgentId(agentName), counterpartyPubkeyHex);
|
|
2397
|
+
return Number(res.changes);
|
|
1823
2398
|
}
|
|
1824
2399
|
/** MONIKER-4: the operator's pet name for a pubkey (whoLabel's top tier), or null. Read-only
|
|
1825
2400
|
* and tolerant of a not-yet-open DB (a missing label degrades the doorbell, never blocks it). */
|
|
@@ -1934,14 +2509,14 @@ export class SessionNodeManager {
|
|
|
1934
2509
|
// over-cap UNKNOWN must be indistinguishable, or the refusal tells someone they are blocked.
|
|
1935
2510
|
// The operator's alarm needs numbers, so it asks for them SEPARATELY via capDiagnostics;
|
|
1936
2511
|
// hanging them off this object would put a distinguishing oracle in the return value.
|
|
1937
|
-
return { ok: false, reason:
|
|
2512
|
+
return { ok: false, reason: CAPACITY_REASONS.ABUSE_BOUND_SESSIONS_PER_SENDER };
|
|
1938
2513
|
}
|
|
1939
2514
|
// The global stranger cap is only for the UNKNOWN pool. A KNOWN+ sender is past it by trust;
|
|
1940
2515
|
// a BLOCKED sender was already refused above (cap 0).
|
|
1941
2516
|
if (tier === TIER.UNKNOWN) {
|
|
1942
2517
|
const globalUnknown = this.countActiveSessionsFromUnknownSenders(agentName);
|
|
1943
2518
|
if (globalUnknown >= ABUSE_MAX_UNKNOWN_SESSIONS_GLOBAL) {
|
|
1944
|
-
return { ok: false, reason:
|
|
2519
|
+
return { ok: false, reason: CAPACITY_REASONS.ABUSE_BOUND_UNKNOWN_SESSIONS_GLOBAL };
|
|
1945
2520
|
}
|
|
1946
2521
|
}
|
|
1947
2522
|
return { ok: true };
|
|
@@ -2058,7 +2633,11 @@ export class SessionNodeManager {
|
|
|
2058
2633
|
const retry = this.#srReservationRetry.get(agentName);
|
|
2059
2634
|
return retry !== undefined && retry.attempts > SR_RESERVATION_MAX_RETRIES ? "unreachable" : "retrying";
|
|
2060
2635
|
}
|
|
2061
|
-
/**
|
|
2636
|
+
/**
|
|
2637
|
+
* First ready standing receiver (any agent) — for agent-agnostic OUTBOUND use. Its gater admits
|
|
2638
|
+
* nobody INBOUND until a session names them (DOD-M15-ASSIGN-1); outbound stays open, which is the
|
|
2639
|
+
* property these callers depend on.
|
|
2640
|
+
*/
|
|
2062
2641
|
#anyStandingReceiver() {
|
|
2063
2642
|
for (const sr of this.#standingReceivers.values())
|
|
2064
2643
|
return sr;
|
|
@@ -2081,10 +2660,115 @@ export class SessionNodeManager {
|
|
|
2081
2660
|
return { peerId: sr.node.getPeerId(), addrs: sr.node.listenAddresses() };
|
|
2082
2661
|
}
|
|
2083
2662
|
/**
|
|
2084
|
-
*
|
|
2085
|
-
*
|
|
2086
|
-
*
|
|
2087
|
-
*
|
|
2663
|
+
* DOD-M15-ASSIGN-1 — name the one peer allowed to dial this agent's standing receiver, at the
|
|
2664
|
+
* moment the directory's `session_offer` says who is coming.
|
|
2665
|
+
*
|
|
2666
|
+
* This is what makes the receiver's deny-by-default safe. The offer names
|
|
2667
|
+
* `initiator_session_peer_id`, and the responder answers it by advertising its OWN address in
|
|
2668
|
+
* `session_offer_accept`. Narrowing here — BEFORE that answer goes out — means the door opens to
|
|
2669
|
+
* exactly one peer at the same instant the address that reaches them is published, and never
|
|
2670
|
+
* before. The initiator cannot know where to dial until the accept it triggers has been sent.
|
|
2671
|
+
*
|
|
2672
|
+
* Returns WHICH failure it was, never a bare false (review F6). The caller reports a distinct
|
|
2673
|
+
* reason per cause: "no receiver" and "the directory named nobody" are different subsystems, and
|
|
2674
|
+
* collapsing them sent the operator to the directory for a local problem. This method never
|
|
2675
|
+
* widens the gate to compensate.
|
|
2676
|
+
*
|
|
2677
|
+
* Narrows INBOUND ONLY. The receiver is still the daemon's general-purpose dialer at this point
|
|
2678
|
+
* — no assignment exists yet — so revoking its outbound latitude here would break content
|
|
2679
|
+
* parking and restart-seal submission (review F2).
|
|
2680
|
+
*/
|
|
2681
|
+
admitOfferedDialer(agentName, initiatorSessionPeerId, sessionIdHex) {
|
|
2682
|
+
const sr = this.#standingReceivers.get(agentName);
|
|
2683
|
+
if (!sr)
|
|
2684
|
+
return "no_receiver";
|
|
2685
|
+
if (initiatorSessionPeerId === "")
|
|
2686
|
+
return "no_peer_named";
|
|
2687
|
+
sr.gater.admitInboundPeer(initiatorSessionPeerId);
|
|
2688
|
+
this.#offeredDialer.set(this.#k(agentName, sessionIdHex), initiatorSessionPeerId);
|
|
2689
|
+
return "narrowed";
|
|
2690
|
+
}
|
|
2691
|
+
/**
|
|
2692
|
+
* What the UNSIGNED offer claimed, so the SIGNED assignment can be checked against it.
|
|
2693
|
+
*
|
|
2694
|
+
* DOD-M15-OFFER-SIGNED-1. Decision 2 rules that the listening socket is "gated on the
|
|
2695
|
+
* assignment", and the gate is narrowed from `session_offer` — a frame carrying no signature —
|
|
2696
|
+
* because that is the only thing that arrives early enough. Timing forced the offer; it does not
|
|
2697
|
+
* excuse trusting it.
|
|
2698
|
+
*
|
|
2699
|
+
* Keeping what the offer said turns the two frames into a CHECK ON EACH OTHER. The assignment is
|
|
2700
|
+
* FROST-signed by the initiator's own threshold group, which no single directory can produce, and
|
|
2701
|
+
* it names the same peer id. A directory that says one peer in the offer and another in the
|
|
2702
|
+
* assignment is naming two different dialers for one session — which a truthful directory never
|
|
2703
|
+
* does, and which is exactly the move a compromised one would make to slip a peer past the gate
|
|
2704
|
+
* before the signed document arrives.
|
|
2705
|
+
*/
|
|
2706
|
+
getOfferedDialer(agentName, sessionIdHex) {
|
|
2707
|
+
return this.#offeredDialer.get(this.#k(agentName, sessionIdHex)) ?? null;
|
|
2708
|
+
}
|
|
2709
|
+
/**
|
|
2710
|
+
* Which peer this agent's standing receiver is currently admitting INBOUND — `null` for nobody.
|
|
2711
|
+
*
|
|
2712
|
+
* Read-only, and it answers a question the daemon otherwise cannot: *"whose dial would this
|
|
2713
|
+
* receiver accept right now?"* The gate is narrowed and re-closed from several paths (an offer
|
|
2714
|
+
* arrives, an assignment is refused, a session is promoted), and until now the only way to know
|
|
2715
|
+
* where it had ended up was to reproduce the sequence in your head.
|
|
2716
|
+
*
|
|
2717
|
+
* Added for `DOD-M15-RESPONDER-VERIFY-1`, where a refusal for one session was closing the gate a
|
|
2718
|
+
* DIFFERENT session had narrowed — a defect with no observable symptom short of the second
|
|
2719
|
+
* session's initiator being refused with "nothing invited it".
|
|
2720
|
+
*/
|
|
2721
|
+
getStandingReceiverAllowedPeer(agentName) {
|
|
2722
|
+
return this.#standingReceivers.get(agentName)?.gater.getAllowedPeerId() ?? null;
|
|
2723
|
+
}
|
|
2724
|
+
/** Forget the offered dialer for ONE session — called on BOTH the claim and the refusal paths. */
|
|
2725
|
+
clearOfferedDialer(agentName, sessionIdHex) {
|
|
2726
|
+
this.#offeredDialer.delete(this.#k(agentName, sessionIdHex));
|
|
2727
|
+
}
|
|
2728
|
+
/**
|
|
2729
|
+
* RE-CLOSE the standing receiver — but ONLY if this session is still the one holding it.
|
|
2730
|
+
*
|
|
2731
|
+
* DOD-M15-OFFER-SIGNED-1 review F4, then N1. The first version closed the gate unconditionally,
|
|
2732
|
+
* and that was worse than the defect it fixed: an agent has ONE standing receiver with ONE allowed
|
|
2733
|
+
* peer, so a refusal for session P closed the gate that offer Q had narrowed. Q's initiator —
|
|
2734
|
+
* invited, legitimate — was then refused with *"nothing invited it"*, which this daemon had.
|
|
2735
|
+
*
|
|
2736
|
+
* That is the same cross-session interference F1 was written to remove, moved one method along,
|
|
2737
|
+
* and triggerable the same way: one bogus offer/assignment pair collapses a concurrent real
|
|
2738
|
+
* session.
|
|
2739
|
+
*
|
|
2740
|
+
* So the gate is closed only when it still names the peer THIS session opened it to. If a later
|
|
2741
|
+
* offer has already re-narrowed it, that offer owns the receiver and its narrowing stands.
|
|
2742
|
+
*
|
|
2743
|
+
* NO EVICTION SWEEP, deliberately (N4). The sweep evicts by "not the allowed peer", and
|
|
2744
|
+
* `getConnections()` returns OUTBOUND connections too — including the content-park and
|
|
2745
|
+
* restart-seal dials this node makes as the daemon's general-purpose dialer, whose targets are on
|
|
2746
|
+
* no allowlist by construction. Sweeping here hung those up, and the failure surfaced as
|
|
2747
|
+
* `relay_unavailable`: a transport label for a local decision, which is the exact substitution
|
|
2748
|
+
* that comment was written to prevent. The load-bearing control is `DOD-M15-FRAME-1`'s frame gate,
|
|
2749
|
+
* which refuses what an unauthorised peer sends; closing the door is enough here.
|
|
2750
|
+
*/
|
|
2751
|
+
revokeOfferedDialer(agentName, sessionIdHex, offeredPeerId) {
|
|
2752
|
+
this.clearOfferedDialer(agentName, sessionIdHex);
|
|
2753
|
+
const sr = this.#standingReceivers.get(agentName);
|
|
2754
|
+
if (!sr || offeredPeerId === null)
|
|
2755
|
+
return;
|
|
2756
|
+
if (sr.gater.getAllowedPeerId() !== offeredPeerId) {
|
|
2757
|
+
// A later offer already owns the receiver. Closing it would refuse THAT session's initiator.
|
|
2758
|
+
this.#logger.debug("session.gate.revoke.skipped", {
|
|
2759
|
+
agentName,
|
|
2760
|
+
sessionId: sessionIdHex,
|
|
2761
|
+
reason: "a later offer has re-narrowed this receiver; its narrowing stands",
|
|
2762
|
+
});
|
|
2763
|
+
return;
|
|
2764
|
+
}
|
|
2765
|
+
sr.gater.closeInbound();
|
|
2766
|
+
}
|
|
2767
|
+
/**
|
|
2768
|
+
* The standing receiver's libp2p node — a general-purpose node usable for OUTBOUND dials that
|
|
2769
|
+
* are not session-scoped (e.g. the content-park deposit/pull to the relay, MSG-001-3b). Its
|
|
2770
|
+
* gater admits nobody INBOUND until a session names them (DOD-M15-ASSIGN-1), but leaves these
|
|
2771
|
+
* outbound errands open. Returns null until the receiver is ready.
|
|
2088
2772
|
*/
|
|
2089
2773
|
getStandingReceiverNode(agentName) {
|
|
2090
2774
|
// With an agentName: that agent's own standing-receiver node (needed when the dial must
|
|
@@ -2137,8 +2821,9 @@ export class SessionNodeManager {
|
|
|
2137
2821
|
this.#onContentArrived = cb;
|
|
2138
2822
|
}
|
|
2139
2823
|
/** M14 / DOD-DOC-INBOUND-2: inject the document-frame interception. See the field's note. */
|
|
2140
|
-
setOnDocumentFrame(cb) {
|
|
2824
|
+
setOnDocumentFrame(cb, classifyOnly) {
|
|
2141
2825
|
this.#onDocumentFrame = cb;
|
|
2826
|
+
this.#isDocumentFrame = classifyOnly ?? null;
|
|
2142
2827
|
}
|
|
2143
2828
|
/**
|
|
2144
2829
|
* DOD-LOOP-1: the session core is keyed by (agentName, sessionId), NOT sessionId alone. Two of
|
|
@@ -2266,6 +2951,7 @@ export class SessionNodeManager {
|
|
|
2266
2951
|
}
|
|
2267
2952
|
({ node, gater, autoNat, seed } = sr);
|
|
2268
2953
|
gater.setAllowedPeer(counterpartyPeerId);
|
|
2954
|
+
await this.#evictPeersOutsideGate(node, gater, sessionId, counterpartyPeerId, "outbound_promotion");
|
|
2269
2955
|
// Hand this agent's standing receiver off to this session; a replacement is spun up below.
|
|
2270
2956
|
this.#standingReceivers.delete(agentName);
|
|
2271
2957
|
}
|
|
@@ -2606,6 +3292,21 @@ export class SessionNodeManager {
|
|
|
2606
3292
|
correlationId,
|
|
2607
3293
|
});
|
|
2608
3294
|
}
|
|
3295
|
+
/**
|
|
3296
|
+
* DOD-M15-SEALWIRE-1 bullet 6 (part A) — ANNOUNCE OUR SALT STATE, here and nowhere else.
|
|
3297
|
+
*
|
|
3298
|
+
* This is the only hook that fires on BOTH sides for every way a session's direct path comes
|
|
3299
|
+
* up: the initiator's first dial, the responder's inbound connection, every reconnect, and a
|
|
3300
|
+
* revived node. `newStream` never dials — it only finds an already-open connection — so a
|
|
3301
|
+
* send placed at `createSessionNode` would be an announcement to a peer that is not attached
|
|
3302
|
+
* yet, and the responder's half has no dial of its own to hang one on at all.
|
|
3303
|
+
*
|
|
3304
|
+
* Fire-and-forget: a failed announcement must not turn a peer-connect handler into a rejected
|
|
3305
|
+
* promise, and there is nothing to await it. We re-announce on the next connect, and
|
|
3306
|
+
* `#handleSaltFrame` answers a peer contribution on a connection that is provably up — so a
|
|
3307
|
+
* single lost frame does not strand the agreement.
|
|
3308
|
+
*/
|
|
3309
|
+
void this.#sendSaltFrame(agentName, sessionId, correlationId);
|
|
2609
3310
|
});
|
|
2610
3311
|
node.onPeerDisconnect((peerId) => {
|
|
2611
3312
|
if (!isCounterparty(peerId)) {
|
|
@@ -2822,6 +3523,12 @@ export class SessionNodeManager {
|
|
|
2822
3523
|
* unexpected peer could connect during the hand-off.
|
|
2823
3524
|
*/
|
|
2824
3525
|
async acceptSession(sessionId, agentName, counterpartyPubkey, initiatorPeerId, correlationId, relay) {
|
|
3526
|
+
// DOD-M15-OFFER-SIGNED-1 review N5: the offer record has done its job the moment this session is
|
|
3527
|
+
// claimed. Keying it by session (the F1 fix) removed the accidental bound that agent-keying gave
|
|
3528
|
+
// it — each new offer used to overwrite the last — so without a clear on the SUCCESS path the
|
|
3529
|
+
// map gained one permanent entry per offer ever received, on directory-supplied keys. Cleared
|
|
3530
|
+
// here rather than only on refusal, which is what the doc comment always claimed.
|
|
3531
|
+
this.clearOfferedDialer(agentName, sessionId);
|
|
2825
3532
|
const inboundSr = this.#standingReceivers.get(agentName);
|
|
2826
3533
|
if (!inboundSr) {
|
|
2827
3534
|
// DOD-LOOP-1: per-agent — kick off (idempotent) creation so a retry finds it.
|
|
@@ -2850,6 +3557,7 @@ export class SessionNodeManager {
|
|
|
2850
3557
|
const { node, gater, autoNat, seed } = inboundSr;
|
|
2851
3558
|
// AC-015: update gater BEFORE retrieving multiaddr / returning to caller
|
|
2852
3559
|
gater.setAllowedPeer(initiatorPeerId);
|
|
3560
|
+
await this.#evictPeersOutsideGate(node, gater, sessionId, initiatorPeerId, "inbound_promotion");
|
|
2853
3561
|
const peerId = node.getPeerId();
|
|
2854
3562
|
const addrs = node.listenAddresses();
|
|
2855
3563
|
// Persist to SQLite. D4 review F1 (same as createSessionNode): a swallowed row-write failure
|
|
@@ -3114,10 +3822,47 @@ export class SessionNodeManager {
|
|
|
3114
3822
|
this.#sessionLiveness.delete(key);
|
|
3115
3823
|
// M7-UPGRADE-002: drop the auto-acknowledge bookkeeping for a torn-down session.
|
|
3116
3824
|
this.#contentDesynced.delete(key);
|
|
3825
|
+
// DOD-M15-REFUSED-INBOUND-SILENT-1: and the unshown refusals. Bounded (a fixed set of reasons
|
|
3826
|
+
// per session) so leaving them was a slow leak rather than a bug — but this list IS the
|
|
3827
|
+
// documented teardown set, and a map that is not in it drifts out of everyone's mental model.
|
|
3828
|
+
this.#contentRefusals.delete(key);
|
|
3829
|
+
this.#unreadableAlgSeen.delete(key);
|
|
3117
3830
|
this.#responderSealSubmitted.delete(key);
|
|
3118
3831
|
// DOD-MSG-4: drop the strict-in-order bookkeeping (witness map, held plaintext, high-water)
|
|
3119
3832
|
// so a torn-down session retains no stale ordering state or buffered plaintext.
|
|
3120
3833
|
this.#witnessedSeq.delete(key);
|
|
3834
|
+
/**
|
|
3835
|
+
* DOD-M15-SEALWIRE-1 bullet 6 (part A) — both salt maps are CACHES and both go.
|
|
3836
|
+
*
|
|
3837
|
+
* The salt is re-read from `sessions.content_salt` on revival, which is the reason Decision #8
|
|
3838
|
+
* persists it. The contribution is worthless once a salt exists, and if none was agreed yet, a
|
|
3839
|
+
* revival minting a fresh one is correct: the peer either also holds nothing (both re-derive
|
|
3840
|
+
* from the two current halves and reach the same bytes), or it derived against our old half
|
|
3841
|
+
* while we were down — in which case the agreement refuses by name, which is the outcome
|
|
3842
|
+
* Decision #10 asks for. What must never happen is a NEW contribution mid-session without a
|
|
3843
|
+
* teardown, and that is why `#saltContributionFor` mints once rather than per send.
|
|
3844
|
+
*/
|
|
3845
|
+
this.#saltContributions.delete(key);
|
|
3846
|
+
this.#sessionSalts.delete(key);
|
|
3847
|
+
this.#saltRepairedAgainst.delete(key);
|
|
3848
|
+
/**
|
|
3849
|
+
* B2b-2's three, and the pending one is SETTLED rather than dropped.
|
|
3850
|
+
*
|
|
3851
|
+
* A `delete` alone leaves any send waiting on that promise waiting until its own timer fires —
|
|
3852
|
+
* five seconds of a torn-down session holding a message that has nowhere to go. `closed` is the
|
|
3853
|
+
* truthful outcome: the agreement is over, because the session is.
|
|
3854
|
+
*
|
|
3855
|
+
* `#hashedWithoutSalt` and `#unsaltedAnnounced` go with the caches, and that is correct rather
|
|
3856
|
+
* than merely tidy. A revived session re-derives its frontier from durable state — leaves, held
|
|
3857
|
+
* rows, queued rows — so the adoption question is answered from disk, not from a flag that would
|
|
3858
|
+
* be a stale in-memory claim about a process that no longer exists. Re-announcing the fallback
|
|
3859
|
+
* once per revival is the right frequency too: it is what an operator reading a fresh log needs
|
|
3860
|
+
* in order to know why this session has no salt.
|
|
3861
|
+
*/
|
|
3862
|
+
this.#settleSaltPending(agentName, sessionId, "closed");
|
|
3863
|
+
this.#hashedWithoutSalt.delete(key);
|
|
3864
|
+
this.#unsaltedAnnounced.delete(key);
|
|
3865
|
+
this.#saltLastOutcome.delete(key);
|
|
3121
3866
|
// HELD CONTENT IS LOST HERE, AND IT MUST SAY SO.
|
|
3122
3867
|
//
|
|
3123
3868
|
// These are frames we RECEIVED and VERIFIED and could not yet append, because the relay's
|
|
@@ -3189,7 +3934,24 @@ export class SessionNodeManager {
|
|
|
3189
3934
|
// this unit exists to stop.
|
|
3190
3935
|
this.#heldRestored.delete(key);
|
|
3191
3936
|
this.#heldReleased.delete(key);
|
|
3192
|
-
|
|
3937
|
+
// DOD-M15-DIVERGE-1: `#diverged` is NOT evicted here, and the omission is the point.
|
|
3938
|
+
//
|
|
3939
|
+
// It was, and that made the gate that reads it best-effort in exactly the population it targets.
|
|
3940
|
+
// This eviction runs on EVERY teardown including `destroySessionNode` with a non-sealed reason,
|
|
3941
|
+
// which writes status `interrupted` — one of the two statuses the seal gate is scoped to. So a
|
|
3942
|
+
// session that diverged and was then torn down arrived at the gate with the fact already
|
|
3943
|
+
// forgotten, and the read site cannot tell "not diverged" from "we forgot": both are
|
|
3944
|
+
// `has() === false`, both read ready, and the close proceeds.
|
|
3945
|
+
//
|
|
3946
|
+
// Divergence is a fact about the DURABLE TREE, not about the live node — the tree keeps the
|
|
3947
|
+
// misplaced leaf whether or not a node exists — so it does not belong to a cache keyed on node
|
|
3948
|
+
// lifetime. It is cleared where it actually stops being true: `clearDivergedOnTerminal`, below.
|
|
3949
|
+
//
|
|
3950
|
+
// NOT YET DURABLE ACROSS A RESTART, stated here rather than left to be rediscovered — the same
|
|
3951
|
+
// way `frontier-mismatch.ts` states its own trade. A daemon restart still empties this set, and
|
|
3952
|
+
// unlike a frontier mismatch (re-detected by the very next close) divergence is only
|
|
3953
|
+
// re-detected by the next send that gets an ack behind the frontier. Until it has a column, a
|
|
3954
|
+
// restarted daemon can read a diverged session as ready. Tracked as `DOD-M15-DIVERGE-DURABLE-1`.
|
|
3193
3955
|
// DOD-M12B-SESSION-SEED-1: HAND THEM TO THE REVIVAL RECORD BEFORE DROPPING THEM. This eviction
|
|
3194
3956
|
// runs on every teardown, including the interruption a revival is meant to undo — so clearing
|
|
3195
3957
|
// the addresses here is what left a revived session unable to dial anyone. The revival record
|
|
@@ -3277,9 +4039,42 @@ export class SessionNodeManager {
|
|
|
3277
4039
|
else {
|
|
3278
4040
|
const interruptedAt = new Date(now).toISOString();
|
|
3279
4041
|
try {
|
|
3280
|
-
this.#db.prepare(
|
|
4042
|
+
const res = this.#db.prepare(
|
|
3281
4043
|
// DOD-CAP-SELF-HEAL-1: OURS. Our own shutdown ended these, not the counterparty.
|
|
3282
4044
|
"UPDATE sessions SET status = 'interrupted', updated_at = ?, interrupted_at = COALESCE(interrupted_at, ?), interrupted_by = 'local' WHERE status = 'active'").run(now, interruptedAt);
|
|
4045
|
+
/**
|
|
4046
|
+
* ⚠️ THE AUTHORITATIVE PERSISTENCE STEP WAS SILENT ON SUCCESS, AND THAT IS WHY ITS OWN TEST
|
|
4047
|
+
* CANNOT BE DIAGNOSED.
|
|
4048
|
+
*
|
|
4049
|
+
* `AC-009 (binary): SIGTERM marks active sessions interrupted` failed twice in a row on CI
|
|
4050
|
+
* and blocked a publish. The captured daemon log ends at `daemon.started` with nothing after
|
|
4051
|
+
* it — because the ONLY thing this block could ever log was a thrown error. So the evidence
|
|
4052
|
+
* is equally consistent with two very different failures:
|
|
4053
|
+
*
|
|
4054
|
+
* - the shutdown never ran (signal handler, early exit, teardown ordering), or
|
|
4055
|
+
* - it ran and the UPDATE matched ZERO rows (visibility, or the rows genuinely were not
|
|
4056
|
+
* `active` at that moment).
|
|
4057
|
+
*
|
|
4058
|
+
* Nothing in the log separates them, so the test's own comment picked one — *"the daemon's
|
|
4059
|
+
* connection can begin its shutdown UPDATE against a snapshot that predates this commit"* —
|
|
4060
|
+
* added a `wal_checkpoint(TRUNCATE)` for it, and the test failed again. One diagnosis, one
|
|
4061
|
+
* fix, one recurrence: the point at which the diagnosis is the thing to doubt.
|
|
4062
|
+
*
|
|
4063
|
+
* `changes` was on the result object the whole time and nobody read it. It discriminates the
|
|
4064
|
+
* two outright, and this is the last line the daemon writes before it dies, so it is the last
|
|
4065
|
+
* thing anyone investigating a bad shutdown will see.
|
|
4066
|
+
*
|
|
4067
|
+
* ⚠️ INFO AND NOT AN ERROR EVEN AT ZERO. Zero is the ordinary case for a daemon with no
|
|
4068
|
+
* active sessions — most shutdowns. Making it a warning would fire on nearly every clean exit
|
|
4069
|
+
* and train operators to filter the one signal that matters.
|
|
4070
|
+
*/
|
|
4071
|
+
this.#logger.info("session.interrupt.db.write.complete", {
|
|
4072
|
+
sessionId: "__all__",
|
|
4073
|
+
rowsMarkedInterrupted: Number(res.changes),
|
|
4074
|
+
impact: Number(res.changes) === 0
|
|
4075
|
+
? "no session rows were 'active' at shutdown, so none was marked interrupted. Ordinary for a daemon with no live sessions — but if a session WAS expected to be interrupted, the UPDATE ran and matched nothing, which is a visibility or state question and NOT a shutdown that failed to run."
|
|
4076
|
+
: "these sessions are recorded as interrupted by OUR OWN shutdown, so they can be resumed or sealed rather than read as abandoned.",
|
|
4077
|
+
});
|
|
3283
4078
|
}
|
|
3284
4079
|
catch (err) {
|
|
3285
4080
|
this.#logger.error("session.interrupt.db.write.failed", {
|
|
@@ -3711,6 +4506,27 @@ export class SessionNodeManager {
|
|
|
3711
4506
|
* cello_get_sealed_receipt) depend on — without it an agent has no way to learn
|
|
3712
4507
|
* its own session ids after a restart or from a fresh MCP connection.
|
|
3713
4508
|
*/
|
|
4509
|
+
/**
|
|
4510
|
+
* DOD-M15-REFUSED-INBOUND-SILENT-1, the DECLINED PROTECTION half — a FIELD, not an alert.
|
|
4511
|
+
*
|
|
4512
|
+
* An unsalted session is exactly as verifiable as every session shipped before salting existed,
|
|
4513
|
+
* so there is nothing to interrupt the operator with and no event to fire. What was missing is
|
|
4514
|
+
* STATE: nothing let anyone tell *"unsalted because this build predates the feature"* from
|
|
4515
|
+
* *"unsalted because adoption was refused"* — and only the second says something about their
|
|
4516
|
+
* setup. The session's own status now answers it, which costs nothing per message and cannot
|
|
4517
|
+
* become a flood.
|
|
4518
|
+
*
|
|
4519
|
+
* The raw salt is dropped on the way out rather than passed through. `SELECT *` was handing the
|
|
4520
|
+
* BLOB to a listing surface that has no use for it; the boolean is the whole question a reader of
|
|
4521
|
+
* this list is asking, and shipping key material to answer a yes/no is not a trade worth making.
|
|
4522
|
+
*/
|
|
4523
|
+
#saltStatusOf(row) {
|
|
4524
|
+
const { content_salt, ...rest } = row;
|
|
4525
|
+
return {
|
|
4526
|
+
...rest,
|
|
4527
|
+
content_hashes_salted: content_salt != null && content_salt.length > 0,
|
|
4528
|
+
};
|
|
4529
|
+
}
|
|
3714
4530
|
getSessionsForAgent(agentName) {
|
|
3715
4531
|
if (!this.#db)
|
|
3716
4532
|
return [];
|
|
@@ -3720,7 +4536,7 @@ export class SessionNodeManager {
|
|
|
3720
4536
|
const rows = this.#db
|
|
3721
4537
|
.prepare("SELECT * FROM sessions WHERE agent_id = ? ORDER BY updated_at DESC")
|
|
3722
4538
|
.all(this.#requireAgentId(agentName));
|
|
3723
|
-
return rows.map((r) => ({ ...r, agent_name: agentName }));
|
|
4539
|
+
return rows.map((r) => ({ ...this.#saltStatusOf(r), agent_name: agentName }));
|
|
3724
4540
|
}
|
|
3725
4541
|
/**
|
|
3726
4542
|
* Every persisted session across ALL agents, most-recently-updated first. Backs the daemon-wide
|
|
@@ -3737,7 +4553,8 @@ export class SessionNodeManager {
|
|
|
3737
4553
|
.prepare(`SELECT s.*, a.agent_name AS agent_name
|
|
3738
4554
|
FROM sessions s LEFT JOIN agents a ON a.agent_id = s.agent_id
|
|
3739
4555
|
ORDER BY s.updated_at DESC`)
|
|
3740
|
-
.all()
|
|
4556
|
+
.all()
|
|
4557
|
+
.map((r) => this.#saltStatusOf(r));
|
|
3741
4558
|
}
|
|
3742
4559
|
/**
|
|
3743
4560
|
* M7-SESSION-004 (AC-005): persist the seal certificate's legibility object with the
|
|
@@ -3807,6 +4624,35 @@ export class SessionNodeManager {
|
|
|
3807
4624
|
* signature locally. Best-effort — a missing row (race) is a no-op; the seal then falls back to
|
|
3808
4625
|
* accept-without-verify (still sound: the live frame arrives over the authenticated Noise channel).
|
|
3809
4626
|
*/
|
|
4627
|
+
/**
|
|
4628
|
+
* The counterparty's threshold group key as this agent has seen it BEFORE — trust on first use.
|
|
4629
|
+
*
|
|
4630
|
+
* DOD-M15-OFFER-SIGNED-1 / RESPONDER-VERIFY-1. The responder does not verify the assignment's
|
|
4631
|
+
* signature (deferred to SESSION-004), so every field in it is whatever the directory said. That
|
|
4632
|
+
* makes a same-frame check circular: a compromised directory just says the same thing twice.
|
|
4633
|
+
*
|
|
4634
|
+
* This is the one anchor the responder holds that a directory CANNOT retroactively change — its
|
|
4635
|
+
* own memory of previous sessions with this counterparty. A directory that names a different
|
|
4636
|
+
* threshold group key for someone you have already talked to is either substituting an identity
|
|
4637
|
+
* or has been compromised since; neither is a session to accept quietly.
|
|
4638
|
+
*
|
|
4639
|
+
* THE BOUND, stated rather than glossed: this is worth nothing on FIRST contact, which is the
|
|
4640
|
+
* definition of trust-on-first-use. It hardens every session after it, which is where a long-lived
|
|
4641
|
+
* counterparty relationship actually lives.
|
|
4642
|
+
*
|
|
4643
|
+
* Keyed on `counterparty_pubkey` — the K_local IDENTITY, which is the stable thing — not on a
|
|
4644
|
+
* session id or a display name.
|
|
4645
|
+
*/
|
|
4646
|
+
getPinnedCounterpartyPrimary(agentName, counterpartyPubkeyHex) {
|
|
4647
|
+
if (!this.#db)
|
|
4648
|
+
return null;
|
|
4649
|
+
const row = this.#db
|
|
4650
|
+
.prepare(`SELECT counterparty_primary_pubkey FROM sessions
|
|
4651
|
+
WHERE agent_id = ? AND counterparty_pubkey = ? AND counterparty_primary_pubkey IS NOT NULL
|
|
4652
|
+
ORDER BY updated_at DESC LIMIT 1`)
|
|
4653
|
+
.get(this.#requireAgentId(agentName), counterpartyPubkeyHex);
|
|
4654
|
+
return row?.counterparty_primary_pubkey ?? null;
|
|
4655
|
+
}
|
|
3810
4656
|
recordCounterpartyPrimary(agentName, sessionId, primaryPubkeyHex) {
|
|
3811
4657
|
if (!this.#db)
|
|
3812
4658
|
return;
|
|
@@ -4327,14 +5173,45 @@ export class SessionNodeManager {
|
|
|
4327
5173
|
* cross-process ordering (and thus AC-002 root agreement under concurrent
|
|
4328
5174
|
* bidirectional traffic) requires the relay-assigned sequence from MSG-001.
|
|
4329
5175
|
*/
|
|
4330
|
-
async sendContent(agentName, sessionId, content, contentHash,
|
|
5176
|
+
async sendContent(agentName, sessionId, content, contentHash,
|
|
5177
|
+
/**
|
|
5178
|
+
* Required alongside the two below — every production caller already passes one, and an optional
|
|
5179
|
+
* parameter in front of a required one is what TypeScript refuses. Making it explicit costs
|
|
5180
|
+
* nothing and removes the last place a positional argument can silently shift.
|
|
5181
|
+
*/
|
|
5182
|
+
correlationId,
|
|
4331
5183
|
/**
|
|
4332
5184
|
* The DOMAIN this content belongs to, as the relay and the directory will see it. Defaults to
|
|
4333
5185
|
* MESSAGE so `cello_send` is unchanged; the document path passes 0x04/0x05. Not cosmetic — the
|
|
4334
5186
|
* directory computes `final_message` and `answered` from the witnessed kind, and both of its
|
|
4335
5187
|
* document exclusions were dead while every document leaf arrived here as a message.
|
|
5188
|
+
*
|
|
5189
|
+
* ⚠️ ALSO REQUIRED NOW, and for the same reason as `contentHashAlg` below — this parameter is the
|
|
5190
|
+
* precedent, not a bystander. It defaulted to MESSAGE, the document adapter in `daemon.ts`
|
|
5191
|
+
* silently dropped it, and the wire was wrong for a whole release: *"0.0.145 shipped the fix
|
|
5192
|
+
* everywhere except here."* A default that matches the common case makes the omission invisible
|
|
5193
|
+
* at every call site and at typecheck. Every caller states its kind now.
|
|
4336
5194
|
*/
|
|
4337
|
-
leafKind
|
|
5195
|
+
leafKind,
|
|
5196
|
+
/**
|
|
5197
|
+
* `DOD-M15-SEALWIRE-1` part B2b — the algorithm `contentHash` was produced under, taken from
|
|
5198
|
+
* `contentHashForSession` by the caller that computed the hash.
|
|
5199
|
+
*
|
|
5200
|
+
* Passed rather than re-derived HERE, deliberately: re-deriving would ask "how would this
|
|
5201
|
+
* session hash something now?", and the answer can differ from how THIS message was actually
|
|
5202
|
+
* hashed. A hash and its label must travel together or the peer refuses a message nobody touched.
|
|
5203
|
+
*
|
|
5204
|
+
* ⚠️ REQUIRED, NOT DEFAULTED — review B2b-1 F4, and the default is what made four mutants
|
|
5205
|
+
* unfalsifiable. It was `= CONTENT_HASH_ALGS.SHA256`, which equals the only value in play today,
|
|
5206
|
+
* so DROPPING THE ARGUMENT AT ANY OF THE FIVE HOPS produced byte-identical output and the whole
|
|
5207
|
+
* 2,800-test daemon suite stayed green. Measured at all four send sites individually.
|
|
5208
|
+
*
|
|
5209
|
+
* A default that equals the current value makes every threading edit invisible until the value
|
|
5210
|
+
* changes — and the day it changes, the dropped argument mislabels the message and every peer
|
|
5211
|
+
* refuses it as a tamper. Required makes a dropped argument a TYPECHECK failure instead of a
|
|
5212
|
+
* test question nobody can answer.
|
|
5213
|
+
*/
|
|
5214
|
+
contentHashAlg) {
|
|
4338
5215
|
const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
|
|
4339
5216
|
if (!entry) {
|
|
4340
5217
|
// M12-P13: no node, so nothing was witnessed and nothing was queued — the caller must NOT
|
|
@@ -4442,6 +5319,26 @@ export class SessionNodeManager {
|
|
|
4442
5319
|
// document for a killed conversation, which is a strictly worse bug than the one this
|
|
4443
5320
|
// unit fixes. It still refuses the send; it just does not reach for the shovel.
|
|
4444
5321
|
retireSession: (id) => {
|
|
5322
|
+
/**
|
|
5323
|
+
* `session_sealed` retires as SEALED. `seal_refused` retires as ABANDONED —
|
|
5324
|
+
* `DOD-M15-TERMINAL-REASON-1`, and the distinction is the whole reason that unit
|
|
5325
|
+
* exists.
|
|
5326
|
+
*
|
|
5327
|
+
* A refused seal has NO certificate: a directory read it and rejected it. Writing
|
|
5328
|
+
* `sealed` here would be a fabricated notarization claim — `cello_close_session`
|
|
5329
|
+
* answering "already sealed, view its notarization" while the receipt read answers
|
|
5330
|
+
* "not sealed yet", which is the two-answers-pointing-at-each-other deadlock
|
|
5331
|
+
* described above. `abandoned` is the state invented for exactly this:
|
|
5332
|
+
* locally terminal with nothing to notarize.
|
|
5333
|
+
*/
|
|
5334
|
+
if (witnessed.reason === "seal_refused") {
|
|
5335
|
+
// `abandonSession`, not a hand-rolled flip-then-teardown: it already does the
|
|
5336
|
+
// status write synchronously BEFORE the async teardown yields, which is the
|
|
5337
|
+
// ordering the comment above spends a paragraph on. Reimplementing it here would
|
|
5338
|
+
// be a second copy of that reasoning, free to drift from the first.
|
|
5339
|
+
void this.abandonSession(agentName, id);
|
|
5340
|
+
return;
|
|
5341
|
+
}
|
|
4445
5342
|
if (witnessed.reason !== "session_sealed")
|
|
4446
5343
|
return;
|
|
4447
5344
|
// STATUS FIRST AND SYNCHRONOUS, teardown second — the order `abandonSession` uses,
|
|
@@ -4461,6 +5358,10 @@ export class SessionNodeManager {
|
|
|
4461
5358
|
this.#logger.warn("session.relay.hash.submit.failed", {
|
|
4462
5359
|
sessionId,
|
|
4463
5360
|
reason: witnessed.reason,
|
|
5361
|
+
// The relay's own words about what happened, when it sent any. `reason` is the class and
|
|
5362
|
+
// this is the cause — without it a refusal reaches the operator as a bare code, which is
|
|
5363
|
+
// the state this field existed to end and never actually did.
|
|
5364
|
+
...(witnessed.detail === undefined ? {} : { detail: witnessed.detail }),
|
|
4464
5365
|
correlationId,
|
|
4465
5366
|
});
|
|
4466
5367
|
relayRefusal = witnessed.reason;
|
|
@@ -4494,7 +5395,7 @@ export class SessionNodeManager {
|
|
|
4494
5395
|
// resolves it (content.delivery.acked) and TTF expiry hands it to the park
|
|
4495
5396
|
// backstop. The correlationId rides in the frame so the receiver's
|
|
4496
5397
|
// session.content.received shares ONE flow id with the sender.
|
|
4497
|
-
this.#trackAwaitingAck(agentName, sessionId, content, contentHash, correlationId, orderingS1, orderingS2);
|
|
5398
|
+
this.#trackAwaitingAck(agentName, sessionId, content, contentHash, correlationId, orderingS1, orderingS2, contentHashAlg);
|
|
4498
5399
|
const frame = encodeCbor({
|
|
4499
5400
|
type: "content_frame",
|
|
4500
5401
|
session_id: sessionId,
|
|
@@ -4507,6 +5408,10 @@ export class SessionNodeManager {
|
|
|
4507
5408
|
// prev_root (order). Omitted if the relay was unreachable — receiver falls back to the witness.
|
|
4508
5409
|
structure1_cbor: orderingS1,
|
|
4509
5410
|
structure2_cbor: orderingS2,
|
|
5411
|
+
// DOD-M15-SEALWIRE-1 part B2b: HOW `content_hash` was produced. An older peer ignores an
|
|
5412
|
+
// unknown CBOR key, so emitting it is safe for every build in existence; a newer one reads
|
|
5413
|
+
// it and verifies under the named algorithm instead of assuming.
|
|
5414
|
+
content_hash_alg: contentHashAlg,
|
|
4510
5415
|
});
|
|
4511
5416
|
// Injected dial failure — thrown from inside the try so it lands in exactly the catch the
|
|
4512
5417
|
// real connection_lost lands in, and the whole downstream path (untrack → park → durable
|
|
@@ -4575,7 +5480,7 @@ export class SessionNodeManager {
|
|
|
4575
5480
|
...this.#streamCensus(entry.node, entry.counterpartySessionPeerId),
|
|
4576
5481
|
correlationId,
|
|
4577
5482
|
});
|
|
4578
|
-
const attempt = await this.#parkContent(agentName, sessionId, hashHex, content, orderingS1, orderingS2);
|
|
5483
|
+
const attempt = await this.#parkContent(agentName, sessionId, hashHex, content, orderingS1, orderingS2, contentHashAlg);
|
|
4579
5484
|
if (attempt.outcome === "parked") {
|
|
4580
5485
|
this.#noteImpairmentRetention(agentName, sessionId, "parked");
|
|
4581
5486
|
return { ok: true, delivered: false, parked: true, ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }), ...(relayRefusal === undefined ? {} : { relayRefusal }) };
|
|
@@ -4602,7 +5507,10 @@ export class SessionNodeManager {
|
|
|
4602
5507
|
});
|
|
4603
5508
|
}
|
|
4604
5509
|
else {
|
|
4605
|
-
|
|
5510
|
+
// B2b-1 review F1: the DURABLE writer. Without the 7th argument the column this unit
|
|
5511
|
+
// added has no producer at all — every queued row would carry NULL, and the crash
|
|
5512
|
+
// backstop would re-park a salted message as sha256 and have it refused forever.
|
|
5513
|
+
durable = this.#onParkFailed(agentName, sessionId, hashHex, content, orderingS1, orderingS2, contentHashAlg);
|
|
4606
5514
|
}
|
|
4607
5515
|
if (!durable) {
|
|
4608
5516
|
if (this.#onParkFailed !== null) {
|
|
@@ -4678,9 +5586,24 @@ export class SessionNodeManager {
|
|
|
4678
5586
|
// died inside #parkContent. An operator keying on `reason` alone is sent to the transport
|
|
4679
5587
|
// when the blocker is the receiver.
|
|
4680
5588
|
...(attempt.cause !== undefined ? { cause: attempt.cause } : {}),
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
5589
|
+
/**
|
|
5590
|
+
* ⚠️ A FAULT THAT IS NOT THE RELAY MUST NOT SAY IT IS — B2b-2 constraint 6.
|
|
5591
|
+
*
|
|
5592
|
+
* The `durable` branch below is written for a relay that is down: queued, retried, nothing
|
|
5593
|
+
* for you to do. That is right for the case it was written for and WRONG for a producer-side
|
|
5594
|
+
* refusal, which reaches the same branch by the same route. There the relay was never asked,
|
|
5595
|
+
* and the drain re-parks the same entry into the same throw — so the message is genuinely
|
|
5596
|
+
* durable and will genuinely never leave. Telling that operator to wait costs them however
|
|
5597
|
+
* long they are willing to wait before they stop believing the message.
|
|
5598
|
+
*
|
|
5599
|
+
* This is the reason `cause` had to become a code first: the distinction is unbranchable
|
|
5600
|
+
* while the field holds an English paragraph.
|
|
5601
|
+
*/
|
|
5602
|
+
guidance: attempt.cause === PARK_ENVELOPE_REASONS.ALG_UNREADABLE
|
|
5603
|
+
? "This message names a content-hash algorithm your build cannot produce, so it could not be sealed for hand-off. The relay is NOT involved and this will not clear on its own — the message is safely stored but every retry fails the same way. Upgrade to a build that knows the algorithm, or start a new session with this counterparty. Re-sending on this build changes nothing."
|
|
5604
|
+
: durable
|
|
5605
|
+
? "Direct delivery failed and the relay refused the hand-off, so the message is queued and will be re-sent automatically when the relay link is back. Do not re-send it: an identical re-send is not separately queued."
|
|
5606
|
+
: "Direct delivery failed and the message could NOT be queued for retry — it is lost. Send it again.",
|
|
4684
5607
|
};
|
|
4685
5608
|
}
|
|
4686
5609
|
}
|
|
@@ -4761,7 +5684,22 @@ export class SessionNodeManager {
|
|
|
4761
5684
|
});
|
|
4762
5685
|
// content_hash = SHA-256(0x02 || seal_payload) — the ctrl leaf kind byte is 0x02.
|
|
4763
5686
|
const contentHash = new Uint8Array(createHash("sha256").update(new Uint8Array([LEAF_KIND_CTRL])).update(sealPayload).digest());
|
|
4764
|
-
|
|
5687
|
+
/**
|
|
5688
|
+
* ⚠️ `sealPayload` IS PASSED, AND ITS ABSENCE WAS THE WHOLE DEFECT — `DOD-M15-SEALWIRE-1`
|
|
5689
|
+
* bullets 3+4, review pass 1, F1.
|
|
5690
|
+
*
|
|
5691
|
+
* These exact bytes were computed two lines above, hashed, and then dropped: `submitLeaf` had
|
|
5692
|
+
* no parameter for them. So the directory received a SHA-256 pre-image nobody transmitted, and
|
|
5693
|
+
* the client's SIGNED `final_root` — the one value in the seal the relay cannot produce — was
|
|
5694
|
+
* unrecoverable. Four legs of this line shipped and were reviewed green while the head of the
|
|
5695
|
+
* chain did not exist.
|
|
5696
|
+
*
|
|
5697
|
+
* The payload and the hash MUST come from the same derivation. If they ever diverge the
|
|
5698
|
+
* directory reports `seal_payload_unbound`, whose guidance says *"someone between them and here
|
|
5699
|
+
* altered or fabricated the payload — the relay is the only party on that path"* — a correct
|
|
5700
|
+
* relay accused by name, in an error written to sound like an attack, for a mismatch made here.
|
|
5701
|
+
*/
|
|
5702
|
+
const result = await entry.relayClient.submitLeaf(entry.node, entry.relaySessionIdBytes, contentHash, LEAF_KIND_CTRL, sealPayload);
|
|
4765
5703
|
if (!result.ok) {
|
|
4766
5704
|
// Clear the idempotency mark so a genuine retry (agent close / reconnect) can proceed (DB-001).
|
|
4767
5705
|
this.#responderSealSubmitted.delete(sealKey);
|
|
@@ -4810,7 +5748,19 @@ export class SessionNodeManager {
|
|
|
4810
5748
|
const record = this.getSessionRecord(agentName, sessionId);
|
|
4811
5749
|
return {
|
|
4812
5750
|
known: !!record,
|
|
4813
|
-
|
|
5751
|
+
/**
|
|
5752
|
+
* THE REASON TRAVELS WITH THE VERDICT — review F-A, correcting review F1's fix.
|
|
5753
|
+
*
|
|
5754
|
+
* F1 made the gate binary and moved the label into the log, and I did that in ONE consumer.
|
|
5755
|
+
* This struct feeds a second one (`evaluateSealUpgrade`), which had only a boolean to read and
|
|
5756
|
+
* therefore called every cause `content_tamper` — at ERROR, with no guidance. An honest peer on
|
|
5757
|
+
* a newer build raised a security alarm on B's reconnect: exactly the harm the F1 fix was
|
|
5758
|
+
* written to remove, reached through the consumer it did not check.
|
|
5759
|
+
*
|
|
5760
|
+
* Returning the LABEL rather than a boolean is what makes that impossible to reintroduce: a
|
|
5761
|
+
* caller cannot flatten what it never receives flat.
|
|
5762
|
+
*/
|
|
5763
|
+
unverifiable: this.#contentDesynced.get(this.#k(agentName, sessionId)) ?? null,
|
|
4814
5764
|
};
|
|
4815
5765
|
}
|
|
4816
5766
|
/**
|
|
@@ -4844,17 +5794,41 @@ export class SessionNodeManager {
|
|
|
4844
5794
|
// scope (already sealing, or needs the interrupted/upgrade path), not an auto-ack candidate.
|
|
4845
5795
|
if (!record || record.status !== "active")
|
|
4846
5796
|
return;
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
|
|
4850
|
-
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
5797
|
+
/**
|
|
5798
|
+
* SI-002 verifiability gate: never auto-sign a session whose content we could not verify.
|
|
5799
|
+
*
|
|
5800
|
+
* ⚠️ THIS COMMENT SAID THE OPPOSITE UNTIL `DOD-M15-SEALWIRE-1` part B1 (review F-C). It read
|
|
5801
|
+
* *"Today the ONLY tracked unverifiable cause is a content_hash mismatch = TAMPER"*, which was
|
|
5802
|
+
* true when a mismatch was the only way to fail the cross-check and is false now: B1 added an
|
|
5803
|
+
* unreadable algorithm name and a salted frame with no salt, and **both are ordinary**.
|
|
5804
|
+
*
|
|
5805
|
+
* Genuine tamper is a SECURITY event — ERROR, reason `content_tamper`, which is what the AC-008
|
|
5806
|
+
* alarm keys on. The other two must NOT wear that name, or the alarm fires on an honest peer
|
|
5807
|
+
* running a newer build and the operator learns to dismiss it.
|
|
5808
|
+
*
|
|
5809
|
+
* 🚨 AND `content_unverifiable` IS RESERVED — DO NOT REUSE IT HERE. It is specced for *parked
|
|
5810
|
+
* content unrecoverable*, one of the two reasons (with `desynced`, B's tree behind the canonical
|
|
5811
|
+
* sealed tail) awaiting the deferred MSG-001-3b canonical-sequence reconciliation. B1's first
|
|
5812
|
+
* attempt emitted exactly that string for a different condition, which would have made the two
|
|
5813
|
+
* indistinguishable in the log the day the follow-on landed. Hence
|
|
5814
|
+
* `content_verification_unavailable`, deliberately distinct.
|
|
5815
|
+
*/
|
|
5816
|
+
const unverifiable = this.#contentDesynced.get(ackKey);
|
|
5817
|
+
if (unverifiable) {
|
|
5818
|
+
/**
|
|
5819
|
+
* DOD-M15-SEALWIRE-1 part B1 (review F1) — THE GATE FIRES FOR BOTH, THE ALARM ONLY FOR ONE.
|
|
5820
|
+
*
|
|
5821
|
+
* `content_tamper` is what the AC-008 alarm keys on, and it must keep meaning what it says. A
|
|
5822
|
+
* frame we could not verify because the peer named an algorithm this build cannot read is an
|
|
5823
|
+
* ordinary version difference; raising a tamper alarm for it would train an operator to
|
|
5824
|
+
* dismiss the alarm, which costs more than the skew.
|
|
5825
|
+
*
|
|
5826
|
+
* But the REFUSAL TO AUTO-SIGN is identical in both cases and non-negotiable: SI-002 is "never
|
|
5827
|
+
* auto-sign a session whose content we could not verify", and "could not" covers both.
|
|
5828
|
+
*/
|
|
4855
5829
|
this.#logger.error("session.seal.autoack.skipped", {
|
|
4856
5830
|
sessionId,
|
|
4857
|
-
reason: "content_tamper",
|
|
5831
|
+
reason: unverifiable === "tampered" ? "content_tamper" : "content_verification_unavailable",
|
|
4858
5832
|
correlationId,
|
|
4859
5833
|
});
|
|
4860
5834
|
// AC-002: the verifiability gate refused — surface counterparty_closing to B's agent as a
|
|
@@ -4966,36 +5940,206 @@ export class SessionNodeManager {
|
|
|
4966
5940
|
*
|
|
4967
5941
|
* @returns the appended leaf index (as sequenceNumber) on success.
|
|
4968
5942
|
*/
|
|
4969
|
-
async ingestReceivedContent(agentName, sessionId, content, contentHash, correlationId,
|
|
4970
5943
|
/**
|
|
4971
|
-
*
|
|
4972
|
-
*
|
|
4973
|
-
*
|
|
4974
|
-
*
|
|
4975
|
-
*
|
|
5944
|
+
* Mark this session's content unverifiable — review F1, and every path that fails the cross-check
|
|
5945
|
+
* must come through here.
|
|
5946
|
+
*
|
|
5947
|
+
* It gates `getSealUpgradeReadiness().tampered` and the auto-acknowledge check, i.e. whether this
|
|
5948
|
+
* agent's key signs anything covering content it could not check. Three refusal paths reach it and
|
|
5949
|
+
* they carry different labels, because what the operator is told must differ; **what the gate does
|
|
5950
|
+
* must not.**
|
|
5951
|
+
*
|
|
5952
|
+
* TAMPERED NEVER DOWNGRADES. A session that has already seen a hash mismatch stays `tampered` even
|
|
5953
|
+
* if a later frame merely names an unreadable algorithm — otherwise a sender that had been caught
|
|
5954
|
+
* could clear its own alarm by sending one more frame with a junk algorithm name, and the seal
|
|
5955
|
+
* would auto-complete.
|
|
4976
5956
|
*/
|
|
4977
|
-
|
|
4978
|
-
|
|
4979
|
-
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
5957
|
+
/**
|
|
5958
|
+
* Remember that THIS frame was refused for naming an unreadable algorithm, so the park path can
|
|
5959
|
+
* say so if the same message comes back the other way (review F2/F-D).
|
|
5960
|
+
*
|
|
5961
|
+
* BOUNDED, and it has to be: a peer that keeps sending unreadable frames would otherwise grow this
|
|
5962
|
+
* without limit, and it is fed entirely by a remote party. The cap is per session and it drops the
|
|
5963
|
+
* OLDEST entry — losing one only costs a missing reconciliation line, whereas an unbounded map fed
|
|
5964
|
+
* by a counterparty is the leak class this codebase has already caught twice.
|
|
5965
|
+
*/
|
|
5966
|
+
#noteUnreadableAlgFrame(agentName, sessionId, contentHash, declaredAlg) {
|
|
5967
|
+
const key = this.#k(agentName, sessionId);
|
|
5968
|
+
let byHash = this.#unreadableAlgSeen.get(key);
|
|
5969
|
+
if (!byHash) {
|
|
5970
|
+
byHash = new Map();
|
|
5971
|
+
this.#unreadableAlgSeen.set(key, byHash);
|
|
5972
|
+
}
|
|
5973
|
+
if (byHash.size >= MAX_UNREADABLE_ALG_FRAMES) {
|
|
5974
|
+
const oldest = byHash.keys().next();
|
|
5975
|
+
if (!oldest.done)
|
|
5976
|
+
byHash.delete(oldest.value);
|
|
5977
|
+
}
|
|
5978
|
+
byHash.set(Buffer.from(contentHash).toString("hex"), declaredAlg);
|
|
5979
|
+
}
|
|
5980
|
+
/**
|
|
5981
|
+
* ─── DOD-M15-REFUSED-INBOUND-SILENT-1: refusals the RECEIVING operator can actually see ───────
|
|
5982
|
+
*
|
|
5983
|
+
* Every inbound refusal already logs a `reason`, an `impact` and a `guidance` — and they are
|
|
5984
|
+
* good. They had no reader. From the receiving operator's chair a refused message simply never
|
|
5985
|
+
* arrives: the conversation goes quiet with a full explanation sitting in a file they have no
|
|
5986
|
+
* reason to open, and they conclude the other person stopped replying.
|
|
5987
|
+
*
|
|
5988
|
+
* `content_hash_alg_unknown` is why this matters more than it sounds. It is a VERSION SKEW, so it
|
|
5989
|
+
* affects every message from that counterparty, permanently — not a rare one-off.
|
|
5990
|
+
*
|
|
5991
|
+
* **DEDUPLICATED PER SESSION PER REASON, and that is the design, not an optimisation.** A skewed
|
|
5992
|
+
* peer turns one problem into a flood: the first refusal of a kind is the signal, the ninetieth is
|
|
5993
|
+
* noise that trains the operator to ignore the surface. `count` keeps the scale visible without
|
|
5994
|
+
* repeating the alert.
|
|
5995
|
+
*
|
|
5996
|
+
* **NEVER carries the content.** It failed verification; surfacing it is the injection path the
|
|
5997
|
+
* cross-check exists to close. The operator learns that a message was refused and why — never
|
|
5998
|
+
* what it said.
|
|
5999
|
+
*
|
|
6000
|
+
* In memory, deliberately: a restart re-signalling a still-broken peer is correct behaviour, not
|
|
6001
|
+
* duplication, and durability here would buy nothing the next refusal does not.
|
|
6002
|
+
*/
|
|
6003
|
+
/**
|
|
6004
|
+
* (agentName, sessionId) → reason → the notice. `firstAt` was dropped: it was written and read by
|
|
6005
|
+
* nothing, and a field nobody consumes is a claim the code does not keep.
|
|
6006
|
+
*/
|
|
6007
|
+
#contentRefusals = new Map();
|
|
6008
|
+
/** Record an inbound refusal for the operator. First of its kind per session is the signal. */
|
|
6009
|
+
noteContentRefusal(agentName, sessionId, reason, detail) {
|
|
6010
|
+
const key = this.#k(agentName, sessionId);
|
|
6011
|
+
let perSession = this.#contentRefusals.get(key);
|
|
6012
|
+
if (!perSession) {
|
|
6013
|
+
perSession = new Map();
|
|
6014
|
+
this.#contentRefusals.set(key, perSession);
|
|
6015
|
+
}
|
|
6016
|
+
const existing = perSession.get(reason);
|
|
6017
|
+
if (existing) {
|
|
6018
|
+
existing.count += 1;
|
|
6019
|
+
return;
|
|
6020
|
+
}
|
|
6021
|
+
perSession.set(reason, {
|
|
6022
|
+
reason,
|
|
6023
|
+
impact: detail?.impact,
|
|
6024
|
+
guidance: detail?.guidance,
|
|
6025
|
+
count: 1,
|
|
6026
|
+
// Per CONSUMER, not one global flag. See `takeContentRefusals`.
|
|
6027
|
+
surfacedTo: new Map(),
|
|
6028
|
+
});
|
|
6029
|
+
}
|
|
6030
|
+
/**
|
|
6031
|
+
* Drain the refusals a GIVEN CONSUMER has not been shown yet, and remember what it was shown.
|
|
6032
|
+
*
|
|
6033
|
+
* ─── Why this is keyed by connection, and not by a single flag ─────────────────────────────────
|
|
6034
|
+
*
|
|
6035
|
+
* It used to set one `surfaced: boolean` on the notice. Two MCP windows attending the same agent
|
|
6036
|
+
* is the ordinary case, and under that flag whoever read FIRST consumed the notice — the second
|
|
6037
|
+
* window was told nothing, permanently. **That is the same defect `takeReceivedContent` had**, and
|
|
6038
|
+
* the comment above the delivery loop in `session-content-handlers.ts` spells out why it was
|
|
6039
|
+
* removed: *"reading is non-destructive by construction. Nothing one consumer does mutates state
|
|
6040
|
+
* another consumer reads."* The whole `taken_by_sibling` apparatus exists because this was paid
|
|
6041
|
+
* for once already; re-introducing it on a different surface makes it no less true.
|
|
6042
|
+
*
|
|
6043
|
+
* ─── Why the count now has a reader ────────────────────────────────────────────────────────────
|
|
6044
|
+
*
|
|
6045
|
+
* The old docstring claimed *"count still grows underneath, so a later reader can ask how many
|
|
6046
|
+
* without being told again."* **There was no later reader.** After the first surfacing the count
|
|
6047
|
+
* incremented under a flag the drain skipped unconditionally, so 3 refusals became 903 and nothing
|
|
6048
|
+
* anywhere could say so — while the comment asserted the opposite.
|
|
6049
|
+
*
|
|
6050
|
+
* So a reason RE-ANNOUNCES to a consumer when its count has grown by an order of magnitude since
|
|
6051
|
+
* that consumer last saw it (1 → 10 → 100 → …), marked `repeat: true`. That keeps the first
|
|
6052
|
+
* refusal the signal and the ninetieth silent, which is the dedup's point, while still making a
|
|
6053
|
+
* skew that has swallowed hundreds of messages visible — at a handful of announcements per
|
|
6054
|
+
* session, not one per message.
|
|
6055
|
+
*/
|
|
6056
|
+
takeContentRefusals(agentName, sessionId,
|
|
6057
|
+
/**
|
|
6058
|
+
* REQUIRED, deliberately — no default.
|
|
6059
|
+
*
|
|
6060
|
+
* It had one (`"default"`), and a default is the defect this method was rewritten to remove,
|
|
6061
|
+
* lying in wait: any future call site that omits the argument silently shares ONE bucket across
|
|
6062
|
+
* every window, the first reader consumes the notice for all the others, and nothing fails to
|
|
6063
|
+
* compile and no test goes red. The parameter existing is not the protection; being unable to
|
|
6064
|
+
* forget it is.
|
|
6065
|
+
*/
|
|
6066
|
+
consumerId) {
|
|
6067
|
+
const perSession = this.#contentRefusals.get(this.#k(agentName, sessionId));
|
|
6068
|
+
if (!perSession)
|
|
6069
|
+
return [];
|
|
6070
|
+
const out = [];
|
|
6071
|
+
for (const notice of perSession.values()) {
|
|
6072
|
+
const shownAt = notice.surfacedTo.get(consumerId);
|
|
6073
|
+
const firstTime = shownAt === undefined;
|
|
6074
|
+
// `shownAt` is at least 1 whenever it is set, so this cannot loop on zero.
|
|
6075
|
+
if (!firstTime && notice.count < shownAt * 10)
|
|
6076
|
+
continue;
|
|
6077
|
+
notice.surfacedTo.set(consumerId, notice.count);
|
|
6078
|
+
out.push({
|
|
6079
|
+
reason: notice.reason,
|
|
6080
|
+
impact: notice.impact,
|
|
6081
|
+
guidance: notice.guidance,
|
|
6082
|
+
count: notice.count,
|
|
6083
|
+
...(firstTime ? {} : { repeat: true }),
|
|
6084
|
+
});
|
|
6085
|
+
}
|
|
6086
|
+
return out;
|
|
6087
|
+
}
|
|
6088
|
+
#markContentUnverifiable(agentName, sessionId, why) {
|
|
6089
|
+
const key = this.#k(agentName, sessionId);
|
|
6090
|
+
if (why === "unverifiable" && this.#contentDesynced.get(key) === "tampered")
|
|
6091
|
+
return;
|
|
6092
|
+
this.#contentDesynced.set(key, why);
|
|
6093
|
+
}
|
|
6094
|
+
async ingestReceivedContent(agentName, sessionId, content, contentHash, correlationId,
|
|
6095
|
+
/**
|
|
6096
|
+
* DOD-FRONTIER-STRAND-1 AC1: the relay-assigned canonical position for THIS message, taken from
|
|
6097
|
+
* the verified ordering record by the caller. Passed EXPLICITLY rather than recovered from
|
|
6098
|
+
* `#witnessedSeq`, because that map is keyed by content hash — so two byte-identical messages
|
|
6099
|
+
* collapse in it before dedup is ever consulted, which is the whole defect. Absent when the
|
|
6100
|
+
* session has no relay witness (relay-degraded): see the announced fallback below.
|
|
6101
|
+
*/
|
|
6102
|
+
canonicalSeqIn,
|
|
6103
|
+
/**
|
|
6104
|
+
* DOD-M15-SEALWIRE-1 part B1 — the algorithm the SENDER named on the frame, verbatim.
|
|
6105
|
+
*
|
|
6106
|
+
* `undefined` means the frame carried no name, which is a peer that predates the field and is
|
|
6107
|
+
* the one case we may safely assume `sha256` for. It is threaded through rather than read off
|
|
6108
|
+
* the session, because whether a hash is salted is a fact about the FRAME and its sender, never
|
|
6109
|
+
* about what this side happens to hold.
|
|
6110
|
+
*/
|
|
6111
|
+
contentHashAlgIn,
|
|
6112
|
+
/**
|
|
6113
|
+
* DOD-M15-SEALWIRE-1 bullet 5: the VERIFIED authorship proof for this message, when the caller
|
|
6114
|
+
* has one. The caller is the only place that has it — `#recordFrameOrdering` verifies the
|
|
6115
|
+
* signature against the key inside the sender's own signed bytes and matches the signer to this
|
|
6116
|
+
* session's counterparty, and that result reaches here or nowhere.
|
|
6117
|
+
*
|
|
6118
|
+
* Optional, because the soft decode-failure path ingests without it. The row records which it
|
|
6119
|
+
* was, so absence is never silent.
|
|
6120
|
+
*/
|
|
6121
|
+
verifiedAuthorship) {
|
|
6122
|
+
// The transcript is frozen ONLY once it is COMMITTED + signed — 'sealed' or
|
|
6123
|
+
// 'seal_interrupted_pending' (the bilateral seal commitment) — because a later FROST
|
|
6124
|
+
// notarization attests that exact root; a late leaf would diverge from it.
|
|
6125
|
+
//
|
|
6126
|
+
// MSG-001-3b recovery: a merely 'interrupted' session is NOT yet committed. The
|
|
6127
|
+
// counterparty's last message(s) may have been parked while this party was offline, so its
|
|
6128
|
+
// local transcript is INCOMPLETE (not frozen-final). Recovering that parked content COMPLETES
|
|
6129
|
+
// the local view to match the counterparty BEFORE the bilateral seal — it is not a resumption
|
|
6130
|
+
// (no new activity, no re-accept) and its root was never committed. So allow 'active' AND
|
|
6131
|
+
// 'interrupted'; reject only the two committed states.
|
|
6132
|
+
const record = this.getSessionRecord(agentName, sessionId);
|
|
6133
|
+
// DOD-UNREAD-1 D4a: NEVER record content you cannot attribute. With no sessions row there is
|
|
6134
|
+
// no counterparty — the transcript has no counterparty column, so a row written here is
|
|
6135
|
+
// unattributable forever, counted unread by getUnreadSummary, and unreadable by cello_receive
|
|
6136
|
+
// (the phantom-session residue). The old "(No DB row = test-only path, allowed.)" fallback
|
|
6137
|
+
// papered that in with senderPubkey="unknown". Refuse loudly instead; the content stays
|
|
6138
|
+
// un-acked, so a live sender redelivers once the session actually exists. After D3
|
|
6139
|
+
// (DOD-INBOUND-GUARD-1) this path is unreachable from the wire — a fail-loud assertion.
|
|
6140
|
+
if (!record) {
|
|
6141
|
+
this.#logger.warn("session.content.orphaned", { agentName, sessionId, correlationId });
|
|
6142
|
+
return { ok: false, reason: "session_orphaned" };
|
|
4999
6143
|
}
|
|
5000
6144
|
// DOD-TERMINAL-WAKE-1 (review F1): `abandoned` belongs here too. It is terminal and, unlike
|
|
5001
6145
|
// `interrupted`, can NEVER complete — there is nothing left to append to and no seal to join.
|
|
@@ -5018,18 +6162,126 @@ export class SessionNodeManager {
|
|
|
5018
6162
|
});
|
|
5019
6163
|
return { ok: false, reason: "session_committed" };
|
|
5020
6164
|
}
|
|
5021
|
-
|
|
6165
|
+
/**
|
|
6166
|
+
* DOD-M15-SEALWIRE-1 part B1 — VERIFY UNDER THE ALGORITHM THE SENDER NAMED.
|
|
6167
|
+
*
|
|
6168
|
+
* Three outcomes and they must stay apart, because two of them are version differences and only
|
|
6169
|
+
* the third is evidence of tampering. Collapsing them is how a routine skew becomes a security
|
|
6170
|
+
* incident in the operator's log, and how a real tamper gets dismissed as a skew.
|
|
6171
|
+
*/
|
|
6172
|
+
/**
|
|
6173
|
+
* ⚠️ `content_hash_alg` IS NOT COVERED BY ANY SIGNATURE — review F1, and it shapes both branches
|
|
6174
|
+
* below.
|
|
6175
|
+
*
|
|
6176
|
+
* The sender's signature is over `structure1_cbor`, which binds `content_hash`. It does NOT bind
|
|
6177
|
+
* the frame envelope, so this field is an unauthenticated CLAIM by whoever sent the frame. That
|
|
6178
|
+
* is fine for choosing how to verify — a wrong choice simply fails — but it means neither branch
|
|
6179
|
+
* may state, as fact, anything it learned only from this field.
|
|
6180
|
+
*
|
|
6181
|
+
* It also means both branches MUST mark the session unverifiable. Before B1 every frame that
|
|
6182
|
+
* failed the cross-check reached `#contentDesynced`, which gates auto-co-signing and unilateral
|
|
6183
|
+
* ratification. Returning early here would have let a sender bypass the tamper detector by
|
|
6184
|
+
* appending one unsigned string: sign hash H, send different bytes, add an unreadable algorithm
|
|
6185
|
+
* name, and the receiver refuses politely, records nothing, and auto-co-signs at seal time.
|
|
6186
|
+
*/
|
|
6187
|
+
const algResolved = resolveContentHashAlg(contentHashAlgIn);
|
|
6188
|
+
if (!algResolved.ok) {
|
|
6189
|
+
// A NAME WE CANNOT READ. Not a legacy peer — an unreadable one. There is no value to compare
|
|
6190
|
+
// against, so `content_hash_mismatch` here would be an exit-point label standing in for
|
|
6191
|
+
// "their build is newer than ours" (Invariant 2). Refused by its own name instead.
|
|
6192
|
+
this.#markContentUnverifiable(agentName, sessionId, "unverifiable");
|
|
6193
|
+
this.#noteUnreadableAlgFrame(agentName, sessionId, contentHash, algResolved.value);
|
|
6194
|
+
this.#logger.error("session.content.cross_check.failed", {
|
|
6195
|
+
sessionId, correlationId,
|
|
6196
|
+
reason: "content_hash_alg_unknown",
|
|
6197
|
+
declaredAlg: algResolved.value,
|
|
6198
|
+
// States only what is KNOWN. The old wording said "nothing was altered and nobody did
|
|
6199
|
+
// anything wrong" and "Do not treat this as a security event" — both inferred from the
|
|
6200
|
+
// unsigned field, i.e. from the attacker in the case that matters.
|
|
6201
|
+
impact: "this message could not be verified, so it was NOT ingested and NOT shown. The algorithm name is a claim by the sender and is not covered by any signature, so it does not establish what they actually did. This session will not auto-co-sign at close.",
|
|
6202
|
+
guidance: "Almost always their CELLO build is newer than this one: ask which version they are running, and upgrade. If they are on the SAME version as you, that explanation does not hold and the frame was malformed or crafted — do not close the session by auto-acknowledgement.",
|
|
6203
|
+
});
|
|
6204
|
+
// DOD-M15-REFUSED-INBOUND-SILENT-1: the SAME strings the log just carried, to the operator.
|
|
6205
|
+
// This reason is a version skew, so it affects every message from that counterparty — without
|
|
6206
|
+
// this the conversation goes permanently quiet and they conclude the peer stopped replying.
|
|
6207
|
+
this.noteContentRefusal(agentName, sessionId, "content_hash_alg_unknown", {
|
|
6208
|
+
impact: "this message could not be verified, so it was NOT ingested and NOT shown. The algorithm name is a claim by the sender and is not covered by any signature, so it does not establish what they actually did. This session will not auto-co-sign at close.",
|
|
6209
|
+
guidance: "Almost always their CELLO build is newer than this one: ask which version they are running, and upgrade. If they are on the SAME version as you, that explanation does not hold and the frame was malformed or crafted — do not close the session by auto-acknowledgement.",
|
|
6210
|
+
});
|
|
6211
|
+
return { ok: false, reason: "content_hash_alg_unknown" };
|
|
6212
|
+
}
|
|
6213
|
+
let computed;
|
|
6214
|
+
try {
|
|
6215
|
+
computed = contentHashFor(content, {
|
|
6216
|
+
alg: algResolved.alg,
|
|
6217
|
+
// The salt is OURS — the sender's frame never carries one, and could not be trusted if it
|
|
6218
|
+
// did. A salted frame we hold no salt for throws below and is refused by name.
|
|
6219
|
+
salt: this.#getSessionSalt(agentName, sessionId),
|
|
6220
|
+
});
|
|
6221
|
+
}
|
|
6222
|
+
catch (err) {
|
|
6223
|
+
// Reached when the peer named the salted algorithm and this side holds no salt for the
|
|
6224
|
+
// session — the agreement never completed, or its record is gone. Distinct from a mismatch
|
|
6225
|
+
// for the same reason as above: nothing was tampered with, we simply cannot check it.
|
|
6226
|
+
this.#markContentUnverifiable(agentName, sessionId, "unverifiable");
|
|
6227
|
+
this.#logger.error("session.content.cross_check.failed", {
|
|
6228
|
+
sessionId, correlationId,
|
|
6229
|
+
reason: "content_hash_salt_unavailable",
|
|
6230
|
+
declaredAlg: algResolved.alg,
|
|
6231
|
+
detail: extractErrorMessage(err),
|
|
6232
|
+
// "Nothing was altered" was the same mistake as the branch above: it is not knowable from
|
|
6233
|
+
// here. What IS knowable is that we could not check.
|
|
6234
|
+
impact: "this message could not be verified — the sender says it is salted and this side holds no salt for the session — so it was NOT ingested and NOT shown. This session will not auto-co-sign at close.",
|
|
6235
|
+
// Review F6: `#getSessionSalt` returns null for THREE conditions and only one of them wants
|
|
6236
|
+
// a close. A read failure and a corrupt row both leave us holding no salt, which is exactly
|
|
6237
|
+
// what makes the agreement re-offer a contribution and repair itself on the next connect.
|
|
6238
|
+
//
|
|
6239
|
+
// The adoption refusal is the FOURTH, added with the Decision #8 guard, and it is the only
|
|
6240
|
+
// one that does not repair: this side declined the salt permanently for this session, so
|
|
6241
|
+
// waiting for a reconnect is exactly the wrong advice. Leaving it out of this list would
|
|
6242
|
+
// have sent an operator to look for a read failure that is not there and never will be.
|
|
6243
|
+
guidance: "Look for session.salt.adoption.refused first: if it is there, this side declined the salt because the session had already hashed messages, that is permanent for this session, and reconnecting will NOT fix it — close the session and start a new one. Otherwise look for session.salt.read.failed or session.salt.persist.failed. If either is present the agreement re-runs on the next reconnect and this repairs itself — wait for that before doing anything. If none of the three is present, the agreement never completed with this counterparty: close the session and start a new one. In every case the transcript up to here is intact.",
|
|
6244
|
+
});
|
|
6245
|
+
// DOD-M15-REFUSED-INBOUND-SILENT-1 — and this branch needed it MORE than the two that had it.
|
|
6246
|
+
//
|
|
6247
|
+
// It was refused, logged with a full impact and guidance, not ingested, not shown — and the
|
|
6248
|
+
// operator was told nothing. Twenty lines below the branches that were wired, in the same
|
|
6249
|
+
// function, with the same shape.
|
|
6250
|
+
//
|
|
6251
|
+
// One of its four causes is permanent, and the guidance above says so in its own words: an
|
|
6252
|
+
// adoption refusal means this side declined the salt for the life of the session and
|
|
6253
|
+
// reconnecting will NOT fix it. So the failure this line exists to close — the conversation
|
|
6254
|
+
// goes quiet, the explanation sits in a log nobody opens — was still live on the one branch
|
|
6255
|
+
// that never repairs itself.
|
|
6256
|
+
//
|
|
6257
|
+
// The guidance is passed by reference to the log's own text rather than duplicated: a second
|
|
6258
|
+
// copy is a second thing to keep true, and the log's version is the one that gets maintained.
|
|
6259
|
+
this.noteContentRefusal(agentName, sessionId, "content_hash_salt_unavailable", {
|
|
6260
|
+
impact: "this message could not be verified — the sender says it is salted and this side holds no salt for the session — so it was NOT ingested and NOT shown. This session will not auto-co-sign at close.",
|
|
6261
|
+
guidance: "If this side refused the salt because the session had already hashed messages, that is PERMANENT for this session and reconnecting will not fix it — close the session and start a new one. Otherwise the salt agreement re-runs on the next reconnect and this repairs itself. Check session.salt.adoption.refused in the log to tell which. The transcript up to here is intact either way.",
|
|
6262
|
+
});
|
|
6263
|
+
return { ok: false, reason: "content_hash_salt_unavailable" };
|
|
6264
|
+
}
|
|
5022
6265
|
const contentHashHex = Buffer.from(contentHash).toString("hex");
|
|
5023
6266
|
if (Buffer.from(computed).toString("hex") !== contentHashHex) {
|
|
5024
6267
|
this.#logger.warn("session.content.cross_check.failed", {
|
|
5025
6268
|
sessionId,
|
|
5026
6269
|
reason: "content_hash_mismatch",
|
|
6270
|
+
// WHICH algorithm the comparison ran under. Without it, a mismatch is unfalsifiable from the
|
|
6271
|
+
// log: an operator cannot tell "the bytes were altered" from "we checked it the wrong way".
|
|
6272
|
+
declaredAlg: algResolved.alg,
|
|
5027
6273
|
correlationId,
|
|
5028
6274
|
});
|
|
5029
6275
|
// M7-UPGRADE-002 (SI-002): a tamper makes this session's content unverifiable — the
|
|
5030
6276
|
// auto-acknowledge gate must never auto-co-sign it. The session stays alive (DOD-MSG-7),
|
|
5031
6277
|
// but the responder seal now requires the agent's explicit decision, not an auto-ack.
|
|
5032
|
-
this.#
|
|
6278
|
+
this.#markContentUnverifiable(agentName, sessionId, "tampered");
|
|
6279
|
+
// DOD-M15-REFUSED-INBOUND-SILENT-1. Deliberately does NOT include the content or the hashes:
|
|
6280
|
+
// it failed verification, and showing it is the injection path this cross-check closes.
|
|
6281
|
+
this.noteContentRefusal(agentName, sessionId, "content_hash_mismatch", {
|
|
6282
|
+
impact: "a message arrived whose bytes do not match the hash the sender committed to, so it was NOT ingested and NOT shown. This session will not auto-co-sign at close.",
|
|
6283
|
+
guidance: "Either the message was altered in transit or the sender's record is wrong. Ask the counterparty to resend. Do not close this session by auto-acknowledgement — seal it only by an explicit decision.",
|
|
6284
|
+
});
|
|
5033
6285
|
return { ok: false, reason: "content_hash_mismatch" };
|
|
5034
6286
|
}
|
|
5035
6287
|
const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
|
|
@@ -5183,12 +6435,36 @@ export class SessionNodeManager {
|
|
|
5183
6435
|
// verdict means the content is NOT delivered to the agent: it is not held, not buffered, and
|
|
5184
6436
|
// no leaf is appended — the message stays un-acked so the sender's TTF/park/retry redelivers
|
|
5185
6437
|
// it once the gateway is reachable again (DB-001 fail-closed: hold, never expose ungated).
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
6438
|
+
// DOD-DOC-SCREEN-CLASSIFY-1: a DOCUMENT frame skips the gateway's content screen HERE, and is
|
|
6439
|
+
// screened later on text instead of bytes. Every content step is inert or worse for one at this
|
|
6440
|
+
// point — the sanitizer's rewrites are deliberately discarded by the funnel below (rewriting a
|
|
6441
|
+
// signed envelope destroys it), and language/injection judge a UTF-8 decode of binary. Size stays
|
|
6442
|
+
// bounded twice (MAX_DOCUMENT_FRAME_BYTES at classify, the gate's own cap).
|
|
6443
|
+
//
|
|
6444
|
+
// WHAT IS TRADED, stated plainly: the screen skipped here is fail-CLOSED (a gateway that is down
|
|
6445
|
+
// returns a transient block, and the frame is held un-acked for redelivery). Its replacement —
|
|
6446
|
+
// the gate's in-process rules, then the semantic screen at `document-inbound.ts` step 7a-bis —
|
|
6447
|
+
// is fail-OPEN on that same condition, because holding document convergence hostage to an
|
|
6448
|
+
// optional layer breaks a layer that degrades by design. That degradation is LOGGED BY NAME
|
|
6449
|
+
// there (`document.inbound.screen.unavailable`); it is not silent, and it is not free.
|
|
6450
|
+
//
|
|
6451
|
+
// Logged by name so the skip is visible rather than assumed.
|
|
6452
|
+
const isDocFrame = this.#isDocumentFrame?.(content) === true;
|
|
6453
|
+
if (isDocFrame) {
|
|
6454
|
+
this.#logger.info("session.content.screen.skipped_document_frame", {
|
|
6455
|
+
sessionId,
|
|
6456
|
+
agentName,
|
|
6457
|
+
correlationId,
|
|
6458
|
+
});
|
|
6459
|
+
}
|
|
6460
|
+
const inboundVerdict = isDocFrame
|
|
6461
|
+
? { disposition: "allow", content }
|
|
6462
|
+
: await this.#securityGateway.screenInbound(content, {
|
|
6463
|
+
direction: "inbound",
|
|
6464
|
+
agentName,
|
|
6465
|
+
sessionId,
|
|
6466
|
+
correlationId,
|
|
6467
|
+
});
|
|
5192
6468
|
// M9 terminal-vs-transient split. A TERMINAL block (inboundVerdict.terminal) is a detector
|
|
5193
6469
|
// rejecting the CONTENT itself — a confident non-allowlisted language (IN-003), a high-score
|
|
5194
6470
|
// injection (IN-002), or an oversized payload (IN-001). The identical bytes would be rejected
|
|
@@ -5377,7 +6653,7 @@ export class SessionNodeManager {
|
|
|
5377
6653
|
// agent (screenedOut); a delivered message buffers + leafs via #appendVerifiedContent.
|
|
5378
6654
|
const leafIndex = terminalBlock
|
|
5379
6655
|
? this.appendSessionLeaf(agentName, sessionId, "msg", contentHashHex, correlationId).leafIndex
|
|
5380
|
-
: this.#appendVerifiedContent(agentName, sessionId, deliverContent, contentHashHex, senderPubkey, correlationId, content).leafIndex;
|
|
6656
|
+
: this.#appendVerifiedContent(agentName, sessionId, deliverContent, contentHashHex, senderPubkey, correlationId, content, verifiedAuthorship).leafIndex;
|
|
5381
6657
|
// DOD-COATTEND-1 (review F2): the plaintext failed to reach the transcript, and since Tier 1 the
|
|
5382
6658
|
// transcript IS the delivery path — so this message can never be handed to any session. Report
|
|
5383
6659
|
// the ingest as failed. Reporting `ok: true` here is what let a local SQLCipher failure surface,
|
|
@@ -5580,11 +6856,20 @@ export class SessionNodeManager {
|
|
|
5580
6856
|
// Review HIGH-2: NOT `(highWaterSeq + 1) - treeSize`. That subtraction silently assumes the
|
|
5581
6857
|
// relay's sequence space and this tree's index space count the same things, and they do not:
|
|
5582
6858
|
// `relay-node.ts` increments seq_counter for EVERY accepted leaf including CTRL (0x02), while
|
|
5583
|
-
//
|
|
5584
|
-
//
|
|
5585
|
-
//
|
|
5586
|
-
//
|
|
6859
|
+
// NOTHING APPENDS A CTRL LEAF TO THIS TREE — `submitSealLeaf` deliberately computes its root
|
|
6860
|
+
// without mutating the durable tree. So one seal ctrl leaf offsets the two spaces permanently,
|
|
6861
|
+
// and any msg witnessed afterwards would read as a missing leaf FOREVER. That is a false
|
|
6862
|
+
// positive, and a false positive here is worse than the bug it guards: it makes a healthy
|
|
5587
6863
|
// session unsealable, leaving force-abandon (no receipt) as the only exit. `seal-upgrade.ts`
|
|
6864
|
+
//
|
|
6865
|
+
// ⚠️ THIS SENTENCE USED TO READ *"`appendSessionLeaf` is only ever called with 'msg'"*, AND THAT
|
|
6866
|
+
// WAS FALSE WHEN IT WAS WRITTEN — corrected, not deleted, per review pass 1 F8. Line ~7730
|
|
6867
|
+
// appends "doc", and `WritableSessionTreeLeafKind` permits "ctrl" outright, so the type does not
|
|
6868
|
+
// enforce it either. Doc leaves are harmless to this subtraction because they are witnessed by
|
|
6869
|
+
// the relay too (LEAF_KIND_DOC) and counted in BOTH spaces; the load-bearing property is only
|
|
6870
|
+
// ever about CTRL. Stating it as "msg only" made a narrower claim than the code supports and a
|
|
6871
|
+
// stronger one than it holds — so a reader checking it would find a counter-example, conclude
|
|
6872
|
+
// the reasoning was stale, and be one step from "fixing" the subtraction back.
|
|
5588
6873
|
// already documents the same `leaf_count - 1` offset.
|
|
5589
6874
|
//
|
|
5590
6875
|
// `#witnessedSeq` answers the question directly instead of inferring it. It gains an entry when
|
|
@@ -5601,10 +6886,16 @@ export class SessionNodeManager {
|
|
|
5601
6886
|
for (const e of this.#heldContent.get(key)?.values() ?? [])
|
|
5602
6887
|
if (e.origin === "sent")
|
|
5603
6888
|
heldOwn++;
|
|
6889
|
+
// DOD-M15-DIVERGE-1: the third term, and the one that closes the asymmetry. `#diverged` is set
|
|
6890
|
+
// only where the parting is PROVEN — an ack came back behind our frontier — never where it is
|
|
6891
|
+
// merely suspected, because a gate that refuses a healthy session forever is worse than the bug
|
|
6892
|
+
// it guards: force-abandon, with no receipt, becomes the only exit.
|
|
6893
|
+
const diverged = this.#diverged.has(key);
|
|
5604
6894
|
return {
|
|
5605
|
-
ready: missingLeaves === 0 && heldCount === 0,
|
|
6895
|
+
ready: missingLeaves === 0 && heldCount === 0 && !diverged,
|
|
5606
6896
|
treeSize, highWaterSeq, heldCount, missingLeaves,
|
|
5607
6897
|
heldOwn, heldReceived: heldCount - heldOwn,
|
|
6898
|
+
diverged,
|
|
5608
6899
|
};
|
|
5609
6900
|
}
|
|
5610
6901
|
/**
|
|
@@ -5626,6 +6917,17 @@ export class SessionNodeManager {
|
|
|
5626
6917
|
sealReadinessView(agentName, sessionId) {
|
|
5627
6918
|
const key = this.#k(agentName, sessionId);
|
|
5628
6919
|
const r = this.sealReadiness(agentName, sessionId);
|
|
6920
|
+
// DOD-M15-DIVERGE-1: READ THIS BEFORE THE `!ready` BRANCH, and the order is load-bearing.
|
|
6921
|
+
// `diverged` is now a term in `ready`, so a diverged session reaches `!ready` — where it would
|
|
6922
|
+
// report `blocked` with awaitingArrival and heldBehindGap both ZERO, replacing an accurate,
|
|
6923
|
+
// specific answer with one that describes nothing and invites a retry that can never work.
|
|
6924
|
+
// Divergence is permanent and has its own state; the gap cases below are the ones that resolve.
|
|
6925
|
+
if (r.diverged) {
|
|
6926
|
+
// NOT `ready`. The tree is ahead of the relay's counter for good, so a close here signs a root
|
|
6927
|
+
// the counterparty answers `leaf_count_mismatch` to — terminal, and the receipt is gone. The
|
|
6928
|
+
// raw counters cannot see this: nothing is missing and nothing is held.
|
|
6929
|
+
return { state: "unknown", reason: "record_diverged_from_relay" };
|
|
6930
|
+
}
|
|
5629
6931
|
if (!r.ready) {
|
|
5630
6932
|
const oldestHeldMs = this.#oldestHeldMs(agentName, sessionId);
|
|
5631
6933
|
return {
|
|
@@ -5638,12 +6940,6 @@ export class SessionNodeManager {
|
|
|
5638
6940
|
oldestHeldMs,
|
|
5639
6941
|
};
|
|
5640
6942
|
}
|
|
5641
|
-
if (this.#diverged.has(key)) {
|
|
5642
|
-
// NOT `ready`. The tree is ahead of the relay's counter for good, so a close here signs a root
|
|
5643
|
-
// the counterparty answers `leaf_count_mismatch` to — terminal, and the receipt is gone. The
|
|
5644
|
-
// raw counters cannot see this: nothing is missing and nothing is held.
|
|
5645
|
-
return { state: "unknown", reason: "record_diverged_from_relay" };
|
|
5646
|
-
}
|
|
5647
6943
|
if (r.treeSize > 0 && !this.#orderingObserved.has(key)) {
|
|
5648
6944
|
return {
|
|
5649
6945
|
state: "unknown",
|
|
@@ -5788,7 +7084,17 @@ export class SessionNodeManager {
|
|
|
5788
7084
|
* CRDT is not a false positive, it is permanent divergence that both sides converge on and
|
|
5789
7085
|
* neither can see.
|
|
5790
7086
|
*/
|
|
5791
|
-
originalContent
|
|
7087
|
+
originalContent,
|
|
7088
|
+
/**
|
|
7089
|
+
* DOD-M15-SEALWIRE-1 bullet 5: threaded from `ingestReceivedContent`, which is the only place
|
|
7090
|
+
* that has it — `#recordFrameOrdering` verified this signature against the pubkey inside the
|
|
7091
|
+
* sender's own signed bytes and matched the signer to this session's counterparty. It reaches
|
|
7092
|
+
* the transcript row from here or not at all.
|
|
7093
|
+
*
|
|
7094
|
+
* Undefined on the held-release and soft-fallback paths; the row records that as
|
|
7095
|
+
* `local_session_state` rather than leaving it indistinguishable from a proven one.
|
|
7096
|
+
*/
|
|
7097
|
+
verifiedAuthorship) {
|
|
5792
7098
|
// M14 / DOD-DOC-INBOUND-2 — DOCUMENT FRAMES DIVERGE HERE, and the three-way split is the whole
|
|
5793
7099
|
// contract:
|
|
5794
7100
|
//
|
|
@@ -5838,7 +7144,11 @@ export class SessionNodeManager {
|
|
|
5838
7144
|
// DOD-LOG-1: persist the readable RECEIVED plaintext to the durable transcript, keyed by the
|
|
5839
7145
|
// canonical leaf sequence so it joins the committed hash chain (survives restart; INV-3 — the
|
|
5840
7146
|
// relay/directory never see this plaintext, only the hash).
|
|
5841
|
-
const durable = this.recordTranscriptMessage(agentName, sessionId, leafIndex, "received", content, correlationId
|
|
7147
|
+
const durable = this.recordTranscriptMessage(agentName, sessionId, leafIndex, "received", content, correlationId,
|
|
7148
|
+
// DOD-M15-SEALWIRE-1 bullet 5: present only when the ordering record verified AND the signer
|
|
7149
|
+
// matched this session's counterparty. Undefined on the soft fallback, which the row records
|
|
7150
|
+
// as `local_session_state` rather than leaving indistinguishable.
|
|
7151
|
+
verifiedAuthorship);
|
|
5842
7152
|
const recvKey = this.#k(agentName, sessionId);
|
|
5843
7153
|
if (!durable) {
|
|
5844
7154
|
// The leaf is committed and the plaintext is not. Delivery reads the transcript, so this
|
|
@@ -6178,6 +7488,33 @@ export class SessionNodeManager {
|
|
|
6178
7488
|
const nextExpected = this.getSessionTree(agentName, sessionId).size();
|
|
6179
7489
|
if (assignedSeq === undefined) {
|
|
6180
7490
|
const { leafIndex } = this.appendSessionLeaf(agentName, sessionId, kind, contentHashHex, correlationId);
|
|
7491
|
+
/**
|
|
7492
|
+
* ─── DOD-M15-UNWITNESSED-1(b): SAY IT HERE, where the code says the damage happens ────────
|
|
7493
|
+
*
|
|
7494
|
+
* This branch was entirely silent — no log, no flag — while the `position_behind_frontier`
|
|
7495
|
+
* branch twenty lines below logs at ERROR and its own comment says the loss occurred
|
|
7496
|
+
* *"at the unwitnessed append, not here."* So the system announced the consequence one send
|
|
7497
|
+
* later than it announced nothing about the cause.
|
|
7498
|
+
*
|
|
7499
|
+
* **This does NOT gate the seal, deliberately.** The DoD bar is explicit: do not gate on
|
|
7500
|
+
* suspicion, because a relay that has not witnessed a leaf YET is indistinguishable here from
|
|
7501
|
+
* one that never will, and refusing on the first would make a healthy session unsealable —
|
|
7502
|
+
* which is worse than the thing it guards. What was missing is not a refusal, it is the
|
|
7503
|
+
* ERROR at the moment the code already believes something was lost.
|
|
7504
|
+
*/
|
|
7505
|
+
this.#logger.error("session.tree.own_leaf_unwitnessed", {
|
|
7506
|
+
sessionId,
|
|
7507
|
+
leafIndex,
|
|
7508
|
+
kind,
|
|
7509
|
+
correlationId,
|
|
7510
|
+
impact: "this message was appended to the local record with NO relay witness, so the ordering " +
|
|
7511
|
+
"authority has no copy of it. The counterparty's tree cannot gain this leaf from the " +
|
|
7512
|
+
"relay, so the two records may no longer agree — and a bilateral seal needs them to.",
|
|
7513
|
+
guidance: "Usually the relay was briefly unreachable and the next send re-establishes ordering. If " +
|
|
7514
|
+
"it repeats, the relay is not carrying this session: check connectivity before closing, " +
|
|
7515
|
+
"because sealing on a record the counterparty cannot match produces a receipt only one " +
|
|
7516
|
+
"side can verify.",
|
|
7517
|
+
});
|
|
6181
7518
|
return { placed: true, leafIndex };
|
|
6182
7519
|
}
|
|
6183
7520
|
if (assignedSeq === nextExpected) {
|
|
@@ -6202,7 +7539,7 @@ export class SessionNodeManager {
|
|
|
6202
7539
|
agentName, sessionId, assignedSeq, nextExpected, contentHash: contentHashHex, correlationId,
|
|
6203
7540
|
impact: "this side's tree is ahead of the relay's counter, so the two can no longer agree on a root — the message is kept in the local record and this session can no longer be sealed bilaterally",
|
|
6204
7541
|
});
|
|
6205
|
-
this
|
|
7542
|
+
this.markSessionDiverged(agentName, sessionId);
|
|
6206
7543
|
const { leafIndex } = this.appendSessionLeaf(agentName, sessionId, kind, contentHashHex, correlationId);
|
|
6207
7544
|
return { placed: true, leafIndex, diverged: true };
|
|
6208
7545
|
}
|
|
@@ -6569,7 +7906,15 @@ export class SessionNodeManager {
|
|
|
6569
7906
|
* `unref`'d so an in-flight wait never keeps the daemon process (or a test runner)
|
|
6570
7907
|
* alive on its own.
|
|
6571
7908
|
*/
|
|
6572
|
-
|
|
7909
|
+
/**
|
|
7910
|
+
* `contentHashAlg` is `string | undefined`, NOT optional — B2b-1 pass-2 F1.
|
|
7911
|
+
*
|
|
7912
|
+
* The previous fix applied this shape to `#parkContent` and left its SIBLING on the same code path
|
|
7913
|
+
* optional, so dropping the argument here compiled clean and no test could see it — the TTF park
|
|
7914
|
+
* route reads this map, and a v2 envelope omits the field entirely whenever the value is `sha256`,
|
|
7915
|
+
* which is every value in play today. That re-opened the exact finding the fix closed.
|
|
7916
|
+
*/
|
|
7917
|
+
#trackAwaitingAck(agentName, sessionId, content, contentHash, correlationId, structure1Cbor, structure2Cbor, contentHashAlg) {
|
|
6573
7918
|
const hashHex = Buffer.from(contentHash).toString("hex");
|
|
6574
7919
|
const ackKey = this.#k(agentName, sessionId);
|
|
6575
7920
|
let bySession = this.#awaitingAck.get(ackKey);
|
|
@@ -6588,7 +7933,10 @@ export class SessionNodeManager {
|
|
|
6588
7933
|
timer.unref();
|
|
6589
7934
|
// DOD-MSG-4 (2b, review #1): retain the relay's ordering record so a TTF-triggered park carries
|
|
6590
7935
|
// it too (not only the direct-dial-fail park) — so a TTF-parked entry is self-ordering on recover.
|
|
6591
|
-
|
|
7936
|
+
// B2b-1 review F2: the algorithm rides WITH the entry. The TTF-expiry park route reads this map
|
|
7937
|
+
// minutes later, in-process, and without it that copy names nothing (= sha256) while the direct
|
|
7938
|
+
// frame named something else — the same message, two claims about what it is, no restart needed.
|
|
7939
|
+
bySession.set(hashHex, { timer, content, correlationId, structure1Cbor, structure2Cbor, contentHashAlg });
|
|
6592
7940
|
}
|
|
6593
7941
|
/**
|
|
6594
7942
|
* Resolve an awaiting-ACK entry on a `persisted` delivery ACK (AC-001/AC-002): cancel
|
|
@@ -6644,7 +7992,7 @@ export class SessionNodeManager {
|
|
|
6644
7992
|
// M12-P12 (review pass 2): the ordering record travels on THIS path too. It is in hand — the
|
|
6645
7993
|
// very next statement hands it to #parkContent — and a TTF row written without it re-parks in
|
|
6646
7994
|
// arrival order, which is the divergent-leaf-index failure the durable columns exist to stop.
|
|
6647
|
-
this.#onAwaitingTtf?.(agentName, sessionId, hashHex, entry.content, entry.structure1Cbor, entry.structure2Cbor);
|
|
7995
|
+
this.#onAwaitingTtf?.(agentName, sessionId, hashHex, entry.content, entry.structure1Cbor, entry.structure2Cbor, entry.contentHashAlg);
|
|
6648
7996
|
}
|
|
6649
7997
|
catch (err) {
|
|
6650
7998
|
this.#logger.error("content.park.backstop.failed", {
|
|
@@ -6658,7 +8006,8 @@ export class SessionNodeManager {
|
|
|
6658
8006
|
// Fire-and-forget: unlike sendContent's live caller, nothing here is awaiting an IPC response
|
|
6659
8007
|
// to shape (the TTF timer fires long after cello_send already returned) — the deposit's own
|
|
6660
8008
|
// success/failure logging inside #parkContent is the only observability this path needs.
|
|
6661
|
-
|
|
8009
|
+
// B2b-1 review F2 — the THIRD `#parkContent` caller, and the one that was left unthreaded.
|
|
8010
|
+
void this.#parkContent(agentName, sessionId, hashHex, entry.content, entry.structure1Cbor, entry.structure2Cbor, entry.contentHashAlg);
|
|
6662
8011
|
}
|
|
6663
8012
|
/**
|
|
6664
8013
|
* Send an unsigned `persisted` delivery ACK back to the sender over the same
|
|
@@ -6915,7 +8264,88 @@ export class SessionNodeManager {
|
|
|
6915
8264
|
contentHash: contentHashHex,
|
|
6916
8265
|
correlationId,
|
|
6917
8266
|
});
|
|
6918
|
-
|
|
8267
|
+
/**
|
|
8268
|
+
* DOD-M15-SEALWIRE-1 part B1 — RECOVERED-FROM-PARK CONTENT CARRIES NO ALGORITHM NAME, and that
|
|
8269
|
+
* is correct today rather than an oversight.
|
|
8270
|
+
*
|
|
8271
|
+
* The park envelope has no field for one, so this passes `undefined`, which resolves to
|
|
8272
|
+
* `sha256`. In part B1 that is exactly right and provably so: **no sender salts yet**, so every
|
|
8273
|
+
* parked entry in existence was hashed unsalted.
|
|
8274
|
+
*
|
|
8275
|
+
* ✅ FIXED IN PART B2a, at BOTH sites: here, and the independent verifier in `content-park.ts`.
|
|
8276
|
+
* The envelope carries the algorithm from v3 onward, and a v2 envelope's absent field resolves to
|
|
8277
|
+
* `sha256` — which is what a peer predating the field actually used. Every envelope this build
|
|
8278
|
+
* emits is still v2, because nothing salts yet.
|
|
8279
|
+
*
|
|
8280
|
+
* ─── AND THE REFUSAL DOES NOT HOLD — review F2 ────────────────────────────────────────────
|
|
8281
|
+
*
|
|
8282
|
+
* A direct-path refusal sends no delivery ACK, so the sender's TTF backstop parks the message
|
|
8283
|
+
* and it arrives here seconds later, where `undefined` means `sha256` and it may well succeed.
|
|
8284
|
+
* A frame refused BY NAME on one path is then accepted on the other, with nothing tying the two
|
|
8285
|
+
* together in the log — an operator sees an ERROR and, ten seconds on, a healthy delivery.
|
|
8286
|
+
*
|
|
8287
|
+
* That is not repaired by refusing here as well: today's park entry genuinely IS `sha256`, and
|
|
8288
|
+
* refusing it would drop good mail. What is wrong is the SILENCE, so the reconciliation is
|
|
8289
|
+
* logged instead — the two events become one story, and B2 removes the ambiguity for real by
|
|
8290
|
+
* putting the name in the envelope.
|
|
8291
|
+
*/
|
|
8292
|
+
/**
|
|
8293
|
+
* ⚠️ LOGGED AFTER THE INGEST, NOT BEFORE IT — review B2a F1, and the previous version of this
|
|
8294
|
+
* block ANNOUNCED A DELIVERY THAT DOES NOT HAPPEN.
|
|
8295
|
+
*
|
|
8296
|
+
* It fired on a memo hit and said *"the message is being delivered by the other route"*, which
|
|
8297
|
+
* was true-by-construction only while the park path passed `undefined` for the algorithm. Part
|
|
8298
|
+
* B2a made the park path carry `env.contentHashAlg` — so a peer that names an unreadable
|
|
8299
|
+
* algorithm on the direct path AND parks the same content as v3 with the same name is refused
|
|
8300
|
+
* AGAIN here, re-arms the memo, is never confirm-deleted, and repeats on every drain. An
|
|
8301
|
+
* unbounded stream of warnings asserting a delivery that never occurs, drowning the real
|
|
8302
|
+
* reconciliation when the sender eventually re-parks as v2.
|
|
8303
|
+
*
|
|
8304
|
+
* The claim is only sound once the ingest has actually succeeded, so it is made there.
|
|
8305
|
+
*/
|
|
8306
|
+
const memoKey = this.#k(agentName, sessionId);
|
|
8307
|
+
const priorDeclaredAlg = this.#unreadableAlgSeen.get(memoKey)?.get(contentHashHex);
|
|
8308
|
+
const result = await this.ingestReceivedContent(agentName, sessionId, env.content, contentHash, correlationId, recoveredSeq ?? undefined,
|
|
8309
|
+
// The envelope's own claim, verbatim — `undefined` on a v2 envelope, which resolves to
|
|
8310
|
+
// `sha256` and is exactly right for a peer that predates the field.
|
|
8311
|
+
env.contentHashAlg);
|
|
8312
|
+
/**
|
|
8313
|
+
* `ok` IS NOT "DELIVERED" — review B2a pass-2 F1, and this is the same class the F1 fix was
|
|
8314
|
+
* raised for, one predicate over.
|
|
8315
|
+
*
|
|
8316
|
+
* `ingestReceivedContent` returns `ok` in three shapes and only one is a delivery:
|
|
8317
|
+
* `{ok, held: true}` — buffered behind an ordering gap; not appended, not shown YET.
|
|
8318
|
+
* `{ok, screenedOut: true}` — leafed and PERMANENTLY never shown to the agent.
|
|
8319
|
+
* Announcing *"the message was delivered by the other route"* for either is a false all-clear on
|
|
8320
|
+
* the operator's one line about this message, and the memo is deleted in the same breath, so
|
|
8321
|
+
* nothing ever re-raises it.
|
|
8322
|
+
*
|
|
8323
|
+
* Leaving the memo ARMED on `held` is deliberate: the release path re-enters ingest, which is
|
|
8324
|
+
* when delivery actually happens, and the claim becomes true there.
|
|
8325
|
+
*
|
|
8326
|
+
* ⚠️ THE `screenedOut` CLAUSE IS UNREACHABLE TODAY and is kept anyway — said out loud so nobody
|
|
8327
|
+
* reads it as covered. A terminal inbound block needs a detector the shipping security gateway
|
|
8328
|
+
* does not wire, so it returns only `allow` and a fail-closed non-terminal `block`. Measured: a
|
|
8329
|
+
* mutant dropping just that clause SURVIVES the suite. It stays because the day a detector is
|
|
8330
|
+
* wired, this line is the difference between an all-clear and a permanent silent discard — and
|
|
8331
|
+
* finding that then costs more than the clause costs now.
|
|
8332
|
+
*/
|
|
8333
|
+
if (priorDeclaredAlg !== undefined && result.ok && result.held !== true && result.screenedOut !== true) {
|
|
8334
|
+
// Cleared ONLY on a real reconciliation. Clearing on the lookup (as this did) forgets the
|
|
8335
|
+
// refusal even when the recovery fails, so the next genuine reconciliation says nothing.
|
|
8336
|
+
const byHash = this.#unreadableAlgSeen.get(memoKey);
|
|
8337
|
+
byHash?.delete(contentHashHex);
|
|
8338
|
+
if (byHash && byHash.size === 0)
|
|
8339
|
+
this.#unreadableAlgSeen.delete(memoKey);
|
|
8340
|
+
this.#logger.warn("content.recover.alg_refusal_reconciled", {
|
|
8341
|
+
agentName, sessionId, correlationId,
|
|
8342
|
+
contentHash: contentHashHex,
|
|
8343
|
+
priorDeclaredAlg,
|
|
8344
|
+
recoveredAlg: env.contentHashAlg ?? "(absent → sha256)",
|
|
8345
|
+
impact: "THIS EXACT MESSAGE was refused on the direct path because it named an algorithm this build cannot read, and the same content has now been accepted via the relay park under an algorithm this build CAN read. The refusal did not hold: the message was delivered by the other route.",
|
|
8346
|
+
});
|
|
8347
|
+
}
|
|
8348
|
+
return result;
|
|
6919
8349
|
}
|
|
6920
8350
|
/**
|
|
6921
8351
|
* Review M4: bounded memo of parked entries we have already refused, so a mailbox stuffed with
|
|
@@ -6937,101 +8367,1349 @@ export class SessionNodeManager {
|
|
|
6937
8367
|
* record (the recover handler lives in daemon.ts, which has no access to the private method).
|
|
6938
8368
|
*/
|
|
6939
8369
|
recordOrderingRecord(agentName, sessionId, structure1Cbor, structure2Cbor, contentHash, correlationId) {
|
|
6940
|
-
|
|
6941
|
-
|
|
6942
|
-
|
|
8370
|
+
// DOD-M15-FRAME-1: the POSITION only, deliberately — this path does not act on `fatal`, and the
|
|
8371
|
+
// reason is that its identity proof is somewhere else and is already fail-closed. Parked content
|
|
8372
|
+
// arrives inside a sealed envelope carrying the sender's signature over
|
|
8373
|
+
// (session_id, recipient_pubkey, content_hash), and recovery already refuses a missing, bad, or
|
|
8374
|
+
// wrong-signer envelope. Adding a second, weaker refusal here on the ordering record would gate
|
|
8375
|
+
// mail retrieval on a record the relay-degraded path is allowed to omit, which is the
|
|
8376
|
+
// false-positive shape this unit is careful to avoid. The live direct path is where the ordering
|
|
8377
|
+
// record IS the proof, and that is where `fatal` is consumed.
|
|
8378
|
+
return this.#recordFrameOrdering(agentName, sessionId, structure1Cbor, structure2Cbor, contentHash, correlationId, "park").seq;
|
|
8379
|
+
}
|
|
8380
|
+
/**
|
|
8381
|
+
* DOD-M15-FRAME-1 — NARROWING THE GATE DOES NOT EVICT ANYONE ALREADY INSIDE. This does.
|
|
8382
|
+
*
|
|
8383
|
+
* libp2p consults the gater only when a connection is ESTABLISHED, so narrowing it never evicts a
|
|
8384
|
+
* peer already attached. That is why this sweep exists and why it cannot be replaced by the gate.
|
|
8385
|
+
*
|
|
8386
|
+
* A peer that attached early can therefore hold its connection open, still be attached when the
|
|
8387
|
+
* receiver is promoted, and be sitting there when the content protocol activates. DOD-M15-ASSIGN-1
|
|
8388
|
+
* shrank who can get that foothold — an unclaimed standing receiver now admits nobody inbound,
|
|
8389
|
+
* where it used to admit everyone — but it did not, and could not, change the constraint above.
|
|
8390
|
+
*
|
|
8391
|
+
* That is the foothold the whole injection path depends on — placed before the door narrows. The
|
|
8392
|
+
* frame-level gate above refuses what they send; this closes the connection they send it on, so
|
|
8393
|
+
* the stranger is not merely ineffective but gone, and is not sitting there for the next protocol
|
|
8394
|
+
* to activate.
|
|
8395
|
+
*
|
|
8396
|
+
* BEST-EFFORT BY CONSTRUCTION, and it must stay that way. A failure to hang up one peer must not
|
|
8397
|
+
* fail the session setup that is mid-flight — the frame gate is the load-bearing control and it
|
|
8398
|
+
* does not depend on this succeeding. Relay peers are exempt: they are on the OUTBOUND allowlist
|
|
8399
|
+
* because reservation refreshes ride them, and hanging one up would cost the agent its inbound
|
|
8400
|
+
* reachability to remove a peer that cannot speak the content protocol anyway.
|
|
8401
|
+
*/
|
|
8402
|
+
async #evictPeersOutsideGate(node, gater, sessionId, allowedPeerId, trigger) {
|
|
8403
|
+
let connections;
|
|
6943
8404
|
try {
|
|
6944
|
-
|
|
6945
|
-
const s2 = decode(structure2Cbor);
|
|
6946
|
-
const s1Hash = s1?.[1];
|
|
6947
|
-
const s1Pubkey = s1?.[2];
|
|
6948
|
-
const seq = typeof s2?.[0] === "number" ? s2[0] : -1;
|
|
6949
|
-
const s2Sig = s2?.[3];
|
|
6950
|
-
if (!(s1Hash instanceof Uint8Array) || !(s1Pubkey instanceof Uint8Array) || !(s2Sig instanceof Uint8Array) || seq < 1) {
|
|
6951
|
-
this.#logger.warn("session.content.ordering.malformed", { sessionId, correlationId });
|
|
6952
|
-
return null;
|
|
6953
|
-
}
|
|
6954
|
-
// The framed ordering record must bind to THIS content (its hash) — else it orders the wrong bytes.
|
|
6955
|
-
const contentHashHex = Buffer.from(contentHash).toString("hex");
|
|
6956
|
-
if (Buffer.from(s1Hash).toString("hex") !== contentHashHex) {
|
|
6957
|
-
this.#logger.warn("session.content.ordering.hash_mismatch", { sessionId, correlationId });
|
|
6958
|
-
return null;
|
|
6959
|
-
}
|
|
6960
|
-
// Verify the SENDER's Ed25519 signature over the exact signed bytes (structure1_cbor) — the same
|
|
6961
|
-
// check the relay performs. Proves the counterparty committed to this (content_hash @ sequence).
|
|
6962
|
-
if (!verify(s1Pubkey, structure1Cbor, s2Sig)) {
|
|
6963
|
-
this.#logger.warn("session.content.ordering.bad_signature", { sessionId, correlationId });
|
|
6964
|
-
return null;
|
|
6965
|
-
}
|
|
6966
|
-
// Sovereign-node cross-check: the signer MUST be THIS session's counterparty, not an unrelated
|
|
6967
|
-
// key. FAIL CLOSED (review L) — if the counterparty pubkey is unknown we cannot prove the signer,
|
|
6968
|
-
// so we do NOT trust the framed ordering record (fall back to the witness stream / arrival). The
|
|
6969
|
-
// "B does not trust the counterparty for ordering" invariant is non-negotiable; never fail open.
|
|
6970
|
-
// Review M1: compare BYTES, not hex strings — `counterparty_pubkey` is stored verbatim from the
|
|
6971
|
-
// IPC param and is never case-normalized, so a string compare would fail for a mixed-case
|
|
6972
|
-
// pubkey and silently strip the canonical ordering from every message in that session.
|
|
6973
|
-
const counterparty = this.getSessionRecord(agentName, sessionId)?.counterparty_pubkey;
|
|
6974
|
-
if (!pubkeyMatchesHex(s1Pubkey, counterparty)) {
|
|
6975
|
-
this.#logger.warn("session.content.ordering.wrong_signer", {
|
|
6976
|
-
sessionId,
|
|
6977
|
-
reason: counterparty ? "signer_not_counterparty" : "counterparty_unknown",
|
|
6978
|
-
correlationId,
|
|
6979
|
-
});
|
|
6980
|
-
return null;
|
|
6981
|
-
}
|
|
6982
|
-
// Verified — record the relay-assigned canonical sequence (1-based → 0-based leaf index) for the gate.
|
|
6983
|
-
this.recordWitnessedSequence(agentName, sessionId, contentHashHex, seq - 1);
|
|
6984
|
-
this.#logger.info("session.content.ordering.recorded", {
|
|
6985
|
-
sessionId,
|
|
6986
|
-
canonicalSeq: seq - 1,
|
|
6987
|
-
source,
|
|
6988
|
-
correlationId,
|
|
6989
|
-
});
|
|
6990
|
-
return seq - 1;
|
|
8405
|
+
connections = node.getConnections();
|
|
6991
8406
|
}
|
|
6992
8407
|
catch (err) {
|
|
6993
|
-
this.#logger.
|
|
6994
|
-
sessionId,
|
|
6995
|
-
error: err instanceof Error ? err.message : String(err),
|
|
6996
|
-
correlationId,
|
|
8408
|
+
this.#logger.debug("session.gate.evict.unavailable", {
|
|
8409
|
+
sessionId, trigger, error: extractErrorMessage(err),
|
|
6997
8410
|
});
|
|
8411
|
+
return;
|
|
6998
8412
|
}
|
|
6999
|
-
|
|
7000
|
-
|
|
8413
|
+
const toEvict = connections
|
|
8414
|
+
.map((c) => c.peerId)
|
|
8415
|
+
.filter((peerId) => peerId !== allowedPeerId && !gater.isAllowedOutboundPeer(peerId));
|
|
8416
|
+
/**
|
|
8417
|
+
* CONCURRENT AND CAPPED, because the count is ATTACKER-CONTROLLED (review F4).
|
|
8418
|
+
*
|
|
8419
|
+
* This runs inside `acceptSession`, before the session row is written, and the standing receiver
|
|
8420
|
+
* used to accept everyone (closed by DOD-M15-ASSIGN-1) — so opening N connections to an agent's advertised receiver used
|
|
8421
|
+
* to make every later session setup on that agent wait for N sequential graceful closes.
|
|
8422
|
+
* `hangUp` is libp2p's graceful close and takes no timeout, so the wait was unbounded in both
|
|
8423
|
+
* directions. Evicting an injection foothold must not itself become the way to stall an agent.
|
|
8424
|
+
*
|
|
8425
|
+
* The cap is a LOGGED truncation, never a silent one: what is left behind still cannot inject
|
|
8426
|
+
* (the frame gate refuses it and now hangs it up on first contact), and the next promotion
|
|
8427
|
+
* sweeps again — but an operator reading this needs to know the sweep did not finish.
|
|
8428
|
+
*/
|
|
8429
|
+
const EVICT_CAP = 32;
|
|
8430
|
+
const batch = toEvict.slice(0, EVICT_CAP);
|
|
8431
|
+
if (toEvict.length > batch.length) {
|
|
8432
|
+
this.#logger.warn("session.gate.evict.capped", {
|
|
8433
|
+
sessionId, trigger, attached: toEvict.length, evicting: batch.length,
|
|
8434
|
+
impact: "more peers were attached outside the gate than one promotion evicts; the rest keep their connections until a later sweep, and are refused and hung up by the frame gate if they speak",
|
|
8435
|
+
});
|
|
8436
|
+
}
|
|
8437
|
+
await Promise.allSettled(batch.map(async (peerId) => {
|
|
8438
|
+
try {
|
|
8439
|
+
await node.hangUp(peerId);
|
|
8440
|
+
this.#logger.warn("session.gate.evicted", {
|
|
8441
|
+
sessionId, trigger, evictedPeerId: peerId, allowedPeerId,
|
|
8442
|
+
impact: "a peer attached to this node before the session narrowed its gate was disconnected; libp2p does not re-run the gater against live connections, so it would otherwise have stayed attached when the content protocol activated",
|
|
8443
|
+
});
|
|
8444
|
+
}
|
|
8445
|
+
catch (err) {
|
|
8446
|
+
// Best-effort: the frame gate still refuses anything this peer sends, and now hangs it up.
|
|
8447
|
+
this.#logger.debug("session.gate.evict.failed", {
|
|
8448
|
+
sessionId, trigger, peerId, error: extractErrorMessage(err),
|
|
8449
|
+
});
|
|
8450
|
+
}
|
|
8451
|
+
}));
|
|
7001
8452
|
}
|
|
7002
|
-
|
|
7003
|
-
|
|
7004
|
-
|
|
7005
|
-
|
|
7006
|
-
|
|
7007
|
-
|
|
7008
|
-
|
|
7009
|
-
|
|
7010
|
-
|
|
7011
|
-
|
|
7012
|
-
|
|
7013
|
-
|
|
7014
|
-
|
|
7015
|
-
|
|
7016
|
-
|
|
7017
|
-
|
|
7018
|
-
|
|
7019
|
-
|
|
7020
|
-
|
|
7021
|
-
|
|
7022
|
-
|
|
7023
|
-
|
|
7024
|
-
|
|
7025
|
-
|
|
7026
|
-
|
|
7027
|
-
|
|
7028
|
-
|
|
7029
|
-
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
|
|
7034
|
-
|
|
8453
|
+
/**
|
|
8454
|
+
* DOD-M15-FRAME-1 — a proven identity failure ends the session, and says so as an OBSERVATION.
|
|
8455
|
+
*
|
|
8456
|
+
* SESSION-ENDING, NOT PER-MESSAGE. One frame that fails to verify against the expected
|
|
8457
|
+
* counterparty is not a bad message to drop while hoping the next is better — it is evidence
|
|
8458
|
+
* about the CONNECTION. Dropping the frame and continuing leaves the same peer able to try again
|
|
8459
|
+
* with a frame that omits the proof entirely.
|
|
8460
|
+
*
|
|
8461
|
+
* THE WORDING IS NOT A VERDICT, and that is deliberate rather than squeamish. The identical
|
|
8462
|
+
* signal comes from a real impersonation attempt and from our own infrastructure mishandling a
|
|
8463
|
+
* fallback — a relay bug, a bad deploy, an uncovered edge in the direct-connection failover. The
|
|
8464
|
+
* daemon cannot tell those apart from this signal, so it must not pretend to. This mirrors the
|
|
8465
|
+
* account-recovery pattern already in the codebase, which anchors a compromise window to logged
|
|
8466
|
+
* events and accepts that some evidence cannot separate misconduct from an innocent cause.
|
|
8467
|
+
*
|
|
8468
|
+
* IT MUST NEVER FEED A TRUST SIGNAL. An automatic reputation consequence driven by a signal this
|
|
8469
|
+
* ambiguous would let a hostile peer — or a bad deploy of ours — manufacture a mark against an
|
|
8470
|
+
* innocent counterparty. Recorded here because the absence of that wiring is a decision, not an
|
|
8471
|
+
* omission, and the next person to reach for it should find this comment first.
|
|
8472
|
+
*
|
|
8473
|
+
* The accusatory half stays local for the same reason. Freezing what THIS daemon trusts is always
|
|
8474
|
+
* safe unilaterally; asserting on the record that a counterparty misbehaved needs corroboration
|
|
8475
|
+
* from a party the accusing client does not control, which is `DOD-M15-CORROBORATE-1` (the relay
|
|
8476
|
+
* holds the sender's signed hash independently and never routes it through the receiver).
|
|
8477
|
+
*/
|
|
8478
|
+
/**
|
|
8479
|
+
* DOD-M15-SEALWIRE-1 bullet 6 (part A) — THE SALT AGREEMENT, the I/O half.
|
|
8480
|
+
*
|
|
8481
|
+
* The decisions live in `session-salt-agreement.ts` as a pure function; everything here is the
|
|
8482
|
+
* three things that function cannot do: read and write the durable row, put a frame on the wire,
|
|
8483
|
+
* and stop a session.
|
|
8484
|
+
*
|
|
8485
|
+
* ─── Where a contribution may travel, and it is the one rule that cannot be fixed later ───────
|
|
8486
|
+
*
|
|
8487
|
+
* `/cello/content/1.0.0` ONLY. It rides circuit-relay-v2 carrying its own Noise session, so a
|
|
8488
|
+
* relay forwarding it sees ciphertext. It must never be added to `session_offer` /
|
|
8489
|
+
* `session_offer_accept` or anything a DIRECTORY brokers — and that is the trap, because the only
|
|
8490
|
+
* round trip at session open today runs on the directory's signaling stream, which makes it the
|
|
8491
|
+
* obvious place to put one. A session that shipped it there could not be repaired: the relay
|
|
8492
|
+
* would already hold the salt and every hash it protects.
|
|
8493
|
+
*/
|
|
8494
|
+
#saltContributionFor(agentName, sessionId) {
|
|
8495
|
+
const key = this.#k(agentName, sessionId);
|
|
8496
|
+
let contribution = this.#saltContributions.get(key);
|
|
8497
|
+
if (!contribution) {
|
|
8498
|
+
contribution = generateSaltContribution();
|
|
8499
|
+
this.#saltContributions.set(key, contribution);
|
|
8500
|
+
}
|
|
8501
|
+
return contribution;
|
|
8502
|
+
}
|
|
8503
|
+
/**
|
|
8504
|
+
* Our half for a session, **without minting one** — review F1, and the distinction is the whole
|
|
8505
|
+
* safety of the repair.
|
|
8506
|
+
*
|
|
8507
|
+
* A session that already holds a salt must never mint a fresh half. If it did, the repair would
|
|
8508
|
+
* offer the peer a half the stored salt was NOT derived from, they would compute a different salt,
|
|
8509
|
+
* and both sides would believe they had agreed — silently, which is the one outcome worse than
|
|
8510
|
+
* refusing. So `null` from here means exactly "we hold a salt and the half behind it is gone",
|
|
8511
|
+
* and that is the only state the agreement is allowed to call unrepairable.
|
|
8512
|
+
*/
|
|
8513
|
+
#ownSaltHalf(agentName, sessionId) {
|
|
8514
|
+
return this.#saltContributions.get(this.#k(agentName, sessionId)) ?? null;
|
|
8515
|
+
}
|
|
8516
|
+
/**
|
|
8517
|
+
* Test seam: force this session's own salt half, so the LOCAL-defect path is reachable.
|
|
8518
|
+
*
|
|
8519
|
+
* `generateSaltContribution` cannot produce a degenerate half, which is the point of it — so the
|
|
8520
|
+
* only way to exercise "our own random source is broken" end-to-end is to stand in for the broken
|
|
8521
|
+
* source. Named `…ForTest` like every other seam in this file, and it writes the same map
|
|
8522
|
+
* production writes rather than a parallel one, so a test cannot pass against state the daemon
|
|
8523
|
+
* never reads.
|
|
8524
|
+
*/
|
|
8525
|
+
/**
|
|
8526
|
+
* Test seam: run the auto-acknowledge gate, exactly as the counterparty's SEAL ctrl leaf does.
|
|
8527
|
+
*
|
|
8528
|
+
* `DOD-M15-SEALWIRE-1` part B1, review F-B. The gate has ONE production call site — inside the
|
|
8529
|
+
* relay leaf handler, behind `leaf_kind === CTRL && !authored_by_us` — so reaching it from a test
|
|
8530
|
+
* needs a live relay client delivering a real ctrl leaf. The consequence was measured: my
|
|
8531
|
+
* "tampered never downgrades" test wrapped its decisive assertion in
|
|
8532
|
+
* `if (skipped.length > 0)`, which was ALWAYS FALSE, so the whole `content_tamper` vs
|
|
8533
|
+
* `content_verification_unavailable` branch had no coverage anywhere in the repo and two mutants
|
|
8534
|
+
* on it survived the full gate.
|
|
8535
|
+
*
|
|
8536
|
+
* It calls the REAL private method rather than reproducing its logic, so a test cannot pass
|
|
8537
|
+
* against a decision production does not make.
|
|
8538
|
+
*/
|
|
8539
|
+
runAutoAcknowledgeGateForTest(agentName, sessionId, correlationId = "test") {
|
|
8540
|
+
this.#maybeAutoAcknowledgeSeal(agentName, sessionId, correlationId);
|
|
8541
|
+
}
|
|
8542
|
+
setSaltContributionForTest(agentName, sessionId, contribution) {
|
|
8543
|
+
this.#saltContributions.set(this.#k(agentName, sessionId), contribution);
|
|
8544
|
+
}
|
|
8545
|
+
/**
|
|
8546
|
+
* Test seam: drop this session's own half while leaving the stored salt in place — the state every
|
|
8547
|
+
* teardown produces, because `#evictSessionCaches` clears the map and the row survives.
|
|
8548
|
+
*
|
|
8549
|
+
* It clears the SAME map the eviction clears rather than a stand-in, so a test cannot pass against
|
|
8550
|
+
* a state the daemon never reaches. Reproducing it through a real teardown/revive would also drag
|
|
8551
|
+
* in node rebuild and relay reconnection, none of which this is about.
|
|
8552
|
+
*/
|
|
8553
|
+
forgetSaltContributionForTest(agentName, sessionId) {
|
|
8554
|
+
this.#saltContributions.delete(this.#k(agentName, sessionId));
|
|
8555
|
+
}
|
|
8556
|
+
/**
|
|
8557
|
+
* The pair the agreement reasons over: our salt, and the half that goes with it.
|
|
8558
|
+
*
|
|
8559
|
+
* Minting is deliberate and conditional. With NO salt we are certain to need a half — to offer, or
|
|
8560
|
+
* to derive with — so minting here is what makes the exchange work at all. WITH a salt we must
|
|
8561
|
+
* never mint; see `#ownSaltHalf`.
|
|
8562
|
+
*/
|
|
8563
|
+
#saltState(agentName, sessionId) {
|
|
8564
|
+
const ownSalt = this.#getSessionSalt(agentName, sessionId);
|
|
8565
|
+
return {
|
|
8566
|
+
ownSalt,
|
|
8567
|
+
ownContribution: ownSalt
|
|
8568
|
+
? this.#ownSaltHalf(agentName, sessionId)
|
|
8569
|
+
: this.#saltContributionFor(agentName, sessionId),
|
|
8570
|
+
};
|
|
8571
|
+
}
|
|
8572
|
+
/**
|
|
8573
|
+
* THE ONE PLACE THAT DECIDES HOW A SESSION'S OUTBOUND CONTENT IS HASHED —
|
|
8574
|
+
* `DOD-M15-SEALWIRE-1` part B2b.
|
|
8575
|
+
*
|
|
8576
|
+
* Returns the hash AND the algorithm that produced it, together, because the two must not be
|
|
8577
|
+
* decided separately. `wire-content-hash.ts` exists for exactly this reason and says so in its own
|
|
8578
|
+
* header: the expression was written out at five call sites, the two added last got it wrong, and
|
|
8579
|
+
* the failure was invisible — *"the send succeeds, `parked: false`, the sender's log says the frame
|
|
8580
|
+
* left, and the receiver discards it at the authenticity check."* It took two real daemons.
|
|
8581
|
+
*
|
|
8582
|
+
* There are FOUR outbound sites (`session-content-handlers.ts`, two in `daemon.ts`,
|
|
8583
|
+
* `document-delivery-transport.ts`). Once salting is switchable, each of them independently
|
|
8584
|
+
* deciding whether to salt is that defect again with a worse failure mode — a message hashed one
|
|
8585
|
+
* way and LABELLED another is refused by every peer, including a correct one.
|
|
8586
|
+
*
|
|
8587
|
+
* ⚠️ ASYNC, AND THAT IS THE POINT — B2b-2 constraint 2, not an implementation detail.
|
|
8588
|
+
*
|
|
8589
|
+
* The agreement is in flight while the operator composes their first message. Hash without waiting
|
|
8590
|
+
* and it comes out unsalted, and that first unsalted hash closes adoption for the LIFE of the
|
|
8591
|
+
* session (Decision #8, unit 1). Every session would fall back permanently while every log line
|
|
8592
|
+
* about it stayed true — the feature present, wired, tested, and never once reached.
|
|
8593
|
+
*
|
|
8594
|
+
* The wait lives HERE rather than at the four call sites for the same reason `contentHashAlg` is a
|
|
8595
|
+
* required parameter rather than a defaulted one: a site that forgets it must fail to compile. A
|
|
8596
|
+
* caller that drops the `await` gets a `Promise` where bytes belong, which is a typecheck error;
|
|
8597
|
+
* a caller that forgot to call a separate `awaitSaltSettled()` would silently send unsalted.
|
|
8598
|
+
*/
|
|
8599
|
+
async contentHashForSession(agentName, sessionId, content) {
|
|
8600
|
+
const { salt, reason } = await this.#saltForHashing(agentName, sessionId);
|
|
8601
|
+
if (salt !== null) {
|
|
8602
|
+
const alg = CONTENT_HASH_ALGS.HMAC_SALT_V1;
|
|
8603
|
+
return { hash: contentHashFor(content, { alg, salt }), alg };
|
|
8604
|
+
}
|
|
8605
|
+
/**
|
|
8606
|
+
* ⚠️ MARKED BEFORE THE HASH IS RETURNED, and this closes a window the row cannot see.
|
|
8607
|
+
*
|
|
8608
|
+
* `#saltAdoptionClosed` counts leaves, held content and in-flight sends. For the FIRST message of
|
|
8609
|
+
* a session none of the three exists at this moment — the leaf lands after `sendContent` returns,
|
|
8610
|
+
* which is a network round trip later. A peer contribution arriving in that gap would be adopted,
|
|
8611
|
+
* and the message already on the wire would become the single unsalted leaf in an otherwise
|
|
8612
|
+
* salted transcript: the exact split Decision #8 forbids, reached by the one route every count
|
|
8613
|
+
* reads as empty.
|
|
8614
|
+
*
|
|
8615
|
+
* In memory rather than in a column, and that is sufficient rather than convenient: if this
|
|
8616
|
+
* process survives, the flag holds; if it does not, the message it protects either reached a
|
|
8617
|
+
* durable form (leaf, held row, queued row — all of which the counts see) or never left, in which
|
|
8618
|
+
* case there is nothing to split. The one remaining case — hashed, sent, and no local record —
|
|
8619
|
+
* is covered from the other side, because the peer DID leaf it and closes its own adoption, and
|
|
8620
|
+
* the wire state added in unit 1 tells us so.
|
|
8621
|
+
*/
|
|
8622
|
+
/**
|
|
8623
|
+
* ⚠️ NOT FOR A TORN-DOWN SESSION — review Finding 5. `#evictSessionCaches` settles the wait and
|
|
8624
|
+
* clears both of these sets; a `.add()` afterwards re-populates a map whose eviction has already
|
|
8625
|
+
* run, and the entries then outlive the session they describe. There is also nothing to protect:
|
|
8626
|
+
* a session that no longer exists cannot adopt a salt or split a transcript.
|
|
8627
|
+
*/
|
|
8628
|
+
if (reason !== UNSALTED_REASONS.SESSION_TORN_DOWN) {
|
|
8629
|
+
/**
|
|
8630
|
+
* ⚠️ A COUNT, NOT A BIT — review pass 2, F1 (HIGH). It was a `Set`, and that made it ONE FLAG
|
|
8631
|
+
* PER SESSION for a fact that is per MESSAGE.
|
|
8632
|
+
*
|
|
8633
|
+
* The `sibling_send_in_flight` refusal path exists precisely when another connection is
|
|
8634
|
+
* mid-send with an unsalted hash it computed itself — and `sendContent` awaits a full relay
|
|
8635
|
+
* round trip before `#trackAwaitingAck` records anything. So: connection A hashes and sets the
|
|
8636
|
+
* flag; A enters that round trip, visible in no count; connection B hashes, sees A's claim,
|
|
8637
|
+
* refuses, and calls `abandonUnsaltedHash` — **deleting the flag A is still relying on.** The
|
|
8638
|
+
* frontier then reads entirely empty, a salt frame arriving in that window is adopted, and A's
|
|
8639
|
+
* message lands as leaf 0 hashed sha256 in a session that hashes everything after it under
|
|
8640
|
+
* HMAC.
|
|
8641
|
+
*
|
|
8642
|
+
* That is the split transcript this unit exists to prevent, through a window a relay round
|
|
8643
|
+
* trip wide. A count makes each in-flight hash hold its own claim.
|
|
8644
|
+
*/
|
|
8645
|
+
const key = this.#k(agentName, sessionId);
|
|
8646
|
+
this.#hashedWithoutSalt.set(key, (this.#hashedWithoutSalt.get(key) ?? 0) + 1);
|
|
8647
|
+
}
|
|
8648
|
+
/**
|
|
8649
|
+
* NO `??` DEFAULT — review pass 2, F6. It read `reason ?? ADOPTION_CLOSED_LOCALLY`, which is the
|
|
8650
|
+
* shape the closed set was built to eliminate: a seventh return path forgetting its reason would
|
|
8651
|
+
* have been silently labelled *"you already hashed"* and inherited guidance about a frontier that
|
|
8652
|
+
* never moved. `#saltForHashing` returns a discriminated union now, so a null salt without a
|
|
8653
|
+
* reason does not compile.
|
|
8654
|
+
*/
|
|
8655
|
+
this.#announceUnsaltedOnce(agentName, sessionId, reason);
|
|
8656
|
+
const alg = CONTENT_HASH_ALGS.SHA256;
|
|
8657
|
+
return { hash: contentHashFor(content, { alg, salt: null }), alg };
|
|
8658
|
+
}
|
|
8659
|
+
/**
|
|
8660
|
+
* The salt to hash this session's next message under, waiting for a pending agreement if one is
|
|
8661
|
+
* genuinely in flight — B2b-2 constraints 2 and 5.
|
|
8662
|
+
*
|
|
8663
|
+
* Three exits, and the order matters:
|
|
8664
|
+
*
|
|
8665
|
+
* 1. We already hold one. No wait, ever.
|
|
8666
|
+
* 2. Adoption is closed — this session has hashed or leafed something already, so a salt could
|
|
8667
|
+
* never be adopted now even if one arrived. Waiting would be waiting for a value we would
|
|
8668
|
+
* then have to refuse.
|
|
8669
|
+
* 3. Nothing is pending. **This is the park-only case (constraint 5)**: the announcement hangs
|
|
8670
|
+
* off `onPeerConnect`, an offline counterparty never connects, so no agreement was ever
|
|
8671
|
+
* started. Waiting the full bound there pauses every message to an offline peer and falls
|
|
8672
|
+
* back anyway — a stall bought for nothing.
|
|
8673
|
+
*
|
|
8674
|
+
* Only a session with an agreement actually in flight waits, and only until it settles or the
|
|
8675
|
+
* bound expires.
|
|
8676
|
+
*/
|
|
8677
|
+
async #saltForHashing(agentName, sessionId) {
|
|
8678
|
+
const held = this.#getSessionSalt(agentName, sessionId);
|
|
8679
|
+
if (held !== null)
|
|
8680
|
+
return { salt: held };
|
|
8681
|
+
const key = this.#k(agentName, sessionId);
|
|
8682
|
+
if (this.#saltAdoptionClosed(agentName, sessionId).closed) {
|
|
8683
|
+
return { salt: null, reason: UNSALTED_REASONS.ADOPTION_CLOSED_LOCALLY };
|
|
8684
|
+
}
|
|
8685
|
+
const pending = this.#saltPending.get(key);
|
|
8686
|
+
if (pending === undefined) {
|
|
8687
|
+
// An agreement that already ENDED is not an agreement that never started. Only the second is
|
|
8688
|
+
// "your counterparty was not connected", and only an absent entry means it.
|
|
8689
|
+
const last = this.#saltLastOutcome.get(key);
|
|
8690
|
+
if (last !== undefined)
|
|
8691
|
+
return { salt: null, reason: this.#reasonForOutcome(last) };
|
|
8692
|
+
return { salt: null, reason: UNSALTED_REASONS.NO_AGREEMENT_STARTED };
|
|
8693
|
+
}
|
|
8694
|
+
const settled = await pending.settled;
|
|
8695
|
+
if (settled === "agreed") {
|
|
8696
|
+
const agreed = this.#getSessionSalt(agentName, sessionId);
|
|
8697
|
+
/**
|
|
8698
|
+
* A settled-`agreed` that reads back NULL is a READ failure, not a persist failure — pass 2,
|
|
8699
|
+
* F4. `persist_failed` has its own outcome now, so the only way to arrive here empty is
|
|
8700
|
+
* `#getSessionSalt` returning null after the salt was stored: a throwing read, or a
|
|
8701
|
+
* wrong-width row, with the cache evicted in the microtask between settle and resume. Rare —
|
|
8702
|
+
* and labelling it `our_persist_failed` sent the operator to look for a
|
|
8703
|
+
* `session.salt.persist.failed` line that will not be there.
|
|
8704
|
+
*/
|
|
8705
|
+
return agreed !== null
|
|
8706
|
+
? { salt: agreed }
|
|
8707
|
+
: { salt: null, reason: UNSALTED_REASONS.OUR_READ_FAILED };
|
|
8708
|
+
}
|
|
8709
|
+
if (settled === "announce_failed") {
|
|
8710
|
+
return { salt: null, reason: UNSALTED_REASONS.ANNOUNCE_FAILED };
|
|
8711
|
+
}
|
|
8712
|
+
if (settled === "persist_failed") {
|
|
8713
|
+
// Named separately from the timeout on purpose: the peer answered in time and OUR write
|
|
8714
|
+
// failed, so nothing about their build is involved and sending the operator there wastes them.
|
|
8715
|
+
return { salt: null, reason: UNSALTED_REASONS.OUR_PERSIST_FAILED };
|
|
8716
|
+
}
|
|
8717
|
+
if (settled === "closed") {
|
|
8718
|
+
/**
|
|
8719
|
+
* Two very different things reach `closed`, and only one of them is about the counterparty.
|
|
8720
|
+
*
|
|
8721
|
+
* `#handleSaltFrame`'s terminal branch — the peer told us it cannot adopt — is a settled
|
|
8722
|
+
* bilateral outcome and the session is fine. `#evictSessionCaches` — this session is being
|
|
8723
|
+
* torn down underneath us — is not: there is no session left to be unsalted, and a caller that
|
|
8724
|
+
* marks `#hashedWithoutSalt` for it re-populates a map whose eviction has already run
|
|
8725
|
+
* (review Finding 5). `#saltPending` is gone by the time we look, so the live node is what
|
|
8726
|
+
* distinguishes them.
|
|
8727
|
+
*/
|
|
8728
|
+
return {
|
|
8729
|
+
salt: null,
|
|
8730
|
+
reason: this.#activeNodes.has(key)
|
|
8731
|
+
? UNSALTED_REASONS.PEER_CLOSED_ADOPTION
|
|
8732
|
+
: UNSALTED_REASONS.SESSION_TORN_DOWN,
|
|
8733
|
+
};
|
|
8734
|
+
}
|
|
8735
|
+
if (settled === "timeout") {
|
|
8736
|
+
/**
|
|
8737
|
+
* A DECISION, NOT A RETRY. Logged once, here, because this is the moment the session became
|
|
8738
|
+
* permanently unsalted — and an operator reading a later `session.content.unsalted` needs to
|
|
8739
|
+
* be able to find out WHY this session has no salt when their others do.
|
|
8740
|
+
*/
|
|
8741
|
+
this.#logger.warn("session.salt.agreement.timeout", {
|
|
8742
|
+
agentName, sessionId, waitedMs: pending.boundMs,
|
|
8743
|
+
impact: "the counterparty did not answer the salt agreement in time, so this session is unsalted FOR ITS LIFE — the message is being sent now rather than held any longer. Nothing is lost and nothing is degraded relative to any shipped release.",
|
|
8744
|
+
// Review F4: `session.salt.persist.failed` reaches this same timeout by a completely
|
|
8745
|
+
// different route — the peer answered promptly and OUR OWN write failed, so we returned
|
|
8746
|
+
// before announcing and nothing came back. Omitting it sent that operator to ask their
|
|
8747
|
+
// counterparty about a version mismatch that was never involved.
|
|
8748
|
+
guidance: "Most often the counterparty is on a build that predates the salt agreement, in which case this is expected and permanent for this session — a newer one will agree normally. If you know they are on the same version, look for session.salt.persist.failed on THIS side first (our own write failing produces this same timeout), then session.salt.announce.failed on either side.",
|
|
8749
|
+
});
|
|
8750
|
+
return { salt: null, reason: UNSALTED_REASONS.AGREEMENT_TIMED_OUT };
|
|
8751
|
+
}
|
|
8752
|
+
return { salt: null, reason: UNSALTED_REASONS.AGREEMENT_TIMED_OUT };
|
|
8753
|
+
}
|
|
8754
|
+
/**
|
|
8755
|
+
* Decision #15's fallback announcement — ONCE per session, never per message.
|
|
8756
|
+
*
|
|
8757
|
+
* A warning that fires on every message of every unsalted session is not a signal, it is a reason
|
|
8758
|
+
* to build a filter; and the operator who filters it also filters the one session where it meant
|
|
8759
|
+
* something. Stated once, with what the session actually loses.
|
|
8760
|
+
*/
|
|
8761
|
+
#announceUnsaltedOnce(agentName, sessionId, reason) {
|
|
8762
|
+
const key = this.#k(agentName, sessionId);
|
|
8763
|
+
if (this.#unsaltedAnnounced.has(key))
|
|
8764
|
+
return;
|
|
8765
|
+
this.#unsaltedAnnounced.add(key);
|
|
8766
|
+
this.#logger.info("session.content.unsalted", {
|
|
8767
|
+
agentName, sessionId,
|
|
8768
|
+
// The REASON is the field that makes this line diagnosable, and it was the missing one. The
|
|
8769
|
+
// impact is the same for all six; what to do about it is not.
|
|
8770
|
+
reason,
|
|
8771
|
+
impact: "this session hashes its messages the way every build before this feature did. Nothing is degraded relative to any shipped release and no message is affected — it only means a relay holding the hashes could confirm a guess at a short message in THIS conversation, which a salt would have prevented.",
|
|
8772
|
+
guidance: UNSALTED_GUIDANCE[reason],
|
|
8773
|
+
});
|
|
8774
|
+
}
|
|
8775
|
+
/**
|
|
8776
|
+
* Register that a salt agreement is IN FLIGHT for this session, so the first send waits for it.
|
|
8777
|
+
*
|
|
8778
|
+
* Called where we announce our own state — not at session creation. That distinction is
|
|
8779
|
+
* constraint 5: an agreement exists to be waited for only once a frame has actually gone out.
|
|
8780
|
+
*/
|
|
8781
|
+
#markSaltPending(agentName, sessionId, boundMs = SALT_AGREEMENT_WAIT_MS) {
|
|
8782
|
+
const key = this.#k(agentName, sessionId);
|
|
8783
|
+
if (this.#saltPending.has(key))
|
|
8784
|
+
return;
|
|
8785
|
+
let resolve = () => { };
|
|
8786
|
+
const settled = new Promise((r) => { resolve = r; });
|
|
8787
|
+
const timer = setTimeout(() => this.#settleSaltPending(agentName, sessionId, "timeout"), boundMs);
|
|
8788
|
+
// The daemon must be able to exit with this outstanding — a pending agreement is not a reason to
|
|
8789
|
+
// hold the process open.
|
|
8790
|
+
if (typeof timer.unref === "function")
|
|
8791
|
+
timer.unref();
|
|
8792
|
+
this.#saltPending.set(key, { settled, resolve, timer, boundMs });
|
|
8793
|
+
}
|
|
8794
|
+
/**
|
|
8795
|
+
* ONE mapping from a settled outcome to the operator-facing reason, so the send that WAITED and the
|
|
8796
|
+
* send that arrived afterwards cannot disagree about what happened.
|
|
8797
|
+
*/
|
|
8798
|
+
#reasonForOutcome(outcome) {
|
|
8799
|
+
if (outcome === "announce_failed")
|
|
8800
|
+
return UNSALTED_REASONS.ANNOUNCE_FAILED;
|
|
8801
|
+
if (outcome === "persist_failed")
|
|
8802
|
+
return UNSALTED_REASONS.OUR_PERSIST_FAILED;
|
|
8803
|
+
if (outcome === "closed")
|
|
8804
|
+
return UNSALTED_REASONS.PEER_CLOSED_ADOPTION;
|
|
8805
|
+
return UNSALTED_REASONS.AGREEMENT_TIMED_OUT;
|
|
8806
|
+
}
|
|
8807
|
+
/** Resolve a pending agreement. Idempotent: the first outcome wins and the timer is cleared. */
|
|
8808
|
+
#settleSaltPending(agentName, sessionId, outcome) {
|
|
8809
|
+
const key = this.#k(agentName, sessionId);
|
|
8810
|
+
const pending = this.#saltPending.get(key);
|
|
8811
|
+
if (pending === undefined)
|
|
8812
|
+
return;
|
|
8813
|
+
this.#saltPending.delete(key);
|
|
8814
|
+
// `agreed` is not recorded: the salt itself is the record, and `#getSessionSalt` answers first.
|
|
8815
|
+
if (outcome !== "agreed")
|
|
8816
|
+
this.#saltLastOutcome.set(key, outcome);
|
|
8817
|
+
clearTimeout(pending.timer);
|
|
8818
|
+
pending.resolve(outcome);
|
|
8819
|
+
}
|
|
8820
|
+
/**
|
|
8821
|
+
* THIS SESSION'S UNSALTED HASH NEVER BECAME A MESSAGE — release the permanent closure it caused.
|
|
8822
|
+
*
|
|
8823
|
+
* ⚠️ REVIEW FINDING 3, and it is the opposite of the direction the flag was written to defend.
|
|
8824
|
+
* `#hashedWithoutSalt` closes adoption at hash time, because for a session's first message the
|
|
8825
|
+
* leaf is a network round trip away and every frontier count reads zero in between. Correct — but
|
|
8826
|
+
* `cello_send` has three paths that compute the hash and then produce NOTHING: a sibling send
|
|
8827
|
+
* holding the in-flight claim, the frontier moving under the send, and a non-durable send failure
|
|
8828
|
+
* whose bytes go to a queue with no production consumer.
|
|
8829
|
+
*
|
|
8830
|
+
* In all three the session was permanently unsalted for a message that exists nowhere: no leaf, no
|
|
8831
|
+
* wire, no copy at the peer. And B2b-2 made two of them MORE likely on a first message, because
|
|
8832
|
+
* the five-second wait widens the very window the frontier re-check is watching.
|
|
8833
|
+
*
|
|
8834
|
+
* Only safe because it is called on paths that provably sent nothing. It deliberately does NOT
|
|
8835
|
+
* clear `#unsaltedAnnounced`: the announcement was true when it fired and re-announcing on the
|
|
8836
|
+
* retry would be the per-message flood Decision #15 forbids.
|
|
8837
|
+
*
|
|
8838
|
+
* ─── THREE OTHER SITES HASH AND MAY SEND NOTHING, AND ARE EXEMPT ON PURPOSE (pass 2, F8) ──────
|
|
8839
|
+
*
|
|
8840
|
+
* `daemon.ts`'s one-shot rejection and away reply, and `document-delivery-transport.ts`'s frame
|
|
8841
|
+
* send, can all fail after hashing. None of them needs to abandon, and the reason is the same in
|
|
8842
|
+
* each: every one is a REPLY. The inbound message that triggered it has already been leafed on
|
|
8843
|
+
* this side, so `#saltAdoptionClosed` is already closed by the leaf count and would stay closed
|
|
8844
|
+
* whatever this flag said. Calling abandon there would be a no-op that looks like a guarantee.
|
|
8845
|
+
*
|
|
8846
|
+
* Written down rather than left to be re-derived: the next reader's first question is why the
|
|
8847
|
+
* list is three and not six.
|
|
8848
|
+
*/
|
|
8849
|
+
abandonUnsaltedHash(agentName, sessionId) {
|
|
8850
|
+
const key = this.#k(agentName, sessionId);
|
|
8851
|
+
const held = this.#hashedWithoutSalt.get(key) ?? 0;
|
|
8852
|
+
if (held === 0)
|
|
8853
|
+
return;
|
|
8854
|
+
// DECREMENT, never delete — F1. Deleting released a sibling's claim along with this one.
|
|
8855
|
+
if (held > 1) {
|
|
8856
|
+
this.#hashedWithoutSalt.set(key, held - 1);
|
|
8857
|
+
return;
|
|
8858
|
+
}
|
|
8859
|
+
this.#hashedWithoutSalt.delete(key);
|
|
8860
|
+
/**
|
|
8861
|
+
* INFO, not DEBUG — review pass 2, F3. `session.content.unsalted` has already told this operator
|
|
8862
|
+
* at INFO that the session is unsalted *"permanently… start a new session if you want the
|
|
8863
|
+
* protection."* That statement is now false, and a retraction logged below the level of the
|
|
8864
|
+
* claim it retracts is not a retraction. The announcement itself is deliberately NOT re-armed —
|
|
8865
|
+
* re-announcing on the retry is the per-message flood Decision #15 forbids.
|
|
8866
|
+
*/
|
|
8867
|
+
this.#logger.info("session.content.unsalted.retracted", {
|
|
8868
|
+
agentName, sessionId,
|
|
8869
|
+
impact: "a hash computed unsalted never became a message — no leaf, nothing on the wire, no copy at the counterparty — so this session CAN still adopt a salt. An earlier session.content.unsalted line said the session was permanently unsalted; that no longer applies.",
|
|
8870
|
+
});
|
|
8871
|
+
}
|
|
8872
|
+
/**
|
|
8873
|
+
* TEST SEAM — put a session into the state a real one is in between announcing and being answered.
|
|
8874
|
+
*
|
|
8875
|
+
* Reaching that state for real needs a live counterparty connection, which the daemon-level
|
|
8876
|
+
* fixtures do not have; without a seam the wait could only be tested by not testing it. It calls
|
|
8877
|
+
* the same private registration the announce path calls, so it cannot drift from it.
|
|
8878
|
+
*/
|
|
8879
|
+
markSaltAgreementPendingForTest(agentName, sessionId, boundMs) {
|
|
8880
|
+
this.#markSaltPending(agentName, sessionId, boundMs);
|
|
8881
|
+
}
|
|
8882
|
+
/**
|
|
8883
|
+
* PUBLIC read of a session's agreed salt — `DOD-M15-SEALWIRE-1` part B2a.
|
|
8884
|
+
*
|
|
8885
|
+
* `content-park.ts` runs a SECOND, independent content-hash verifier (the park signature does not
|
|
8886
|
+
* cover the envelope content, so it checks before `ingestReceivedContent` is ever reached), and it
|
|
8887
|
+
* hardcoded `sha256`. It needs the salt to verify a v3 envelope, and it is outside this class.
|
|
8888
|
+
*
|
|
8889
|
+
* Read-only and cache-backed, so exposing it adds no way to CHANGE the salt from outside — the
|
|
8890
|
+
* only writer remains `#persistSessionSalt`, behind the one-salt-per-session predicate.
|
|
8891
|
+
*/
|
|
8892
|
+
getSessionContentSalt(agentName, sessionId) {
|
|
8893
|
+
return this.#getSessionSalt(agentName, sessionId);
|
|
8894
|
+
}
|
|
8895
|
+
/**
|
|
8896
|
+
* This session's agreed salt, or null. Reads the durable row through a cache, because Decision #8
|
|
8897
|
+
* persists it for exactly one reason: *"a restart silently splits the transcript"* if the lookup
|
|
8898
|
+
* misses and a fresh salt is minted.
|
|
8899
|
+
*
|
|
8900
|
+
* A read failure returns null WITH a log rather than throwing — except the bare `!this.#db` guard,
|
|
8901
|
+
* which is this file's convention at 60+ sites and only reachable during shutdown. Null means "we
|
|
8902
|
+
* hold no salt", which drives the agreement to offer a contribution — and against a peer that does
|
|
8903
|
+
* hold one that is a named, loud `salt_state_divergent` refusal. So the degraded path ends in a
|
|
8904
|
+
* diagnosis, not in a session that quietly hashes under the wrong value.
|
|
8905
|
+
*
|
|
8906
|
+
* ⚠️ THIS PARAGRAPH SPENT A UNIT STRANDED 180 LINES AWAY, directly above `contentHashForSession`
|
|
8907
|
+
* and followed by that method's own block — so a reader hovering the hash decision got prose about
|
|
8908
|
+
* salt read failures. Harmless and exactly the kind of drift that makes a comment stop being read.
|
|
8909
|
+
*/
|
|
8910
|
+
#getSessionSalt(agentName, sessionId) {
|
|
8911
|
+
const key = this.#k(agentName, sessionId);
|
|
8912
|
+
const cached = this.#sessionSalts.get(key);
|
|
8913
|
+
if (cached)
|
|
8914
|
+
return cached;
|
|
8915
|
+
if (!this.#db)
|
|
8916
|
+
return null;
|
|
8917
|
+
try {
|
|
8918
|
+
const row = this.#db
|
|
8919
|
+
.prepare("SELECT content_salt FROM sessions WHERE agent_id = ? AND session_id = ?")
|
|
8920
|
+
.get(this.#requireAgentId(agentName), sessionId);
|
|
8921
|
+
const stored = row?.content_salt;
|
|
8922
|
+
if (!stored || stored.length === 0)
|
|
8923
|
+
return null;
|
|
8924
|
+
/**
|
|
8925
|
+
* A WRONG-WIDTH ROW IS NOT A SALT — review F8.
|
|
8926
|
+
*
|
|
8927
|
+
* Any non-empty blob used to be accepted, so a truncated row became "our salt", the digests
|
|
8928
|
+
* then differed, and the operator was told *"one of you is running an older build — compare
|
|
8929
|
+
* versions with them"*: sent to their counterparty over corruption on their own disk. Refusing
|
|
8930
|
+
* it here makes this side hold NO salt, which re-offers a contribution and repairs.
|
|
8931
|
+
*/
|
|
8932
|
+
if (stored.length !== SESSION_SALT_BYTES) {
|
|
8933
|
+
this.#logger.error("session.salt.read.failed", {
|
|
8934
|
+
agentName, sessionId, storedBytes: stored.length, expected: SESSION_SALT_BYTES,
|
|
8935
|
+
reason: "wrong_width",
|
|
8936
|
+
impact: "the stored salt is the wrong size, so it is not used; this session is treated as holding no salt and will re-agree one with the counterparty rather than comparing a corrupt value and blaming their build",
|
|
8937
|
+
});
|
|
8938
|
+
return null;
|
|
8939
|
+
}
|
|
8940
|
+
const salt = new Uint8Array(stored);
|
|
8941
|
+
this.#sessionSalts.set(key, salt);
|
|
8942
|
+
return salt;
|
|
8943
|
+
}
|
|
8944
|
+
catch (err) {
|
|
8945
|
+
this.#logger.error("session.salt.read.failed", {
|
|
8946
|
+
agentName, sessionId, error: extractErrorMessage(err),
|
|
8947
|
+
impact: "this session is treated as holding no salt, so it will offer a fresh contribution; against a counterparty that still holds theirs the agreement refuses by name rather than hashing under a value only one side has",
|
|
8948
|
+
});
|
|
8949
|
+
return null;
|
|
8950
|
+
}
|
|
8951
|
+
}
|
|
8952
|
+
/**
|
|
8953
|
+
* Is this session past the point where a salt can be adopted? — Decision #8, part B2b-2.
|
|
8954
|
+
*
|
|
8955
|
+
* ⚠️ THE PREDICATE IS "HAS ANYTHING BEEN HASHED", NOT "IS THERE A LEAF" — review F5. A leaf is
|
|
8956
|
+
* APPENDED after `await sendContent(...)` returns, so a message can be hashed, put on the wire, and
|
|
8957
|
+
* still be invisible to `tree.size()`. Adopting inside that window makes leaf 0 unsalted and the
|
|
8958
|
+
* rest salted — the exact split this exists to prevent, with the guard green.
|
|
8959
|
+
*
|
|
8960
|
+
* ⚠️ HELD CONTENT COUNTS, AND MUST BE HYDRATED FIRST — review F6. `#ensureHeldRestored` is lazy and
|
|
8961
|
+
* is not called at session-node creation, so a revived session whose first inbound frame is the
|
|
8962
|
+
* salt frame reads a frontier that excludes durable `held_content` rows — rows already hashed
|
|
8963
|
+
* unsalted, which `#releaseHeld` will append moments later. Every other frontier reader in this
|
|
8964
|
+
* file hydrates first, for this reason. `release: false`, because a salt frame must never deliver
|
|
8965
|
+
* messages as a side effect.
|
|
8966
|
+
*
|
|
8967
|
+
* ⚠️ "CANNOT TELL" IS CLOSED, NOT OPEN. `#requireAgentId` throws for a retired agent, and inferring
|
|
8968
|
+
* "zero leaves" from a failure to count them is how a guard becomes a formality. The cost of
|
|
8969
|
+
* refusing is an unsalted session; the cost of permitting is a transcript neither rule can verify.
|
|
8970
|
+
*/
|
|
8971
|
+
#saltAdoptionClosed(agentName, sessionId) {
|
|
8972
|
+
try {
|
|
8973
|
+
this.#ensureHeldRestored(agentName, sessionId, { release: false });
|
|
8974
|
+
}
|
|
8975
|
+
catch { /* hydration is best-effort; the counts below still refuse on their own failure */ }
|
|
8976
|
+
try {
|
|
8977
|
+
const key = this.#k(agentName, sessionId);
|
|
8978
|
+
const leaves = this.getSessionTree(agentName, sessionId).size();
|
|
8979
|
+
const held = this.#heldContent.get(key)?.size ?? 0;
|
|
8980
|
+
const inFlight = this.#awaitingAck.get(key)?.size ?? 0;
|
|
8981
|
+
/**
|
|
8982
|
+
* ⚠️ THE HASH ITSELF COUNTS — B2b-2, and none of the three counts above can see it.
|
|
8983
|
+
*
|
|
8984
|
+
* Decision #8 closes adoption when content is HASHED. For a session's first message the leaf
|
|
8985
|
+
* lands after `sendContent` returns, a network round trip later; there is no held row and no
|
|
8986
|
+
* in-flight entry yet either. So between the hash and the leaf every count reads zero, and a
|
|
8987
|
+
* peer contribution arriving in that window would be adopted — leaving the message already on
|
|
8988
|
+
* the wire as the one unsalted leaf in a salted transcript.
|
|
8989
|
+
*/
|
|
8990
|
+
const hashed = this.#hashedWithoutSalt.get(key) ?? 0;
|
|
8991
|
+
const total = leaves + held + inFlight + hashed;
|
|
8992
|
+
return {
|
|
8993
|
+
closed: total > 0,
|
|
8994
|
+
// The label crosses the WIRE, so it carries no counts and no error text — only which of the
|
|
8995
|
+
// two refusals this is. The counts stay in `why`, which stays local.
|
|
8996
|
+
label: SALT_ADOPTION_LABELS.ALREADY_HASHING,
|
|
8997
|
+
leafCount: total,
|
|
8998
|
+
why: `leaves=${leaves} held=${held} awaiting_ack=${inFlight} hashed=${hashed}`,
|
|
8999
|
+
};
|
|
9000
|
+
}
|
|
9001
|
+
catch (err) {
|
|
9002
|
+
return {
|
|
9003
|
+
closed: true,
|
|
9004
|
+
label: SALT_ADOPTION_LABELS.FRONTIER_UNREADABLE,
|
|
9005
|
+
leafCount: -1,
|
|
9006
|
+
why: `frontier_unreadable: ${extractErrorMessage(err)}`,
|
|
9007
|
+
};
|
|
9008
|
+
}
|
|
9009
|
+
}
|
|
9010
|
+
/**
|
|
9011
|
+
* Persist the agreed salt, and DO NOT ANNOUNCE ONE WE FAILED TO STORE.
|
|
9012
|
+
*
|
|
9013
|
+
* The caller sends its fingerprint only if this returns true. A salt held in memory and not on
|
|
9014
|
+
* disk would confirm agreement to the counterparty and then be gone at the next restart — turning
|
|
9015
|
+
* a loud `salt_state_divergent` refusal, which is the whole point of Decision #10, into the silent
|
|
9016
|
+
* split it exists to prevent, one restart later.
|
|
9017
|
+
*
|
|
9018
|
+
* ⚠️ SECOND ORPHAN OF THE SAME KIND. This paragraph was stranded above `#saltAdoptionClosed` and
|
|
9019
|
+
* followed by that method's own block, exactly like the `#getSessionSalt` one re-homed in the
|
|
9020
|
+
* previous pass — which walked straight past this one sixty lines below it. Two in one file is not
|
|
9021
|
+
* coincidence: inserting a method between a doc block and its subject leaves no error, no lint,
|
|
9022
|
+
* and no test, so the drift is invisible until someone reads for it.
|
|
9023
|
+
*/
|
|
9024
|
+
#persistSessionSalt(agentName, sessionId, salt) {
|
|
9025
|
+
if (!this.#db) {
|
|
9026
|
+
// NOT a silent return — review F7. The other two persist failures each emit an event, so a
|
|
9027
|
+
// derive that could not store because the handle is closed was the ONE salt path producing no
|
|
9028
|
+
// record at all. Only reachable during shutdown, which is exactly when a lone unexplained
|
|
9029
|
+
// gap in the log is hardest to account for later.
|
|
9030
|
+
this.#logger.error("session.salt.persist.failed", {
|
|
9031
|
+
agentName, sessionId, reason: "db_closed",
|
|
9032
|
+
impact: "the salt was NOT stored and is not announced; the agreement stays open and re-runs on the next connect",
|
|
9033
|
+
});
|
|
9034
|
+
return false;
|
|
9035
|
+
}
|
|
9036
|
+
try {
|
|
9037
|
+
/**
|
|
9038
|
+
* ORDER MATTERS HERE, and getting it wrong cost three findings — review F3, F4, F7.
|
|
9039
|
+
*
|
|
9040
|
+
* The adoption guard used to run FIRST, above `!this.#db` and outside this `try`. That:
|
|
9041
|
+
* - short-circuited the `salt_already_stored` discrimination below, so a session that DOES
|
|
9042
|
+
* hold a valid salt was told it "stays unsalted FOR THE LIFE of the session" after a
|
|
9043
|
+
* transient read failure — a refusal asserting something false about the row (F4);
|
|
9044
|
+
* - put `getSessionTree`'s `#requireAgentId` throw outside the `try`, where it surfaced as
|
|
9045
|
+
* *"the stream read failed"* instead of a named salt-persist failure (F7).
|
|
9046
|
+
*
|
|
9047
|
+
* So the row's own state is established first, and only a session with no salt at all reaches
|
|
9048
|
+
* the adoption question.
|
|
9049
|
+
*/
|
|
9050
|
+
const existingRow = this.#db
|
|
9051
|
+
.prepare("SELECT length(content_salt) AS n FROM sessions WHERE agent_id = ? AND session_id = ?")
|
|
9052
|
+
.get(this.#requireAgentId(agentName), sessionId);
|
|
9053
|
+
if (existingRow?.n === SESSION_SALT_BYTES) {
|
|
9054
|
+
this.#logger.error("session.salt.persist.failed", {
|
|
9055
|
+
agentName, sessionId, reason: "salt_already_stored",
|
|
9056
|
+
impact: "this session already has a salt and it was NOT replaced — Decision #8 is one salt per session. Reaching here means a read failure made this side believe it had none; the stored salt is intact, nothing was announced, and the agreement re-runs against it on the next connect.",
|
|
9057
|
+
});
|
|
9058
|
+
return false;
|
|
9059
|
+
}
|
|
9060
|
+
const adoption = this.#saltAdoptionClosed(agentName, sessionId);
|
|
9061
|
+
if (adoption.closed) {
|
|
9062
|
+
this.#logger.warn("session.salt.adoption.refused", {
|
|
9063
|
+
agentName, sessionId, reason: "already_hashing", leafCount: adoption.leafCount, frontier: adoption.why,
|
|
9064
|
+
impact: "this session has already hashed content under the unsalted rule, so the salt was NOT adopted — it stays unsalted FOR THE LIFE of the session. Adopting now would hash the rest of the conversation differently and leave a transcript that neither rule can verify end to end.",
|
|
9065
|
+
guidance: "Nothing is broken and no message was lost: an unsalted session is exactly as verifiable as every session before this feature existed. It only means a relay holding the hashes could confirm a guess at a short message in THIS conversation. If you want the protection, start a new session — the agreement runs at open, before anything is hashed.",
|
|
9066
|
+
});
|
|
9067
|
+
return false;
|
|
9068
|
+
}
|
|
9069
|
+
/**
|
|
9070
|
+
* THE ROW COUNT IS THE CHECK, and without it this method reported success for a write that
|
|
9071
|
+
* stored nothing.
|
|
9072
|
+
*
|
|
9073
|
+
* An `UPDATE` that matches no row does not throw — it returns `changes: 0`. So a session whose
|
|
9074
|
+
* row is missing (retired agent, a row that failed to write at creation, an id that does not
|
|
9075
|
+
* line up) took the success branch, cached the salt in memory, and announced our fingerprint
|
|
9076
|
+
* to the counterparty. Agreement confirmed, nothing on disk, and the failure surfaces at the
|
|
9077
|
+
* next restart as the divergence this whole design exists to make loud — except one restart
|
|
9078
|
+
* late and with both sides believing they had agreed.
|
|
9079
|
+
*
|
|
9080
|
+
* Found by a mutant that removed the caller's `if (!persisted) return`: the suite stayed green,
|
|
9081
|
+
* because nothing could produce a false from here.
|
|
9082
|
+
*/
|
|
9083
|
+
/**
|
|
9084
|
+
* ONE SALT PER SESSION, ENFORCED AT THE WRITE — review F18.
|
|
9085
|
+
*
|
|
9086
|
+
* This `UPDATE` was unconditional, so it could replace an already-stored VALID salt. The path
|
|
9087
|
+
* is real: `#getSessionSalt` returns null on a transient read failure, which sends this side
|
|
9088
|
+
* down the derive path, which then overwrote the perfectly good salt on disk. The read error
|
|
9089
|
+
* was logged; the destruction of the durable value was not — and the read log actively said
|
|
9090
|
+
* the wrong thing, promising only that we would "offer a fresh contribution".
|
|
9091
|
+
*
|
|
9092
|
+
* The predicate has to allow ONE overwrite: a wrong-width blob is refused by `#getSessionSalt`
|
|
9093
|
+
* (F8) precisely so a corrupt row can be replaced rather than stranding the session forever.
|
|
9094
|
+
* So: write when there is nothing there, or when what is there is not a salt.
|
|
9095
|
+
*/
|
|
9096
|
+
const written = this.#db
|
|
9097
|
+
.prepare("UPDATE sessions SET content_salt = ? WHERE agent_id = ? AND session_id = ? " +
|
|
9098
|
+
"AND (content_salt IS NULL OR length(content_salt) <> ?)")
|
|
9099
|
+
.run(Buffer.from(salt), this.#requireAgentId(agentName), sessionId, SESSION_SALT_BYTES);
|
|
9100
|
+
if (Number(written.changes) !== 1) {
|
|
9101
|
+
// WHICH of the two it was. "No row" is a broken session record; "a salt is already there" is
|
|
9102
|
+
// this guard doing its job, and telling an operator the row is missing when it is not would
|
|
9103
|
+
// send them to look at the wrong thing.
|
|
9104
|
+
// The `salt_already_stored` case is decided above now, before the adoption question, so
|
|
9105
|
+
// reaching here with a valid salt in the row is not possible. Re-read anyway rather than
|
|
9106
|
+
// assume: a wrong-width blob also fails the predicate and must not be reported as a missing
|
|
9107
|
+
// row, which would send the operator to look at session state for a corrupt value.
|
|
9108
|
+
const existing = this.#db
|
|
9109
|
+
.prepare("SELECT length(content_salt) AS n FROM sessions WHERE agent_id = ? AND session_id = ?")
|
|
9110
|
+
.get(this.#requireAgentId(agentName), sessionId);
|
|
9111
|
+
const alreadyStored = existing?.n === SESSION_SALT_BYTES;
|
|
9112
|
+
this.#logger.error("session.salt.persist.failed", {
|
|
9113
|
+
agentName, sessionId, changes: Number(written.changes),
|
|
9114
|
+
reason: alreadyStored ? "salt_already_stored" : "no_session_row",
|
|
9115
|
+
impact: alreadyStored
|
|
9116
|
+
? "this session already has a salt and it was NOT replaced — Decision #8 is one salt per session. Reaching here means a read failure made this side believe it had none; the stored salt is intact, nothing was announced, and the agreement re-runs against it on the next connect."
|
|
9117
|
+
: "the salt was NOT stored — no session row matched — so it is not announced either; the agreement stays open rather than being confirmed against a value that exists only in memory",
|
|
9118
|
+
});
|
|
9119
|
+
return false;
|
|
9120
|
+
}
|
|
9121
|
+
this.#sessionSalts.set(this.#k(agentName, sessionId), salt);
|
|
9122
|
+
return true;
|
|
9123
|
+
}
|
|
9124
|
+
catch (err) {
|
|
9125
|
+
this.#logger.error("session.salt.persist.failed", {
|
|
9126
|
+
agentName, sessionId, error: extractErrorMessage(err),
|
|
9127
|
+
impact: "the salt was NOT stored, so it is not announced to the counterparty either; the agreement stays open rather than being confirmed against a value that would vanish at the next restart",
|
|
9128
|
+
});
|
|
9129
|
+
return false;
|
|
9130
|
+
}
|
|
9131
|
+
}
|
|
9132
|
+
/**
|
|
9133
|
+
* Announce our state to the counterparty: a contribution if we hold no salt, a fingerprint if we
|
|
9134
|
+
* do. Called on every counterparty connect — first connection, reconnect and revival alike — and
|
|
9135
|
+
* on receiving a peer contribution.
|
|
9136
|
+
*
|
|
9137
|
+
* ⚠️ THIS PARAGRAPH USED TO SAY *"nothing hashes with the salt yet, so a frame that never lands
|
|
9138
|
+
* costs nothing today"*, and ended by instructing the next unit not to carry it forward unchanged.
|
|
9139
|
+
* **This is that unit, and pass 2 caught me leaving it** — I corrected the same claim inside
|
|
9140
|
+
* `session.salt.announce.failed`'s `impact` in the previous pass and walked past the method header
|
|
9141
|
+
* saying the opposite twenty lines above it.
|
|
9142
|
+
*
|
|
9143
|
+
* As it stands now: the SEND is still best-effort, but a frame that never lands is no longer free.
|
|
9144
|
+
* A first send waiting on this agreement falls back to an unsalted hash, and that is permanent for
|
|
9145
|
+
* the session. The catch therefore settles the pending under `announce_failed` rather than leaving
|
|
9146
|
+
* the send to time out — so the cost is one dial attempt and an accurate reason, instead of five
|
|
9147
|
+
* seconds and a diagnosis blaming the counterparty for a frame we never sent.
|
|
9148
|
+
*/
|
|
9149
|
+
async #sendSaltFrame(agentName, sessionId, correlationId, override) {
|
|
9150
|
+
const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
|
|
9151
|
+
if (!entry)
|
|
9152
|
+
return;
|
|
9153
|
+
// `override` is the repair and the mismatch notice: a frame the AGREEMENT chose, which is not
|
|
9154
|
+
// the one our current state would produce. A side that holds a salt normally announces a
|
|
9155
|
+
// fingerprint — the repair has it send its CONTRIBUTION instead, which is the whole mechanism.
|
|
9156
|
+
/**
|
|
9157
|
+
* `#saltState`, NOT a direct call to the minting accessor — review F15, and this one line was
|
|
9158
|
+
* the difference between the safety argument written for the repair and the code that ran.
|
|
9159
|
+
*
|
|
9160
|
+
* It read `ownContribution: this.#saltContributionFor(...)`, which mints unconditionally. Both
|
|
9161
|
+
* arguments are evaluated, so a session that already held a salt got a fresh half minted into
|
|
9162
|
+
* `#saltContributions` even though `ownSaltFrame` discards it on that branch — and
|
|
9163
|
+
* `#evictSessionCaches` drops the half on every teardown while the salt stays on disk, so ANY
|
|
9164
|
+
* revived session hit it, before any inbound frame could be handled.
|
|
9165
|
+
*
|
|
9166
|
+
* The consequence was not cosmetic. `#ownSaltHalf` never returned null, so `STATE_DIVERGENT`
|
|
9167
|
+
* was dead code, and a revived session would REPAIR its counterparty onto a half its salt was
|
|
9168
|
+
* never built from — then both sides froze on `salt_fingerprint_mismatch`, whose guidance sends
|
|
9169
|
+
* the operator to compare build versions with a counterparty that did nothing wrong.
|
|
9170
|
+
*/
|
|
9171
|
+
const state = this.#saltState(agentName, sessionId);
|
|
9172
|
+
const frame = override ?? ownSaltFrame(state);
|
|
9173
|
+
if (!frame) {
|
|
9174
|
+
// Neither a salt nor a half. `#saltState` does not produce this, so it is a defect rather
|
|
9175
|
+
// than a state to paper over — and inventing a contribution here is exactly what F15 was.
|
|
9176
|
+
this.#logger.error("session.salt.announce.failed", {
|
|
9177
|
+
agentName, sessionId, correlationId, reason: "no_salt_and_no_contribution",
|
|
9178
|
+
impact: "this side has neither an agreed salt nor a half to offer, so it announced nothing; no half was invented, because one minted now would not be the half any stored salt was built from",
|
|
9179
|
+
});
|
|
9180
|
+
return;
|
|
9181
|
+
}
|
|
9182
|
+
/**
|
|
9183
|
+
* ⚠️ REGISTERED BEFORE THE DIAL, NOT AFTER IT — review Finding 2, and this ordering is the whole
|
|
9184
|
+
* of constraint 2 for the case that actually happens.
|
|
9185
|
+
*
|
|
9186
|
+
* This sat after `await newStream(...)`, which negotiates the protocol with the peer and takes
|
|
9187
|
+
* tens to hundreds of milliseconds. A `cello_send` landing inside that interval found nothing
|
|
9188
|
+
* pending, took the park-only exit, hashed unsalted, and **closed adoption for the life of the
|
|
9189
|
+
* session** — a session with a live counterparty and an agreement about to complete. The feature
|
|
9190
|
+
* off forever, and the log telling the operator their counterparty was on an old build.
|
|
9191
|
+
*
|
|
9192
|
+
* That is precisely the failure constraint 2 exists to prevent, arriving by the one route the
|
|
9193
|
+
* implementation did not cover: the gap between deciding to announce and the frame leaving.
|
|
9194
|
+
*
|
|
9195
|
+
* Park-only is untouched — it never reaches this line, because `#sendSaltFrame` returns at the
|
|
9196
|
+
* `!entry` guard above when there is no active node. And a failed announce settles the waiter in
|
|
9197
|
+
* the catch below rather than leaving a send to sit out the full bound for a frame that never
|
|
9198
|
+
* left.
|
|
9199
|
+
*/
|
|
9200
|
+
if (!frame.adoptionClosed)
|
|
9201
|
+
this.#markSaltPending(agentName, sessionId);
|
|
9202
|
+
// Held outside the try so the catch can retire a stream that was opened and then failed to
|
|
9203
|
+
// write — the same leak, and the same fix, as `#sendDeliveryAck`.
|
|
9204
|
+
let saltStream;
|
|
9205
|
+
try {
|
|
9206
|
+
const stream = await entry.node.newStream(entry.counterpartySessionPeerId, CELLO_CONTENT_PROTOCOL_ID);
|
|
9207
|
+
saltStream = stream;
|
|
9208
|
+
stream.send(lp.encode.single(encodeCbor({
|
|
9209
|
+
type: "session_salt_agreement",
|
|
9210
|
+
session_id: sessionId,
|
|
9211
|
+
...(frame.contribution ? { contribution: frame.contribution } : {}),
|
|
9212
|
+
...(frame.fingerprint ? { fingerprint: frame.fingerprint } : {}),
|
|
9213
|
+
...(frame.adoptionClosed ? { adoption_closed: frame.adoptionClosed } : {}),
|
|
9214
|
+
})));
|
|
9215
|
+
await stream.close();
|
|
9216
|
+
this.#logger.debug("session.salt.announced", {
|
|
9217
|
+
agentName, sessionId, correlationId,
|
|
9218
|
+
/**
|
|
9219
|
+
* THREE STATES, not two. This read `fingerprint ? holds_salt : offering_contribution`, which
|
|
9220
|
+
* was exhaustive until the adoption refusal put a third frame on the wire — and a refusal
|
|
9221
|
+
* carries no fingerprint, so it logged as `offering_contribution`: the log claiming we asked
|
|
9222
|
+
* for a salt at the exact moment we told the peer we were declining one. An operator reading
|
|
9223
|
+
* the pair would see an offer that was never answered and go looking for a dropped frame.
|
|
9224
|
+
*/
|
|
9225
|
+
state: frame.adoptionClosed ? "adoption_closed" : frame.fingerprint ? "holds_salt" : "offering_contribution",
|
|
9226
|
+
});
|
|
9227
|
+
}
|
|
9228
|
+
catch (err) {
|
|
9229
|
+
/**
|
|
9230
|
+
* ⚠️ RELEASE THE WAITER — the frame never left, so there is nothing to wait for.
|
|
9231
|
+
*
|
|
9232
|
+
* Registering before the dial (above) means a failed announce would otherwise leave a first
|
|
9233
|
+
* send holding for the full five seconds against a frame that was never sent. Settling here
|
|
9234
|
+
* makes the failure cost one dial attempt instead of the whole bound, and B2b-2 turned this
|
|
9235
|
+
* from theory into something a user feels: it is the pause before their first message.
|
|
9236
|
+
*/
|
|
9237
|
+
this.#settleSaltPending(agentName, sessionId, "announce_failed");
|
|
9238
|
+
this.#logger.warn("session.salt.announce.failed", {
|
|
9239
|
+
agentName, sessionId, correlationId, error: extractErrorMessage(err),
|
|
9240
|
+
// Review F1: this used to end "so no message is affected", which was FALSE in the situation
|
|
9241
|
+
// it fires in — a one-sided announce is exactly what put the two sides out of step, and the
|
|
9242
|
+
// peer used to freeze the session over it. It converges now, so the claim is true again;
|
|
9243
|
+
// it is stated with its reason rather than as a bare reassurance.
|
|
9244
|
+
//
|
|
9245
|
+
// ⚠️ B2b-2 CHANGED THE LAST CLAUSE'S TRUTH. "Nothing hashes with the salt yet" was true for
|
|
9246
|
+
// part A and is now false: a first send waiting on this agreement falls back to sha256 when
|
|
9247
|
+
// it fails, which permanently unsalts the session. Not a message LOST — a protection not
|
|
9248
|
+
// taken, and the sentence has to stop promising otherwise.
|
|
9249
|
+
impact: "the counterparty was not told our salt state on this attempt. We re-announce on every reconnect, and a side that is out of step re-offers its half rather than refusing, so the agreement converges from here. No message is affected, but a first send waiting on this agreement now falls back to an unsalted hash, which is permanent for this session.",
|
|
9250
|
+
});
|
|
9251
|
+
if (saltStream !== undefined) {
|
|
9252
|
+
try {
|
|
9253
|
+
saltStream.abort(err instanceof Error ? err : new Error(String(err)));
|
|
9254
|
+
}
|
|
9255
|
+
catch { /* already gone */ }
|
|
9256
|
+
}
|
|
9257
|
+
}
|
|
9258
|
+
}
|
|
9259
|
+
/** Apply one inbound salt-agreement frame. The verdict is the pure function's; this executes it. */
|
|
9260
|
+
async #handleSaltFrame(agentName, sessionId, frame, correlationId) {
|
|
9261
|
+
const key = this.#k(agentName, sessionId);
|
|
9262
|
+
const peerHalfHex = frame.contribution ? Buffer.from(frame.contribution).toString("hex") : null;
|
|
9263
|
+
const adoption = this.#saltAdoptionClosed(agentName, sessionId);
|
|
9264
|
+
const action = onPeerSaltFrame({
|
|
9265
|
+
...this.#saltState(agentName, sessionId),
|
|
9266
|
+
// Review F2: the frontier is what decides whether THIS side can still adopt, and only the
|
|
9267
|
+
// caller can count it. Without this the state machine derives, the persist refuses, and the
|
|
9268
|
+
// peer never learns — which is how the two sides end up on opposite verdicts.
|
|
9269
|
+
ownAdoption: adoption.closed
|
|
9270
|
+
? { closed: true, label: adoption.label, why: adoption.why }
|
|
9271
|
+
: { closed: false },
|
|
9272
|
+
// Keyed on the peer's BYTES, not on a repair counter: a genuinely NEW half from the peer must
|
|
9273
|
+
// still get our contribution back, and only an identical re-offer is the loop (review F14).
|
|
9274
|
+
alreadyRepairedAgainstPeerHalf: peerHalfHex !== null && this.#saltRepairedAgainst.get(key) === peerHalfHex,
|
|
9275
|
+
frame,
|
|
9276
|
+
});
|
|
9277
|
+
if (action.action === "confirmed") {
|
|
9278
|
+
this.#logger.info("session.salt.agreed", {
|
|
9279
|
+
agentName, sessionId, correlationId, via: "fingerprint_match",
|
|
9280
|
+
});
|
|
9281
|
+
// B2b-2: release a first send that is waiting on this agreement. Both `confirmed` and
|
|
9282
|
+
// `derive_and_announce` end with a salt this side can hash under, so both settle the wait.
|
|
9283
|
+
this.#settleSaltPending(agentName, sessionId, "agreed");
|
|
9284
|
+
return;
|
|
9285
|
+
}
|
|
9286
|
+
if (action.action === "derive_and_announce") {
|
|
9287
|
+
/**
|
|
9288
|
+
* ⚠️ I DEFENDED THE OPPOSITE OF THIS TWICE, AND BOTH DEFENCES WERE WRONG. The code now does
|
|
9289
|
+
* what the "surviving mutant" did; recording that rather than quietly switching, because the
|
|
9290
|
+
* reasoning is the useful part.
|
|
9291
|
+
*
|
|
9292
|
+
* A failed persist used to fall through with no settle, so a waiting first send sat out the
|
|
9293
|
+
* FULL FIVE SECONDS and was then told, by the timeout path, to go and check its counterparty's
|
|
9294
|
+
* build version — for a fault that was this machine's own disk.
|
|
9295
|
+
*
|
|
9296
|
+
* Defence #1 said releasing the waiter "would hand it a null it would hash unsalted under."
|
|
9297
|
+
* True, and not a consequence: that is exactly what the timeout does. Defence #2 said the
|
|
9298
|
+
* remaining bound gave a repair a chance to land — and the review showed that essentially
|
|
9299
|
+
* cannot fire. This branch returns BEFORE the announce, so nothing goes out and nothing comes
|
|
9300
|
+
* back; all five of `#sendSaltFrame`'s callers are triggered by a peer connect or an inbound
|
|
9301
|
+
* frame. Only a counterparty reconnect inside those seconds could do it.
|
|
9302
|
+
*
|
|
9303
|
+
* So the real trade was a rare reconnect-within-five-seconds repair against five seconds of
|
|
9304
|
+
* visible latency on the operator's first message AND a diagnosis pointing at the wrong
|
|
9305
|
+
* machine. The repair loses. Settle immediately under its own name, so `#saltForHashing` can
|
|
9306
|
+
* say *our own write failed* instead of *they did not answer*.
|
|
9307
|
+
*/
|
|
9308
|
+
if (!this.#persistSessionSalt(agentName, sessionId, action.salt)) {
|
|
9309
|
+
this.#settleSaltPending(agentName, sessionId, "persist_failed");
|
|
9310
|
+
return;
|
|
9311
|
+
}
|
|
9312
|
+
this.#logger.info("session.salt.agreed", {
|
|
9313
|
+
agentName, sessionId, correlationId, via: "derived",
|
|
9314
|
+
});
|
|
9315
|
+
this.#settleSaltPending(agentName, sessionId, "agreed");
|
|
9316
|
+
// `void`, not `await` — review F10. This runs inside the INBOUND content-stream handler, so
|
|
9317
|
+
// awaiting an outbound `newStream` here lets a stalled dial hold up the stream we are reading.
|
|
9318
|
+
// The connect-side call is `void`-ed for the same reason and this is now consistent with it.
|
|
9319
|
+
void this.#sendSaltFrame(agentName, sessionId, correlationId);
|
|
9320
|
+
return;
|
|
9321
|
+
}
|
|
9322
|
+
if (action.action === "adoption_closed") {
|
|
9323
|
+
// B2b-2: terminal means there is nothing left to wait for. A send still holding on the bound
|
|
9324
|
+
// would otherwise sit out the full five seconds for an answer that has already arrived and
|
|
9325
|
+
// said no — the slowest possible way to reach a decision both sides already agree on.
|
|
9326
|
+
this.#settleSaltPending(agentName, sessionId, "closed");
|
|
9327
|
+
/**
|
|
9328
|
+
* Terminal, and NOT a freeze — review F1/F2. Both sides stay unsalted, which is exactly as
|
|
9329
|
+
* verifiable as every session shipped before the salt existed; the thing that was broken was
|
|
9330
|
+
* them disagreeing about it silently.
|
|
9331
|
+
*
|
|
9332
|
+
* WHICH SIDE DECLINED decides the level, and it is not decoration.
|
|
9333
|
+
*
|
|
9334
|
+
* If WE closed, an operator has lost a protection they could otherwise have had, and there is
|
|
9335
|
+
* something they can do about it — that is a WARN under `session.salt.adoption.refused`, which
|
|
9336
|
+
* keeps meaning what it has always meant.
|
|
9337
|
+
*
|
|
9338
|
+
* If we are merely LEARNING the peer closed, nothing about this machine is at fault and there
|
|
9339
|
+
* is nothing for its operator to do. Logging that at WARN would fire on the innocent side of
|
|
9340
|
+
* every such session and train them to ignore the name.
|
|
9341
|
+
*/
|
|
9342
|
+
const shared = {
|
|
9343
|
+
agentName, sessionId, correlationId, detail: action.detail,
|
|
9344
|
+
impact: "neither side will use a content salt for this session, and both now know it. Messages are hashed the way every build before this feature hashed them — nothing is degraded relative to any shipped release, and no message is affected.",
|
|
9345
|
+
};
|
|
9346
|
+
if (adoption.closed) {
|
|
9347
|
+
/**
|
|
9348
|
+
* ⚠️ TWO REFUSALS, TWO DIFFERENT THINGS TO DO — and this used to report both as
|
|
9349
|
+
* `already_hashing`.
|
|
9350
|
+
*
|
|
9351
|
+
* A session that has already sent messages is the feature working: the fix is a new session,
|
|
9352
|
+
* and it will work. A frontier this side could not READ is local storage trouble: a new
|
|
9353
|
+
* session will refuse in exactly the same way, so sending the operator to open one is
|
|
9354
|
+
* sending them somewhere that cannot help. `frontier` carries the counts (or the error) so
|
|
9355
|
+
* the two are separable from the log alone.
|
|
9356
|
+
*/
|
|
9357
|
+
const unreadable = adoption.label === SALT_ADOPTION_LABELS.FRONTIER_UNREADABLE;
|
|
9358
|
+
this.#logger.warn("session.salt.adoption.refused", {
|
|
9359
|
+
...shared,
|
|
9360
|
+
reason: adoption.label,
|
|
9361
|
+
leafCount: adoption.leafCount,
|
|
9362
|
+
frontier: adoption.why,
|
|
9363
|
+
guidance: unreadable
|
|
9364
|
+
? "This side could not read its own message frontier, so it refused the salt rather than risk hashing half the session one way and half the other. Starting a new session will NOT help — it will refuse the same way. Look for session.content.held.restore.failed or other storage errors around this line; the conversation still works and every message is intact, it is just unsalted."
|
|
9365
|
+
: "Nothing is broken and no message was lost: an unsalted session is exactly as verifiable as every session before this feature existed. It only means a relay holding the hashes could confirm a guess at a short message in THIS conversation. If you want the protection, start a new session — the agreement runs at open, before anything is hashed.",
|
|
9366
|
+
});
|
|
9367
|
+
}
|
|
9368
|
+
else {
|
|
9369
|
+
this.#logger.info("session.salt.adoption.closed", shared);
|
|
9370
|
+
}
|
|
9371
|
+
if (action.announce) {
|
|
9372
|
+
void this.#sendSaltFrame(agentName, sessionId, correlationId, action.announce);
|
|
9373
|
+
}
|
|
9374
|
+
return;
|
|
9375
|
+
}
|
|
9376
|
+
if (action.action === "repair") {
|
|
9377
|
+
/**
|
|
9378
|
+
* THE REPAIR — review F1. The two sides are out of step and CAN converge, so re-send our half
|
|
9379
|
+
* rather than destroying the session.
|
|
9380
|
+
*
|
|
9381
|
+
* At INFO because it is a real event an operator may need to correlate with a
|
|
9382
|
+
* `session.salt.announce.failed` or `session.salt.persist.failed` on either machine, and
|
|
9383
|
+
* because a session that repairs REPEATEDLY is a signal even though each repair is benign.
|
|
9384
|
+
*/
|
|
9385
|
+
this.#logger.info("session.salt.repair", {
|
|
9386
|
+
agentName, sessionId, correlationId, detail: action.detail,
|
|
9387
|
+
answeredWith: action.frame.contribution ? "contribution" : "fingerprint",
|
|
9388
|
+
});
|
|
9389
|
+
// Recorded ONLY for a repair that sent our half, because that is the one a second identical
|
|
9390
|
+
// offer must not repeat (review F14). Recording the fingerprint answer too would be harmless
|
|
9391
|
+
// but says nothing — that branch is already terminal for the peer.
|
|
9392
|
+
if (peerHalfHex && action.frame.contribution)
|
|
9393
|
+
this.#saltRepairedAgainst.set(key, peerHalfHex);
|
|
9394
|
+
void this.#sendSaltFrame(agentName, sessionId, correlationId, action.frame);
|
|
9395
|
+
return;
|
|
9396
|
+
}
|
|
9397
|
+
// `detail` is the primitive's own sentence wherever the primitive produced it — never a code of
|
|
9398
|
+
// ours substituted for it (Invariant 2). `guidance` is what the operator can DO, and it comes
|
|
9399
|
+
// from the total map so a reason can never reach a log without one.
|
|
9400
|
+
this.#logger.error("session.salt.disagreement", {
|
|
9401
|
+
agentName, sessionId, correlationId,
|
|
9402
|
+
reason: action.reason,
|
|
9403
|
+
detail: action.detail,
|
|
9404
|
+
guidance: SALT_FREEZE_GUIDANCE[action.reason],
|
|
9405
|
+
});
|
|
9406
|
+
/**
|
|
9407
|
+
* TELL THE PEER BEFORE TEARING DOWN — review F1's mirror.
|
|
9408
|
+
*
|
|
9409
|
+
* Only the fingerprint mismatch carries a notice, and only it can: the peer holds everything
|
|
9410
|
+
* needed to run the identical comparison and has simply not been given our side of it. Without
|
|
9411
|
+
* this the session stops answering and the far operator gets no reason at all, while ours gets a
|
|
9412
|
+
* full explanation — Decision #10 asks for BOTH sides to refuse by name.
|
|
9413
|
+
*
|
|
9414
|
+
* Awaited, unlike the other sends, because `destroySessionNode` on the next line takes the node
|
|
9415
|
+
* away and an un-awaited write would race its own transport. A failure is already handled
|
|
9416
|
+
* inside — the refusal here has happened either way.
|
|
9417
|
+
*/
|
|
9418
|
+
if (action.notifyPeer) {
|
|
9419
|
+
await this.#sendSaltFrame(agentName, sessionId, correlationId, action.notifyPeer);
|
|
9420
|
+
}
|
|
9421
|
+
await this.#freezeSession(agentName, sessionId, action.reason, {
|
|
9422
|
+
event: "session.salt.frozen",
|
|
9423
|
+
observation: `the salt agreement could not be completed with this counterparty: ${action.detail}`,
|
|
9424
|
+
impact: "the session was stopped rather than left to hash under a value the two sides do not share; no message was lost and the transcript is unaffected — only a NEW session moves this forward",
|
|
9425
|
+
reviveReason: `session_frozen_${action.reason}`,
|
|
9426
|
+
// The operator-facing sentence comes from the TOTAL guidance map, so a reason can never reach
|
|
9427
|
+
// this refusal without one — and it is what stops a salt disagreement being reported to them
|
|
9428
|
+
// as their counterparty failing a key check.
|
|
9429
|
+
reviveGuidance: SALT_FREEZE_GUIDANCE[action.reason],
|
|
9430
|
+
}, correlationId);
|
|
9431
|
+
}
|
|
9432
|
+
async #freezeOnIdentityFailure(agentName, sessionId, reason, correlationId) {
|
|
9433
|
+
await this.#freezeSession(agentName, sessionId, reason, {
|
|
9434
|
+
event: "session.content.identity.frozen",
|
|
9435
|
+
observation: "a frame failed to verify against the expected counterparty's key; the session was frozen defensively; cause undetermined",
|
|
9436
|
+
reviveReason: "session_frozen_identity_failure",
|
|
9437
|
+
reviveGuidance: "This session was frozen because a message failed to verify against the expected counterparty's key. " +
|
|
9438
|
+
"Cause undetermined — that signal looks the same whether someone was impersonating your counterparty or CELLO's own delivery mishandled a fallback, so it is recorded as an observation and nothing has been concluded about them.",
|
|
9439
|
+
// Review F1: this said "no further content will be accepted on this session", which was FALSE
|
|
9440
|
+
// as shipped — the next read revived it. It is true now, and it says how it is true, because
|
|
9441
|
+
// an operator who reads "frozen" and then watches the session work again learns to distrust
|
|
9442
|
+
// the log rather than the session.
|
|
9443
|
+
impact: "the content was NOT ingested, NOT displayed and NOT attributed to anyone; the session will not be revived by a read or a send, and only a close or a fresh session moves it now",
|
|
9444
|
+
}, correlationId);
|
|
9445
|
+
}
|
|
9446
|
+
/**
|
|
9447
|
+
* Stop a session and refuse to revive it. Shared by the identity freeze and the salt
|
|
9448
|
+
* disagreement, because the MECHANISM is identical — mark, then tear down — while the two have
|
|
9449
|
+
* nothing else in common and must not describe each other. An identity failure is evidence about
|
|
9450
|
+
* the counterparty; a salt disagreement is usually two builds that do not match, and telling an
|
|
9451
|
+
* operator their counterparty failed a key check when they did not is worse than saying nothing.
|
|
9452
|
+
* Hence the caller supplies both sentences.
|
|
9453
|
+
*/
|
|
9454
|
+
async #freezeSession(agentName, sessionId, reason, narrative, correlationId) {
|
|
9455
|
+
// Review F1: MARK BEFORE TEARING DOWN. `destroySessionNode` writes `interrupted`, which is the
|
|
9456
|
+
// revivable status — if the mark landed after, a read racing the teardown could revive the
|
|
9457
|
+
// session out from under the freeze.
|
|
9458
|
+
//
|
|
9459
|
+
// `reviveReason` is SPELLED OUT by the caller rather than built from `reason`. Deriving it looks
|
|
9460
|
+
// tidier and silently changed a shipped contract: the identity freeze's `reason` is the specific
|
|
9461
|
+
// ordering failure (`bad_signature`, `signer_not_counterparty`), so a derived code would have
|
|
9462
|
+
// turned the stable `session_frozen_identity_failure` into a family of varying strings that no
|
|
9463
|
+
// caller matches. Caught by that path's own test.
|
|
9464
|
+
this.#frozenSessions.set(this.#k(agentName, sessionId), {
|
|
9465
|
+
reason: narrative.reviveReason,
|
|
9466
|
+
guidance: narrative.reviveGuidance,
|
|
9467
|
+
});
|
|
9468
|
+
// The EVENT NAME is the caller's, not this method's. Both freezes share a mechanism and nothing
|
|
9469
|
+
// else, and a salt disagreement logged under `…identity.frozen` would tell an operator their
|
|
9470
|
+
// counterparty failed a key check when they did not — a worse outcome than saying nothing.
|
|
9471
|
+
this.#logger.error(narrative.event, {
|
|
9472
|
+
agentName, sessionId, reason, correlationId,
|
|
9473
|
+
observation: narrative.observation,
|
|
9474
|
+
// Review F1: the identity path's impact line said "no further content will be accepted on this
|
|
9475
|
+
// session", which was FALSE as shipped — the next read revived it. It is true now, and it says
|
|
9476
|
+
// how it is true, because an operator who reads "frozen" and then watches the session work
|
|
9477
|
+
// again learns to distrust the log rather than the session.
|
|
9478
|
+
impact: narrative.impact,
|
|
9479
|
+
});
|
|
9480
|
+
try {
|
|
9481
|
+
/**
|
|
9482
|
+
* `"error"` maps to DB status `interrupted`, which is the SAME row an ordinary
|
|
9483
|
+
* counterparty-gone teardown writes — so a freeze is distinguishable in the log and in
|
|
9484
|
+
* behaviour, but not in the session record.
|
|
9485
|
+
*
|
|
9486
|
+
* ⚠️ THIS COMMENT USED TO SAY THE COLUMNS DID NOT EXIST. They do now: `frozen_at` and
|
|
9487
|
+
* `frozen_reason` were added to `sessions` by the salt migration, which carried them for
|
|
9488
|
+
* `DOD-M15-FREEZE-STATUS-1` so that two lanes would not both edit this file's migration list.
|
|
9489
|
+
* What is still missing is a WRITER — that is `FREEZE-STATUS-1`'s work and it belongs to the
|
|
9490
|
+
* other lane, so nothing here fills them in. The gap is unchanged; only the reason for it is.
|
|
9491
|
+
*/
|
|
9492
|
+
await this.destroySessionNode(agentName, sessionId, "error");
|
|
9493
|
+
}
|
|
9494
|
+
catch (err) {
|
|
9495
|
+
// The refusal already happened — the content did not ingest. A teardown failure must not
|
|
9496
|
+
// turn a successful refusal into a thrown handler, which would close the stream on a path
|
|
9497
|
+
// that reads as "nothing arrived".
|
|
9498
|
+
this.#logger.warn("session.content.identity.freeze_teardown_failed", {
|
|
9499
|
+
agentName, sessionId, error: extractErrorMessage(err), correlationId,
|
|
9500
|
+
});
|
|
9501
|
+
}
|
|
9502
|
+
}
|
|
9503
|
+
#recordFrameOrdering(agentName, sessionId, structure1Cbor, structure2Cbor, contentHash, correlationId, source = "content_frame") {
|
|
9504
|
+
try {
|
|
9505
|
+
const s1 = decode(structure1Cbor);
|
|
9506
|
+
const s2 = decode(structure2Cbor);
|
|
9507
|
+
const s1Hash = s1?.[1];
|
|
9508
|
+
const s1Pubkey = s1?.[2];
|
|
9509
|
+
const seq = typeof s2?.[0] === "number" ? s2[0] : -1;
|
|
9510
|
+
const s2Sig = s2?.[3];
|
|
9511
|
+
if (!(s1Hash instanceof Uint8Array) || !(s1Pubkey instanceof Uint8Array) || !(s2Sig instanceof Uint8Array) || seq < 1) {
|
|
9512
|
+
// SOFT: we could not read the record, so we learned nothing about the signer either way.
|
|
9513
|
+
// Position falls back to the witness stream, exactly as an absent record does.
|
|
9514
|
+
this.#logger.warn("session.content.ordering.malformed", { sessionId, correlationId });
|
|
9515
|
+
return { seq: null };
|
|
9516
|
+
}
|
|
9517
|
+
// The framed ordering record must bind to THIS content (its hash) — else it orders the wrong bytes.
|
|
9518
|
+
const contentHashHex = Buffer.from(contentHash).toString("hex");
|
|
9519
|
+
if (Buffer.from(s1Hash).toString("hex") !== contentHashHex) {
|
|
9520
|
+
// SOFT: the record does not describe this content. Nothing is proven about the signer's
|
|
9521
|
+
// identity — only that this record and these bytes do not belong together.
|
|
9522
|
+
this.#logger.warn("session.content.ordering.hash_mismatch", { sessionId, correlationId });
|
|
9523
|
+
return { seq: null };
|
|
9524
|
+
}
|
|
9525
|
+
// Verify the SENDER's Ed25519 signature over the exact signed bytes (structure1_cbor) — the same
|
|
9526
|
+
// check the relay performs. Proves the counterparty committed to this (content_hash @ sequence).
|
|
9527
|
+
if (!verify(s1Pubkey, structure1Cbor, s2Sig)) {
|
|
9528
|
+
// FATAL. The sender supplied a signature and it does not verify against the key inside its
|
|
9529
|
+
// own record. That is not an absence we could not resolve — it is a proof that failed.
|
|
9530
|
+
this.#logger.warn("session.content.ordering.bad_signature", { sessionId, correlationId });
|
|
9531
|
+
return { seq: null, fatal: { reason: "bad_signature" } };
|
|
9532
|
+
}
|
|
9533
|
+
// Sovereign-node cross-check: the signer MUST be THIS session's counterparty, not an unrelated
|
|
9534
|
+
// key. FAIL CLOSED (review L) — if the counterparty pubkey is unknown we cannot prove the signer,
|
|
9535
|
+
// so we do NOT trust the framed ordering record (fall back to the witness stream / arrival). The
|
|
9536
|
+
// "B does not trust the counterparty for ordering" invariant is non-negotiable; never fail open.
|
|
9537
|
+
// Review M1: compare BYTES, not hex strings — `counterparty_pubkey` is stored verbatim from the
|
|
9538
|
+
// IPC param and is never case-normalized, so a string compare would fail for a mixed-case
|
|
9539
|
+
// pubkey and silently strip the canonical ordering from every message in that session.
|
|
9540
|
+
const counterparty = this.getSessionRecord(agentName, sessionId)?.counterparty_pubkey;
|
|
9541
|
+
if (!pubkeyMatchesHex(s1Pubkey, counterparty)) {
|
|
9542
|
+
/**
|
|
9543
|
+
* FATAL when the counterparty is KNOWN and the signer is someone else. SOFT when we simply
|
|
9544
|
+
* do not know who the counterparty is.
|
|
9545
|
+
*
|
|
9546
|
+
* The fatal half is the session-open MITM detection from the 2026-08-21 T-of-N
|
|
9547
|
+
* investigation, which found this check *"fires correctly, and its answer is thrown away."*
|
|
9548
|
+
* A rogue quorum of the directories holding shares for agent B can sign a false
|
|
9549
|
+
* SessionAssignment naming M's key as B's, and everything downstream is genuinely real —
|
|
9550
|
+
* M signs with M's own valid key. Nothing is missing for A to notice. This comparison is
|
|
9551
|
+
* where the substitution shows, because `counterparty_pubkey` comes from A's own request
|
|
9552
|
+
* and is untouched by anything the directory returns.
|
|
9553
|
+
*
|
|
9554
|
+
* ⚠️ IT SHOWS ONLY WHEN THE RECORD IS PRESENT (review F3). An earlier version of this
|
|
9555
|
+
* comment said this was "the one place the substitution shows", full stop — and that
|
|
9556
|
+
* asserted a property the code does not have: M can decline to supply an ordering record
|
|
9557
|
+
* and be ingested without ever reaching this line. The caller logs
|
|
9558
|
+
* `session.content.ordering.absent` so the weaker case is at least visible, and closing it
|
|
9559
|
+
* needs a check that does not depend on the sender's cooperation — the relay's independent
|
|
9560
|
+
* copy, `DOD-M15-CORROBORATE-1`.
|
|
9561
|
+
*
|
|
9562
|
+
* The soft half stays soft deliberately: `counterparty_unknown` means we cannot prove the
|
|
9563
|
+
* signer either way, and refusing there would strand sessions whose record we failed to
|
|
9564
|
+
* read rather than sessions that are under attack.
|
|
9565
|
+
*/
|
|
9566
|
+
const reason = counterparty ? "signer_not_counterparty" : "counterparty_unknown";
|
|
9567
|
+
this.#logger.warn("session.content.ordering.wrong_signer", { sessionId, reason, correlationId });
|
|
9568
|
+
return counterparty ? { seq: null, fatal: { reason } } : { seq: null };
|
|
9569
|
+
}
|
|
9570
|
+
// Verified — record the relay-assigned canonical sequence (1-based → 0-based leaf index) for the gate.
|
|
9571
|
+
this.recordWitnessedSequence(agentName, sessionId, contentHashHex, seq - 1);
|
|
9572
|
+
this.#logger.info("session.content.ordering.recorded", {
|
|
9573
|
+
sessionId,
|
|
9574
|
+
canonicalSeq: seq - 1,
|
|
9575
|
+
source,
|
|
9576
|
+
correlationId,
|
|
9577
|
+
});
|
|
9578
|
+
/**
|
|
9579
|
+
* DOD-M15-SEALWIRE-1 bullet 5: return the VERIFIED proof, not just the position.
|
|
9580
|
+
*
|
|
9581
|
+
* Three lines above, `verify(s1Pubkey, structure1Cbor, s2Sig)` has already passed and the
|
|
9582
|
+
* signer has been matched to this session's counterparty. That is the strongest statement
|
|
9583
|
+
* this daemon ever makes about who wrote a message — and until now it was made, used to
|
|
9584
|
+
* decide a sequence number, and then discarded. The transcript row that outlives it recorded
|
|
9585
|
+
* only a direction.
|
|
9586
|
+
*
|
|
9587
|
+
* Returned rather than stashed, for the same reason `seq` is: a caller that has to go looking
|
|
9588
|
+
* for it in a side map is a caller that will not.
|
|
9589
|
+
*/
|
|
9590
|
+
return { seq: seq - 1, senderPubkey: s1Pubkey, senderSig: s2Sig };
|
|
9591
|
+
}
|
|
9592
|
+
catch (err) {
|
|
9593
|
+
this.#logger.warn("session.content.ordering.decode_failed", {
|
|
9594
|
+
sessionId,
|
|
9595
|
+
error: err instanceof Error ? err.message : String(err),
|
|
9596
|
+
correlationId,
|
|
9597
|
+
});
|
|
9598
|
+
}
|
|
9599
|
+
// No verified position — the caller falls back to the announced hash-dedup path. SOFT: a decode
|
|
9600
|
+
// throw tells us nothing about the signer, so it is the absent case, not the refuted one.
|
|
9601
|
+
return { seq: null };
|
|
9602
|
+
}
|
|
9603
|
+
async #handleContentStream(agentName, sessionId, stream, remotePeerId) {
|
|
9604
|
+
// CLOSING THIS STREAM IS WHAT KEEPS THE SESSION ALIVE PAST ITS 33RD MESSAGE.
|
|
9605
|
+
//
|
|
9606
|
+
// Every content frame and every delivery ACK opens a fresh /cello/content/1.0.0 stream on the
|
|
9607
|
+
// one muxed connection the session holds, and libp2p caps INBOUND streams per protocol per
|
|
9608
|
+
// connection. It enforces that cap AFTER multistream-select has answered, so an over-cap stream
|
|
9609
|
+
// negotiates fine and is reset an instant later, and the SENDER's next `stream.send(...)`
|
|
9610
|
+
// throws "Cannot write to a stream that is closed" — an error that names the exit point and not
|
|
9611
|
+
// one thing about the cause.
|
|
9612
|
+
//
|
|
9613
|
+
// A stream leaves the muxer's set only on its `close` event, and closing our write end triggers
|
|
9614
|
+
// that only once the peer has closed its end too. So a handler that reads its frame and returns
|
|
9615
|
+
// leaves the stream half-open for the life of the connection and the count only ever rises.
|
|
9616
|
+
// Measured on a live daemon: 115 failures over 3.5 hours, with EXACTLY 32 successful streams
|
|
9617
|
+
// before the first one on both affected sessions (M12B Entry 10).
|
|
9618
|
+
//
|
|
9619
|
+
// The decoder is built INSIDE the try so a malformed stream cannot throw past the close below.
|
|
9620
|
+
let iter;
|
|
9621
|
+
try {
|
|
9622
|
+
iter = lp.decode(stream)[Symbol.asyncIterator]();
|
|
9623
|
+
const result = await iter.next();
|
|
9624
|
+
if (result.done || result.value === undefined)
|
|
9625
|
+
return;
|
|
9626
|
+
const bytes = result.value instanceof Uint8Array ? result.value
|
|
9627
|
+
: Buffer.isBuffer(result.value) ? new Uint8Array(result.value)
|
|
9628
|
+
: result.value.slice();
|
|
9629
|
+
const frame = decode(bytes);
|
|
9630
|
+
const correlationId = typeof frame["correlation_id"] === "string" ? frame["correlation_id"] : undefined;
|
|
9631
|
+
const frameType = typeof frame["type"] === "string" ? frame["type"] : "(absent)";
|
|
9632
|
+
/**
|
|
9633
|
+
* DOD-M15-FRAME-1 — ONE GATE, BEFORE THE DISPATCH, FOR EVERY FRAME ON THIS PROTOCOL.
|
|
9634
|
+
*
|
|
9635
|
+
* A stranger could dial an agent's standing receiver (it admitted everyone until DOD-M15-ASSIGN-1), hold
|
|
9636
|
+
* the connection open through promotion — libp2p's gater runs only at connection
|
|
9637
|
+
* establishment, so narrowing it does not evict anyone already attached — and then speak the
|
|
9638
|
+
* content protocol the moment it activated. The frame was ingested, leafed, transcribed, and
|
|
9639
|
+
* attributed to the legitimate counterparty, because attribution is read from local session
|
|
9640
|
+
* state rather than from anything the frame proved.
|
|
9641
|
+
*
|
|
9642
|
+
* DELIBERATELY SHARED RATHER THAN COPIED INTO EACH BRANCH. `session_abandoned_notice` already
|
|
9643
|
+
* had both checks, correct and complete, twenty lines below — and the other two frame types
|
|
9644
|
+
* did not. Copying the pattern a third and fourth time would fix today's three and leave the
|
|
9645
|
+
* fifth frame type, added later by someone who did not read this comment, unguarded again.
|
|
9646
|
+
* Placing it above the dispatch makes the guard the DEFAULT: a new frame type is protected by
|
|
9647
|
+
* construction and has to opt OUT visibly rather than opt in silently.
|
|
9648
|
+
*
|
|
9649
|
+
* Verified safe for all three current types by enumeration, not assumption — `content_frame`
|
|
9650
|
+
* (:5169), `session_abandoned_notice` (:6663) and `content_delivery_ack` (:7439) are the only
|
|
9651
|
+
* senders on `CELLO_CONTENT_PROTOCOL_ID`, and all three put `session_id` in the frame.
|
|
9652
|
+
*
|
|
9653
|
+
* MISSING, MALFORMED AND MISMATCHED TAKE ONE PATH. An attacker evading a mismatch check does
|
|
9654
|
+
* not send a wrong value — it sends no value, and a guard that only fires on a present-and-
|
|
9655
|
+
* wrong field is a guard that is trivially skipped. That is exactly what the old
|
|
9656
|
+
* `content_frame` check did: `typeof x === "string" && x !== sessionId`.
|
|
9657
|
+
*/
|
|
9658
|
+
const expectedPeer = this.#activeNodes.get(this.#k(agentName, sessionId))?.counterpartySessionPeerId;
|
|
9659
|
+
if (!remotePeerId || !expectedPeer || remotePeerId !== expectedPeer) {
|
|
9660
|
+
// Loud in the LOG — there is no caller to answer on an inbound stream, so this is the whole
|
|
9661
|
+
// surface. Neutral wording: this is an observation, not a verdict about intent. The same
|
|
9662
|
+
// signal comes from a real impersonation attempt and from our own fallback paths
|
|
9663
|
+
// mishandling a reconnect, and nothing here can tell them apart.
|
|
9664
|
+
this.#logger.warn("session.content.peer_mismatch", {
|
|
9665
|
+
agentName, sessionId, frameType,
|
|
9666
|
+
remotePeerId: remotePeerId ?? "(absent)", expected: expectedPeer ?? "(unknown)",
|
|
9667
|
+
impact: "a frame arrived on this session's content protocol from a peer that is not its counterparty; it was refused — not ingested, not attributed, not recorded — and the peer was disconnected",
|
|
9668
|
+
});
|
|
9669
|
+
/**
|
|
9670
|
+
* PEER-ENDING, NOT SESSION-ENDING — and the difference is a deliberate deviation from the
|
|
9671
|
+
* DoD clause (review F2).
|
|
9672
|
+
*
|
|
9673
|
+
* The clause says the refusal is session-ending. Applied HERE that would be a worse hole
|
|
9674
|
+
* than the one it closes: a pre-positioned stranger could kill any session on the machine
|
|
9675
|
+
* with a single frame, trading an injection hole for a denial-of-service hole. The
|
|
9676
|
+
* session-ending response belongs where the evidence is about the SESSION's counterparty —
|
|
9677
|
+
* `#freezeOnIdentityFailure`, reached when a party that IS the peer we dialled signs with a
|
|
9678
|
+
* key that is not theirs.
|
|
9679
|
+
*
|
|
9680
|
+
* Here the evidence is about the PEER: they are not party to this session at all. So the
|
|
9681
|
+
* connection goes and the session is untouched. Without this the stranger stayed attached
|
|
9682
|
+
* for the life of the session and the gate re-refused each frame forever — and the eviction
|
|
9683
|
+
* sweep's own fallback ("the frame gate still refuses anything this peer sends") only closes
|
|
9684
|
+
* the loop if the frame gate does something about the connection.
|
|
9685
|
+
*
|
|
9686
|
+
* Fire-and-forget: a hang-up that fails must not turn a successful refusal into a thrown
|
|
9687
|
+
* handler, and the refusal above has already done the load-bearing work.
|
|
9688
|
+
*/
|
|
9689
|
+
if (remotePeerId) {
|
|
9690
|
+
const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
|
|
9691
|
+
void entry?.node.hangUp(remotePeerId).catch((err) => {
|
|
9692
|
+
this.#logger.debug("session.content.peer_mismatch.hangup_failed", {
|
|
9693
|
+
sessionId, peerId: remotePeerId, error: extractErrorMessage(err),
|
|
9694
|
+
});
|
|
9695
|
+
});
|
|
9696
|
+
}
|
|
9697
|
+
return;
|
|
9698
|
+
}
|
|
9699
|
+
const claimedSessionId = frame["session_id"];
|
|
9700
|
+
if (typeof claimedSessionId !== "string" || claimedSessionId !== sessionId) {
|
|
9701
|
+
this.#logger.warn("session.content.session_mismatch", {
|
|
9702
|
+
agentName, sessionId, frameType,
|
|
9703
|
+
claimedSessionId: typeof claimedSessionId === "string" ? claimedSessionId : "(absent)",
|
|
9704
|
+
impact: "the frame does not name the session whose stream it arrived on; it was refused rather than routed, because the authenticated stream is the better authority for where content belongs",
|
|
9705
|
+
});
|
|
9706
|
+
return;
|
|
9707
|
+
}
|
|
9708
|
+
// CELLO-M7-MSG-001 (AC-001/AC-002): a `persisted` delivery ACK arriving on the
|
|
9709
|
+
// same /cello/content/1.0.0 protocol resolves the sender's awaiting-ACK timer.
|
|
9710
|
+
// The protocol acts on `persisted` ONLY — any other level leaves the timer armed.
|
|
9711
|
+
if (frame["type"] === "content_delivery_ack") {
|
|
9712
|
+
const ackHash = frame["content_hash"];
|
|
7035
9713
|
const level = frame["level"];
|
|
7036
9714
|
if (ackHash instanceof Uint8Array && level === "persisted") {
|
|
7037
9715
|
this.#resolveAwaitingAck(agentName, sessionId, ackHash);
|
|
@@ -7042,36 +9720,41 @@ export class SessionNodeManager {
|
|
|
7042
9720
|
// authenticated stream the delivery acknowledgement rides, and AFTER the session-id check
|
|
7043
9721
|
// below cannot be skipped — the frame names its session and the handler is bound to one.
|
|
7044
9722
|
if (frame["type"] === "session_abandoned_notice") {
|
|
7045
|
-
//
|
|
7046
|
-
//
|
|
7047
|
-
//
|
|
7048
|
-
//
|
|
7049
|
-
//
|
|
7050
|
-
//
|
|
7051
|
-
//
|
|
7052
|
-
// `remotePeerId` is the Noise-authenticated transport identity, which the handler was
|
|
7053
|
-
// throwing away. Absent means we cannot prove who is speaking, and an unprovable claim to
|
|
7054
|
-
// end a session is refused.
|
|
7055
|
-
const expected = this.#activeNodes.get(this.#k(agentName, sessionId))?.counterpartySessionPeerId;
|
|
7056
|
-
if (!remotePeerId || !expected || remotePeerId !== expected) {
|
|
7057
|
-
this.#logger.warn("session.content.peer_mismatch", {
|
|
7058
|
-
sessionId, frameType: "session_abandoned_notice",
|
|
7059
|
-
remotePeerId: remotePeerId ?? "(absent)", expected: expected ?? "(unknown)",
|
|
7060
|
-
});
|
|
7061
|
-
return;
|
|
7062
|
-
}
|
|
7063
|
-
// REQUIRED and equal — absence is not a pass. The frame names its session and the handler
|
|
7064
|
-
// is bound to one; treating a missing field as agreement is how a guard stops guarding.
|
|
7065
|
-
const claimed = frame["session_id"];
|
|
7066
|
-
if (typeof claimed !== "string" || claimed !== sessionId) {
|
|
7067
|
-
this.#logger.warn("session.content.session_mismatch", {
|
|
7068
|
-
sessionId, claimedSessionId: typeof claimed === "string" ? claimed : "(absent)",
|
|
7069
|
-
});
|
|
7070
|
-
return;
|
|
7071
|
-
}
|
|
9723
|
+
// DOD-M15-FRAME-1: the peer and session checks that used to live here now run above, for
|
|
9724
|
+
// EVERY frame type, unchanged in substance — this branch was where they were written first
|
|
9725
|
+
// and correctly, and it is the reference the shared gate was lifted from. Its comment is
|
|
9726
|
+
// preserved there, including the reason the transport being authenticated is not enough.
|
|
9727
|
+
// Left as a bare dispatch on purpose: a second copy of a guard is a second thing to keep in
|
|
9728
|
+
// step, and the one that drifts is the one nobody is reading.
|
|
7072
9729
|
void this.retireOnCounterpartyAbandon(agentName, sessionId, correlationId);
|
|
7073
9730
|
return;
|
|
7074
9731
|
}
|
|
9732
|
+
/**
|
|
9733
|
+
* DOD-M15-SEALWIRE-1 bullet 6 (part A) — the salt agreement.
|
|
9734
|
+
*
|
|
9735
|
+
* Placed BELOW the shared peer/session gate deliberately, which is the whole reason that gate
|
|
9736
|
+
* was lifted above the dispatch: a new frame type is protected by construction rather than
|
|
9737
|
+
* having to remember to opt in. A stranger's salt frame is refused before it reaches here, so
|
|
9738
|
+
* nothing about this session's salt can be steered by a peer that is not its counterparty.
|
|
9739
|
+
*
|
|
9740
|
+
* The fields are read defensively into the frame shape rather than cast: an inbound value is
|
|
9741
|
+
* whatever a peer chose to encode, and `onPeerSaltFrame` refuses both-fields and neither-field
|
|
9742
|
+
* by name — so a non-Uint8Array in either slot must arrive at that function as ABSENT, not as
|
|
9743
|
+
* a present-but-wrong value it would then try to use.
|
|
9744
|
+
*/
|
|
9745
|
+
if (frame["type"] === "session_salt_agreement") {
|
|
9746
|
+
const contribution = frame["contribution"];
|
|
9747
|
+
const fingerprint = frame["fingerprint"];
|
|
9748
|
+
const adoptionClosed = frame["adoption_closed"];
|
|
9749
|
+
await this.#handleSaltFrame(agentName, sessionId, {
|
|
9750
|
+
...(contribution instanceof Uint8Array ? { contribution } : {}),
|
|
9751
|
+
...(fingerprint instanceof Uint8Array ? { fingerprint } : {}),
|
|
9752
|
+
// A non-string stays ABSENT rather than being coerced, exactly like the other two: the
|
|
9753
|
+
// decision function refuses a shape it cannot read, and must never be handed a `"42"`.
|
|
9754
|
+
...(typeof adoptionClosed === "string" && adoptionClosed.length > 0 ? { adoptionClosed } : {}),
|
|
9755
|
+
}, correlationId);
|
|
9756
|
+
return;
|
|
9757
|
+
}
|
|
7075
9758
|
if (frame["type"] !== "content_frame") {
|
|
7076
9759
|
// LOGGED, not silently dropped. This handler is bound to one session, and a frame it does
|
|
7077
9760
|
// not understand arriving on that stream is either a peer speaking a newer protocol or a
|
|
@@ -7083,25 +9766,11 @@ export class SessionNodeManager {
|
|
|
7083
9766
|
});
|
|
7084
9767
|
return;
|
|
7085
9768
|
}
|
|
7086
|
-
//
|
|
7087
|
-
//
|
|
7088
|
-
//
|
|
7089
|
-
//
|
|
7090
|
-
//
|
|
7091
|
-
// The sealed transcript is the artifact this protocol exists to produce; a message in it that
|
|
7092
|
-
// its own author addressed elsewhere is exactly the thing it must not contain.
|
|
7093
|
-
//
|
|
7094
|
-
// Refused rather than re-routed: routing it to the session it names would honour a claim made
|
|
7095
|
-
// by the party whose frame arrived in the wrong place, and the stream — which is authenticated
|
|
7096
|
-
// — is the better authority. Refusing leaves the sender to redeliver on the right one.
|
|
7097
|
-
const framedSessionId = frame["session_id"];
|
|
7098
|
-
if (typeof framedSessionId === "string" && framedSessionId !== sessionId) {
|
|
7099
|
-
this.#logger.warn("session.content.session_mismatch", {
|
|
7100
|
-
sessionId,
|
|
7101
|
-
claimedSessionId: framedSessionId,
|
|
7102
|
-
});
|
|
7103
|
-
return;
|
|
7104
|
-
}
|
|
9769
|
+
// DOD-M15-FRAME-1: the session-id check moved to the shared gate above, and its `&&` became
|
|
9770
|
+
// `||` on the way. It read `typeof x === "string" && x !== sessionId` — firing only when the
|
|
9771
|
+
// field was PRESENT and wrong, so omitting it passed. Its own sibling twenty lines up already
|
|
9772
|
+
// refused absence, with a comment saying treating a missing field as agreement is how a guard
|
|
9773
|
+
// stops guarding. Same file, same switch, opposite conclusion.
|
|
7105
9774
|
const contentBytes = frame["content_bytes"];
|
|
7106
9775
|
const contentHash = frame["content_hash"];
|
|
7107
9776
|
if (!(contentBytes instanceof Uint8Array) || !(contentHash instanceof Uint8Array)) {
|
|
@@ -7118,17 +9787,70 @@ export class SessionNodeManager {
|
|
|
7118
9787
|
// DOD-MSG-4 (self-ordering content frame): if the frame carries the relay's signed ordering
|
|
7119
9788
|
// record, verify the sender signature and record the canonical sequence FROM THE FRAME, BEFORE
|
|
7120
9789
|
// ingest — so the strict-in-order gate has the position without waiting on the separate
|
|
7121
|
-
// leaf_deliver witness (removes the content-before-witness race).
|
|
7122
|
-
//
|
|
9790
|
+
// leaf_deliver witness (removes the content-before-witness race).
|
|
9791
|
+
//
|
|
9792
|
+
// DOD-M15-FRAME-1 — POSITION MAY BE SOFT; IDENTITY MAY NOT. The old comment here read "A
|
|
9793
|
+
// bad/absent record is non-fatal: the content still ingests", and it was accurate: a
|
|
9794
|
+
// signature that failed to verify, and a signature by a key that is NOT this session's
|
|
9795
|
+
// counterparty, both returned null and the content was ingested and attributed anyway. An
|
|
9796
|
+
// ABSENT record stays soft — that is the documented relay-degraded path and refusing it would
|
|
9797
|
+
// make the relay a precondition for reading mail. A record that is PRESENT and REFUTED is a
|
|
9798
|
+
// different fact, and it is now refused.
|
|
7123
9799
|
const s1Cbor = frame["structure1_cbor"];
|
|
7124
9800
|
const s2Cbor = frame["structure2_cbor"];
|
|
7125
9801
|
let framedSeq = null;
|
|
9802
|
+
/**
|
|
9803
|
+
* DOD-M15-SEALWIRE-1 bullet 5. Set ONLY when the ordering record verified — the signature
|
|
9804
|
+
* checked against the pubkey inside the sender's own signed bytes AND the signer matched this
|
|
9805
|
+
* session's counterparty. It is deliberately NOT set on the two soft paths below (no record
|
|
9806
|
+
* supplied; decode failed), because on those the author is attested by local session state
|
|
9807
|
+
* and the transcript row must say so rather than imply a proof it does not have.
|
|
9808
|
+
*/
|
|
9809
|
+
let verifiedAuthorship;
|
|
7126
9810
|
if (s1Cbor instanceof Uint8Array && s2Cbor instanceof Uint8Array) {
|
|
7127
|
-
|
|
9811
|
+
const ordering = this.#recordFrameOrdering(agentName, sessionId, s1Cbor, s2Cbor, contentHash, correlationId);
|
|
9812
|
+
if (ordering.fatal) {
|
|
9813
|
+
await this.#freezeOnIdentityFailure(agentName, sessionId, ordering.fatal.reason, correlationId);
|
|
9814
|
+
return;
|
|
9815
|
+
}
|
|
9816
|
+
framedSeq = ordering.seq;
|
|
9817
|
+
if (ordering.senderPubkey !== undefined && ordering.senderSig !== undefined) {
|
|
9818
|
+
verifiedAuthorship = { senderPubkey: ordering.senderPubkey, senderSig: ordering.senderSig };
|
|
9819
|
+
}
|
|
9820
|
+
}
|
|
9821
|
+
else {
|
|
9822
|
+
/**
|
|
9823
|
+
* Review F3 — THE WEAKER GUARANTEE MUST NOT BE INDISTINGUISHABLE FROM THE STRONGER ONE.
|
|
9824
|
+
*
|
|
9825
|
+
* A frame with no ordering record is still ingested, and that is correct: it is the
|
|
9826
|
+
* documented relay-degraded path, and refusing it would make the relay a precondition for
|
|
9827
|
+
* reading mail. But it means the per-message signer check is **opt-in for the sender** — a
|
|
9828
|
+
* party that passed the peer gate and wants to avoid the comparison simply omits the proof.
|
|
9829
|
+
* Silently, until now: nothing recorded that a message arrived unverified, so the log looked
|
|
9830
|
+
* identical to one where every message had been checked.
|
|
9831
|
+
*
|
|
9832
|
+
* Not fatal, and deliberately not: an absent record proves nothing about the signer, and
|
|
9833
|
+
* refusing on an absence would strand every relay-degraded session. What closes the omission
|
|
9834
|
+
* case is relay-side corroboration — `DOD-M15-CORROBORATE-1` — where the relay holds the
|
|
9835
|
+
* sender's signed hash independently and never routes it through this daemon.
|
|
9836
|
+
*/
|
|
9837
|
+
this.#logger.info("session.content.ordering.absent", {
|
|
9838
|
+
agentName, sessionId, correlationId,
|
|
9839
|
+
impact: "this frame carried no signed ordering record, so its SIGNER was not verified for this message — it was ingested on the strength of the authenticated transport alone",
|
|
9840
|
+
});
|
|
7128
9841
|
}
|
|
7129
9842
|
// AC-001: carry the sender's correlationId from the frame into the receive
|
|
7130
9843
|
// path so both sides log the same flow id (never re-minted on receipt).
|
|
7131
|
-
|
|
9844
|
+
/**
|
|
9845
|
+
* DOD-M15-SEALWIRE-1 part B1 — the algorithm the sender named, taken from the FRAME.
|
|
9846
|
+
*
|
|
9847
|
+
* Read as `unknown` and passed through verbatim, deliberately: `resolveContentHashAlg` is the
|
|
9848
|
+
* one place that decides what a value means, and it distinguishes ABSENT (a peer predating
|
|
9849
|
+
* the field — verify as `sha256`) from a non-string or an unreadable name (refuse by name).
|
|
9850
|
+
* Coercing here would collapse that distinction and turn a version skew into a tamper report.
|
|
9851
|
+
*/
|
|
9852
|
+
const declaredAlg = frame["content_hash_alg"];
|
|
9853
|
+
const ingest = await this.ingestReceivedContent(agentName, sessionId, contentBytes, contentHash, correlationId, framedSeq ?? undefined, declaredAlg === undefined ? undefined : declaredAlg, verifiedAuthorship);
|
|
7132
9854
|
// AC-001: after the content is durably ingested AND its hash cross-check
|
|
7133
9855
|
// succeeds, emit an unsigned `persisted` delivery ACK back to the sender. A
|
|
7134
9856
|
// rejected ingest (tamper / not-active) produces NO ACK, so the sender's TTF
|
|
@@ -7789,7 +10511,9 @@ export class SessionNodeManager {
|
|
|
7789
10511
|
const sessionId = `standing_receiver_${randomUUID()}`;
|
|
7790
10512
|
const gater = new SessionConnectionGater({
|
|
7791
10513
|
sessionId,
|
|
7792
|
-
|
|
10514
|
+
// No named peer: admits NOBODY inbound until a session offer names the dialer, while leaving
|
|
10515
|
+
// this node's own outbound errands open (DOD-M15-ASSIGN-1). It does NOT mean "open".
|
|
10516
|
+
allowedPeerId: null,
|
|
7793
10517
|
logger: this.#logger,
|
|
7794
10518
|
});
|
|
7795
10519
|
// DOD-NAT-REACHABILITY-1: reserve with the agent's known relays. The relay
|
|
@@ -7882,6 +10606,10 @@ export class SessionNodeManager {
|
|
|
7882
10606
|
const reservedRelayPeerId = circuitAddrs > 0
|
|
7883
10607
|
? (heldCircuitAddr?.match(CIRCUIT_RELAY_ID)?.[1] ?? reservations.addrs[0]?.match(CIRCUIT_RELAY_ID)?.[1])
|
|
7884
10608
|
: undefined;
|
|
10609
|
+
// DOD-M15-ASSIGN-1 review N3: the ONE relay this receiver actually reserved with earns the
|
|
10610
|
+
// inbound AutoNAT carve-out — nothing else does. Set only when a reservation genuinely
|
|
10611
|
+
// completed, so a directory that merely NAMES a relay cannot dial in behind it.
|
|
10612
|
+
gater.setReservedRelayPeer(circuitAddrs > 0 && reservedRelayPeerId !== undefined ? reservedRelayPeerId : null);
|
|
7885
10613
|
this.#standingReceivers.set(agentName, {
|
|
7886
10614
|
node,
|
|
7887
10615
|
gater,
|
|
@@ -8207,9 +10935,47 @@ export class SessionNodeManager {
|
|
|
8207
10935
|
const live = this.#activeNodes.get(key);
|
|
8208
10936
|
if (live)
|
|
8209
10937
|
return { ok: true, peerId: live.node.getPeerId() };
|
|
10938
|
+
// PARITY with `acceptSession` — and the parity guard in msg-022 is what caught its absence.
|
|
10939
|
+
// A revived session's offer record has almost always been cleared already (it was cleared when
|
|
10940
|
+
// the session was first accepted), so this is usually a no-op. It is here because "usually a
|
|
10941
|
+
// no-op" is not a reason for establishment and revival to do different things: every divergence
|
|
10942
|
+
// between those two paths in this file has been a defect, and the guard exists because one of
|
|
10943
|
+
// them shipped past a green suite for two days.
|
|
10944
|
+
this.clearOfferedDialer(agentName, sessionId);
|
|
8210
10945
|
const record = this.getSessionRecord(agentName, sessionId);
|
|
8211
10946
|
if (!record)
|
|
8212
10947
|
return { ok: false, reason: "session_not_found" };
|
|
10948
|
+
/**
|
|
10949
|
+
* A REVIVED SESSION GETS ITS SEAL CHANCES BACK — `DOD-M15-SEAL-FAILED-TERMINAL-1` review
|
|
10950
|
+
* MEDIUM-6, and without this a receipt can be lost permanently and silently.
|
|
10951
|
+
*
|
|
10952
|
+
* `restart_seal_gave_up_at` is written when the restart resolver exhausts its attempts, and
|
|
10953
|
+
* NOTHING ever cleared it. Its stated purpose is narrow — *"a machine restarting ~6 times a day
|
|
10954
|
+
* must not re-run five ceremonies against a hopeless session on every boot"* — and a session
|
|
10955
|
+
* being revived is the opposite of hopeless: something is talking to it again.
|
|
10956
|
+
*
|
|
10957
|
+
* The path it closes: resolver gives up → the column is stamped → the session is REVIVED and
|
|
10958
|
+
* carries live traffic → it is closed → the background ceremony dies → the in-memory failure
|
|
10959
|
+
* marker is lost at the next restart → `listRestartOrphanedSessions` excludes the row forever on
|
|
10960
|
+
* this column → and `listExpiredUnrevivableSessions` explicitly INCLUDES
|
|
10961
|
+
* `restart_seal_gave_up_at IS NOT NULL`, so the revival sweep force-abandons it. Receipt gone,
|
|
10962
|
+
* with no surface having ever said so.
|
|
10963
|
+
*
|
|
10964
|
+
* Bounded, because revival is not a boot-loop: it takes a live counterparty or an operator read.
|
|
10965
|
+
*/
|
|
10966
|
+
// One statement, gated in SQL rather than on a field: `SessionRecord` does not carry this column
|
|
10967
|
+
// and widening the type to read it once would spread it through every consumer. `changes` tells
|
|
10968
|
+
// us whether it actually cleared, so the log stays a signal instead of firing on every revival.
|
|
10969
|
+
const clearedGaveUp = this.#db
|
|
10970
|
+
?.prepare("UPDATE sessions SET restart_seal_gave_up_at = NULL, restart_seal_gave_up_reason = NULL " +
|
|
10971
|
+
"WHERE agent_id = ? AND session_id = ? AND restart_seal_gave_up_at IS NOT NULL")
|
|
10972
|
+
.run(this.resolveAgentId(agentName), sessionId);
|
|
10973
|
+
if ((clearedGaveUp?.changes ?? 0) > 0) {
|
|
10974
|
+
this.#logger.info("session.restart_seal.gave_up.cleared", {
|
|
10975
|
+
agentName, sessionId,
|
|
10976
|
+
impact: "this session is eligible for restart-seal recovery again — it is being revived, so it is not hopeless.",
|
|
10977
|
+
});
|
|
10978
|
+
}
|
|
8213
10979
|
if (record.status === "sealed" || record.status === "abandoned" || record.status === "seal_interrupted_pending") {
|
|
8214
10980
|
return {
|
|
8215
10981
|
ok: false,
|
|
@@ -8217,6 +10983,39 @@ export class SessionNodeManager {
|
|
|
8217
10983
|
guidance: `Session is '${record.status}'. A session that has ended cannot be revived; start a new one.`,
|
|
8218
10984
|
};
|
|
8219
10985
|
}
|
|
10986
|
+
/**
|
|
10987
|
+
* DOD-M15-FRAME-1 (review F1) — A DEFENSIVE FREEZE MUST NOT UNDO ITSELF ON THE NEXT READ.
|
|
10988
|
+
*
|
|
10989
|
+
* `#freezeOnIdentityFailure` tears the node down, and a teardown writes status `interrupted`.
|
|
10990
|
+
* `interrupted` is not terminal — it is the *revivable* status — so `reviveIfNeededForRead`
|
|
10991
|
+
* fired on the operator's very next `cello_receive`, rebuilt a node behind a gater allowing the
|
|
10992
|
+
* SAME counterparty peer, flipped the row back to `active`, and logged it as a success.
|
|
10993
|
+
*
|
|
10994
|
+
* The freeze therefore lasted until the next keystroke, while the log line said *"no further
|
|
10995
|
+
* content will be accepted on this session"*. A security decision that silently reverses itself,
|
|
10996
|
+
* with a message asserting the opposite, is a worse defect than the one the freeze was added to
|
|
10997
|
+
* fix — and it is the class this milestone exists to remove, reintroduced by its own fix.
|
|
10998
|
+
*
|
|
10999
|
+
* Checked BEFORE the cap and after the terminal statuses, so the answer names the freeze rather
|
|
11000
|
+
* than whatever else the session would have been refused for.
|
|
11001
|
+
*
|
|
11002
|
+
* In memory, and so lost on a daemon restart — the same bound as `DOD-M15-DIVERGE-DURABLE-1`
|
|
11003
|
+
* and for the same reason. The durable column is `DOD-M15-FREEZE-STATUS-1`; the reversibility
|
|
11004
|
+
* could not wait for it.
|
|
11005
|
+
*/
|
|
11006
|
+
const frozen = this.#frozenSessions.get(key);
|
|
11007
|
+
if (frozen) {
|
|
11008
|
+
// The REASON and the GUIDANCE both come from the site that froze it. Hardcoding them here was
|
|
11009
|
+
// correct while an identity failure was the only way in, and became a false accusation the
|
|
11010
|
+
// moment a second one existed — see the note on `#frozenSessions`.
|
|
11011
|
+
return {
|
|
11012
|
+
ok: false,
|
|
11013
|
+
reason: frozen.reason,
|
|
11014
|
+
guidance: `${frozen.guidance} It is not revived automatically, and reading or sending will not clear it. ` +
|
|
11015
|
+
`Your transcript up to the freeze is intact: cello_transcript ${sessionId} reads it. ` +
|
|
11016
|
+
`To end the session and keep what it earned, close it — cello_close_session ${sessionId}. To talk to them again, start a fresh session rather than reviving this one.`,
|
|
11017
|
+
};
|
|
11018
|
+
}
|
|
8220
11019
|
/**
|
|
8221
11020
|
* THE CAP APPLIES TO A REVIVAL TOO (review: parity gap). Establishment refuses at
|
|
8222
11021
|
* `MAX_SESSION_NODES` because each node is a real libp2p instance with listeners, connections
|
|
@@ -8767,6 +11566,19 @@ export class SessionNodeManager {
|
|
|
8767
11566
|
*/
|
|
8768
11567
|
if (status === "sealed" || status === "abandoned") {
|
|
8769
11568
|
this.#destroySessionSeed(agentName, sessionId);
|
|
11569
|
+
// DOD-M15-DIVERGE-1: divergence stops being true HERE and only here. It used to be dropped by
|
|
11570
|
+
// `#evictSessionCaches` on every node teardown — including the one that writes `interrupted`,
|
|
11571
|
+
// which is a status the seal gate still acts on, so the fact was forgotten while it was still
|
|
11572
|
+
// load-bearing. A terminal status is the one point at which no future close can be refused,
|
|
11573
|
+
// so the flag has nothing left to protect.
|
|
11574
|
+
this.#diverged.delete(this.#k(agentName, sessionId));
|
|
11575
|
+
// DURABLE too (DOD-M15-DIVERGE-DURABLE-1) — otherwise a sealed session comes back after a
|
|
11576
|
+
// restart still carrying a refusal for a close that can no longer happen.
|
|
11577
|
+
// (agent_id, session_id) — see markSessionDiverged. Unkeyed, one side sealing cleared the
|
|
11578
|
+
// OTHER side's divergence on a loopback session.
|
|
11579
|
+
this.#db
|
|
11580
|
+
?.prepare("UPDATE sessions SET diverged_at = NULL WHERE agent_id = ? AND session_id = ?")
|
|
11581
|
+
.run(this.#requireAgentId(agentName), sessionId);
|
|
8770
11582
|
}
|
|
8771
11583
|
// THE TERMINAL GUARD LIVES HERE, not in one wrapper, because there are three writers of
|
|
8772
11584
|
// "sealed": markSealed, destroySessionNode, and retireSession on the witnessed-submit path.
|