@cello-protocol/daemon 0.0.193 → 0.0.194
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/daemon.js +4 -1
- package/dist/daemon.js.map +1 -1
- package/dist/inbound-sessions.d.ts +1 -1
- package/dist/inbound-sessions.d.ts.map +1 -1
- package/dist/inbound-sessions.js +124 -5
- package/dist/inbound-sessions.js.map +1 -1
- package/dist/initiate-session-handler.d.ts.map +1 -1
- package/dist/initiate-session-handler.js +19 -0
- package/dist/initiate-session-handler.js.map +1 -1
- package/dist/refusal-reasons.d.ts +23 -0
- package/dist/refusal-reasons.d.ts.map +1 -1
- package/dist/refusal-reasons.js +66 -0
- package/dist/refusal-reasons.js.map +1 -1
- package/dist/session-node-manager.d.ts +28 -17
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +540 -80
- package/dist/session-node-manager.js.map +1 -1
- package/dist/session-own-chain-store.d.ts +65 -0
- package/dist/session-own-chain-store.d.ts.map +1 -0
- package/dist/session-own-chain-store.js +75 -0
- package/dist/session-own-chain-store.js.map +1 -0
- package/dist/session-relay-client.d.ts +35 -3
- package/dist/session-relay-client.d.ts.map +1 -1
- package/dist/session-relay-client.js +211 -30
- package/dist/session-relay-client.js.map +1 -1
- package/package.json +5 -5
|
@@ -59,6 +59,7 @@ import { extractErrorMessage } from "./error-message.js";
|
|
|
59
59
|
import { terminalRelayRefusal } from "./session-terminal-refusal.js";
|
|
60
60
|
import { RelayReceiptStore } from "./relay-receipt-store.js";
|
|
61
61
|
import { SessionSealLeafStore } from "./session-seal-leaf-store.js";
|
|
62
|
+
import { SessionOwnChainStore } from "./session-own-chain-store.js";
|
|
62
63
|
import { certifiedLeafSetFrom } from "./sealed-leaf-set.js";
|
|
63
64
|
import { addColumnIfMissing } from "./column-birth.js";
|
|
64
65
|
import { quarantineRedaction, retentionSentence } from "./quarantine-framing.js";
|
|
@@ -488,6 +489,20 @@ const AUTHORSHIP_SESSION_MISMATCH = "session_mismatch";
|
|
|
488
489
|
* side looks like, and an error that names a party the code did not check is this milestone's
|
|
489
490
|
* founding defect.
|
|
490
491
|
*/
|
|
492
|
+
/**
|
|
493
|
+
* `DOD-M15-SELFCHAIN-1` — the sender's link to their OWN previous message names content this side
|
|
494
|
+
* did not receive from them as their last one.
|
|
495
|
+
*
|
|
496
|
+
* ⚠️ THIS IS THE ONE THAT MEANS THE ORDER OF THE CONVERSATION IS IN DISPUTE, and it is why it is
|
|
497
|
+
* named apart from the acknowledgement reasons above rather than folded in with them. Those say the
|
|
498
|
+
* sender is wrong about what WE said; this says they are wrong about what THEY said, which is the
|
|
499
|
+
* only thing they cannot be honestly mistaken about for long.
|
|
500
|
+
*
|
|
501
|
+
* ⚠️ AND IT STILL NAMES WHAT WAS OBSERVED, NEVER A CONCLUSION. The same signal is produced by a
|
|
502
|
+
* peer reordering a conversation and by a peer whose own chain record went out of step after a
|
|
503
|
+
* restart, and this side cannot tell them apart.
|
|
504
|
+
*/
|
|
505
|
+
const AUTHORSHIP_SELF_CHAIN_MISMATCH = "self_chain_mismatch";
|
|
491
506
|
/** A v1 claim: it carries no `last_seen_hash`, so it asserts a POSITION and no content at all. */
|
|
492
507
|
const AUTHORSHIP_ACK_HASH_ABSENT = "ack_hash_absent";
|
|
493
508
|
/** The hash names content this side does not hold at the position the claim names. */
|
|
@@ -583,6 +598,14 @@ function carryContentHashInputs(carry) {
|
|
|
583
598
|
}
|
|
584
599
|
return inputs;
|
|
585
600
|
}
|
|
601
|
+
/**
|
|
602
|
+
* How many of the counterparty's content hashes a session remembers for the self-link check.
|
|
603
|
+
*
|
|
604
|
+
* Bounded because a peer feeds it. 256 covers any realistic gap in our own copy of a conversation —
|
|
605
|
+
* a held message, one the inbound screen refused, one lost in flight — while keeping the memory a
|
|
606
|
+
* single session can cost fixed. Past the cap the check gets STRICTER, never looser.
|
|
607
|
+
*/
|
|
608
|
+
const SELF_CHAIN_MEMORY = 256;
|
|
586
609
|
export class SessionNodeManager {
|
|
587
610
|
#factory;
|
|
588
611
|
#logger;
|
|
@@ -599,6 +622,8 @@ export class SessionNodeManager {
|
|
|
599
622
|
#relayReceiptStore = null;
|
|
600
623
|
/** FED-OPTIONB-SEAL-001: the per-session leaf log (both parties) carried at a unilateral seal. */
|
|
601
624
|
#sealLeafStore = null;
|
|
625
|
+
/** `DOD-M15-SELFCHAIN-1` — this agent's own last message per session, so the next one links to it. */
|
|
626
|
+
#ownChainStore = null;
|
|
602
627
|
// M9-CORE-001: the inbound screening seam. Every byte that reaches the agent passes
|
|
603
628
|
// through #appendVerifiedContent's buffer write; screenInbound gates it there, on every
|
|
604
629
|
// arrival path (direct, held-release, recovered-park). Defaults to always-allow when no
|
|
@@ -678,6 +703,7 @@ export class SessionNodeManager {
|
|
|
678
703
|
client = this.#detachedRelayClientBuilder?.(agentName, ep.relayPeerId, [...ep.relayAddrs], {
|
|
679
704
|
receiptStore: this.#relayReceiptStore ?? undefined,
|
|
680
705
|
sealLeafStore: this.#sealLeafStore ?? undefined,
|
|
706
|
+
ownChainStore: this.#ownChainStore ?? undefined,
|
|
681
707
|
// DOD-M15-RELAYSLOTS-1: read at each auth, never snapshotted — the token expires hourly.
|
|
682
708
|
onlineToken: () => this.getDirectoryOnlineToken(agentName),
|
|
683
709
|
});
|
|
@@ -791,6 +817,7 @@ export class SessionNodeManager {
|
|
|
791
817
|
client = this.#detachedRelayClientBuilder?.(agentName, relayPeerId, [baseRelayAddr], {
|
|
792
818
|
receiptStore: this.#relayReceiptStore ?? undefined,
|
|
793
819
|
sealLeafStore: this.#sealLeafStore ?? undefined,
|
|
820
|
+
ownChainStore: this.#ownChainStore ?? undefined,
|
|
794
821
|
// DOD-M15-RELAYSLOTS-1: read at each auth, never snapshotted — the token expires hourly.
|
|
795
822
|
onlineToken: () => this.getDirectoryOnlineToken(agentName),
|
|
796
823
|
});
|
|
@@ -1995,6 +2022,20 @@ export class SessionNodeManager {
|
|
|
1995
2022
|
this.#logger.info("persist.db.opened", { encrypted: true, migrated: migration.migrated });
|
|
1996
2023
|
// PERSIST-002: the identity store (agents + manifest_state) lives in the same encrypted DB.
|
|
1997
2024
|
ensureIdentitySchema(this.#db);
|
|
2025
|
+
/**
|
|
2026
|
+
* ⚠️ THE OWN-CHAIN STORE IS BUILT HERE, NOT LAZILY INSIDE THE RELAY-CLIENT BUILDERS —
|
|
2027
|
+
* `DOD-M15-SELFCHAIN-1`, review F2.
|
|
2028
|
+
*
|
|
2029
|
+
* It was constructed only when a relay client was attached, so a session that never attached one
|
|
2030
|
+
* left it null: nothing was ever recorded, and every message that side sent carried the same
|
|
2031
|
+
* self link. That is the exact defect this unit exists to close, on the path its own comments
|
|
2032
|
+
* call the one that matters most — a conversation that ran while the relay was down is precisely
|
|
2033
|
+
* the one whose order gets disputed later.
|
|
2034
|
+
*
|
|
2035
|
+
* The chain belongs to the AGENT and the SESSION. The relay is how the conversation travels; it
|
|
2036
|
+
* is not what makes the conversation provable.
|
|
2037
|
+
*/
|
|
2038
|
+
this.#ownChainStore = new SessionOwnChainStore(this.#db, this.#logger);
|
|
1998
2039
|
this.#db.exec(`
|
|
1999
2040
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
2000
2041
|
session_id TEXT NOT NULL,
|
|
@@ -4598,6 +4639,20 @@ export class SessionNodeManager {
|
|
|
4598
4639
|
"Close an existing session before starting a new one.",
|
|
4599
4640
|
};
|
|
4600
4641
|
}
|
|
4642
|
+
/**
|
|
4643
|
+
* ⚠️ THE "NO ASSIGNMENT" REFUSAL IS NOT HERE, AND THE PLACE IT MOVED TO IS THE POINT.
|
|
4644
|
+
*
|
|
4645
|
+
* `DOD-M15-SELFCHAIN-1`, ruled 2026-09-06: a session offered with no directory assignment is
|
|
4646
|
+
* suspicious and must be refused and surfaced. It was briefly enforced HERE, and that was the
|
|
4647
|
+
* wrong door: `createSessionNode` also runs on this agent's OWN outbound path, where the
|
|
4648
|
+
* counterparty has no say in whether an assignment exists. A refusal there fires on our own
|
|
4649
|
+
* initiations and says nothing about anyone's conduct.
|
|
4650
|
+
*
|
|
4651
|
+
* A counterparty can only attempt it INBOUND, so that is where it is refused and recorded —
|
|
4652
|
+
* see `inbound-sessions.ts`. What remains true here is the correctness backstop: a session with
|
|
4653
|
+
* no anchor cannot sign a chained message, so the SEND path refuses (`session_unchainable`)
|
|
4654
|
+
* rather than emitting a message whose place could never be proven.
|
|
4655
|
+
*/
|
|
4601
4656
|
// The session node N_A: either a FRESH ephemeral node (default), or — for the initiator
|
|
4602
4657
|
// path (reuseStandingReceiver) — the standing receiver handed off as the session node. The
|
|
4603
4658
|
// latter makes N_A's peer id equal the SESSION endpoint the initiator ADVERTISED to the
|
|
@@ -7276,6 +7331,19 @@ export class SessionNodeManager {
|
|
|
7276
7331
|
if (assignment) {
|
|
7277
7332
|
return computeGenesisPrevRoot(assignment.participantA, assignment.participantB, Uint8Array.from(Buffer.from(sessionId, "hex")), assignment.sessionTimestamp);
|
|
7278
7333
|
}
|
|
7334
|
+
/**
|
|
7335
|
+
* ⚠️ THE IN-MEMORY RECORD, READ BEFORE THE DATABASE — and this ordering is the fix, not a
|
|
7336
|
+
* cache.
|
|
7337
|
+
*
|
|
7338
|
+
* `recordSessionGenesis` is called BEFORE the session node exists, because registering the
|
|
7339
|
+
* session is what seeds the relay client's acknowledgement state and the seed has to be
|
|
7340
|
+
* available by then. At that moment there is no session ROW to write to — `createSessionNode`
|
|
7341
|
+
* inserts it — so a database-only record would still be empty at the one moment it is read.
|
|
7342
|
+
* The row is written from this map when the insert happens, and read back after a restart.
|
|
7343
|
+
*/
|
|
7344
|
+
const recorded = this.#sessionGenesis.get(this.#k(agentName, sessionId));
|
|
7345
|
+
if (recorded)
|
|
7346
|
+
return recorded;
|
|
7279
7347
|
/**
|
|
7280
7348
|
* THE RESTART CASE. A session restored from the database re-registers with no assignment, so
|
|
7281
7349
|
* the derivation above has nothing to work from and the stored copy is the only answer. Read
|
|
@@ -7289,7 +7357,9 @@ export class SessionNodeManager {
|
|
|
7289
7357
|
const bytes = stored instanceof Uint8Array ? stored : Buffer.isBuffer(stored) ? new Uint8Array(stored) : null;
|
|
7290
7358
|
// A stored value of the wrong width is not a genesis. Refusing it here sends the caller down its
|
|
7291
7359
|
// own named refusal, which is a better outcome than signing an acknowledgement of 17 bytes.
|
|
7292
|
-
|
|
7360
|
+
if (bytes && bytes.length === 32)
|
|
7361
|
+
return bytes;
|
|
7362
|
+
return undefined;
|
|
7293
7363
|
}
|
|
7294
7364
|
/**
|
|
7295
7365
|
* Persist the session's genesis prev_root, once, at the moment the assignment arrives.
|
|
@@ -7298,11 +7368,64 @@ export class SessionNodeManager {
|
|
|
7298
7368
|
* change for the life of a session, so the second writer is either redundant or wrong, and the
|
|
7299
7369
|
* first write is the one derived closest to the assignment that opened the session.
|
|
7300
7370
|
*/
|
|
7371
|
+
/**
|
|
7372
|
+
* Record the session's starting point from the ASSIGNMENT — `DOD-M15-SELFCHAIN-1`.
|
|
7373
|
+
*
|
|
7374
|
+
* ⚠️ **CALL THIS BEFORE `createSessionNode` / `acceptSession`, NOT AFTER.** Registering the
|
|
7375
|
+
* session is what seeds the relay client's acknowledgement state, so the value has to exist by
|
|
7376
|
+
* then; recorded afterwards, the first message of the session has nothing to chain to and is
|
|
7377
|
+
* refused. Both the initiator and the responder derive this from the same FROST-signed assignment
|
|
7378
|
+
* before they build anything.
|
|
7379
|
+
*
|
|
7380
|
+
* ⚠️ THE ANCHOR BELONGS TO THE SESSION, NOT TO THE RELAY, and treating it as the relay's was a
|
|
7381
|
+
* real gap. It was derived only when a relay assignment CARRY was present — and that carry is
|
|
7382
|
+
* built only for a relay-mode assignment that also carries a per-node relay signature. So a
|
|
7383
|
+
* direct-mode session, brokered and FROST-signed exactly like any other, recorded no starting
|
|
7384
|
+
* point at all, and every message on it had nothing to chain to.
|
|
7385
|
+
*
|
|
7386
|
+
* Both transport modes get their assignment from the same ceremony and derive the same value from
|
|
7387
|
+
* it. The relay is how the conversation travels; it is not what makes the conversation provable.
|
|
7388
|
+
*/
|
|
7389
|
+
recordSessionGenesis(agentName, sessionId, participantA, participantB, sessionTimestamp) {
|
|
7390
|
+
this.#persistGenesisPrevRoot(agentName, sessionId, {
|
|
7391
|
+
participantA, participantB, sessionTimestamp,
|
|
7392
|
+
});
|
|
7393
|
+
}
|
|
7394
|
+
/**
|
|
7395
|
+
* The starting point of each live session's chain, in memory.
|
|
7396
|
+
*
|
|
7397
|
+
* ⚠️ NOT A CACHE OF THE DATABASE — it is the only copy that exists at the moment the value is
|
|
7398
|
+
* first needed. It is recorded before the session node is built, and the session ROW does not
|
|
7399
|
+
* exist until that build inserts it (`#insertSessionRow` writes the column from here). The row is
|
|
7400
|
+
* what survives a restart; this is what the session open itself reads.
|
|
7401
|
+
*/
|
|
7402
|
+
#sessionGenesis = new Map();
|
|
7301
7403
|
#persistGenesisPrevRoot(agentName, sessionId, assignment) {
|
|
7404
|
+
let genesis;
|
|
7405
|
+
try {
|
|
7406
|
+
genesis = computeGenesisPrevRoot(assignment.participantA, assignment.participantB, Uint8Array.from(Buffer.from(sessionId, "hex")), assignment.sessionTimestamp);
|
|
7407
|
+
}
|
|
7408
|
+
catch (err) {
|
|
7409
|
+
/**
|
|
7410
|
+
* The DERIVATION failed, which is a different failure from the write below and must not be
|
|
7411
|
+
* reported as one. It means the assignment's own fields are not what this function needs, and
|
|
7412
|
+
* no amount of database health would help.
|
|
7413
|
+
*/
|
|
7414
|
+
this.#logger.error("session.genesis.derive.failed", {
|
|
7415
|
+
agentName, sessionId,
|
|
7416
|
+
error: err instanceof Error ? err.message : String(err),
|
|
7417
|
+
impact: "this session's starting point could not be computed from its assignment, so nothing " +
|
|
7418
|
+
"sent on it can be chained and every send will be refused by name. The session open " +
|
|
7419
|
+
"continues; the conversation cannot.",
|
|
7420
|
+
});
|
|
7421
|
+
return;
|
|
7422
|
+
}
|
|
7423
|
+
// The in-memory record FIRST, and unconditionally: it is what the session open reads, and it
|
|
7424
|
+
// must not depend on a database write that may not have anywhere to land yet.
|
|
7425
|
+
this.#sessionGenesis.set(this.#k(agentName, sessionId), genesis);
|
|
7302
7426
|
if (!this.#db)
|
|
7303
7427
|
return;
|
|
7304
7428
|
try {
|
|
7305
|
-
const genesis = computeGenesisPrevRoot(assignment.participantA, assignment.participantB, Uint8Array.from(Buffer.from(sessionId, "hex")), assignment.sessionTimestamp);
|
|
7306
7429
|
this.#db
|
|
7307
7430
|
.prepare("UPDATE sessions SET genesis_prev_root = ? WHERE agent_id = ? AND session_id = ? AND genesis_prev_root IS NULL")
|
|
7308
7431
|
.run(Buffer.from(genesis), this.#requireAgentId(agentName), sessionId);
|
|
@@ -7943,6 +8066,10 @@ export class SessionNodeManager {
|
|
|
7943
8066
|
// `finally` retires — same defect, other end, other cap (64 outbound per protocol per
|
|
7944
8067
|
// connection). See the note on #handleContentStream's finally.
|
|
7945
8068
|
let sendStream;
|
|
8069
|
+
// 🔗 `DOD-M15-SELFCHAIN-1`: true when THIS side built the claim, so this side owns advancing its
|
|
8070
|
+
// chain once the message has gone. Declared out here because the PARK path — which is also a
|
|
8071
|
+
// delivery — runs in the catch below.
|
|
8072
|
+
let ownClaimAwaitingSend = false;
|
|
7946
8073
|
/**
|
|
7947
8074
|
* 034-CARRYLEAF — HOISTED OUT OF THE TRY so the PARK path in the catch can carry them.
|
|
7948
8075
|
*
|
|
@@ -7979,8 +8106,10 @@ export class SessionNodeManager {
|
|
|
7979
8106
|
frameS1 = orderingS1;
|
|
7980
8107
|
frameSig = orderingSig;
|
|
7981
8108
|
let frameS2 = orderingS2;
|
|
8109
|
+
// A relay-witnessed claim advanced the chain on its ack; this flag covers the other case.
|
|
7982
8110
|
if (frameS1 === undefined || frameSig === undefined) {
|
|
7983
8111
|
const own = await this.#signOwnContentClaim(agentName, sessionId, entry, contentHash);
|
|
8112
|
+
ownClaimAwaitingSend = true;
|
|
7984
8113
|
frameS1 = own.structure1;
|
|
7985
8114
|
frameSig = own.signature;
|
|
7986
8115
|
frameS2 = undefined;
|
|
@@ -8155,6 +8284,16 @@ export class SessionNodeManager {
|
|
|
8155
8284
|
// A close that failed for a benign reason costs a redundant park, which the receiver dedups
|
|
8156
8285
|
// on the content hash. A false delivered costs the message.
|
|
8157
8286
|
await stream.close();
|
|
8287
|
+
/**
|
|
8288
|
+
* 🔗 THE MESSAGE HAS GONE, SO THE CHAIN ADVANCES — `DOD-M15-SELFCHAIN-1`, review F7.
|
|
8289
|
+
*
|
|
8290
|
+
* Below `stream.close()` deliberately: close waits for the write buffer to drain, so a reset
|
|
8291
|
+
* mid-flush throws above this line and the bytes never left. Advancing before it would point
|
|
8292
|
+
* the chain at a message the counterparty never saw, and every later message would then be
|
|
8293
|
+
* refused by them for a reason that names tampering.
|
|
8294
|
+
*/
|
|
8295
|
+
if (ownClaimAwaitingSend)
|
|
8296
|
+
this.#advanceOwnChain(agentName, sessionId, entry, contentHash);
|
|
8158
8297
|
this.#clearSessionImpairment(agentName, sessionId, "direct_send", correlationId);
|
|
8159
8298
|
return { ok: true, delivered: true, ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }), ...(sentAuthorship === undefined ? {} : { authorship: sentAuthorship }), ...(relayRefusal === undefined ? {} : { relayRefusal }) };
|
|
8160
8299
|
}
|
|
@@ -8226,6 +8365,17 @@ export class SessionNodeManager {
|
|
|
8226
8365
|
*/
|
|
8227
8366
|
const attempt = await this.#parkContent(agentName, sessionId, hashHex, content, frameS1, orderingS2, contentHashAlg, frameSig, leafKind);
|
|
8228
8367
|
if (attempt.outcome === "parked") {
|
|
8368
|
+
/**
|
|
8369
|
+
* 🔗 A PARK IS A DELIVERY, so the chain advances here too — `DOD-M15-SELFCHAIN-1`.
|
|
8370
|
+
*
|
|
8371
|
+
* The mailbox copy is sealed to the counterparty's long-term key and they WILL open it, so
|
|
8372
|
+
* the message is part of the conversation. Leaving the chain behind here would make this
|
|
8373
|
+
* side's next message link to something the counterparty has already moved past, and they
|
|
8374
|
+
* would refuse it as a broken chain — a false tamper report caused by our own relay being
|
|
8375
|
+
* briefly unreachable.
|
|
8376
|
+
*/
|
|
8377
|
+
if (ownClaimAwaitingSend)
|
|
8378
|
+
this.#advanceOwnChain(agentName, sessionId, entry, contentHash);
|
|
8229
8379
|
this.#noteImpairmentRetention(agentName, sessionId, "parked");
|
|
8230
8380
|
return { ok: true, delivered: false, parked: true, ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }), ...(sentAuthorship === undefined ? {} : { authorship: sentAuthorship }), ...(relayRefusal === undefined ? {} : { relayRefusal }) };
|
|
8231
8381
|
}
|
|
@@ -11789,6 +11939,17 @@ export class SessionNodeManager {
|
|
|
11789
11939
|
throw new Error(`patchRelayClientForTest: no active node for (${agentName}, ${sessionId})`);
|
|
11790
11940
|
entry.relayClient = relayClient;
|
|
11791
11941
|
entry.relaySessionIdBytes = relaySessionIdBytes;
|
|
11942
|
+
/**
|
|
11943
|
+
* ⚠️ REGISTER THE SESSION TOO — the seam must leave the state production leaves.
|
|
11944
|
+
*
|
|
11945
|
+
* A relay client that has never been told about a session holds no starting point for it, and
|
|
11946
|
+
* since `DOD-M15-SELFCHAIN-1` every submit on such a session is refused: there is nothing for
|
|
11947
|
+
* the chain links to anchor to. Production always registers, because attaching a relay is what
|
|
11948
|
+
* registration IS. A seam that attached the client and skipped the registration left fixtures
|
|
11949
|
+
* exercising a refusal path, and the failure surfaced as "the seal never happened" in a test
|
|
11950
|
+
* about away-mode replies.
|
|
11951
|
+
*/
|
|
11952
|
+
relayClient.registerSession(Buffer.from(relaySessionIdBytes).toString("hex"), entry.node, undefined, entry.relayAssignment, this.#sessionGenesisPrevRoot(agentName, sessionId));
|
|
11792
11953
|
}
|
|
11793
11954
|
pushReceivedContentForTest(agentName, sessionId, seq, content, senderPubkey) {
|
|
11794
11955
|
this.recordTranscriptMessage(agentName, sessionId, seq, "received", new TextEncoder().encode(content), "test");
|
|
@@ -13169,9 +13330,31 @@ export class SessionNodeManager {
|
|
|
13169
13330
|
* on which side of the send it sat on.
|
|
13170
13331
|
*/
|
|
13171
13332
|
setSessionGenesisForTest(agentName, sessionId, genesis) {
|
|
13172
|
-
|
|
13173
|
-
|
|
13174
|
-
|
|
13333
|
+
/**
|
|
13334
|
+
* ⚠️ WRITES THE SAME MAP PRODUCTION WRITES, deliberately — `DOD-M15-SELFCHAIN-1`.
|
|
13335
|
+
*
|
|
13336
|
+
* This seam exists because a fixture builds a session below the paths that derive a starting
|
|
13337
|
+
* point from a directory assignment; it does NOT exist to install a second, quieter source of
|
|
13338
|
+
* the value. Sharing the map means a fixture and a real session read through exactly the same
|
|
13339
|
+
* lookup, so a change to that lookup cannot pass the tests while breaking production.
|
|
13340
|
+
*
|
|
13341
|
+
* ⚠️ CALL IT BEFORE `createSessionNode`, the same rule production follows: registering the
|
|
13342
|
+
* session is what seeds the relay client's acknowledgement state, and a value recorded after
|
|
13343
|
+
* that leaves the first send with nothing to chain to.
|
|
13344
|
+
*/
|
|
13345
|
+
this.#sessionGenesis.set(this.#k(agentName, sessionId), Uint8Array.from(genesis));
|
|
13346
|
+
/**
|
|
13347
|
+
* The durable half is BEST EFFORT. Some fixtures run against a database whose schema was never
|
|
13348
|
+
* created, and a seam that threw there would turn "this fixture has no sessions table" into a
|
|
13349
|
+
* failure of whatever it was actually testing. Harmless when the row does not exist yet either:
|
|
13350
|
+
* `#insertSessionRow` writes the column from the map above.
|
|
13351
|
+
*/
|
|
13352
|
+
try {
|
|
13353
|
+
this.#db
|
|
13354
|
+
?.prepare("UPDATE sessions SET genesis_prev_root = ? WHERE agent_id = ? AND session_id = ?")
|
|
13355
|
+
.run(Buffer.from(genesis), this.#requireAgentId(agentName), sessionId);
|
|
13356
|
+
}
|
|
13357
|
+
catch { /* see above — the in-memory half is the load-bearing one */ }
|
|
13175
13358
|
}
|
|
13176
13359
|
/**
|
|
13177
13360
|
* Test seam: drop the agreed key while leaving the session up — the state before an exchange
|
|
@@ -14730,35 +14913,21 @@ export class SessionNodeManager {
|
|
|
14730
14913
|
?? (() => { const g = this.#sessionGenesisPrevRoot(agentName, sessionId); return g ? { seq: 0, hash: g } : undefined; })();
|
|
14731
14914
|
if (!ack) {
|
|
14732
14915
|
/**
|
|
14733
|
-
* ⚠️
|
|
14734
|
-
* for the receiving half of the same rule, which is what makes this safe rather than a
|
|
14735
|
-
* downgrade.**
|
|
14916
|
+
* ⚠️ NO STARTING POINT MEANS NO SEND — `DOD-M15-SELFCHAIN-1`.
|
|
14736
14917
|
*
|
|
14737
|
-
*
|
|
14738
|
-
*
|
|
14739
|
-
*
|
|
14740
|
-
*
|
|
14741
|
-
* no position. A receiver refuses a v1 claim the moment it acknowledges position 1 or beyond.
|
|
14918
|
+
* This used to emit a shorter claim: `last_seen_seq: 0` with no hashes, on the argument that
|
|
14919
|
+
* "I have seen nothing of yours" is honest and asserts nothing false. It IS honest, and it is
|
|
14920
|
+
* no longer a shape this protocol has. Both chain links are required, and a session with no
|
|
14921
|
+
* recorded starting point has nothing for them to anchor to.
|
|
14742
14922
|
*
|
|
14743
|
-
*
|
|
14744
|
-
*
|
|
14745
|
-
*
|
|
14746
|
-
*
|
|
14923
|
+
* Refusing costs a message on a session that was brokered without an assignment. Sending one
|
|
14924
|
+
* costs the ability to prove the order of the whole conversation later, and the cost is
|
|
14925
|
+
* invisible until someone disputes it — which is exactly the failure this unit exists to end.
|
|
14926
|
+
* Refuse loudly, and say what to do about it.
|
|
14747
14927
|
*/
|
|
14748
|
-
|
|
14749
|
-
|
|
14750
|
-
|
|
14751
|
-
"session has no recorded starting point and nothing has arrived on it yet. It binds the " +
|
|
14752
|
-
"sender and the content as always; it makes no claim about the counterparty's messages.",
|
|
14753
|
-
});
|
|
14754
|
-
const bare = encodeStructure1({
|
|
14755
|
-
contentHash,
|
|
14756
|
-
senderPubkey: await signer.getPublicKey(),
|
|
14757
|
-
sessionId: sessionIdBytes,
|
|
14758
|
-
lastSeenSeq: 0,
|
|
14759
|
-
timestamp: Date.now(),
|
|
14760
|
-
});
|
|
14761
|
-
return { structure1: bare, signature: await signer.sign(bare) };
|
|
14928
|
+
throw new Error("session_unchainable: this session has no recorded starting point on this machine, so a " +
|
|
14929
|
+
"message sent on it could not link to anything and its place in the conversation could " +
|
|
14930
|
+
"never be proven. Restart the session so it is registered with its genesis.");
|
|
14762
14931
|
}
|
|
14763
14932
|
const structure1 = encodeStructure1({
|
|
14764
14933
|
contentHash,
|
|
@@ -14770,9 +14939,76 @@ export class SessionNodeManager {
|
|
|
14770
14939
|
lastSeenSeq: ack.seq,
|
|
14771
14940
|
timestamp: Date.now(),
|
|
14772
14941
|
lastSeenHash: ack.hash,
|
|
14942
|
+
/**
|
|
14943
|
+
* ─── THE SELF LINK ON THE UNWITNESSED PATH — `DOD-M15-SELFCHAIN-1` ────────────────────────
|
|
14944
|
+
*
|
|
14945
|
+
* This is the path that matters most for it. A conversation that ran while the relay was down
|
|
14946
|
+
* is precisely the one whose order gets disputed later, so the chain cannot depend on a relay
|
|
14947
|
+
* having been there to witness it.
|
|
14948
|
+
*
|
|
14949
|
+
* ⚠️ ONE CHAIN, READ THROUGH THE RELAY CLIENT FIRST. This used to read only the durable
|
|
14950
|
+
* store, while the witnessed path reads an in-memory map first — so a session that mixed the
|
|
14951
|
+
* two, which is every session where the relay comes and goes, walked two different chains and
|
|
14952
|
+
* the lagging one produced a link the counterparty refuses.
|
|
14953
|
+
*
|
|
14954
|
+
* ⚠️ AND THE LAST FALLBACK IS THE SESSION GENESIS, NOT `ack.hash`. `ack.hash` is what the
|
|
14955
|
+
* COUNTERPARTY last said; it is only the genesis until they have said anything. Falling back
|
|
14956
|
+
* to it meant this agent's own first message linked to the other party's message — refused by
|
|
14957
|
+
* every checker, and reported as tampering against a party that had done nothing.
|
|
14958
|
+
*/
|
|
14959
|
+
prevOwnHash: this.#ownChainOf(agentName, sessionId, entry, await signer.getPublicKey())
|
|
14960
|
+
?? this.#sessionGenesisPrevRoot(agentName, sessionId)
|
|
14961
|
+
?? ack.hash,
|
|
14773
14962
|
});
|
|
14963
|
+
/**
|
|
14964
|
+
* ⚠️ THE CHAIN IS NOT ADVANCED HERE, AND IT USED TO BE — review F7.
|
|
14965
|
+
*
|
|
14966
|
+
* `SessionOwnChainStore.record` says in capitals that it is called AFTER the send succeeds. This
|
|
14967
|
+
* ran at signing time, before a stream was even opened. Any failure between here and delivery
|
|
14968
|
+
* left the chain pointing at a message the counterparty never saw, and every later message was
|
|
14969
|
+
* then refused by them for a reason that names tampering. A retransmission must re-use the same
|
|
14970
|
+
* predecessor, because a retransmission is the same message.
|
|
14971
|
+
*
|
|
14972
|
+
* `#advanceOwnChain` is called by the send path at each point where the message has actually
|
|
14973
|
+
* gone — the direct send, and the relay park that is the fallback for it.
|
|
14974
|
+
*/
|
|
14774
14975
|
return { structure1, signature: await signer.sign(structure1) };
|
|
14775
14976
|
}
|
|
14977
|
+
/**
|
|
14978
|
+
* This agent's own last message on a session, from the ONE chain both send paths share.
|
|
14979
|
+
*
|
|
14980
|
+
* `undefined` means it has not spoken here yet, which is the session genesis and not an absence.
|
|
14981
|
+
* The caller supplies that; this does not guess.
|
|
14982
|
+
*/
|
|
14983
|
+
#ownChainOf(agentName, sessionId, entry, ownPubkey) {
|
|
14984
|
+
const relayHex = entry?.relaySessionIdBytes
|
|
14985
|
+
? Buffer.from(entry.relaySessionIdBytes).toString("hex")
|
|
14986
|
+
: sessionId;
|
|
14987
|
+
return entry?.relayClient?.lastOwnHash(relayHex)
|
|
14988
|
+
?? this.#ownChainStore?.lastOwnHash(Buffer.from(ownPubkey).toString("hex"), sessionId)
|
|
14989
|
+
?? undefined;
|
|
14990
|
+
}
|
|
14991
|
+
/**
|
|
14992
|
+
* Record what this agent just sent — called ONLY once the message has actually gone.
|
|
14993
|
+
*
|
|
14994
|
+
* Writes through the relay client when there is one, so the in-memory chain the witnessed path
|
|
14995
|
+
* reads and the durable row stay one chain rather than two. Falls back to the store directly for
|
|
14996
|
+
* a session with no relay client at all, which is exactly the unwitnessed case this unit exists
|
|
14997
|
+
* to cover.
|
|
14998
|
+
*/
|
|
14999
|
+
#advanceOwnChain(agentName, sessionId, entry, contentHash) {
|
|
15000
|
+
const relayHex = entry?.relaySessionIdBytes
|
|
15001
|
+
? Buffer.from(entry.relaySessionIdBytes).toString("hex")
|
|
15002
|
+
: sessionId;
|
|
15003
|
+
if (entry?.relayClient) {
|
|
15004
|
+
entry.relayClient.noteOwnLeaf(relayHex, contentHash);
|
|
15005
|
+
return;
|
|
15006
|
+
}
|
|
15007
|
+
const ownPubkeyHex = this.#ownPubkeyHex(agentName);
|
|
15008
|
+
if (!ownPubkeyHex)
|
|
15009
|
+
return;
|
|
15010
|
+
this.#ownChainStore?.record(ownPubkeyHex, sessionId, contentHash, Date.now());
|
|
15011
|
+
}
|
|
14776
15012
|
/**
|
|
14777
15013
|
* `DOD-M15-AUTHORSHIP-ABSENT-1` — DID THIS SENDER PROVE THEY WROTE THIS MESSAGE?
|
|
14778
15014
|
*
|
|
@@ -14925,6 +15161,20 @@ export class SessionNodeManager {
|
|
|
14925
15161
|
const ackVerdict = this.#verifyAcknowledgedContent(agentName, sessionId, s1.fields);
|
|
14926
15162
|
if (ackVerdict)
|
|
14927
15163
|
return ackVerdict;
|
|
15164
|
+
/**
|
|
15165
|
+
* ─── AND IT MUST LINK TO THEIR OWN PREVIOUS MESSAGE — `DOD-M15-SELFCHAIN-1` ──────────────────
|
|
15166
|
+
*
|
|
15167
|
+
* The check above asks whether they are right about what WE said. This one asks whether they
|
|
15168
|
+
* are right about what THEY said, and it is the check that makes the ORDER of the conversation
|
|
15169
|
+
* provable rather than merely its contents.
|
|
15170
|
+
*
|
|
15171
|
+
* **THIS IS THE HALF THAT NEEDS NO RELAY**, in the strongest sense: the expected value is the
|
|
15172
|
+
* last message we received from them, which is a fact about our own inbox. A relay that is
|
|
15173
|
+
* absent, slow, colluding or lying cannot change it, and cannot wave a broken chain past us.
|
|
15174
|
+
*/
|
|
15175
|
+
const chainVerdict = this.#verifySenderSelfChain(agentName, sessionId, s1.fields);
|
|
15176
|
+
if (chainVerdict)
|
|
15177
|
+
return chainVerdict;
|
|
14928
15178
|
return { verdict: "verified", senderPubkey: s1Pubkey, senderSig: senderSignature };
|
|
14929
15179
|
}
|
|
14930
15180
|
/**
|
|
@@ -14942,6 +15192,120 @@ export class SessionNodeManager {
|
|
|
14942
15192
|
* width, so `lastSeenHash === null` here means exactly one thing — a v1 layout — and it is
|
|
14943
15193
|
* refused by its own name.
|
|
14944
15194
|
*/
|
|
15195
|
+
/**
|
|
15196
|
+
* Does this claim link to the last message we actually received from this sender?
|
|
15197
|
+
*
|
|
15198
|
+
* ─── Why the expected value is our INBOX and not our tree ──────────────────────────────────────
|
|
15199
|
+
*
|
|
15200
|
+
* The tree holds every leaf in canonical order and records no authorship, so it cannot say which
|
|
15201
|
+
* of them this sender wrote. What can is the acknowledgement state this daemon already keeps: the
|
|
15202
|
+
* last content hash received from the counterparty, updated on every message as it is ingested.
|
|
15203
|
+
* That IS their previous message, by definition.
|
|
15204
|
+
*
|
|
15205
|
+
* It is seeded at session registration with the session GENESIS, so a sender's first message has
|
|
15206
|
+
* a real expected value rather than a special case — "they have not spoken here yet" is a value,
|
|
15207
|
+
* derived per session, and not an absence.
|
|
15208
|
+
*
|
|
15209
|
+
* ⚠️ RUNS BEFORE THIS MESSAGE IS INGESTED, which is what makes the comparison meaningful: the
|
|
15210
|
+
* state still holds their PREVIOUS message. Moving this after ingest would compare a message to
|
|
15211
|
+
* itself and pass every time.
|
|
15212
|
+
*/
|
|
15213
|
+
#verifySenderSelfChain(agentName, sessionId, fields) {
|
|
15214
|
+
const key = this.#k(agentName, sessionId);
|
|
15215
|
+
const genesis = this.#sessionGenesisPrevRoot(agentName, sessionId);
|
|
15216
|
+
/**
|
|
15217
|
+
* ⚠️ THE EXPECTED VALUE IS OUR INBOX — `#lastAck` — AND NOT THE SESSION GENESIS.
|
|
15218
|
+
*
|
|
15219
|
+
* `#lastFromCounterparty` is the last content hash we accepted FROM THIS COUNTERPARTY, written
|
|
15220
|
+
* on every successful ingest. That IS their previous message, by definition. The genesis is only
|
|
15221
|
+
* the answer before they have said anything.
|
|
15222
|
+
*
|
|
15223
|
+
* It used to read the relay client's acknowledgement with the genesis as a fallback, which is
|
|
15224
|
+
* the same conflation the emitter had: on a session with no relay client the fallback never
|
|
15225
|
+
* moved, so the counterparty's SECOND message was refused as a broken chain for the rest of the
|
|
15226
|
+
* conversation. Four live-transport fixtures caught it the moment the emitter started producing
|
|
15227
|
+
* a real chain.
|
|
15228
|
+
*/
|
|
15229
|
+
const expected = this.#lastFromCounterparty.get(key) ?? genesis;
|
|
15230
|
+
/**
|
|
15231
|
+
* ⚠️ NO EXPECTED VALUE MEANS NO COMPARISON, AND THIS IS THE ONE PLACE THAT IS NOT A FAIL-OPEN —
|
|
15232
|
+
* because of WHO CONTROLS THE ABSENCE. Whether this side holds a starting point for the session
|
|
15233
|
+
* depends on our own assignment and our own database. Nothing the sender puts on the wire can
|
|
15234
|
+
* cause it, so it is not a switch they can reach for. A session restored from a row written
|
|
15235
|
+
* before this existed is the real case, and refusing there would refuse every message on it for
|
|
15236
|
+
* something the counterparty did not do.
|
|
15237
|
+
*/
|
|
15238
|
+
if (!expected) {
|
|
15239
|
+
this.#logger.info("session.content.self_chain.unverifiable", {
|
|
15240
|
+
agentName, sessionId,
|
|
15241
|
+
impact: "this side holds no record of this sender's previous message in this session, so the " +
|
|
15242
|
+
"link inside their signed bytes was not compared. The message is accepted; it is already " +
|
|
15243
|
+
"bound to this conversation and to its author by the checks above.",
|
|
15244
|
+
});
|
|
15245
|
+
return undefined;
|
|
15246
|
+
}
|
|
15247
|
+
if (bytesEqual(fields.prevOwnHash, expected))
|
|
15248
|
+
return undefined;
|
|
15249
|
+
/**
|
|
15250
|
+
* ─── OUR OWN GAP IS NOT THEIR TAMPERING, AND THE DIFFERENCE IS DECIDABLE ─────────────────────
|
|
15251
|
+
*
|
|
15252
|
+
* `expected` is the last message from them we ACCEPTED. Our record can legitimately be behind
|
|
15253
|
+
* theirs: a message of theirs can arrive out of order and be HELD, be refused by the inbound
|
|
15254
|
+
* screen, or be lost in flight. In every one of those cases their next message links to
|
|
15255
|
+
* something real that we simply do not have at the front of our record — and refusing it would
|
|
15256
|
+
* be a fabricated tamper report caused by our own gap, against a party that did nothing.
|
|
15257
|
+
*
|
|
15258
|
+
* So a link naming ANY message we have accepted from them is accepted, and the gap is reported
|
|
15259
|
+
* as OUR problem. A link naming something we have never held from them is the actual accusation:
|
|
15260
|
+
* it is either invented or it belongs to a conversation this is not.
|
|
15261
|
+
*
|
|
15262
|
+
* ⚠️ THIS IS DELIBERATELY WEAKER THAN THE RELAY'S CHECK, AND SAYING SO IS THE POINT. The relay
|
|
15263
|
+
* holds the whole ordered log and refuses anything but the immediate predecessor; the directory
|
|
15264
|
+
* does the same at seal time. This side holds only what reached it, so the strongest honest
|
|
15265
|
+
* question it can ask is "did you name something you actually said to me?". Two strict checkers
|
|
15266
|
+
* plus one honest one beats three checkers where the weakest one fabricates accusations.
|
|
15267
|
+
*/
|
|
15268
|
+
const seen = this.#receivedFromCounterparty.get(key);
|
|
15269
|
+
if (seen?.has(Buffer.from(fields.prevOwnHash).toString("hex"))) {
|
|
15270
|
+
this.#logger.info("session.content.self_chain.behind", {
|
|
15271
|
+
agentName, sessionId,
|
|
15272
|
+
impact: "this message links to an earlier message from your counterparty than the last one this " +
|
|
15273
|
+
"side accepted, so this side's copy of the conversation has a gap in it. The message is " +
|
|
15274
|
+
"accepted — the link names something they really did send you. The gap is on this side.",
|
|
15275
|
+
});
|
|
15276
|
+
return undefined;
|
|
15277
|
+
}
|
|
15278
|
+
return { verdict: "unusable", reason: AUTHORSHIP_SELF_CHAIN_MISMATCH };
|
|
15279
|
+
}
|
|
15280
|
+
/**
|
|
15281
|
+
* Every content hash accepted from the counterparty on a session, so a link to one of THEIR
|
|
15282
|
+
* earlier messages can be told apart from a link to something they never sent.
|
|
15283
|
+
*
|
|
15284
|
+
* ⚠️ BOUNDED, because it is fed by a peer. A conversation must not cost unbounded memory because
|
|
15285
|
+
* the other side kept talking: the oldest entries are dropped past the cap, and a link older than
|
|
15286
|
+
* that is refused. That is the right way round — the cap makes the check STRICTER as it bites,
|
|
15287
|
+
* never looser.
|
|
15288
|
+
*/
|
|
15289
|
+
#receivedFromCounterparty = new Map();
|
|
15290
|
+
/** The MOST RECENT of those, which is what an honest next message links to. */
|
|
15291
|
+
#lastFromCounterparty = new Map();
|
|
15292
|
+
#noteReceivedFromCounterparty(agentName, sessionId, contentHash) {
|
|
15293
|
+
const key = this.#k(agentName, sessionId);
|
|
15294
|
+
this.#lastFromCounterparty.set(key, Uint8Array.from(contentHash));
|
|
15295
|
+
let seen = this.#receivedFromCounterparty.get(key);
|
|
15296
|
+
if (!seen) {
|
|
15297
|
+
seen = new Set();
|
|
15298
|
+
this.#receivedFromCounterparty.set(key, seen);
|
|
15299
|
+
}
|
|
15300
|
+
seen.add(Buffer.from(contentHash).toString("hex"));
|
|
15301
|
+
while (seen.size > SELF_CHAIN_MEMORY) {
|
|
15302
|
+
// Sets iterate in insertion order, so the first entry is the oldest.
|
|
15303
|
+
const oldest = seen.values().next().value;
|
|
15304
|
+
if (oldest === undefined)
|
|
15305
|
+
break;
|
|
15306
|
+
seen.delete(oldest);
|
|
15307
|
+
}
|
|
15308
|
+
}
|
|
14945
15309
|
#verifyAcknowledgedContent(agentName, sessionId, fields) {
|
|
14946
15310
|
/**
|
|
14947
15311
|
* ⚠️ **A v1 CLAIM IS REFUSED THE MOMENT IT NAMES A POSITION — and accepted when it names none.
|
|
@@ -14964,11 +15328,15 @@ export class SessionNodeManager {
|
|
|
14964
15328
|
* AHEAD of its counter, never one that lags). This unit does not change that either way, and
|
|
14965
15329
|
* the follow-on that does is the receiver submitting a hash for what it received.
|
|
14966
15330
|
*/
|
|
14967
|
-
|
|
14968
|
-
|
|
14969
|
-
|
|
14970
|
-
|
|
14971
|
-
|
|
15331
|
+
/**
|
|
15332
|
+
* ⚠️ THE "NO ACKNOWLEDGEMENT AT ALL" BRANCH IS GONE, and its absence is the point.
|
|
15333
|
+
*
|
|
15334
|
+
* It used to accept a claim carrying no `last_seen_hash` as long as it also named no position —
|
|
15335
|
+
* honest, and a shape a peer could choose. `DOD-M15-SELFCHAIN-1` deleted every layout that can
|
|
15336
|
+
* express it: there is one Structure 1 and both chain links are required, so a claim without one
|
|
15337
|
+
* does not decode at all and never reaches this method. `AUTHORSHIP_ACK_HASH_ABSENT` is kept as
|
|
15338
|
+
* a reason because the wording that routes off it is still reachable from other callers.
|
|
15339
|
+
*/
|
|
14972
15340
|
/**
|
|
14973
15341
|
* THE GENESIS IS A VALUE, NEVER AN ABSENCE. The first message of a session has seen nothing, and
|
|
14974
15342
|
* that case is a defined 32 bytes — the agreed starting point of this two-party chain, derived
|
|
@@ -15113,13 +15481,26 @@ export class SessionNodeManager {
|
|
|
15113
15481
|
* conclusion the code did not reach is the error-fidelity defect this milestone exists
|
|
15114
15482
|
* for.
|
|
15115
15483
|
*/
|
|
15116
|
-
|
|
15117
|
-
|
|
15118
|
-
|
|
15119
|
-
|
|
15120
|
-
|
|
15121
|
-
|
|
15122
|
-
|
|
15484
|
+
/**
|
|
15485
|
+
* `DOD-M15-SELFCHAIN-1` — THE ORDER, NOT THE CONTENT, AND IT NEEDS ITS OWN WORDS.
|
|
15486
|
+
*
|
|
15487
|
+
* This reached the operator under the generic `authorship_proof_unusable` sentence, which
|
|
15488
|
+
* says the proof was "unreadable, or signed over different content". Neither is true: the
|
|
15489
|
+
* proof is perfect and it is about this conversation. What is in dispute is WHERE this
|
|
15490
|
+
* message sits — and telling someone their decoder failed sends them to audit the wrong
|
|
15491
|
+
* subsystem entirely. The reason's own comment claimed it was "named apart from the
|
|
15492
|
+
* acknowledgement reasons"; the surface collapsed it back, which is error substitution on
|
|
15493
|
+
* the strongest evidence this protocol can produce.
|
|
15494
|
+
*/
|
|
15495
|
+
: reason === AUTHORSHIP_SELF_CHAIN_MISMATCH
|
|
15496
|
+
? "a message arrived that is genuinely from your counterparty, about this conversation, and correctly signed — and it names a message of THEIR OWN that they never sent you. Each message says which of their own came before it, and that is what fixes the ORDER of the conversation. This one points somewhere your record has never been. It was NOT ingested and NOT shown."
|
|
15497
|
+
: reason === AUTHORSHIP_ACK_HASH_ABSENT
|
|
15498
|
+
? "a message arrived that is genuinely from your counterparty and genuinely about this conversation — and it does not say which of your messages they had received. Their build is older than yours: a message has to say what it is answering, so that nobody can later leave your last message out of the receipt. It was NOT ingested and NOT shown."
|
|
15499
|
+
: reason === AUTHORSHIP_ACK_HASH_MISMATCH
|
|
15500
|
+
? "a message arrived that is genuinely from your counterparty — and it names a DIFFERENT message of yours in the position where your own record holds one. Both sides agree the message exists; you disagree about which one sits there. It was NOT ingested and NOT shown."
|
|
15501
|
+
: reason === AUTHORSHIP_ACK_HASH_UNKNOWN
|
|
15502
|
+
? "a message arrived that is genuinely from your counterparty — and it says they received something from you that this side has no record of ever holding. It was NOT ingested and NOT shown. This is the check that stops someone quietly rewriting what was said before the receipt is made."
|
|
15503
|
+
: "a message arrived whose proof of authorship could not be checked against it — it was unreadable, or it was signed over different content. It was NOT ingested, NOT shown and NOT attributed to anyone.";
|
|
15123
15504
|
/**
|
|
15124
15505
|
* ⚠️ THE VERB IS THE COUNTERPARTY'S, AND THE GUIDANCE SAYS SO. The reader is the RECEIVING
|
|
15125
15506
|
* operator, and there is nothing on their machine to change — the missing signature is produced
|
|
@@ -15175,30 +15556,63 @@ export class SessionNodeManager {
|
|
|
15175
15556
|
* abandon the conversation. Two is the cap on each (Invariant 4); the verb is the
|
|
15176
15557
|
* counterparty's in every case, because there is nothing to change on this machine.
|
|
15177
15558
|
*/
|
|
15178
|
-
: reason ===
|
|
15179
|
-
? "STOPPED ON PURPOSE, and this is
|
|
15559
|
+
: reason === AUTHORSHIP_SELF_CHAIN_MISMATCH
|
|
15560
|
+
? "STOPPED ON PURPOSE, and this is the most serious of these refusals. " +
|
|
15180
15561
|
(this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE) +
|
|
15181
|
-
" Their
|
|
15182
|
-
"
|
|
15183
|
-
"
|
|
15184
|
-
|
|
15185
|
-
|
|
15186
|
-
|
|
15187
|
-
|
|
15188
|
-
|
|
15189
|
-
|
|
15190
|
-
|
|
15191
|
-
"carry on in this one."
|
|
15192
|
-
: "STOPPED ON PURPOSE. This copy was refused and the message itself was not kept. " +
|
|
15193
|
-
// Review F2: chosen from what THIS machine can do, not asserted. An agent with no identity
|
|
15194
|
-
// key cannot open a mailbox copy either, and telling them to wait for one would be the same
|
|
15195
|
-
// false promise on a different refusal.
|
|
15562
|
+
" Their signature is real and it is about this conversation. What does not hold is the " +
|
|
15563
|
+
"ORDER: this message names one of their own as the one before it, and that message was " +
|
|
15564
|
+
"never sent to you. Either something between you is rearranging what they say, or their " +
|
|
15565
|
+
"agent's record of what it has said went out of step. THIS SESSION IS NOW FROZEN — no " +
|
|
15566
|
+
"further message on it will be accepted, because carrying on writes a disputed order into " +
|
|
15567
|
+
"the receipt. Reach them OUT OF BAND — a channel that is not this one — and ask them to " +
|
|
15568
|
+
"read back the last few things they sent you. If it matches what you have, open a NEW " +
|
|
15569
|
+
"session; if it does not, do not."
|
|
15570
|
+
: reason === AUTHORSHIP_ACK_HASH_ABSENT
|
|
15571
|
+
? "STOPPED ON PURPOSE, and this is NOT about their signature — it verified. " +
|
|
15196
15572
|
(this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE) +
|
|
15197
|
-
"
|
|
15198
|
-
"
|
|
15199
|
-
"
|
|
15200
|
-
|
|
15201
|
-
|
|
15573
|
+
" Their build is older than yours and does not say what it has received. Ask which version " +
|
|
15574
|
+
"they are running and tell them to upgrade — only they can fix it, and this will keep " +
|
|
15575
|
+
"happening until they do."
|
|
15576
|
+
/**
|
|
15577
|
+
* ⚠️ THESE TWO SHARED ONE SENTENCE, AND THE TEST THAT WAS MEANT TO CATCH THAT COULD NOT SEE
|
|
15578
|
+
* IT — `DOD-M15-SELFCHAIN-1`.
|
|
15579
|
+
*
|
|
15580
|
+
* The file's thesis is "three causes, three sentences", and it compared ABSENT against
|
|
15581
|
+
* MISMATCH and stopped. Mismatch and unknown-content shared a remedy the whole time; the pair
|
|
15582
|
+
* was never compared, so the defect the test existed for was live inside it.
|
|
15583
|
+
*
|
|
15584
|
+
* They are not the same situation. A MISMATCH means you both agree a message sits at that
|
|
15585
|
+
* position and disagree about which one — a record that has drifted. UNKNOWN CONTENT means
|
|
15586
|
+
* they are acknowledging something this side never held at all, which is the shape of content
|
|
15587
|
+
* being attributed to you that you did not send. The second is the more serious reading and
|
|
15588
|
+
* the operator's next move differs, so it gets its own sentence.
|
|
15589
|
+
*/
|
|
15590
|
+
: reason === AUTHORSHIP_ACK_HASH_MISMATCH
|
|
15591
|
+
? "STOPPED ON PURPOSE, and this is NOT about their signature or their version — both are " +
|
|
15592
|
+
"fine. " +
|
|
15593
|
+
(this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE) +
|
|
15594
|
+
" Your record of this conversation and theirs have stopped agreeing about what you sent " +
|
|
15595
|
+
"them. Confirm with them OUT OF BAND what they actually received from you. If it matches " +
|
|
15596
|
+
"what you sent, this was a fault and a new session will clear it; if it does not, do not " +
|
|
15597
|
+
"carry on in this one."
|
|
15598
|
+
: reason === AUTHORSHIP_ACK_HASH_UNKNOWN
|
|
15599
|
+
? "STOPPED ON PURPOSE, and this one is more serious than a record that has drifted. " +
|
|
15600
|
+
(this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE) +
|
|
15601
|
+
" They are acknowledging a message from you that this side has NEVER held — not at that " +
|
|
15602
|
+
"position, not anywhere. Either their record contains something you did not send, or " +
|
|
15603
|
+
"yours is missing something you did. Ask them OUT OF BAND to read you back what they " +
|
|
15604
|
+
"believe you sent. Do not continue this conversation until you know which of the two it " +
|
|
15605
|
+
"is: carrying on writes their version into the receipt."
|
|
15606
|
+
: "STOPPED ON PURPOSE. This copy was refused and the message itself was not kept. " +
|
|
15607
|
+
// Review F2: chosen from what THIS machine can do, not asserted. An agent with no identity
|
|
15608
|
+
// key cannot open a mailbox copy either, and telling them to wait for one would be the same
|
|
15609
|
+
// false promise on a different refusal.
|
|
15610
|
+
(this.#mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE) +
|
|
15611
|
+
" Almost always their CELLO build is older than this one: a build from before message signing " +
|
|
15612
|
+
"does not attach a signature at all. Ask which version they are running, and tell them to " +
|
|
15613
|
+
"upgrade — this will keep happening until they do, and only they can fix it. If they are on " +
|
|
15614
|
+
"the SAME version as you, that explanation does not hold: confirm with them OUT OF BAND " +
|
|
15615
|
+
"before opening another session.";
|
|
15202
15616
|
this.#logger.error("session.content.refused", {
|
|
15203
15617
|
agentName, sessionId, correlationId, reason, ...detail, impact, guidance,
|
|
15204
15618
|
});
|
|
@@ -15737,18 +16151,41 @@ export class SessionNodeManager {
|
|
|
15737
16151
|
*/
|
|
15738
16152
|
this.#refuseUnprovenAuthorship(agentName, sessionId, authorship.reason === AUTHORSHIP_SESSION_MISMATCH
|
|
15739
16153
|
? "authorship_wrong_conversation"
|
|
15740
|
-
|
|
15741
|
-
|
|
15742
|
-
|
|
15743
|
-
|
|
15744
|
-
|
|
15745
|
-
|
|
15746
|
-
|
|
15747
|
-
|
|
15748
|
-
|
|
15749
|
-
|
|
15750
|
-
|
|
15751
|
-
|
|
16154
|
+
/**
|
|
16155
|
+
* `DOD-M15-SELFCHAIN-1` — its own name on the surface the operator reads, not only in a
|
|
16156
|
+
* log field. See the sentences in `#refuseUnprovenAuthorship`.
|
|
16157
|
+
*/
|
|
16158
|
+
: authorship.reason === AUTHORSHIP_SELF_CHAIN_MISMATCH
|
|
16159
|
+
? AUTHORSHIP_SELF_CHAIN_MISMATCH
|
|
16160
|
+
: ACK_HASH_REASONS.has(authorship.reason)
|
|
16161
|
+
/**
|
|
16162
|
+
* ⚠️ THE SPECIFIC CAUSE, NOT THE CLASS — review F5, and the diff's own comment on
|
|
16163
|
+
* `ACK_HASH_REASONS` had already said why: "the operator's next move differs for
|
|
16164
|
+
* each." It then collapsed all three into ONE surface reason carrying ONE sentence,
|
|
16165
|
+
* so the three names survived only in a log field nobody reads. For an absent
|
|
16166
|
+
* acknowledgement the shared impact was flatly false — there is no part that "does
|
|
16167
|
+
* not match", because there is no part — and for the other two the shared guidance
|
|
16168
|
+
* sent the reader to ask about a build version that cannot be the cause.
|
|
16169
|
+
*/
|
|
16170
|
+
? authorship.reason
|
|
16171
|
+
: "authorship_proof_unusable", contentHash, { detail: authorship.reason }, correlationId);
|
|
16172
|
+
/**
|
|
16173
|
+
* ─── AND THE SESSION FREEZES — `DOD-M15-SELFCHAIN-1`, the escalation clause ──────────────
|
|
16174
|
+
*
|
|
16175
|
+
* ⚠️ ONLY THIS ONE OF THE `unusable` CAUSES FREEZES, and the split is the whole rule.
|
|
16176
|
+
*
|
|
16177
|
+
* The acknowledgement causes say the sender is wrong about what WE said, which a record
|
|
16178
|
+
* that has drifted produces honestly, and refusing the message is proportionate. This one
|
|
16179
|
+
* says they are wrong about what THEY said — the one thing a party cannot be honestly
|
|
16180
|
+
* mistaken about for long — so continuing writes a disputed order into the receipt. There
|
|
16181
|
+
* is nothing to gain from message N+1 on a conversation whose order is already in question.
|
|
16182
|
+
*
|
|
16183
|
+
* The freeze is what makes the refusal an ESCALATION rather than a dropped frame: it is
|
|
16184
|
+
* visible in the session's own state, not only in a notice the operator has to go and read.
|
|
16185
|
+
*/
|
|
16186
|
+
if (authorship.reason === AUTHORSHIP_SELF_CHAIN_MISMATCH) {
|
|
16187
|
+
await this.#freezeOnIdentityFailure(agentName, sessionId, authorship.reason, correlationId);
|
|
16188
|
+
}
|
|
15752
16189
|
return;
|
|
15753
16190
|
}
|
|
15754
16191
|
if (authorship.verdict === "verified") {
|
|
@@ -15803,6 +16240,20 @@ export class SessionNodeManager {
|
|
|
15803
16240
|
// acknowledged `persisted` — the sender's TTF→park backstop then guarantees the
|
|
15804
16241
|
// missing-earlier message is fetchable, and dedup absorbs the redundant copy.
|
|
15805
16242
|
if (ingest.ok && !ingest.held) {
|
|
16243
|
+
/**
|
|
16244
|
+
* ─── THE SELF CHAIN IS PURELY CONTENT, SO IT ADVANCES HERE — `DOD-M15-SELFCHAIN-1` ───────
|
|
16245
|
+
*
|
|
16246
|
+
* ⚠️ NOT INSIDE `#noteAcknowledgeable`, and that placement was the bug. The acknowledgement
|
|
16247
|
+
* is a (POSITION, content) pair and needs the relay's number, so on a session the relay
|
|
16248
|
+
* never witnessed it is never written at all. The self link needs no position — it is one
|
|
16249
|
+
* party's hash chain over their own messages — so tying it to the acknowledgement meant the
|
|
16250
|
+
* receiver's record never moved on an unwitnessed session, and the counterparty's SECOND
|
|
16251
|
+
* message was refused as a broken chain for the rest of the conversation.
|
|
16252
|
+
*
|
|
16253
|
+
* That is the path this unit exists for: a conversation that ran while the relay was down is
|
|
16254
|
+
* precisely the one whose order gets disputed later.
|
|
16255
|
+
*/
|
|
16256
|
+
this.#noteReceivedFromCounterparty(agentName, sessionId, contentHash);
|
|
15806
16257
|
/**
|
|
15807
16258
|
* 033-ACKEMIT review F1 — ACKNOWLEDGE WHAT ARRIVED, HERE, not when the relay gets round to
|
|
15808
16259
|
* delivering its copy back to us.
|
|
@@ -18225,11 +18676,20 @@ export class SessionNodeManager {
|
|
|
18225
18676
|
return false;
|
|
18226
18677
|
const now = Date.now();
|
|
18227
18678
|
try {
|
|
18679
|
+
/**
|
|
18680
|
+
* ⚠️ THE SESSION'S STARTING POINT GOES IN AT INSERT — `DOD-M15-SELFCHAIN-1`.
|
|
18681
|
+
*
|
|
18682
|
+
* It is recorded before this row exists (the session open needs it before the node is built),
|
|
18683
|
+
* so an UPDATE at that moment has nothing to match. Writing it here is what puts it on disk,
|
|
18684
|
+
* and on disk is what lets the chain be resumed after a restart. `null` when nothing recorded
|
|
18685
|
+
* one, which is a session whose sends will be refused by name rather than silently unlinked.
|
|
18686
|
+
*/
|
|
18687
|
+
const genesis = this.#sessionGenesis.get(this.#k(agentName, sessionId));
|
|
18228
18688
|
this.#db
|
|
18229
18689
|
.prepare(`INSERT INTO sessions
|
|
18230
|
-
(session_id, agent_id, counterparty_pubkey, status, created_at, updated_at)
|
|
18231
|
-
VALUES (?, ?, ?, ?, ?, ?)`)
|
|
18232
|
-
.run(sessionId, this.#requireAgentId(agentName), counterpartyPubkey, status, now, now);
|
|
18690
|
+
(session_id, agent_id, counterparty_pubkey, status, created_at, updated_at, genesis_prev_root)
|
|
18691
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
18692
|
+
.run(sessionId, this.#requireAgentId(agentName), counterpartyPubkey, status, now, now, genesis ? Buffer.from(genesis) : null);
|
|
18233
18693
|
return true;
|
|
18234
18694
|
}
|
|
18235
18695
|
catch (err) {
|