@cello-protocol/daemon 0.0.186 → 0.0.188
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.map +1 -1
- package/dist/agent-id-migration.js +5 -0
- package/dist/agent-id-migration.js.map +1 -1
- package/dist/contact-pubkey-case.d.ts +65 -0
- package/dist/contact-pubkey-case.d.ts.map +1 -0
- package/dist/contact-pubkey-case.js +135 -0
- package/dist/contact-pubkey-case.js.map +1 -0
- package/dist/content-park.d.ts.map +1 -1
- package/dist/content-park.js +24 -5
- package/dist/content-park.js.map +1 -1
- package/dist/daemon.js +19 -1
- package/dist/daemon.js.map +1 -1
- package/dist/inbound-sessions.d.ts.map +1 -1
- package/dist/inbound-sessions.js +37 -7
- package/dist/inbound-sessions.js.map +1 -1
- package/dist/notification-handlers.d.ts.map +1 -1
- package/dist/notification-handlers.js +103 -2
- package/dist/notification-handlers.js.map +1 -1
- package/dist/orphan-triage.d.ts +130 -0
- package/dist/orphan-triage.d.ts.map +1 -0
- package/dist/orphan-triage.js +207 -0
- package/dist/orphan-triage.js.map +1 -0
- package/dist/quarantine-framing.d.ts +92 -0
- package/dist/quarantine-framing.d.ts.map +1 -0
- package/dist/quarantine-framing.js +111 -0
- package/dist/quarantine-framing.js.map +1 -0
- package/dist/refusal-reasons.d.ts +39 -0
- package/dist/refusal-reasons.d.ts.map +1 -1
- package/dist/refusal-reasons.js +88 -0
- package/dist/refusal-reasons.js.map +1 -1
- package/dist/seal-frontier-verify.d.ts.map +1 -1
- package/dist/seal-frontier-verify.js +24 -16
- package/dist/seal-frontier-verify.js.map +1 -1
- package/dist/sealed-leaf-set.d.ts +4 -4
- package/dist/sealed-leaf-set.d.ts.map +1 -1
- package/dist/sealed-leaf-set.js +11 -18
- package/dist/sealed-leaf-set.js.map +1 -1
- package/dist/session-content-handlers.d.ts.map +1 -1
- package/dist/session-content-handlers.js +74 -17
- package/dist/session-content-handlers.js.map +1 -1
- package/dist/session-node-manager.d.ts +185 -28
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +1409 -134
- package/dist/session-node-manager.js.map +1 -1
- package/dist/session-read-handlers.d.ts.map +1 -1
- package/dist/session-read-handlers.js +133 -4
- package/dist/session-read-handlers.js.map +1 -1
- package/dist/session-relay-client.d.ts +0 -7
- package/dist/session-relay-client.d.ts.map +1 -1
- package/dist/session-relay-client.js +52 -37
- package/dist/session-relay-client.js.map +1 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +1 -0
- package/dist/vocabulary.js.map +1 -1
- package/package.json +5 -5
|
@@ -29,6 +29,8 @@ import { migrateToEncryptedIfNeeded } from "./identity-migration.js";
|
|
|
29
29
|
import { ensureIdentitySchema } from "./db-identity-store.js";
|
|
30
30
|
import { migrateSessionTablesToAgentId } from "./agent-id-migration.js";
|
|
31
31
|
import { TIER, normalizeTier, isKnownTierValue, tierBoundsFor, DEFAULT_TIER_BOUNDS, migrateContactsAddTierMetadata } from "./contacts-tier-migration.js";
|
|
32
|
+
import { normalizeContactPubkey, foldContactPubkeyCase } from "./contact-pubkey-case.js";
|
|
33
|
+
import { REFUSAL_KINDS } from "./refusal-reasons.js";
|
|
32
34
|
import { migrateCborBlobsToCanonical } from "./cbor-blob-migration.js";
|
|
33
35
|
import { ensureTrustSignalSchema } from "./trust-signal-store.js";
|
|
34
36
|
import { boundSettingKey, settableTierName, isValidSettingKey, awayTierSettingKey, AWAY_DEFAULT_KEY } from "./agent-settings-keys.js";
|
|
@@ -36,7 +38,7 @@ import { publishableEndpoint, relayOnlyState } from "./relay-only.js";
|
|
|
36
38
|
import { randomUUID, createHash, randomBytes } from "node:crypto";
|
|
37
39
|
import * as lp from "it-length-prefixed";
|
|
38
40
|
import { decode } from "cbor-x";
|
|
39
|
-
import { encodeCbor } from "@cello-protocol/protocol-types";
|
|
41
|
+
import { encodeCbor, decodeStructure1 } from "@cello-protocol/protocol-types";
|
|
40
42
|
import { MAX_SESSION_NODES, STANDING_RECEIVER_AGENT_NAME } from "./types.js";
|
|
41
43
|
import { SessionConnectionGater } from "./session-connection-gater.js";
|
|
42
44
|
import { SessionTree, sessionTreeLeafKindFromDb } from "./session-tree.js";
|
|
@@ -48,6 +50,7 @@ import { encodeSealPayload, MONIKER_RE, validateMoniker } from "@cello-protocol/
|
|
|
48
50
|
// receives the already-classified `ParkAuthFailure`, so importing the code table here would invite a
|
|
49
51
|
// second, drifting copy of the classification logic.
|
|
50
52
|
import { decodeParkEnvelope, authenticateParkedEntry, pubkeyMatchesHex, ParkEnvelopeError, parkRefusalGuidance } from "./park-envelope.js";
|
|
53
|
+
import { triageOrphanedContent } from "./orphan-triage.js";
|
|
51
54
|
import { isValidMultiaddr } from "@cello-protocol/transport";
|
|
52
55
|
// `LEAF_KIND_MSG` is no longer imported here: `sendContent`'s `leafKind` stopped defaulting to it
|
|
53
56
|
// (B2b-1 review F4), so this file no longer names a default — every caller states its own kind.
|
|
@@ -58,6 +61,7 @@ import { RelayReceiptStore } from "./relay-receipt-store.js";
|
|
|
58
61
|
import { SessionSealLeafStore } from "./session-seal-leaf-store.js";
|
|
59
62
|
import { certifiedLeafSetFrom } from "./sealed-leaf-set.js";
|
|
60
63
|
import { addColumnIfMissing } from "./column-birth.js";
|
|
64
|
+
import { quarantineRedaction, retentionSentence } from "./quarantine-framing.js";
|
|
61
65
|
import { GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT, } from "@cello-protocol/gateway";
|
|
62
66
|
/** SEC-1 / review M4: cap on the refused-parked-entry memo (remote-fed → must be bounded). */
|
|
63
67
|
/**
|
|
@@ -77,6 +81,25 @@ const MAX_REFUSED_PARKED_ENTRIES = 512;
|
|
|
77
81
|
* losing an old one costs a log line rather than correctness.
|
|
78
82
|
*/
|
|
79
83
|
const MAX_UNREADABLE_ALG_FRAMES = 64;
|
|
84
|
+
/**
|
|
85
|
+
* How many consumers' read positions a single refusal notice remembers.
|
|
86
|
+
*
|
|
87
|
+
* A consumer id is an IPC connection id, so every reconnect mints a new one and the read state would
|
|
88
|
+
* otherwise grow without bound in a durable table. Sixteen is far above the real number of windows
|
|
89
|
+
* attending one agent; past that the OLDEST reader is evicted, which costs at worst one repeated
|
|
90
|
+
* announcement to a window that has already gone.
|
|
91
|
+
*/
|
|
92
|
+
const MAX_REFUSAL_READERS = 16;
|
|
93
|
+
/**
|
|
94
|
+
* How many refusal notices one read returns, newest first.
|
|
95
|
+
*
|
|
96
|
+
* Review F3. The store is never emptied for an agent — a refusal records something that happened —
|
|
97
|
+
* and read state is per IPC connection, so a fresh window after a restart is entitled to every
|
|
98
|
+
* notice ever recorded. Uncapped, the answer to "why did this conversation go quiet?" was at the
|
|
99
|
+
* bottom of an archive. Capped and newest-first, the recent cause leads and the caller is TOLD the
|
|
100
|
+
* list was cut (`refusals_incomplete`), rather than the tail vanishing silently.
|
|
101
|
+
*/
|
|
102
|
+
const MAX_REFUSALS_PER_READ = 25;
|
|
80
103
|
/**
|
|
81
104
|
* How long the first send waits for an in-flight salt agreement before giving up on it —
|
|
82
105
|
* `DOD-M15-SEALWIRE-1` B2b-2 constraint 2.
|
|
@@ -374,24 +397,18 @@ function relayPeerIdOf(circuitAddr) {
|
|
|
374
397
|
* `null` when any leaf is unreadable — the caller must then answer "I cannot judge", never "we
|
|
375
398
|
* disagree". A decode failure is this daemon's limitation, not evidence against anyone.
|
|
376
399
|
*
|
|
377
|
-
* Canonical Structure 1 is
|
|
378
|
-
* `
|
|
379
|
-
*
|
|
380
|
-
* domain the certified root lives in.
|
|
400
|
+
* Canonical Structure 1 is `[version, content_hash, sender_pubkey, session_id, last_seen_seq,
|
|
401
|
+
* timestamp]`, plus `last_seen_hash` at index 6 on a v2 claim (020-ACKHASH). The content hash is at
|
|
402
|
+
* index 1 in both and is used AS the leaf hash (RFC 6962 §2.1 "hash" leaves are taken as-is), which
|
|
403
|
+
* is the domain the certified root lives in.
|
|
381
404
|
*/
|
|
382
405
|
function carryContentHashInputs(carry) {
|
|
383
406
|
const inputs = [];
|
|
384
407
|
for (const leaf of carry) {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
contentHash = decode(leaf.structure1Cbor)[1];
|
|
388
|
-
}
|
|
389
|
-
catch {
|
|
408
|
+
const s1 = decodeStructure1(leaf.structure1Cbor);
|
|
409
|
+
if (!s1.ok)
|
|
390
410
|
return null;
|
|
391
|
-
}
|
|
392
|
-
if (!(contentHash instanceof Uint8Array) || contentHash.length !== 32)
|
|
393
|
-
return null;
|
|
394
|
-
inputs.push({ kind: "hash", data: contentHash });
|
|
411
|
+
inputs.push({ kind: "hash", data: s1.fields.contentHash });
|
|
395
412
|
}
|
|
396
413
|
return inputs;
|
|
397
414
|
}
|
|
@@ -986,6 +1003,15 @@ export class SessionNodeManager {
|
|
|
986
1003
|
// DOD-M12B-ACK-1: WHY a session is impaired and what became of the content. Separate from the
|
|
987
1004
|
// state above because the state is what surfaces print and this is what they must explain.
|
|
988
1005
|
#impairmentCause = new Map();
|
|
1006
|
+
/**
|
|
1007
|
+
* DOD-M15-NO-SILENT-REFUSAL-1 review F6 — refusal notices that could NOT be persisted.
|
|
1008
|
+
*
|
|
1009
|
+
* Empty in every healthy daemon. It exists so that a database failure costs the restart property
|
|
1010
|
+
* and nothing else: without it, the operator-facing surface for a refusal disappears entirely the
|
|
1011
|
+
* moment the write fails, which is strictly worse than the in-memory Map this store replaced.
|
|
1012
|
+
* `session.refusal.persist.failed` fires at ERROR on every entry that lands here.
|
|
1013
|
+
*/
|
|
1014
|
+
#refusalFallback = new Map();
|
|
989
1015
|
// DOD-M12B-STRAND-1: sessions whose durable holds have been read back. One read per session per
|
|
990
1016
|
// process; the Map is the working copy from then on.
|
|
991
1017
|
#heldRestored = new Set();
|
|
@@ -2114,6 +2140,37 @@ export class SessionNodeManager {
|
|
|
2114
2140
|
PRIMARY KEY (agent_id, session_id, sequence, direction)
|
|
2115
2141
|
)
|
|
2116
2142
|
`);
|
|
2143
|
+
/**
|
|
2144
|
+
* DOD-M15-REFUSEDEVIDENCE-1 — the refusal reason on a QUARANTINED row.
|
|
2145
|
+
*
|
|
2146
|
+
* `direction` takes a third value, `'quarantined'`: a message that was received and REFUSED. It
|
|
2147
|
+
* is stored the same way a delivered one is — plaintext blob, sender key, sender signature,
|
|
2148
|
+
* attribution — because a hash with no original proves nothing, and the messages worth proving
|
|
2149
|
+
* (an injection, a probe, a tampered frame) are exactly the refused ones.
|
|
2150
|
+
*
|
|
2151
|
+
* ⚠️ THE DIRECTION VALUE IS THE FLAG, AND THAT IS WHY IT IS NOT A BOOLEAN COLUMN. `direction` is
|
|
2152
|
+
* in the primary key and every delivery and unread reader already filters it with an equality
|
|
2153
|
+
* literal (`findNextReceivedAfter`, `#UNREAD_RECEIVED_WHERE`, `countReceivedMessages`). A row
|
|
2154
|
+
* written `'quarantined'` therefore cannot be returned by `WHERE direction = 'received'` — it is
|
|
2155
|
+
* excluded BY CONSTRUCTION, with no query edited and none left to remember. A boolean column
|
|
2156
|
+
* alone would have been exclusion by EDIT, which rebuilds `DOD-UNREAD-1 D4a`'s phantom-session
|
|
2157
|
+
* residue the first time a new query forgets the predicate.
|
|
2158
|
+
*
|
|
2159
|
+
* `attribution` needs no new value: the expression in `recordTranscriptMessage` is
|
|
2160
|
+
* `direction === "sent" ? … : authorship ? "verified_signature" : "local_session_state"`, and
|
|
2161
|
+
* `'quarantined'` is not `'sent'` — so a verified frame lands `verified_signature` and an
|
|
2162
|
+
* unverified one `local_session_state`, which is the distinction the column exists for.
|
|
2163
|
+
*/
|
|
2164
|
+
// Through `addColumnIfMissing`, not a hand-rolled try/catch — review F7. A bare `ADD COLUMN`
|
|
2165
|
+
// wrapped in a duplicate-name test had already been written twice in this codebase, which is
|
|
2166
|
+
// why the helper exists; a third copy rethrows correctly but emits no `db.column_birth.failed`,
|
|
2167
|
+
// so a failure on a fresh operator's database would name neither the table nor the column. That
|
|
2168
|
+
// is exactly the case — the FIRST run on a new machine — the helper was extracted for.
|
|
2169
|
+
addColumnIfMissing(this.#db, this.#logger, {
|
|
2170
|
+
table: "transcript",
|
|
2171
|
+
column: "quarantine_reason",
|
|
2172
|
+
sql: "ALTER TABLE transcript ADD COLUMN quarantine_reason TEXT",
|
|
2173
|
+
});
|
|
2117
2174
|
// M8C-INBOX-1 (N2): per-agent, per-session read watermark. `last_delivered_seq` is the highest
|
|
2118
2175
|
// RECEIVED transcript sequence the operator has been shown via cello_receive (delivery marks
|
|
2119
2176
|
// read — no ack verb). Unread = received transcript rows with sequence > last_delivered_seq.
|
|
@@ -2251,6 +2308,58 @@ export class SessionNodeManager {
|
|
|
2251
2308
|
noticed_at INTEGER NOT NULL,
|
|
2252
2309
|
PRIMARY KEY (agent_id, pubkey)
|
|
2253
2310
|
)
|
|
2311
|
+
`);
|
|
2312
|
+
/**
|
|
2313
|
+
* A PUBLIC KEY IS BYTES; ITS HEX CASE IS NOT PART OF ITS IDENTITY.
|
|
2314
|
+
*
|
|
2315
|
+
* ⚠️ **PLACED HERE FOR TWO ORDERING REASONS, and getting either wrong is a crash at boot.** It
|
|
2316
|
+
* touches all three contact-keyed tables, so it runs after the LAST of them exists
|
|
2317
|
+
* (`contact_rename_notices`, directly above); and the merge it performs on a collision keeps the
|
|
2318
|
+
* more restrictive TIER, which it cannot read until `migrateContactsAddTierMetadata` has added
|
|
2319
|
+
* that column.
|
|
2320
|
+
*
|
|
2321
|
+
* Normalizing the accessors alone would be worse than the bug for anyone who already has a
|
|
2322
|
+
* mixed-case row: the row becomes UNREACHABLE rather than merely wrong, taking its block, its
|
|
2323
|
+
* away message and its pet name with it. Idempotent and silent on a clean database.
|
|
2324
|
+
*/
|
|
2325
|
+
foldContactPubkeyCase(this.#db, this.#logger);
|
|
2326
|
+
// DOD-M15-NO-SILENT-REFUSAL-1: refusal notices — one per (agent, session, reason). Written every
|
|
2327
|
+
// time an inbound message is refused; read by cello_receive and by the cello_inbox pull. Modelled
|
|
2328
|
+
// on contact_rename_notices above and keyed the same way, on agent_id (the stable key) — the map
|
|
2329
|
+
// this replaced was keyed on agent_name, a mutable display label, which was its second bug.
|
|
2330
|
+
//
|
|
2331
|
+
// DURABLE because the case this exists for is NOBODY ATTENDING. A notice held only in memory is
|
|
2332
|
+
// lost to a restart and is only ever surfaced to whoever happens to call cello_receive on that
|
|
2333
|
+
// exact session, which is a log line with extra steps.
|
|
2334
|
+
//
|
|
2335
|
+
// `content_refusal_reads` is the part rename notices do not need: they clear on operator action,
|
|
2336
|
+
// these are read non-destructively PER CONSUMER. Two MCP windows attending one agent is ordinary,
|
|
2337
|
+
// and under a single surfaced flag the first reader consumed the notice and the second was told
|
|
2338
|
+
// nothing, permanently.
|
|
2339
|
+
this.#db.exec(`
|
|
2340
|
+
CREATE TABLE IF NOT EXISTS content_refusal_notices (
|
|
2341
|
+
agent_id TEXT NOT NULL,
|
|
2342
|
+
session_id TEXT NOT NULL,
|
|
2343
|
+
reason TEXT NOT NULL,
|
|
2344
|
+
kind TEXT NOT NULL,
|
|
2345
|
+
impact TEXT NOT NULL,
|
|
2346
|
+
guidance TEXT NOT NULL,
|
|
2347
|
+
count INTEGER NOT NULL,
|
|
2348
|
+
first_at INTEGER NOT NULL,
|
|
2349
|
+
last_at INTEGER NOT NULL,
|
|
2350
|
+
PRIMARY KEY (agent_id, session_id, reason)
|
|
2351
|
+
)
|
|
2352
|
+
`);
|
|
2353
|
+
this.#db.exec(`
|
|
2354
|
+
CREATE TABLE IF NOT EXISTS content_refusal_reads (
|
|
2355
|
+
agent_id TEXT NOT NULL,
|
|
2356
|
+
session_id TEXT NOT NULL,
|
|
2357
|
+
reason TEXT NOT NULL,
|
|
2358
|
+
consumer_id TEXT NOT NULL,
|
|
2359
|
+
seen_count INTEGER NOT NULL,
|
|
2360
|
+
seen_at INTEGER NOT NULL,
|
|
2361
|
+
PRIMARY KEY (agent_id, session_id, reason, consumer_id)
|
|
2362
|
+
)
|
|
2254
2363
|
`);
|
|
2255
2364
|
// DOD-SETTINGS-1: a daemon-side per-agent settings store for REACHABILITY POLICY (the tier bounds
|
|
2256
2365
|
// overrides and the per-tier/agent away messages). A generic key-value table on the stable
|
|
@@ -2548,17 +2657,18 @@ export class SessionNodeManager {
|
|
|
2548
2657
|
return null;
|
|
2549
2658
|
const reach = new Map();
|
|
2550
2659
|
for (const leaf of carry) {
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2660
|
+
// Structure 1 = [version, content_hash, sender_pubkey, session_id, last_seen_seq, timestamp],
|
|
2661
|
+
// plus last_seen_hash at 6 on a v2 claim. `last_seen_seq` is index 4 in both — 020-ACKHASH
|
|
2662
|
+
// APPENDS, so this read did not move. The hash is not consulted here: this derives a
|
|
2663
|
+
// POSITIONAL boundary, which is the job last_seen_seq keeps doing alongside the new field.
|
|
2664
|
+
const s1 = decodeStructure1(leaf.structure1Cbor);
|
|
2665
|
+
// Unreadable, or a layout this build cannot name: publish no boundary rather than a
|
|
2666
|
+
// half-derived one somebody else could have shaped.
|
|
2667
|
+
if (!s1.ok)
|
|
2668
|
+
return null;
|
|
2669
|
+
const signedLastSeen = Number.isFinite(Number(s1.fields.lastSeenSeq))
|
|
2670
|
+
? Number(s1.fields.lastSeenSeq)
|
|
2671
|
+
: 0;
|
|
2562
2672
|
const prior = reach.get(leaf.senderPubkeyHex) ?? 0;
|
|
2563
2673
|
reach.set(leaf.senderPubkeyHex, Math.max(prior, leaf.sequenceNumber, signedLastSeen));
|
|
2564
2674
|
}
|
|
@@ -2583,7 +2693,14 @@ export class SessionNodeManager {
|
|
|
2583
2693
|
* lost row only cost the unread count. Delivery reads the transcript now, so a swallowed received
|
|
2584
2694
|
* row is TOTAL content loss and the caller has to know.
|
|
2585
2695
|
*/
|
|
2586
|
-
recordTranscriptMessage(agentName, sessionId, sequence,
|
|
2696
|
+
recordTranscriptMessage(agentName, sessionId, sequence,
|
|
2697
|
+
/**
|
|
2698
|
+
* DOD-M15-REFUSEDEVIDENCE-1 adds `'quarantined'` — received and REFUSED, kept as evidence and
|
|
2699
|
+
* never delivered. It goes through THIS writer rather than a second one so that the attribution
|
|
2700
|
+
* rule, the blob handling and the write-failure logging cannot drift between a delivered message
|
|
2701
|
+
* and a refused one. One store, one writer.
|
|
2702
|
+
*/
|
|
2703
|
+
direction, plaintext, correlationId,
|
|
2587
2704
|
/**
|
|
2588
2705
|
* DOD-M15-SEALWIRE-1 bullet 5: the VERIFIED authorship proof, when there is one.
|
|
2589
2706
|
*
|
|
@@ -2592,7 +2709,16 @@ export class SessionNodeManager {
|
|
|
2592
2709
|
* is written into the row as `attribution = 'local_session_state'`, so a reader can tell a row
|
|
2593
2710
|
* whose author was proven from one whose author was assumed. That distinction is the bullet.
|
|
2594
2711
|
*/
|
|
2595
|
-
authorship
|
|
2712
|
+
authorship,
|
|
2713
|
+
/** Required on a `'quarantined'` row and meaningless on any other: WHY it was refused. */
|
|
2714
|
+
quarantineReason,
|
|
2715
|
+
/**
|
|
2716
|
+
* DOD-M15-REFUSEDEVIDENCE-1: the sender's key when there is one but no verified signature to go
|
|
2717
|
+
* with it. A refused frame often has an identified sender and an unusable proof — a tampered
|
|
2718
|
+
* message is still FROM someone — and dropping the key because the signature failed would throw
|
|
2719
|
+
* away the half of the attribution that survived.
|
|
2720
|
+
*/
|
|
2721
|
+
senderPubkeyHexOverride) {
|
|
2596
2722
|
if (!this.#db)
|
|
2597
2723
|
return false;
|
|
2598
2724
|
try {
|
|
@@ -2600,9 +2726,11 @@ export class SessionNodeManager {
|
|
|
2600
2726
|
const blob = Buffer.from(plaintext);
|
|
2601
2727
|
this.#db
|
|
2602
2728
|
.prepare(`INSERT OR IGNORE INTO transcript
|
|
2603
|
-
(agent_id, session_id, sequence, direction, blob, created_at, sender_pubkey, sender_sig, attribution)
|
|
2604
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
2605
|
-
.run(agentId, sessionId, sequence, direction, blob, Date.now(), authorship
|
|
2729
|
+
(agent_id, session_id, sequence, direction, blob, created_at, sender_pubkey, sender_sig, attribution, quarantine_reason)
|
|
2730
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
2731
|
+
.run(agentId, sessionId, sequence, direction, blob, Date.now(), authorship
|
|
2732
|
+
? Buffer.from(authorship.senderPubkey).toString("hex")
|
|
2733
|
+
: senderPubkeyHexOverride ?? null, authorship ? Buffer.from(authorship.senderSig) : null,
|
|
2606
2734
|
/**
|
|
2607
2735
|
* THREE values, not two — caught by CELLO_Coder_1 reviewing the first version, and it was
|
|
2608
2736
|
* the same defect this column exists to prevent, surviving one layer up in the enum.
|
|
@@ -2630,7 +2758,7 @@ export class SessionNodeManager {
|
|
|
2630
2758
|
* `verified_signature` — someone else wrote it and we checked their key against it.
|
|
2631
2759
|
* `local_session_state`— someone else wrote it and nobody checked anything.
|
|
2632
2760
|
*/
|
|
2633
|
-
direction === "sent" ? "self_authored" : authorship ? "verified_signature" : "local_session_state");
|
|
2761
|
+
direction === "sent" ? "self_authored" : authorship ? "verified_signature" : "local_session_state", quarantineReason ?? null);
|
|
2634
2762
|
this.#logger.info("transcript.message.recorded", { sessionId, agentName, sequence, direction, correlationId });
|
|
2635
2763
|
return true;
|
|
2636
2764
|
}
|
|
@@ -2646,12 +2774,17 @@ export class SessionNodeManager {
|
|
|
2646
2774
|
// sentence is corrected rather than kept, because as written it reassured a reader about a
|
|
2647
2775
|
// safety net that no longer exists. Sent-row failures stay a warning (they only affect the
|
|
2648
2776
|
// durable readable transcript, not delivery).
|
|
2649
|
-
|
|
2777
|
+
// A QUARANTINED row that fails to write is an ERROR for the same reason a received one is,
|
|
2778
|
+
// and a different one: nothing else holds these bytes. The message was refused, so it was
|
|
2779
|
+
// never delivered and never acked in a way that brings it back — a failed write here is the
|
|
2780
|
+
// evidence gap this unit exists to close, reopened by a disk fault.
|
|
2781
|
+
const level = direction === "sent" ? "warn" : "error";
|
|
2650
2782
|
this.#logger[level]("transcript.message.record.failed", {
|
|
2651
2783
|
sessionId, agentName, sequence, direction,
|
|
2652
2784
|
reason: err instanceof Error ? err.message : String(err),
|
|
2653
2785
|
correlationId,
|
|
2654
2786
|
...(direction === "received" ? { impact: "content_undeliverable_message_lost" } : {}),
|
|
2787
|
+
...(direction === "quarantined" ? { impact: "refused_message_not_retained_no_other_copy_exists" } : {}),
|
|
2655
2788
|
});
|
|
2656
2789
|
return false;
|
|
2657
2790
|
}
|
|
@@ -2665,7 +2798,7 @@ export class SessionNodeManager {
|
|
|
2665
2798
|
if (!this.#db)
|
|
2666
2799
|
return { messages: [], undecryptable: 0 };
|
|
2667
2800
|
const rows = this.#db
|
|
2668
|
-
.prepare(`SELECT sequence, direction, blob, created_at FROM transcript
|
|
2801
|
+
.prepare(`SELECT sequence, direction, blob, created_at, quarantine_reason FROM transcript
|
|
2669
2802
|
WHERE agent_id = ? AND session_id = ? ORDER BY sequence ASC, direction ASC`)
|
|
2670
2803
|
.all(this.#requireAgentId(agentName), sessionId);
|
|
2671
2804
|
const messages = [];
|
|
@@ -2674,12 +2807,38 @@ export class SessionNodeManager {
|
|
|
2674
2807
|
// already read the field.
|
|
2675
2808
|
for (const r of rows) {
|
|
2676
2809
|
const blob = r.blob instanceof Uint8Array ? r.blob : new Uint8Array(r.blob);
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2810
|
+
/**
|
|
2811
|
+
* DOD-M15-REFUSEDEVIDENCE-1 — THE READ IS REDACTED, THE STORAGE IS NOT.
|
|
2812
|
+
*
|
|
2813
|
+
* The entry stays at its position, because a hole where a message was is the evidence gap
|
|
2814
|
+
* this unit exists to close, one level up: the operator must be able to see that something
|
|
2815
|
+
* arrived here and was refused. What is withheld is the TEXT, and `text` carries the
|
|
2816
|
+
* withholding statement rather than being omitted — every existing renderer of this array
|
|
2817
|
+
* prints `text`, so a missing field would print nothing and an unfiltered one would print the
|
|
2818
|
+
* payload. The statement is the fail-safe value for both.
|
|
2819
|
+
*
|
|
2820
|
+
* ⚠️ THREE-WAY, not `!== "sent" ? "received"`. The old expression labelled anything that was
|
|
2821
|
+
* not `sent` as `received`, which would have handed a refused message to every reader as a
|
|
2822
|
+
* delivered one — with its text.
|
|
2823
|
+
*/
|
|
2824
|
+
const direction = r.direction === "sent" ? "sent" : r.direction === "quarantined" ? "quarantined" : "received";
|
|
2825
|
+
if (direction === "quarantined") {
|
|
2826
|
+
// No `?? "refused"` default — review F11. See `readQuarantined` for why a generic label for
|
|
2827
|
+
// an impossible state is worse than an empty one.
|
|
2828
|
+
const reason = r.quarantine_reason;
|
|
2829
|
+
const redaction = quarantineRedaction(reason, sessionId, r.sequence);
|
|
2830
|
+
messages.push({
|
|
2831
|
+
sequence: r.sequence, direction, createdAt: r.created_at,
|
|
2832
|
+
text: redaction.text,
|
|
2833
|
+
// The key ENDS in `guidance` so `vocabulary.ts` rewrites the verb for a CLI reader — see
|
|
2834
|
+
// the note on `quarantineRedaction`.
|
|
2835
|
+
withheld_guidance: redaction.guidance,
|
|
2836
|
+
refusalReason: reason,
|
|
2837
|
+
withheld: true,
|
|
2838
|
+
});
|
|
2839
|
+
continue;
|
|
2840
|
+
}
|
|
2841
|
+
messages.push({ sequence: r.sequence, direction, text: new TextDecoder().decode(blob), createdAt: r.created_at });
|
|
2683
2842
|
}
|
|
2684
2843
|
return { messages, undecryptable: 0 };
|
|
2685
2844
|
}
|
|
@@ -2864,7 +3023,7 @@ export class SessionNodeManager {
|
|
|
2864
3023
|
isContact(agentName, pubkey) {
|
|
2865
3024
|
if (!this.#db)
|
|
2866
3025
|
return false;
|
|
2867
|
-
const row = this.#db.prepare("SELECT 1 FROM contacts WHERE agent_id = ? AND pubkey = ?").get(this.#requireAgentId(agentName), pubkey);
|
|
3026
|
+
const row = this.#db.prepare("SELECT 1 FROM contacts WHERE agent_id = ? AND pubkey = ?").get(this.#requireAgentId(agentName), normalizeContactPubkey(pubkey));
|
|
2868
3027
|
return row !== undefined;
|
|
2869
3028
|
}
|
|
2870
3029
|
/** DOD-TIER-1: the reachability tier for a counterparty of this agent. The RESULT is total — an
|
|
@@ -2881,7 +3040,7 @@ export class SessionNodeManager {
|
|
|
2881
3040
|
throw new Error(`getTier('${agentName}'): database not initialized`);
|
|
2882
3041
|
const row = this.#db
|
|
2883
3042
|
.prepare("SELECT tier FROM contacts WHERE agent_id = ? AND pubkey = ?")
|
|
2884
|
-
.get(this.#requireAgentId(agentName), pubkey);
|
|
3043
|
+
.get(this.#requireAgentId(agentName), normalizeContactPubkey(pubkey));
|
|
2885
3044
|
if (row && row.tier !== null && !isKnownTierValue(row.tier)) {
|
|
2886
3045
|
// A stored tier outside 0..4 is corruption — surface it. normalizeTier still maps it to the
|
|
2887
3046
|
// tighter UNKNOWN so the caller is safe, but a silent map would hide a broken row.
|
|
@@ -2942,6 +3101,9 @@ export class SessionNodeManager {
|
|
|
2942
3101
|
* at first add, exactly as `added_at`/`moniker` already do; re-adding never downgrades a contact
|
|
2943
3102
|
* the operator has since promoted. Raising the tier later is `cello_contact_set_tier`'s job. */
|
|
2944
3103
|
addContact(agentName, pubkey, moniker, provenance, tier = TIER.UNKNOWN) {
|
|
3104
|
+
// A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
|
|
3105
|
+
// query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
|
|
3106
|
+
pubkey = normalizeContactPubkey(pubkey);
|
|
2945
3107
|
if (!pubkey)
|
|
2946
3108
|
return;
|
|
2947
3109
|
// Review F1: a missing DB handle must FAIL the write loudly — returning silently here let
|
|
@@ -2972,6 +3134,9 @@ export class SessionNodeManager {
|
|
|
2972
3134
|
* when no such contact — fail-loud at the caller, never a silent no-op success. Same
|
|
2973
3135
|
* validate-throw backstop as addContact. */
|
|
2974
3136
|
setContactMoniker(agentName, pubkey, moniker) {
|
|
3137
|
+
// A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
|
|
3138
|
+
// query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
|
|
3139
|
+
pubkey = normalizeContactPubkey(pubkey);
|
|
2975
3140
|
// Review F2: false means exactly "no such contact" — a null DB handle throws instead, so the
|
|
2976
3141
|
// operator is never sent chasing a nonexistent missing-contact problem.
|
|
2977
3142
|
if (!this.#db)
|
|
@@ -3001,6 +3166,9 @@ export class SessionNodeManager {
|
|
|
3001
3166
|
* contact in order to withhold something from them.
|
|
3002
3167
|
*/
|
|
3003
3168
|
setContactSignalPref(agentName, pubkey, signalHash, present) {
|
|
3169
|
+
// A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
|
|
3170
|
+
// query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
|
|
3171
|
+
pubkey = normalizeContactPubkey(pubkey);
|
|
3004
3172
|
if (!this.#db)
|
|
3005
3173
|
throw new Error(`setContactSignalPref('${agentName}'): database not initialized`);
|
|
3006
3174
|
const agentId = this.#requireAgentId(agentName);
|
|
@@ -3031,6 +3199,9 @@ export class SessionNodeManager {
|
|
|
3031
3199
|
* the operator's standing default, never to disclosing something consent has not cleared.
|
|
3032
3200
|
*/
|
|
3033
3201
|
getContactSignalPrefs(agentName, pubkey) {
|
|
3202
|
+
// A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
|
|
3203
|
+
// query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
|
|
3204
|
+
pubkey = normalizeContactPubkey(pubkey);
|
|
3034
3205
|
if (!this.#db)
|
|
3035
3206
|
return new Map();
|
|
3036
3207
|
const rows = this.#db
|
|
@@ -3041,6 +3212,9 @@ export class SessionNodeManager {
|
|
|
3041
3212
|
/** DOD-AWAY-TIER-1: set (or clear, with null) a contact's per-contact away message. Returns false
|
|
3042
3213
|
* when no such contact — fail-loud at the caller (same contract as setContactMoniker/setContactTier). */
|
|
3043
3214
|
setContactAwayMessage(agentName, pubkey, message) {
|
|
3215
|
+
// A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
|
|
3216
|
+
// query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
|
|
3217
|
+
pubkey = normalizeContactPubkey(pubkey);
|
|
3044
3218
|
if (!this.#db)
|
|
3045
3219
|
throw new Error(`setContactAwayMessage('${agentName}'): database not initialized`);
|
|
3046
3220
|
const res = this.#db
|
|
@@ -3054,6 +3228,9 @@ export class SessionNodeManager {
|
|
|
3054
3228
|
* four-level resolution TOTAL. A pure read; the resolved text is screened on the outbound path by
|
|
3055
3229
|
* the caller like any content (SI — it does not bypass the gateway). */
|
|
3056
3230
|
resolveAwayMessage(agentName, pubkey) {
|
|
3231
|
+
// A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
|
|
3232
|
+
// query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
|
|
3233
|
+
pubkey = normalizeContactPubkey(pubkey);
|
|
3057
3234
|
if (!this.#db)
|
|
3058
3235
|
return null;
|
|
3059
3236
|
const agentId = this.#requireAgentId(agentName);
|
|
@@ -3082,6 +3259,9 @@ export class SessionNodeManager {
|
|
|
3082
3259
|
* setContactMoniker). The caller validates the tier is a known constant BEFORE calling; this
|
|
3083
3260
|
* stores whatever it is handed (the handler is the validation boundary). */
|
|
3084
3261
|
setContactTier(agentName, pubkey, tier) {
|
|
3262
|
+
// A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
|
|
3263
|
+
// query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
|
|
3264
|
+
pubkey = normalizeContactPubkey(pubkey);
|
|
3085
3265
|
if (!this.#db)
|
|
3086
3266
|
throw new Error(`setContactTier('${agentName}'): database not initialized`);
|
|
3087
3267
|
const res = this.#db
|
|
@@ -3099,6 +3279,9 @@ export class SessionNodeManager {
|
|
|
3099
3279
|
* (AC5). Limitation: last_offered_moniker updates only on the RECEIVING side of an offer, so rename
|
|
3100
3280
|
* detection works only for peers who INITIATE to you — a property, not a bug. */
|
|
3101
3281
|
recordOfferedMoniker(agentName, pubkey, offered) {
|
|
3282
|
+
// A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
|
|
3283
|
+
// query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
|
|
3284
|
+
pubkey = normalizeContactPubkey(pubkey);
|
|
3102
3285
|
// Fail CLOSED like getTier/setContactTier: a silent skip here would drop a rename baseline update
|
|
3103
3286
|
// (and any notice) while the daemon reports healthy — the inbound path always has an open DB.
|
|
3104
3287
|
if (!this.#db)
|
|
@@ -3140,6 +3323,9 @@ export class SessionNodeManager {
|
|
|
3140
3323
|
/** DOD-RENAME-1: clear a pending rename notice — the operator acted (adopted a name or removed the
|
|
3141
3324
|
* contact). Idempotent (no notice → no-op). Fail-closed on a missing DB, like the writes above. */
|
|
3142
3325
|
clearRenameNotice(agentName, pubkey) {
|
|
3326
|
+
// A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
|
|
3327
|
+
// query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
|
|
3328
|
+
pubkey = normalizeContactPubkey(pubkey);
|
|
3143
3329
|
if (!this.#db)
|
|
3144
3330
|
throw new Error(`clearRenameNotice('${agentName}'): database not initialized`);
|
|
3145
3331
|
this.#db
|
|
@@ -3148,6 +3334,9 @@ export class SessionNodeManager {
|
|
|
3148
3334
|
}
|
|
3149
3335
|
/** M8C-CONTACT-1: known stays known until explicitly removed. */
|
|
3150
3336
|
removeContact(agentName, pubkey) {
|
|
3337
|
+
// A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
|
|
3338
|
+
// query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
|
|
3339
|
+
pubkey = normalizeContactPubkey(pubkey);
|
|
3151
3340
|
if (!this.#db)
|
|
3152
3341
|
return false;
|
|
3153
3342
|
const res = this.#db.prepare("DELETE FROM contacts WHERE agent_id = ? AND pubkey = ?").run(this.#requireAgentId(agentName), pubkey);
|
|
@@ -3206,6 +3395,9 @@ export class SessionNodeManager {
|
|
|
3206
3395
|
/** MONIKER-4: the operator's pet name for a pubkey (whoLabel's top tier), or null. Read-only
|
|
3207
3396
|
* and tolerant of a not-yet-open DB (a missing label degrades the doorbell, never blocks it). */
|
|
3208
3397
|
getContactMoniker(agentName, pubkey) {
|
|
3398
|
+
// A public key is bytes; its hex case is not part of its identity. Normalized HERE so the
|
|
3399
|
+
// query below cannot see two spellings of one contact — see `contact-pubkey-case.ts`.
|
|
3400
|
+
pubkey = normalizeContactPubkey(pubkey);
|
|
3209
3401
|
if (!this.#db) {
|
|
3210
3402
|
// Review F2: the last fully-silent branch in the resolution chain — the label degrades to
|
|
3211
3403
|
// fingerprint, which is correct, but say so rather than returning null wordlessly.
|
|
@@ -3236,15 +3428,223 @@ export class SessionNodeManager {
|
|
|
3236
3428
|
ORDER BY c.added_at ASC`)
|
|
3237
3429
|
.all(this.#requireAgentId(agentName));
|
|
3238
3430
|
}
|
|
3239
|
-
/**
|
|
3431
|
+
/**
|
|
3432
|
+
* M8C-ABUSE-1: cumulative inbound byte total for a session (anti-drip-feed accounting).
|
|
3433
|
+
*
|
|
3434
|
+
* ⚠️ DOD-M15-REFUSEDEVIDENCE-1 — QUARANTINED BYTES COUNT, and the bound depends on it.
|
|
3435
|
+
*
|
|
3436
|
+
* Retaining refused messages puts real bytes on the operator's disk. Left out of this sum,
|
|
3437
|
+
* retention would be an UNBOUNDED SIDE CHANNEL: a counterparty who can get messages refused —
|
|
3438
|
+
* anyone who can trip the screener, which is anyone — stores against a budget that cannot see
|
|
3439
|
+
* what they spent. Counting them makes total retention per session bounded by the same tier cap
|
|
3440
|
+
* that bounds delivery, which is the bound the unit's no-truncation rule rests on.
|
|
3441
|
+
*
|
|
3442
|
+
* No new capability reaches an attacker from this: the counterparty already spends the session's
|
|
3443
|
+
* byte budget by sending ordinary messages. Spending it with refused ones costs them the same.
|
|
3444
|
+
*/
|
|
3240
3445
|
#getReceivedBytesTotal(agentName, sessionId) {
|
|
3241
3446
|
if (!this.#db)
|
|
3242
3447
|
return 0;
|
|
3243
3448
|
const row = this.#db
|
|
3244
|
-
.prepare("SELECT COALESCE(SUM(LENGTH(blob)), 0) AS total FROM transcript WHERE agent_id = ? AND session_id = ? AND direction
|
|
3449
|
+
.prepare("SELECT COALESCE(SUM(LENGTH(blob)), 0) AS total FROM transcript WHERE agent_id = ? AND session_id = ? AND direction IN ('received','quarantined')")
|
|
3245
3450
|
.get(this.#requireAgentId(agentName), sessionId);
|
|
3246
3451
|
return row.total;
|
|
3247
3452
|
}
|
|
3453
|
+
/**
|
|
3454
|
+
* DOD-M15-REFUSEDEVIDENCE-1 — RETAIN a message that was refused. Retention is universal; DELIVERY
|
|
3455
|
+
* is what is withheld.
|
|
3456
|
+
*
|
|
3457
|
+
* Every refusal path that can store calls this. It writes the plaintext, the sender's key, the
|
|
3458
|
+
* sender's signature and the refusal reason into the transcript, flagged `'quarantined'` so it is
|
|
3459
|
+
* excluded by construction from delivery and from unread counts.
|
|
3460
|
+
*
|
|
3461
|
+
* ⚠️ STORING HOSTILE CONTENT IS SAFE; INTERPOLATING IT IS NOT. The blob is a bound parameter and
|
|
3462
|
+
* the database never parses it — SQL injection is not the risk here and must not be defended
|
|
3463
|
+
* against. The risk is on the way OUT, so nothing below puts `content` into a log line, an error
|
|
3464
|
+
* message or a path. The log carries the length and the hash.
|
|
3465
|
+
*
|
|
3466
|
+
* Returns the sequence it was stored at, or `null` when it was not stored — which happens only for
|
|
3467
|
+
* a reason the caller is expected to log.
|
|
3468
|
+
*/
|
|
3469
|
+
#quarantineRefusedContent(agentName, sessionId, reason, content, contentHashHex, opts) {
|
|
3470
|
+
if (!this.#db)
|
|
3471
|
+
return null;
|
|
3472
|
+
try {
|
|
3473
|
+
const agentId = this.#requireAgentId(agentName);
|
|
3474
|
+
/**
|
|
3475
|
+
* ⚠️ **DEDUP FIRST — review F1, and without it retention KILLS THE CONVERSATION IT PROTECTS.**
|
|
3476
|
+
*
|
|
3477
|
+
* Six of the seven retaining exits refuse WITHOUT ACKNOWLEDGING, which is exactly what makes
|
|
3478
|
+
* the sender's daemon redeliver. Each redelivery re-enters here, above the leaf dedup, and
|
|
3479
|
+
* would take a fresh negative sequence — another full copy of the same bytes. The park drain's
|
|
3480
|
+
* own comment measures that loop at *"~120 repeats per message, forever"*.
|
|
3481
|
+
*
|
|
3482
|
+
* **And retained bytes spend the delivery budget** (`#getReceivedBytesTotal` counts them, which
|
|
3483
|
+
* is what makes the bound honest). So a counterparty on a newer build sends ONE message, the
|
|
3484
|
+
* version skew refuses it un-acked, and twenty-five drains later the conversation's 25 MB is
|
|
3485
|
+
* gone — permanently, because the cap does not reset. Honest traffic then hits
|
|
3486
|
+
* `session_size_limit_exceeded` and the daemon tells the operator to start a new conversation.
|
|
3487
|
+
*
|
|
3488
|
+
* The counterbalance, stated properly this time: **evidence and delivery share one monotonic
|
|
3489
|
+
* budget, and when evidence wins the conversation stops working.** Entry 69 claimed there was
|
|
3490
|
+
* nothing to trade off, and this is what that claim was hiding.
|
|
3491
|
+
*
|
|
3492
|
+
* Keyed on (session, reason, bytes) rather than a hash column: SQLite compares BLOBs directly
|
|
3493
|
+
* and short-circuits on length, the candidate set is one session's refusals, and it needs no
|
|
3494
|
+
* schema change. Same bytes refused the same way is ONE piece of evidence — how many times it
|
|
3495
|
+
* arrived is already counted by the refusal notice. The same bytes refused for a DIFFERENT
|
|
3496
|
+
* reason is a different fact and keeps its own row.
|
|
3497
|
+
*/
|
|
3498
|
+
const already = this.#db
|
|
3499
|
+
.prepare(`SELECT sequence FROM transcript
|
|
3500
|
+
WHERE agent_id = ? AND session_id = ? AND direction = 'quarantined'
|
|
3501
|
+
AND quarantine_reason = ? AND blob = ?`)
|
|
3502
|
+
.get(agentId, sessionId, reason, Buffer.from(content));
|
|
3503
|
+
if (already) {
|
|
3504
|
+
this.#logger.debug("session.content.quarantine.duplicate", {
|
|
3505
|
+
agentName, sessionId, reason, sequence: already.sequence,
|
|
3506
|
+
contentHash: contentHashHex, correlationId: opts.correlationId,
|
|
3507
|
+
});
|
|
3508
|
+
return already.sequence;
|
|
3509
|
+
}
|
|
3510
|
+
/**
|
|
3511
|
+
* THE BOUND. A session at its byte cap retains no more.
|
|
3512
|
+
*
|
|
3513
|
+
* `senderPubkeyHex` is absent exactly when there is no session row or no counterparty
|
|
3514
|
+
* (`session_orphaned`, `sender_unresolved`), so there is no contact to look a tier up on. Those
|
|
3515
|
+
* take the UNKNOWN tier — the tightest bound, and the right one for a sender we cannot name.
|
|
3516
|
+
*/
|
|
3517
|
+
const tier = opts.senderPubkeyHex ? this.getTier(agentName, opts.senderPubkeyHex) : TIER.UNKNOWN;
|
|
3518
|
+
const cap = this.resolveTierBound(agentName, tier, "max_bytes");
|
|
3519
|
+
const prior = this.#getReceivedBytesTotal(agentName, sessionId);
|
|
3520
|
+
if (prior + content.length > cap) {
|
|
3521
|
+
this.#logger.warn("session.content.quarantine.skipped", {
|
|
3522
|
+
agentName, sessionId, reason, contentHash: contentHashHex,
|
|
3523
|
+
bytes: content.length, prior, cap, tier,
|
|
3524
|
+
correlationId: opts.correlationId,
|
|
3525
|
+
skipped: "byte_budget_exhausted",
|
|
3526
|
+
impact: "this refused message was NOT retained: the conversation has already spent its storage budget, so there is no evidence of it beyond this line and the refusal notice.",
|
|
3527
|
+
});
|
|
3528
|
+
return null;
|
|
3529
|
+
}
|
|
3530
|
+
/**
|
|
3531
|
+
* WHERE IT SITS.
|
|
3532
|
+
*
|
|
3533
|
+
* A screener block already leafed at its canonical position, and the quarantine row takes that
|
|
3534
|
+
* same sequence so the leaf and the evidence describe one event — DoD 7's leaf index is
|
|
3535
|
+
* untouched by this unit.
|
|
3536
|
+
*
|
|
3537
|
+
* A refusal with NO leaf takes the next NEGATIVE sequence for the session. A leaf position is
|
|
3538
|
+
* never negative, so the two spaces cannot collide, and descending from −1 means two refusals
|
|
3539
|
+
* cannot overwrite each other. This is what lets `session_orphaned` — a session id with no
|
|
3540
|
+
* `sessions` row at all — live in the same table as everything else, which is the whole point
|
|
3541
|
+
* of one store rather than two.
|
|
3542
|
+
*/
|
|
3543
|
+
let sequence = opts.canonicalSeq;
|
|
3544
|
+
if (sequence === undefined || sequence < 0) {
|
|
3545
|
+
const low = this.#db
|
|
3546
|
+
.prepare("SELECT MIN(sequence) AS lo FROM transcript WHERE agent_id = ? AND session_id = ? AND direction = 'quarantined'")
|
|
3547
|
+
.get(agentId, sessionId);
|
|
3548
|
+
sequence = Math.min(low.lo ?? 0, 0) - 1;
|
|
3549
|
+
}
|
|
3550
|
+
const stored = this.recordTranscriptMessage(agentName, sessionId, sequence, "quarantined", content, opts.correlationId, opts.authorship, reason, opts.senderPubkeyHex ?? null);
|
|
3551
|
+
if (!stored)
|
|
3552
|
+
return null;
|
|
3553
|
+
this.#logger.info("session.content.quarantined", {
|
|
3554
|
+
agentName, sessionId, reason, sequence,
|
|
3555
|
+
contentHash: contentHashHex, bytes: content.length,
|
|
3556
|
+
signature: opts.authorship ? "verified" : "none",
|
|
3557
|
+
correlationId: opts.correlationId,
|
|
3558
|
+
});
|
|
3559
|
+
return sequence;
|
|
3560
|
+
}
|
|
3561
|
+
catch (err) {
|
|
3562
|
+
this.#logger.error("session.content.quarantine.failed", {
|
|
3563
|
+
agentName, sessionId, reason, contentHash: contentHashHex,
|
|
3564
|
+
error: extractErrorMessage(err),
|
|
3565
|
+
impact: "a refused message could not be retained, so nothing holds a copy of it — it cannot be shown to anyone or reported.",
|
|
3566
|
+
});
|
|
3567
|
+
return null;
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
/**
|
|
3571
|
+
* DOD-M15-REFUSEDEVIDENCE-1 — retain a message refused OUTSIDE `ingestReceivedContent`.
|
|
3572
|
+
*
|
|
3573
|
+
* Review F6. The park drain terminally-blocks a message that arrived for an already-committed
|
|
3574
|
+
* session, then confirm-deletes the relay copy — the one other route in the tree that discarded
|
|
3575
|
+
* refused content, and the highest-suspicion combination in the product: hostile bytes aimed at a
|
|
3576
|
+
* conversation somebody has already sealed. Shipped guidance now tells every operator that
|
|
3577
|
+
* refused messages are kept, so this is made true rather than the promise narrowed.
|
|
3578
|
+
*
|
|
3579
|
+
* A thin delegate, not a second implementation: the bound, the dedup, the sequence allocation and
|
|
3580
|
+
* the logging are the ones every other refusal uses.
|
|
3581
|
+
*/
|
|
3582
|
+
quarantineRefusedInbound(agentName, sessionId, reason, content, contentHashHex, senderPubkeyHex, correlationId) {
|
|
3583
|
+
return this.#quarantineRefusedContent(agentName, sessionId, reason, content, contentHashHex, {
|
|
3584
|
+
senderPubkeyHex, correlationId,
|
|
3585
|
+
});
|
|
3586
|
+
}
|
|
3587
|
+
/**
|
|
3588
|
+
* DOD-M15-REFUSEDEVIDENCE-1: read retained refused messages back — all of a session's, or the one
|
|
3589
|
+
* at `sequence`.
|
|
3590
|
+
*
|
|
3591
|
+
* Returns the RAW payload. Every caller that hands it to a reader must frame it first
|
|
3592
|
+
* (`frameQuarantinedPayload`); nothing else in the tree may read this without doing so.
|
|
3593
|
+
*/
|
|
3594
|
+
readQuarantined(agentName, sessionId, sequence) {
|
|
3595
|
+
if (!this.#db)
|
|
3596
|
+
return [];
|
|
3597
|
+
const rows = this.#db
|
|
3598
|
+
.prepare(`SELECT sequence, blob, created_at, sender_pubkey, sender_sig, attribution, quarantine_reason
|
|
3599
|
+
FROM transcript
|
|
3600
|
+
WHERE agent_id = ? AND session_id = ? AND direction = 'quarantined'
|
|
3601
|
+
${sequence === undefined ? "" : "AND sequence = ?"}
|
|
3602
|
+
ORDER BY sequence ASC`)
|
|
3603
|
+
.all(...(sequence === undefined
|
|
3604
|
+
? [this.#requireAgentId(agentName), sessionId]
|
|
3605
|
+
: [this.#requireAgentId(agentName), sessionId, sequence]));
|
|
3606
|
+
return rows.map((r) => ({
|
|
3607
|
+
sequence: r.sequence,
|
|
3608
|
+
/**
|
|
3609
|
+
* ⚠️ NOT `?? "refused"` — review F11. A generic default here is a label for a state that
|
|
3610
|
+
* cannot occur: the column exists before any `'quarantined'` row can be written and
|
|
3611
|
+
* `#quarantineRefusedContent` always supplies a reason. A default reads to the next maintainer
|
|
3612
|
+
* as a supported case and would quietly stand in for a real bug. If a NULL ever appears, the
|
|
3613
|
+
* empty reason travels to the frame and the caller, which is loud enough to chase.
|
|
3614
|
+
*/
|
|
3615
|
+
reason: r.quarantine_reason,
|
|
3616
|
+
content: r.blob instanceof Uint8Array ? r.blob : new Uint8Array(r.blob),
|
|
3617
|
+
senderPubkeyHex: r.sender_pubkey,
|
|
3618
|
+
senderSig: r.sender_sig === null ? null : (r.sender_sig instanceof Uint8Array ? r.sender_sig : new Uint8Array(r.sender_sig)),
|
|
3619
|
+
attribution: r.attribution,
|
|
3620
|
+
createdAt: r.created_at,
|
|
3621
|
+
}));
|
|
3622
|
+
}
|
|
3623
|
+
/** The metadata half of a framed quarantine read — everything known ABOUT the message, none of it
|
|
3624
|
+
* taken from the message. Split out so the framing module never touches the database. */
|
|
3625
|
+
quarantineFrameMeta(agentName, sessionId, rec) {
|
|
3626
|
+
return {
|
|
3627
|
+
reason: rec.reason,
|
|
3628
|
+
senderPubkeyHex: rec.senderPubkeyHex,
|
|
3629
|
+
senderLabel: rec.senderPubkeyHex === null ? null : this.getContactMoniker(agentName, rec.senderPubkeyHex),
|
|
3630
|
+
// `attribution` is the column that exists to answer exactly this, so it is read rather than
|
|
3631
|
+
// inferred from `sender_sig` being non-null — a stored signature that was never checked
|
|
3632
|
+
// against the sender's key would otherwise be reported as VERIFIED.
|
|
3633
|
+
signature: rec.attribution === "verified_signature" ? "VERIFIED" : "NOT SIGNED",
|
|
3634
|
+
sessionId,
|
|
3635
|
+
position: rec.sequence,
|
|
3636
|
+
arrivedAtMs: rec.createdAt,
|
|
3637
|
+
/**
|
|
3638
|
+
* The hash OF THE RETAINED BYTES, recomputed here — not the hash the sender committed to.
|
|
3639
|
+
*
|
|
3640
|
+
* On the highest-value case in the whole unit those two differ ON PURPOSE:
|
|
3641
|
+
* `content_hash_mismatch` means the sender's committed hash does not describe these bytes.
|
|
3642
|
+
* Printing their claim over our bytes would label the payload with a hash it does not have,
|
|
3643
|
+
* which is the one thing a reader would use this line to check.
|
|
3644
|
+
*/
|
|
3645
|
+
contentHashHex: Buffer.from(contentHashFor(rec.content, { alg: "sha256", salt: null })).toString("hex"),
|
|
3646
|
+
};
|
|
3647
|
+
}
|
|
3248
3648
|
/** M8C-ABUSE-1 (reviewer HIGH fix, D18): bytes currently sitting in the out-of-order hold
|
|
3249
3649
|
* buffer for this session — NOT yet committed leaves, but real bytes in memory that would
|
|
3250
3650
|
* otherwise let multiple held chunks each individually pass the size gate while cumulatively
|
|
@@ -3679,6 +4079,21 @@ export class SessionNodeManager {
|
|
|
3679
4079
|
#k(agentName, sessionId) {
|
|
3680
4080
|
return `${agentName}\x1f${sessionId}`;
|
|
3681
4081
|
}
|
|
4082
|
+
/**
|
|
4083
|
+
* The inverse of `#k`, for the ONE reader that has a key and needs the session id back: the
|
|
4084
|
+
* unpersisted-refusal fallback, which is keyed like every other per-session map but is drained
|
|
4085
|
+
* per AGENT rather than per session.
|
|
4086
|
+
*
|
|
4087
|
+
* Returns null when the key belongs to a different agent. Split on the FIRST separator only —
|
|
4088
|
+
* `agentName` cannot contain 0x1f, so anything after the first one is the session id, and a
|
|
4089
|
+
* greedy split would silently mis-attribute a key rather than reject it.
|
|
4090
|
+
*/
|
|
4091
|
+
#unk(key, agentName) {
|
|
4092
|
+
const sep = key.indexOf("\x1f");
|
|
4093
|
+
if (sep < 0)
|
|
4094
|
+
return null;
|
|
4095
|
+
return key.slice(0, sep) === agentName ? key.slice(sep + 1) : null;
|
|
4096
|
+
}
|
|
3682
4097
|
/**
|
|
3683
4098
|
* DOD-AGENT-ID-JOINKEY-1: resolve an agent's NAME to its STABLE agent_id. This is the ONE place a
|
|
3684
4099
|
* name becomes a key, and it is the boundary between the two worlds:
|
|
@@ -3944,22 +4359,26 @@ export class SessionNodeManager {
|
|
|
3944
4359
|
});
|
|
3945
4360
|
// DOD-MSG-4 (strict in-order): record the relay-witnessed canonical sequence for the
|
|
3946
4361
|
// counterparty's MSG leaves. The relay is the ordering authority; structure1_cbor =
|
|
3947
|
-
// [
|
|
4362
|
+
// [version, content_hash(32), sender_pubkey, session_id, last_seen_seq, ts] (+ last_seen_hash
|
|
4363
|
+
// at index 6 on a v2 claim — 020-ACKHASH; content_hash stays at 1). The relay sequence
|
|
3948
4364
|
// is 1-based and global per session; the daemon tree is 0-based — normalize with -1. Only
|
|
3949
4365
|
// COUNTERPARTY leaves (the ones B will ingest); our own echoed leaf already lands via the
|
|
3950
4366
|
// send path. The gate (ingestReceivedContent) reads this map to hold out-of-order arrivals.
|
|
3951
4367
|
if (!frame.authored_by_us && frame.leaf_kind !== LEAF_KIND_CTRL) {
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
this.recordWitnessedSequence(agentName, sessionId, Buffer.from(contentHash).toString("hex"), frame.sequence_number - 1);
|
|
4368
|
+
const s1 = decodeStructure1(frame.structure1_cbor);
|
|
4369
|
+
if (s1.ok) {
|
|
4370
|
+
if (frame.sequence_number > 0) {
|
|
4371
|
+
this.recordWitnessedSequence(agentName, sessionId, Buffer.from(s1.fields.contentHash).toString("hex"), frame.sequence_number - 1);
|
|
3957
4372
|
}
|
|
3958
4373
|
}
|
|
3959
|
-
|
|
4374
|
+
else {
|
|
4375
|
+
// `structure1Reason`, not `error` — review F6. This is a named refusal code, and putting
|
|
4376
|
+
// it in a field called `error` reads as an exception message to anyone scanning logs.
|
|
4377
|
+
// The old `try` here also wrapped `recordWitnessedSequence`, so a throw from THAT was
|
|
4378
|
+
// reported as a decode failure; the decode no longer throws, and the split is deliberate.
|
|
3960
4379
|
this.#logger.warn("session.relay.leaf.witness.decode.failed", {
|
|
3961
4380
|
sessionId,
|
|
3962
|
-
|
|
4381
|
+
structure1Reason: s1.reason,
|
|
3963
4382
|
correlationId,
|
|
3964
4383
|
});
|
|
3965
4384
|
}
|
|
@@ -4476,6 +4895,38 @@ export class SessionNodeManager {
|
|
|
4476
4895
|
if (!current)
|
|
4477
4896
|
return;
|
|
4478
4897
|
this.#impairmentCause.set(key, { cause: current.cause, retained });
|
|
4898
|
+
/**
|
|
4899
|
+
* ─── DOD-M15-NO-SILENT-REFUSAL-1: the notice fires HERE, and ONLY on `lost` ─────────────────
|
|
4900
|
+
*
|
|
4901
|
+
* ⚠️ **THE FIRST VERSION WROTE IT ON THE IMPAIRMENT TRANSITION, WHICH IS A SUCCESS PATH.**
|
|
4902
|
+
*
|
|
4903
|
+
* A direct send failing is the ORDINARY case when a counterparty is offline: the message is
|
|
4904
|
+
* then parked with the relay and handed over when they come back, which is the leave-a-message
|
|
4905
|
+
* feature working exactly as designed. Writing a notice at that moment told the operator "a
|
|
4906
|
+
* message this side sent did not reach the counterparty" about a message that was in flight and
|
|
4907
|
+
* would arrive — while `cello_send` was simultaneously telling them it was parked. Two surfaces,
|
|
4908
|
+
* opposite stories, same message.
|
|
4909
|
+
*
|
|
4910
|
+
* By this line the outcome is known, and only one of the three is the operator's problem:
|
|
4911
|
+
* - `parked` — with the relay, delivered when they come back. Nothing to tell.
|
|
4912
|
+
* - `durable` — queued here, re-sent automatically. Nothing to tell.
|
|
4913
|
+
* - `lost` — it could not be queued anywhere. It is gone, and only a resend recovers it.
|
|
4914
|
+
*
|
|
4915
|
+
* A FAILED ACK takes none of these branches (it never reaches this method), and that is correct:
|
|
4916
|
+
* an acknowledgement this side owed them going missing costs the counterparty a redelivery, not
|
|
4917
|
+
* the operator a message.
|
|
4918
|
+
*
|
|
4919
|
+
* NOT retracted when the connection recovers, unlike the impairment state it rides on: a
|
|
4920
|
+
* recovered connection does not un-lose a message. That is also why the count is meaningful —
|
|
4921
|
+
* it is the number of messages lost in this conversation, and it only ever grows.
|
|
4922
|
+
*/
|
|
4923
|
+
if (retained !== "lost")
|
|
4924
|
+
return;
|
|
4925
|
+
this.noteContentRefusal(agentName, sessionId, "outbound_message_lost", {
|
|
4926
|
+
kind: REFUSAL_KINDS.OUTBOUND,
|
|
4927
|
+
impact: "a message you sent could not be delivered and could not be saved to send later, so it is gone. Nothing was added to the conversation and the other person never saw it. Everything you sent before it is unaffected.",
|
|
4928
|
+
guidance: "Send it again. This is the one case where resending is right — there is no copy of it anywhere, so nothing will deliver it for you. If it keeps happening, the connection to this person is not working: check cello_status for this conversation before sending anything long.",
|
|
4929
|
+
});
|
|
4479
4930
|
}
|
|
4480
4931
|
/** DOD-M12B-ACK-1: why this session is impaired, for the surface that has to explain it. Null
|
|
4481
4932
|
* when it is not impaired — a caller must not narrate a failure that is not current. */
|
|
@@ -4859,10 +5310,22 @@ export class SessionNodeManager {
|
|
|
4859
5310
|
this.#sessionLiveness.delete(key);
|
|
4860
5311
|
// M7-UPGRADE-002: drop the auto-acknowledge bookkeeping for a torn-down session.
|
|
4861
5312
|
this.#contentDesynced.delete(key);
|
|
4862
|
-
|
|
4863
|
-
|
|
4864
|
-
|
|
4865
|
-
|
|
5313
|
+
/**
|
|
5314
|
+
* DOD-M15-NO-SILENT-REFUSAL-1 review N2: the UNPERSISTED half IS torn down, and only that half.
|
|
5315
|
+
*
|
|
5316
|
+
* `#refusalFallback` is in memory and restores exactly what the deleted Map did, so it belongs
|
|
5317
|
+
* in the teardown set exactly as that Map did. Leaving it out meant a daemon that could not
|
|
5318
|
+
* write to disk — already in trouble — grew without bound in memory as well. The durable rows
|
|
5319
|
+
* stay, for the reason below.
|
|
5320
|
+
*/
|
|
5321
|
+
this.#refusalFallback.delete(key);
|
|
5322
|
+
// DOD-M15-NO-SILENT-REFUSAL-1: the DURABLE notices are NOT torn down here, and the omission is
|
|
5323
|
+
// deliberate — this list is the documented teardown set, so anything absent from it needs a
|
|
5324
|
+
// reason. They live in `content_refusal_notices`, keyed on agent_id + session_id, and the
|
|
5325
|
+
// question they answer ("why did that person stop replying?") is one an operator asks AFTER a
|
|
5326
|
+
// session ends, most sharply for `session_committed` — a refusal that exists only because the
|
|
5327
|
+
// session was already sealed. Dropping them at seal would delete exactly the ones a sealed
|
|
5328
|
+
// session produces. Growth is one row per (session, reason), i.e. proportional to `sessions`.
|
|
4866
5329
|
this.#unreadableAlgSeen.delete(key);
|
|
4867
5330
|
this.#responderSealSubmitted.delete(key);
|
|
4868
5331
|
// DOD-MSG-4: drop the strict-in-order bookkeeping (witness map, held plaintext, high-water)
|
|
@@ -6718,10 +7181,14 @@ export class SessionNodeManager {
|
|
|
6718
7181
|
});
|
|
6719
7182
|
};
|
|
6720
7183
|
try {
|
|
6721
|
-
// Structure 1 = [
|
|
6722
|
-
//
|
|
6723
|
-
|
|
6724
|
-
const
|
|
7184
|
+
// Structure 1 = [version, content_hash, sender_pubkey, session_id, last_seen_seq,
|
|
7185
|
+
// timestamp] (+ last_seen_hash at 6 on a v2 claim — 020-ACKHASH). The sender pubkey is
|
|
7186
|
+
// index 2 in both; the same read `#recordFrameOrdering` does for the received half.
|
|
7187
|
+
const s1Decoded = decodeStructure1(witnessed.structure1_cbor);
|
|
7188
|
+
// NAMED AT ITS CAUSE. A failed decode falling through to the shape check below would
|
|
7189
|
+
// report `pubkey_shape` for bytes that never yielded a pubkey at all, sending the next
|
|
7190
|
+
// reader to audit a key when the layout is what disagreed.
|
|
7191
|
+
const pk = s1Decoded.ok ? s1Decoded.fields.senderPubkey : undefined;
|
|
6725
7192
|
// The SIGNATURE is length-checked too (review F2): the guard checked the pubkey's 32
|
|
6726
7193
|
// bytes and only truthiness on the signature, so a zero-length one would have stored an
|
|
6727
7194
|
// uncheckable BLOB. Not reachable today — `sign()` returns 64 — and the asymmetry is
|
|
@@ -6750,7 +7217,10 @@ export class SessionNodeManager {
|
|
|
6750
7217
|
* identity problem. Here it means our own encoder and decoder disagree — bad, but it
|
|
6751
7218
|
* must not cost the operator a delivered message.
|
|
6752
7219
|
*/
|
|
6753
|
-
if (!
|
|
7220
|
+
if (!s1Decoded.ok) {
|
|
7221
|
+
dropAuthorship("structure1_decode_failed", undefined, { structure1Reason: s1Decoded.reason });
|
|
7222
|
+
}
|
|
7223
|
+
else if (!(pk instanceof Uint8Array) || pk.length !== 32) {
|
|
6754
7224
|
dropAuthorship("pubkey_shape", undefined, { pubkeyLen: pk instanceof Uint8Array ? pk.length : -1 });
|
|
6755
7225
|
}
|
|
6756
7226
|
else if (witnessed.sender_signature.length !== 64) {
|
|
@@ -6764,7 +7234,11 @@ export class SessionNodeManager {
|
|
|
6764
7234
|
}
|
|
6765
7235
|
}
|
|
6766
7236
|
catch (err) {
|
|
6767
|
-
|
|
7237
|
+
// NOT a decode failure — review F3. `decodeStructure1` never throws and its failure is
|
|
7238
|
+
// handled as the first branch above, with its own reason. What can still throw in here
|
|
7239
|
+
// is `verify()` and the Buffer work, so this names that instead of sending the reader
|
|
7240
|
+
// to audit a CBOR layout that decoded fine.
|
|
7241
|
+
dropAuthorship("authorship_verify_threw", err);
|
|
6768
7242
|
}
|
|
6769
7243
|
}
|
|
6770
7244
|
// 1-BASED → 0-BASED. The relay numbers the first leaf of a session 1
|
|
@@ -7568,15 +8042,18 @@ export class SessionNodeManager {
|
|
|
7568
8042
|
byHash.set(Buffer.from(contentHash).toString("hex"), declaredAlg);
|
|
7569
8043
|
}
|
|
7570
8044
|
/**
|
|
7571
|
-
* ─── DOD-M15-
|
|
8045
|
+
* ─── DOD-M15-NO-SILENT-REFUSAL-1: refusals the RECEIVING operator can actually see ────────────
|
|
7572
8046
|
*
|
|
7573
8047
|
* Every inbound refusal already logs a `reason`, an `impact` and a `guidance` — and they are
|
|
7574
8048
|
* good. They had no reader. From the receiving operator's chair a refused message simply never
|
|
7575
8049
|
* arrives: the conversation goes quiet with a full explanation sitting in a file they have no
|
|
7576
8050
|
* reason to open, and they conclude the other person stopped replying.
|
|
7577
8051
|
*
|
|
7578
|
-
*
|
|
7579
|
-
*
|
|
8052
|
+
* **DURABLE, and that is the half that makes this useful.** The predecessor kept notices in a
|
|
8053
|
+
* `Map` on this instance and drained them on the receive path for one session. So a restart lost
|
|
8054
|
+
* them, and an agent NOBODY IS ATTENDING lost them too — the connection is live, the daemon is
|
|
8055
|
+
* up, and the notice only ever reaches whoever happens to call `cello_receive` on that exact
|
|
8056
|
+
* session. `cello_check_notifications` now reads them as its own inbox category.
|
|
7580
8057
|
*
|
|
7581
8058
|
* **DEDUPLICATED PER SESSION PER REASON, and that is the design, not an optimisation.** A skewed
|
|
7582
8059
|
* peer turns one problem into a flood: the first refusal of a kind is the signal, the ninetieth is
|
|
@@ -7586,62 +8063,141 @@ export class SessionNodeManager {
|
|
|
7586
8063
|
* **NEVER carries the content.** It failed verification; surfacing it is the injection path the
|
|
7587
8064
|
* cross-check exists to close. The operator learns that a message was refused and why — never
|
|
7588
8065
|
* what it said.
|
|
8066
|
+
*/
|
|
8067
|
+
/**
|
|
8068
|
+
* Record an inbound refusal for the operator. First of its kind per session is the signal.
|
|
7589
8069
|
*
|
|
7590
|
-
*
|
|
7591
|
-
*
|
|
8070
|
+
* ⚠️ **DOES NOT THROW, and that is a decision with a cost — stated so it is not mistaken for an
|
|
8071
|
+
* oversight.** Every call site here has already decided to refuse and is about to return a reason
|
|
8072
|
+
* to its caller; a throw would replace that clean refusal with an exception on the ingest path,
|
|
8073
|
+
* changing what the SENDER observes because this daemon could not file a note. So a persistence
|
|
8074
|
+
* failure is logged at ERROR under `session.refusal.persist.failed`, carrying the reason, the
|
|
8075
|
+
* impact and the guidance verbatim — the forensic record survives even when the operator-facing
|
|
8076
|
+
* one does not. It is not silent; it is one surface short, and the log says which notice was lost.
|
|
7592
8077
|
*/
|
|
8078
|
+
noteContentRefusal(agentName, sessionId, reason,
|
|
7593
8079
|
/**
|
|
7594
|
-
*
|
|
7595
|
-
*
|
|
8080
|
+
* ALL THREE REQUIRED, and that is the enforcement rather than the convention.
|
|
8081
|
+
*
|
|
8082
|
+
* The DoD clause is "every reason calls this with an impact and a guidance", and an optional
|
|
8083
|
+
* field makes that a thing a reviewer checks by reading thirteen call sites. `kind` is required
|
|
8084
|
+
* for the same reason one level up: the header over a list of refusals is composed from it, and
|
|
8085
|
+
* a notice that could omit it would silently inherit whichever header happened to be first.
|
|
7596
8086
|
*/
|
|
7597
|
-
|
|
7598
|
-
|
|
7599
|
-
|
|
7600
|
-
|
|
7601
|
-
|
|
7602
|
-
|
|
7603
|
-
|
|
7604
|
-
|
|
8087
|
+
detail) {
|
|
8088
|
+
try {
|
|
8089
|
+
if (!this.#db)
|
|
8090
|
+
throw new Error("database is not open");
|
|
8091
|
+
const agentId = this.#requireAgentId(agentName);
|
|
8092
|
+
const now = Date.now();
|
|
8093
|
+
// `count` grows on conflict; impact and guidance are refreshed, because a later refusal of the
|
|
8094
|
+
// same reason may know more than the first (the salt branch has four causes and names them).
|
|
8095
|
+
this.#db
|
|
8096
|
+
.prepare(`INSERT INTO content_refusal_notices
|
|
8097
|
+
(agent_id, session_id, reason, kind, impact, guidance, count, first_at, last_at)
|
|
8098
|
+
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)
|
|
8099
|
+
ON CONFLICT(agent_id, session_id, reason) DO UPDATE SET
|
|
8100
|
+
count = count + 1, kind = excluded.kind, impact = excluded.impact,
|
|
8101
|
+
guidance = excluded.guidance, last_at = excluded.last_at`)
|
|
8102
|
+
.run(agentId, sessionId, reason, detail.kind, detail.impact, detail.guidance, now, now);
|
|
7605
8103
|
}
|
|
7606
|
-
|
|
7607
|
-
|
|
7608
|
-
|
|
7609
|
-
|
|
8104
|
+
catch (err) {
|
|
8105
|
+
this.#logger.error("session.refusal.persist.failed", {
|
|
8106
|
+
agentName, sessionId, reason,
|
|
8107
|
+
impact: detail.impact,
|
|
8108
|
+
guidance: detail.guidance,
|
|
8109
|
+
error: extractErrorMessage(err),
|
|
8110
|
+
consequence: "this refusal could not be written to the notice store, so it will not survive a restart. It is held in memory for this process instead, so the operator is still told while this daemon runs. The reason, impact and guidance above are the whole notice.",
|
|
8111
|
+
});
|
|
8112
|
+
/**
|
|
8113
|
+
* DOD-M15-NO-SILENT-REFUSAL-1 review F6 — the fallback, and why it is not the silent kind.
|
|
8114
|
+
*
|
|
8115
|
+
* The store this replaced was an in-memory Map, which could not fail: recording a refusal was
|
|
8116
|
+
* a `set`, so the receive door always had the notice for the life of the process. Making the
|
|
8117
|
+
* store durable made it, in the failure case, LESS available than before — a DB write failure
|
|
8118
|
+
* left no operator-facing surface at all, only a log line.
|
|
8119
|
+
*
|
|
8120
|
+
* So a failed write falls back to exactly what the Map did. This is not a fallback that hides
|
|
8121
|
+
* a fault: the ERROR above fires every time, naming the notice and the cause, and what is lost
|
|
8122
|
+
* is only the restart property — which is the property the database was unavailable for
|
|
8123
|
+
* anyway. A silent fallback is one that makes a broken system look healthy; this one is
|
|
8124
|
+
* announced, and it preserves the surface rather than substituting for it.
|
|
8125
|
+
*/
|
|
8126
|
+
const key = this.#k(agentName, sessionId);
|
|
8127
|
+
let perSession = this.#refusalFallback.get(key);
|
|
8128
|
+
if (!perSession) {
|
|
8129
|
+
perSession = new Map();
|
|
8130
|
+
this.#refusalFallback.set(key, perSession);
|
|
8131
|
+
}
|
|
8132
|
+
const existing = perSession.get(reason);
|
|
8133
|
+
if (existing) {
|
|
8134
|
+
existing.count += 1;
|
|
8135
|
+
existing.kind = detail.kind;
|
|
8136
|
+
existing.impact = detail.impact;
|
|
8137
|
+
existing.guidance = detail.guidance;
|
|
8138
|
+
return;
|
|
8139
|
+
}
|
|
8140
|
+
perSession.set(reason, { ...detail, count: 1, surfacedTo: new Map() });
|
|
7610
8141
|
}
|
|
7611
|
-
|
|
7612
|
-
|
|
7613
|
-
|
|
7614
|
-
|
|
7615
|
-
|
|
7616
|
-
|
|
7617
|
-
|
|
7618
|
-
|
|
8142
|
+
}
|
|
8143
|
+
/**
|
|
8144
|
+
* DOD-M15-NO-SILENT-REFUSAL-1: the operator has seen these and does not want to see them again.
|
|
8145
|
+
*
|
|
8146
|
+
* ⚠️ **WITHOUT THIS THE NOTICES ARE PERMANENT, and that is what makes people stop reading the
|
|
8147
|
+
* inbox.** "Already shown you" is tracked per WINDOW — a new MCP connection has been told nothing,
|
|
8148
|
+
* so it is told everything. Someone on an older build messages you, you sort it out with them,
|
|
8149
|
+
* they upgrade, and every new session you ever open still opens with that refusal.
|
|
8150
|
+
*
|
|
8151
|
+
* Dismissing does NOT turn anything off. If the cause fires again the notice comes back, because
|
|
8152
|
+
* a fresh refusal writes a fresh row. The operator is saying "I know", not "stop telling me".
|
|
8153
|
+
*
|
|
8154
|
+
* Returns how many were cleared, so the caller can say so rather than claiming a silent success.
|
|
8155
|
+
*/
|
|
8156
|
+
dismissContentRefusals(agentName, sessionId) {
|
|
8157
|
+
if (!this.#db)
|
|
8158
|
+
return 0;
|
|
8159
|
+
let agentId;
|
|
8160
|
+
try {
|
|
8161
|
+
agentId = this.#requireAgentId(agentName);
|
|
8162
|
+
}
|
|
8163
|
+
catch {
|
|
8164
|
+
return 0;
|
|
8165
|
+
}
|
|
8166
|
+
this.#refusalFallback.delete(this.#k(agentName, sessionId));
|
|
8167
|
+
const res = this.#db
|
|
8168
|
+
.prepare("DELETE FROM content_refusal_notices WHERE agent_id = ? AND session_id = ?")
|
|
8169
|
+
.run(agentId, sessionId);
|
|
8170
|
+
this.#db
|
|
8171
|
+
.prepare("DELETE FROM content_refusal_reads WHERE agent_id = ? AND session_id = ?")
|
|
8172
|
+
.run(agentId, sessionId);
|
|
8173
|
+
return Number(res.changes);
|
|
7619
8174
|
}
|
|
7620
8175
|
/**
|
|
7621
8176
|
* Drain the refusals a GIVEN CONSUMER has not been shown yet, and remember what it was shown.
|
|
7622
8177
|
*
|
|
7623
|
-
* ─── Why this is keyed by
|
|
8178
|
+
* ─── Why this is keyed by consumer, and not by a single flag ──────────────────────────────────
|
|
7624
8179
|
*
|
|
7625
8180
|
* It used to set one `surfaced: boolean` on the notice. Two MCP windows attending the same agent
|
|
7626
8181
|
* is the ordinary case, and under that flag whoever read FIRST consumed the notice — the second
|
|
7627
8182
|
* window was told nothing, permanently. **That is the same defect `takeReceivedContent` had**, and
|
|
7628
8183
|
* the comment above the delivery loop in `session-content-handlers.ts` spells out why it was
|
|
7629
8184
|
* removed: *"reading is non-destructive by construction. Nothing one consumer does mutates state
|
|
7630
|
-
* another consumer reads."*
|
|
7631
|
-
* for once already; re-introducing it on a different surface makes it no less true.
|
|
8185
|
+
* another consumer reads."*
|
|
7632
8186
|
*
|
|
7633
|
-
* ─── Why the count
|
|
8187
|
+
* ─── Why the count has a reader ───────────────────────────────────────────────────────────────
|
|
7634
8188
|
*
|
|
7635
|
-
*
|
|
7636
|
-
*
|
|
7637
|
-
*
|
|
7638
|
-
*
|
|
8189
|
+
* A reason RE-ANNOUNCES to a consumer when its count has grown by an order of magnitude since that
|
|
8190
|
+
* consumer last saw it (1 → 10 → 100 → …), marked `repeat: true`. That keeps the first refusal the
|
|
8191
|
+
* signal and the ninetieth silent, which is the dedup's point, while still making a skew that has
|
|
8192
|
+
* swallowed hundreds of messages visible — at a handful of announcements per session, not one per
|
|
8193
|
+
* message.
|
|
7639
8194
|
*
|
|
7640
|
-
*
|
|
7641
|
-
*
|
|
7642
|
-
*
|
|
7643
|
-
*
|
|
7644
|
-
*
|
|
8195
|
+
* ─── What a restart does, deliberately ────────────────────────────────────────────────────────
|
|
8196
|
+
*
|
|
8197
|
+
* The notices survive; the read state is keyed by IPC connection id, which does not. So after a
|
|
8198
|
+
* restart every notice is unseen again and the next reader is told. That is the correct direction:
|
|
8199
|
+
* a fresh window has not been told anything, and re-announcing costs one line where staying silent
|
|
8200
|
+
* costs the whole point of storing it.
|
|
7645
8201
|
*/
|
|
7646
8202
|
takeContentRefusals(agentName, sessionId,
|
|
7647
8203
|
/**
|
|
@@ -7654,26 +8210,238 @@ export class SessionNodeManager {
|
|
|
7654
8210
|
* forget it is.
|
|
7655
8211
|
*/
|
|
7656
8212
|
consumerId) {
|
|
7657
|
-
|
|
7658
|
-
|
|
7659
|
-
|
|
8213
|
+
// `sessionId` is dropped from each entry: the caller passed it in and every entry carries the
|
|
8214
|
+
// same one, so repeating it back would be a field that can never say anything. The agent-wide
|
|
8215
|
+
// door below keeps it, because there it is the only thing that says WHICH conversation.
|
|
8216
|
+
//
|
|
8217
|
+
// `truncated` is dropped too, and only here: one session's notices cannot reach the cap (the
|
|
8218
|
+
// reasons are a bounded set), so a flag that can never be true is a field readers learn to skip.
|
|
8219
|
+
return this.#drainRefusals(agentName, consumerId, sessionId).notices.map(({ sessionId: _drop, ...rest }) => rest);
|
|
8220
|
+
}
|
|
8221
|
+
/**
|
|
8222
|
+
* DOD-M15-NO-SILENT-REFUSAL-1: every unshown refusal for an agent, ACROSS its sessions.
|
|
8223
|
+
*
|
|
8224
|
+
* The inbox's door. `takeContentRefusals` answers for one session because its caller already holds
|
|
8225
|
+
* one; `cello_check_notifications` holds an agent and nothing else, and the case this whole line
|
|
8226
|
+
* exists for is that nobody is attending any of that agent's sessions — so a per-session read
|
|
8227
|
+
* cannot reach it. Same store, same per-consumer rule, same re-announce.
|
|
8228
|
+
*/
|
|
8229
|
+
takeAgentContentRefusals(agentName, consumerId) {
|
|
8230
|
+
return this.#drainRefusals(agentName, consumerId);
|
|
8231
|
+
}
|
|
8232
|
+
/**
|
|
8233
|
+
* The one read path behind both doors. `sessionId` narrows it; omitted, it spans the agent.
|
|
8234
|
+
*
|
|
8235
|
+
* A single implementation on purpose: the per-consumer rule and the order-of-magnitude rule are
|
|
8236
|
+
* the two properties this unit must not lose, and two copies of them is two things to keep true.
|
|
8237
|
+
*/
|
|
8238
|
+
#drainRefusals(agentName, consumerId, sessionId) {
|
|
7660
8239
|
const out = [];
|
|
7661
|
-
|
|
7662
|
-
|
|
7663
|
-
|
|
7664
|
-
|
|
7665
|
-
|
|
8240
|
+
let truncated = false;
|
|
8241
|
+
const now = Date.now();
|
|
8242
|
+
let agentId = null;
|
|
8243
|
+
if (this.#db) {
|
|
8244
|
+
try {
|
|
8245
|
+
agentId = this.#requireAgentId(agentName);
|
|
8246
|
+
}
|
|
8247
|
+
catch {
|
|
8248
|
+
// A name that resolves to no active agent has no notices by construction. Already logged by
|
|
8249
|
+
// #requireAgentId; re-throwing would fail a read that has nothing to report.
|
|
8250
|
+
agentId = null;
|
|
8251
|
+
}
|
|
8252
|
+
}
|
|
8253
|
+
if (agentId !== null && this.#db) {
|
|
8254
|
+
/**
|
|
8255
|
+
* ⚠️ NEWEST FIRST, AND CAPPED — review F3, and the ordering is the load-bearing half.
|
|
8256
|
+
*
|
|
8257
|
+
* Read state is keyed on IPC connection id, so every new window and every restart is a
|
|
8258
|
+
* consumer that has been told nothing, and the drain returns every notice ever recorded for
|
|
8259
|
+
* that agent. Oldest-first and uncapped, the first thing an operator saw after a restart was a
|
|
8260
|
+
* chronological archive whose top entry was the oldest refusal on record and whose newest —
|
|
8261
|
+
* the one explaining the conversation that just went quiet — was at the bottom. That is a
|
|
8262
|
+
* section people learn to scroll past, which is the failure this whole line exists to end.
|
|
8263
|
+
*
|
|
8264
|
+
* `LIMIT` is `+ 1` so the cap can be DETECTED rather than assumed; the extra row is dropped
|
|
8265
|
+
* and `truncated` is reported to the caller, which says so on the list itself.
|
|
8266
|
+
*
|
|
8267
|
+
* ⚠️ `rowid DESC` is the TIEBREAK and it is load-bearing, not tidiness. `last_at` is
|
|
8268
|
+
* `Date.now()`, so notices recorded in the same millisecond have no defined order and "newest
|
|
8269
|
+
* first" was true only on average — measured by a test that recorded 30 notices in one tick
|
|
8270
|
+
* and got them back in an order SQLite was free to choose. rowid is monotonic per insert, so
|
|
8271
|
+
* the tiebreak is insertion order, which for a same-millisecond batch is exactly recency.
|
|
8272
|
+
*/
|
|
8273
|
+
const rows = (sessionId === undefined
|
|
8274
|
+
? this.#db
|
|
8275
|
+
.prepare(`SELECT n.session_id, n.reason, n.kind, n.impact, n.guidance, n.count, r.seen_count
|
|
8276
|
+
FROM content_refusal_notices n
|
|
8277
|
+
LEFT JOIN content_refusal_reads r
|
|
8278
|
+
ON r.agent_id = n.agent_id AND r.session_id = n.session_id
|
|
8279
|
+
AND r.reason = n.reason AND r.consumer_id = ?
|
|
8280
|
+
WHERE n.agent_id = ?
|
|
8281
|
+
AND (r.seen_count IS NULL OR n.count >= r.seen_count * 10)
|
|
8282
|
+
ORDER BY n.last_at DESC, n.rowid DESC LIMIT ?`)
|
|
8283
|
+
.all(consumerId, agentId, MAX_REFUSALS_PER_READ + 1)
|
|
8284
|
+
: this.#db
|
|
8285
|
+
.prepare(`SELECT n.session_id, n.reason, n.kind, n.impact, n.guidance, n.count, r.seen_count
|
|
8286
|
+
FROM content_refusal_notices n
|
|
8287
|
+
LEFT JOIN content_refusal_reads r
|
|
8288
|
+
ON r.agent_id = n.agent_id AND r.session_id = n.session_id
|
|
8289
|
+
AND r.reason = n.reason AND r.consumer_id = ?
|
|
8290
|
+
WHERE n.agent_id = ? AND n.session_id = ?
|
|
8291
|
+
AND (r.seen_count IS NULL OR n.count >= r.seen_count * 10)
|
|
8292
|
+
ORDER BY n.last_at DESC, n.rowid DESC LIMIT ?`)
|
|
8293
|
+
.all(consumerId, agentId, sessionId, MAX_REFUSALS_PER_READ + 1));
|
|
8294
|
+
if (rows.length > MAX_REFUSALS_PER_READ) {
|
|
8295
|
+
truncated = true;
|
|
8296
|
+
rows.length = MAX_REFUSALS_PER_READ;
|
|
8297
|
+
}
|
|
8298
|
+
for (const row of rows) {
|
|
8299
|
+
const firstTime = row.seen_count === null;
|
|
8300
|
+
/**
|
|
8301
|
+
* ⚠️ The unseen test is IN THE QUERY (review N4), and this line is a belt, not the gate.
|
|
8302
|
+
*
|
|
8303
|
+
* Applied only here, the `LIMIT` cut the newest 25 notices and THEN discarded the ones this
|
|
8304
|
+
* consumer had already seen — so a consumer holding read rows for the newest 25 got an empty
|
|
8305
|
+
* answer forever and a genuinely unseen notice at position 26 could never be reached. The
|
|
8306
|
+
* cap has to cut UNSHOWN notices, which means the filter has to run before it.
|
|
8307
|
+
*
|
|
8308
|
+
* `seen_count` is at least 1 whenever it is set, so this cannot loop on zero.
|
|
8309
|
+
*/
|
|
8310
|
+
if (!firstTime && row.count < row.seen_count * 10)
|
|
8311
|
+
continue;
|
|
8312
|
+
this.#db
|
|
8313
|
+
.prepare(`INSERT INTO content_refusal_reads
|
|
8314
|
+
(agent_id, session_id, reason, consumer_id, seen_count, seen_at)
|
|
8315
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
8316
|
+
ON CONFLICT(agent_id, session_id, reason, consumer_id) DO UPDATE SET
|
|
8317
|
+
seen_count = excluded.seen_count, seen_at = excluded.seen_at`)
|
|
8318
|
+
.run(agentId, row.session_id, row.reason, consumerId, row.count, now);
|
|
8319
|
+
this.#evictOldestReads(agentId, row.session_id, row.reason);
|
|
8320
|
+
out.push({
|
|
8321
|
+
sessionId: row.session_id,
|
|
8322
|
+
reason: row.reason,
|
|
8323
|
+
kind: row.kind,
|
|
8324
|
+
impact: row.impact,
|
|
8325
|
+
guidance: row.guidance,
|
|
8326
|
+
count: row.count,
|
|
8327
|
+
...(firstTime ? {} : { repeat: true }),
|
|
8328
|
+
});
|
|
8329
|
+
}
|
|
8330
|
+
}
|
|
8331
|
+
/**
|
|
8332
|
+
* The unpersisted notices (review F6), under the SAME per-consumer and order-of-magnitude rules.
|
|
8333
|
+
* Reading them differently would make a database failure change WHAT the operator is told rather
|
|
8334
|
+
* than only how long it survives — and that difference is the thing hardest to notice.
|
|
8335
|
+
*
|
|
8336
|
+
* Collected separately and REVERSED before joining, not appended in place — review N2. A Map
|
|
8337
|
+
* yields insertion order, which is oldest-first, so appending them straight after the DB half
|
|
8338
|
+
* (which is newest-first) put the newest notices at the bottom on exactly the daemon where the
|
|
8339
|
+
* fallback is the only half there is.
|
|
8340
|
+
*/
|
|
8341
|
+
const fromFallback = [];
|
|
8342
|
+
for (const [key, perSession] of this.#refusalFallback) {
|
|
8343
|
+
const sid = this.#unk(key, agentName);
|
|
8344
|
+
if (sid === null)
|
|
7666
8345
|
continue;
|
|
7667
|
-
|
|
7668
|
-
|
|
7669
|
-
|
|
7670
|
-
|
|
7671
|
-
|
|
7672
|
-
|
|
7673
|
-
|
|
7674
|
-
|
|
8346
|
+
if (sessionId !== undefined && sid !== sessionId)
|
|
8347
|
+
continue;
|
|
8348
|
+
for (const [reason, notice] of perSession) {
|
|
8349
|
+
const shownAt = notice.surfacedTo.get(consumerId);
|
|
8350
|
+
const firstTime = shownAt === undefined;
|
|
8351
|
+
if (!firstTime && notice.count < shownAt * 10)
|
|
8352
|
+
continue;
|
|
8353
|
+
notice.surfacedTo.set(consumerId, notice.count);
|
|
8354
|
+
// Bounded like the table's read rows are (review N2): a consumer id is an IPC connection id,
|
|
8355
|
+
// so without this a long-running daemon grows one entry per reconnect, in memory, on the
|
|
8356
|
+
// very path that exists because the disk is already failing.
|
|
8357
|
+
if (notice.surfacedTo.size > MAX_REFUSAL_READERS) {
|
|
8358
|
+
const oldest = notice.surfacedTo.keys().next();
|
|
8359
|
+
if (!oldest.done)
|
|
8360
|
+
notice.surfacedTo.delete(oldest.value);
|
|
8361
|
+
}
|
|
8362
|
+
fromFallback.push({
|
|
8363
|
+
sessionId: sid, reason, kind: notice.kind, impact: notice.impact,
|
|
8364
|
+
guidance: notice.guidance, count: notice.count,
|
|
8365
|
+
...(firstTime ? {} : { repeat: true }),
|
|
8366
|
+
});
|
|
8367
|
+
}
|
|
7675
8368
|
}
|
|
7676
|
-
|
|
8369
|
+
out.push(...fromFallback.reverse());
|
|
8370
|
+
/**
|
|
8371
|
+
* ONE cap over BOTH halves — review N2.
|
|
8372
|
+
*
|
|
8373
|
+
* `LIMIT` governs the table only. A persistent database fault (a full disk, which is also the
|
|
8374
|
+
* likeliest cause of `transcript_write_failed`) routes EVERY refusal to the fallback, so the cap
|
|
8375
|
+
* this unit added was undone for exactly the daemon already in trouble.
|
|
8376
|
+
*
|
|
8377
|
+
* The truncation keeps the DB half preferentially, and that is the right bias: those rows are
|
|
8378
|
+
* the ones that survive a restart, and they are already ordered newest-first.
|
|
8379
|
+
*/
|
|
8380
|
+
if (out.length > MAX_REFUSALS_PER_READ) {
|
|
8381
|
+
truncated = true;
|
|
8382
|
+
out.length = MAX_REFUSALS_PER_READ;
|
|
8383
|
+
}
|
|
8384
|
+
return { notices: out, truncated };
|
|
8385
|
+
}
|
|
8386
|
+
#evictOldestReads(agentId, sessionId, reason) {
|
|
8387
|
+
if (!this.#db)
|
|
8388
|
+
return;
|
|
8389
|
+
this.#db
|
|
8390
|
+
.prepare(`DELETE FROM content_refusal_reads
|
|
8391
|
+
WHERE agent_id = ? AND session_id = ? AND reason = ? AND consumer_id NOT IN (
|
|
8392
|
+
SELECT consumer_id FROM content_refusal_reads
|
|
8393
|
+
WHERE agent_id = ? AND session_id = ? AND reason = ?
|
|
8394
|
+
ORDER BY seen_at DESC LIMIT ${MAX_REFUSAL_READERS}
|
|
8395
|
+
)`)
|
|
8396
|
+
.run(agentId, sessionId, reason, agentId, sessionId, reason);
|
|
8397
|
+
}
|
|
8398
|
+
/**
|
|
8399
|
+
* DOD-M15-NO-SILENT-REFUSAL-1 — the per-session byte cap, from the operator's chair.
|
|
8400
|
+
*
|
|
8401
|
+
* This is the harshest refusal on the inbound path and the one that reads least like a fault:
|
|
8402
|
+
* once the cap is crossed, EVERY later message from that sender on that session is refused, for
|
|
8403
|
+
* the life of the session. The counterparty is told nothing either, so from both chairs the other
|
|
8404
|
+
* person simply stopped replying.
|
|
8405
|
+
*
|
|
8406
|
+
* One method rather than two copies because the cap is checked twice — once before the screening
|
|
8407
|
+
* await and once after, against freshly-read totals — and a notice that differs between the two
|
|
8408
|
+
* would describe a different refusal depending on timing.
|
|
8409
|
+
*/
|
|
8410
|
+
/**
|
|
8411
|
+
* DOD-M15-REFUSEDEVIDENCE-1 — **THE BYTE CAP RETAINS NOTHING, and that is a ruling, not an
|
|
8412
|
+
* oversight.**
|
|
8413
|
+
*
|
|
8414
|
+
* Retention is universal everywhere else in this method. Here it is not, because retaining would
|
|
8415
|
+
* defeat the very bound it enforces: a session already over its storage budget cannot be given
|
|
8416
|
+
* more storage as a reward for exceeding it, and `#getReceivedBytesTotal` counts quarantined bytes
|
|
8417
|
+
* precisely so that budget is honest.
|
|
8418
|
+
*
|
|
8419
|
+
* Andre, 2026-09-03: *"The message limit is the message limit, already handled by the cap. If
|
|
8420
|
+
* you're unknown and you have 25 MB and you just tried to send me one gig, well that's it."*
|
|
8421
|
+
*
|
|
8422
|
+
* The ABUSE is still evidenced — this notice records the reason, the cap and the tier, and every
|
|
8423
|
+
* message the session did retain is still there. What is not kept is the oversized payload.
|
|
8424
|
+
*/
|
|
8425
|
+
#noteSizeCapRefusal(agentName, sessionId, cap, tier) {
|
|
8426
|
+
/**
|
|
8427
|
+
* ⚠️ **IN MEGABYTES, WITH THE BYTES BESIDE THEM.** "26214400 bytes" is not a number anyone reads
|
|
8428
|
+
* as 25 MB, and the operator being told a conversation just ended deserves to understand the
|
|
8429
|
+
* limit that ended it at a glance. The raw figure stays because it is the exact bound.
|
|
8430
|
+
*/
|
|
8431
|
+
const mb = Math.round((cap / 1_048_576) * 10) / 10;
|
|
8432
|
+
/**
|
|
8433
|
+
* The access level as a QUOTED LOWERCASE LABEL, never a bare word.
|
|
8434
|
+
*
|
|
8435
|
+
* `their tier is UNKNOWN` reads as "we could not determine their tier" — the opposite of what it
|
|
8436
|
+
* says. UNKNOWN is the NAME of the level a sender has before the operator adds them as a
|
|
8437
|
+
* contact. Quoting it and lowercasing it makes it a label rather than a failure.
|
|
8438
|
+
*/
|
|
8439
|
+
const level = (Object.entries(TIER).find(([, v]) => v === tier)?.[0] ?? String(tier)).toLowerCase();
|
|
8440
|
+
this.noteContentRefusal(agentName, sessionId, "session_size_limit_exceeded", {
|
|
8441
|
+
kind: REFUSAL_KINDS.REFUSED,
|
|
8442
|
+
impact: `This conversation has hit its size limit for this sender: ${mb} MB (${cap} bytes), which is the limit at their access level ("${level}"). The message was not delivered, and neither will anything else they send in this conversation. They were not told — from their side it sent normally.`,
|
|
8443
|
+
guidance: `The limit is per conversation and does not reset, so waiting will not clear it. Start a NEW conversation with them to keep talking. If you trust them, raising their access level with cello_contact_set_tier gives them a larger limit next time — it does not revive this one. Tell them what happened: they have no way to know.`,
|
|
8444
|
+
});
|
|
7677
8445
|
}
|
|
7678
8446
|
#markContentUnverifiable(agentName, sessionId, why) {
|
|
7679
8447
|
const key = this.#k(agentName, sessionId);
|
|
@@ -7681,6 +8449,116 @@ export class SessionNodeManager {
|
|
|
7681
8449
|
return;
|
|
7682
8450
|
this.#contentDesynced.set(key, why);
|
|
7683
8451
|
}
|
|
8452
|
+
/**
|
|
8453
|
+
* 024-ORPHANTRIAGE — the three signals, read from evidence this side owns.
|
|
8454
|
+
*
|
|
8455
|
+
* ⚠️ **THE SEQUENCE NUMBER ON THE FRAME IS NOT ONE OF THEM.** "Was there an ongoing conversation
|
|
8456
|
+
* up to this point" is tempting to answer from the position the sender wrote, and that answer is
|
|
8457
|
+
* worthless: the sender picks the number, so anyone wanting the reach-out branch writes a large
|
|
8458
|
+
* one. It is answered from OUR transcript rows instead — a partial local record of that
|
|
8459
|
+
* conversation is something an attacker cannot put there from the wire.
|
|
8460
|
+
*
|
|
8461
|
+
* ⚠️ **"KNOWN" IS A TIER, NOT A ROW — review F1/F2, and reading it as a row inverted the unit.**
|
|
8462
|
+
* `contacts` rows are written from the WIRE with no operator action: `inbound-sessions.ts` calls
|
|
8463
|
+
* `addContact(..., "signal_presentation")` at `TIER.UNKNOWN` for any inbound offer inside the
|
|
8464
|
+
* acceptance bound, because the trust-signal foreign key needs a row to point at. And BLOCKING a
|
|
8465
|
+
* contact is an UPDATE to `TIER.BLOCKED`, so the row survives that too. A `SELECT … WHERE pubkey`
|
|
8466
|
+
* therefore answers "yes, known" for a stranger who merely dialled, AND for a key the operator
|
|
8467
|
+
* deliberately blocked — handing both the reach-out branch, which is the exact population this
|
|
8468
|
+
* unit exists to refuse. `DOD-TIER-4` had already settled this and retired `isContact` for it:
|
|
8469
|
+
* *"An UNKNOWN-tier contact (a mere row) is NOT known."* The tier is read from the row already
|
|
8470
|
+
* being fetched, so the case-insensitivity below survives (`getTier` compares case-sensitively).
|
|
8471
|
+
*
|
|
8472
|
+
* ⚠️ **HEX CASE IS NOT A DIFFERENCE IN IDENTITY.** This unit originally worked around that with its
|
|
8473
|
+
* own `lower(pubkey)` lookup, because `contacts.pubkey` was stored verbatim from the IPC parameter
|
|
8474
|
+
* and an exact match would report a contact the operator can SEE in `cello_contacts` as an unknown
|
|
8475
|
+
* stranger. The workaround is gone: `contact-pubkey-case.ts` now normalizes every contacts
|
|
8476
|
+
* accessor and folds the rows already on disk, so there is one spelling and one rule.
|
|
8477
|
+
*/
|
|
8478
|
+
#orphanEvidence(agentName, sessionId, verifiedSignerUnmatched) {
|
|
8479
|
+
const signerPubkeyHex = verifiedSignerUnmatched === undefined
|
|
8480
|
+
? null
|
|
8481
|
+
: Buffer.from(verifiedSignerUnmatched).toString("hex");
|
|
8482
|
+
/**
|
|
8483
|
+
* ⚠️ `"not_checked"` IS NOT A COSMETIC THIRD STATE — review F6.
|
|
8484
|
+
*
|
|
8485
|
+
* These two were `false` on every path that did not look, and the log event then reported them
|
|
8486
|
+
* as readings. An investigator filtering `session.content.orphaned` days later would read
|
|
8487
|
+
* `ongoingConversation: false` and conclude there was no local trace, when nothing had been
|
|
8488
|
+
* asked. Clause 1 says the branch RECORDS these; a default wearing the shape of a measurement
|
|
8489
|
+
* is not a record, and it is the cheapest possible way to mislead the one person who comes
|
|
8490
|
+
* looking.
|
|
8491
|
+
*/
|
|
8492
|
+
const notChecked = {
|
|
8493
|
+
signerPubkeyHex, knownContact: "not_checked", contactMoniker: null, ongoingConversation: "not_checked",
|
|
8494
|
+
};
|
|
8495
|
+
// With no verifiable signature the other two signals mean nothing — a claimed key is a string
|
|
8496
|
+
// anyone can type — so they are not looked up at all rather than looked up and ignored.
|
|
8497
|
+
if (signerPubkeyHex === null)
|
|
8498
|
+
return notChecked;
|
|
8499
|
+
try {
|
|
8500
|
+
/**
|
|
8501
|
+
* ⚠️ NO `!this.#db` SHORT-CIRCUIT — review F7, and it was the silent half of this guard.
|
|
8502
|
+
*
|
|
8503
|
+
* The catch below logs ERROR for exactly this outcome; a bare `if (!this.#db) return` did not,
|
|
8504
|
+
* and the state is reachable while the operator still gets a notice — `noteContentRefusal`
|
|
8505
|
+
* keeps its own in-memory fallback when the write fails, so a daemon with an unusable store
|
|
8506
|
+
* still surfaces a refusal saying "that key is not in your address book" about a key that may
|
|
8507
|
+
* well be in it, with nothing anywhere recording that the address book was never opened.
|
|
8508
|
+
* Throwing into the catch is also what this file's other contact reads do (`getTier`,
|
|
8509
|
+
* `addContact`), and for the same reason: a read that decides how to treat a sender must not
|
|
8510
|
+
* degrade to "unclassified" in silence.
|
|
8511
|
+
*/
|
|
8512
|
+
if (!this.#db)
|
|
8513
|
+
throw new Error("database is not open");
|
|
8514
|
+
const agentId = this.#requireAgentId(agentName);
|
|
8515
|
+
const contact = this.#db
|
|
8516
|
+
.prepare("SELECT moniker, tier FROM contacts WHERE agent_id = ? AND pubkey = ?")
|
|
8517
|
+
.get(agentId, normalizeContactPubkey(signerPubkeyHex));
|
|
8518
|
+
/**
|
|
8519
|
+
* ⚠️ **QUARANTINED ROWS ARE NOT A LOCAL TRACE, AND WITHOUT THIS CLAUSE THE PROBE WRITES ITS
|
|
8520
|
+
* OWN EVIDENCE.** Found where `023-REFUSEDEVIDENCE` met `024-ORPHANTRIAGE`.
|
|
8521
|
+
*
|
|
8522
|
+
* This signal answers *"does this machine hold any part of a conversation under the id the
|
|
8523
|
+
* message names?"*, and a `true` is one of the two conditions that flips the triage from
|
|
8524
|
+
* REPORT-ONLY to offering the operator a reach-out. Since 023, a refused message is RETAINED
|
|
8525
|
+
* as a transcript row — so an unfiltered `SELECT 1 FROM transcript` sees the row this very
|
|
8526
|
+
* refusal just wrote.
|
|
8527
|
+
*
|
|
8528
|
+
* From the operator's chair, unfiltered: a stranger with a vouched key probes an id nobody
|
|
8529
|
+
* opened; the first probe is refused and retained; the second probe finds the first one's row,
|
|
8530
|
+
* reads as an ongoing conversation, and the operator is invited to reach out. **The attacker
|
|
8531
|
+
* manufactures the signal by sending twice** — which is precisely the outcome 024 exists to
|
|
8532
|
+
* prevent, reintroduced by the unit that made evidence durable.
|
|
8533
|
+
*
|
|
8534
|
+
* `direction != 'quarantined'` is the same exclusion every delivery reader uses, and it is the
|
|
8535
|
+
* right one: what is asked here is whether anything was ever DELIVERED under this id.
|
|
8536
|
+
*/
|
|
8537
|
+
const trace = this.#db
|
|
8538
|
+
.prepare("SELECT 1 AS present FROM transcript WHERE agent_id = ? AND session_id = ? AND direction != 'quarantined' LIMIT 1")
|
|
8539
|
+
.get(agentId, sessionId);
|
|
8540
|
+
return {
|
|
8541
|
+
signerPubkeyHex,
|
|
8542
|
+
knownContact: contact !== undefined && normalizeTier(contact.tier) >= TIER.KNOWN,
|
|
8543
|
+
contactMoniker: contact?.moniker ?? null,
|
|
8544
|
+
ongoingConversation: trace !== undefined,
|
|
8545
|
+
};
|
|
8546
|
+
}
|
|
8547
|
+
catch (err) {
|
|
8548
|
+
/**
|
|
8549
|
+
* NOT A SILENT FALLBACK. Both signals come back `"not_checked"`, which the triage treats
|
|
8550
|
+
* exactly as it treats a stranger — REPORT, the action that is safe when nothing is known — and
|
|
8551
|
+
* which the log distinguishes from a measured `false`. A read failure here must never invent
|
|
8552
|
+
* the reach-out branch, and it must never be invisible.
|
|
8553
|
+
*/
|
|
8554
|
+
this.#logger.error("session.content.orphaned.evidence.failed", {
|
|
8555
|
+
agentName, sessionId,
|
|
8556
|
+
error: extractErrorMessage(err),
|
|
8557
|
+
impact: "the address book and transcript could not be read, so a message whose signature DID verify is being reported to the operator as coming from a key nothing is known about. The advice is the safe one; it may be more cautious than the evidence warrants.",
|
|
8558
|
+
});
|
|
8559
|
+
return notChecked;
|
|
8560
|
+
}
|
|
8561
|
+
}
|
|
7684
8562
|
async ingestReceivedContent(agentName, sessionId, content, contentHash, correlationId,
|
|
7685
8563
|
/**
|
|
7686
8564
|
* DOD-FRONTIER-STRAND-1 AC1: the relay-assigned canonical position for THIS message, taken from
|
|
@@ -7708,7 +8586,19 @@ export class SessionNodeManager {
|
|
|
7708
8586
|
* Optional, because the soft decode-failure path ingests without it. The row records which it
|
|
7709
8587
|
* was, so absence is never silent.
|
|
7710
8588
|
*/
|
|
7711
|
-
verifiedAuthorship
|
|
8589
|
+
verifiedAuthorship,
|
|
8590
|
+
/**
|
|
8591
|
+
* 024-ORPHANTRIAGE — the key whose signature VERIFIED on a frame we could not tie to a session.
|
|
8592
|
+
*
|
|
8593
|
+
* Read by the orphan branch below and NOWHERE ELSE. It exists because the daemon establishes,
|
|
8594
|
+
* cryptographically, that the sender holds a private key — and then discarded that the instant
|
|
8595
|
+
* the session lookup came back empty, leaving the operator advised to go and make contact with
|
|
8596
|
+
* whoever sent a message for a conversation that does not exist.
|
|
8597
|
+
*
|
|
8598
|
+
* Absent on the park-recovery caller, which cannot reach the orphan branch at all:
|
|
8599
|
+
* `authenticateParkedEntry` refuses `counterparty_unknown` from the same missing record first.
|
|
8600
|
+
*/
|
|
8601
|
+
verifiedSignerUnmatched) {
|
|
7712
8602
|
// The transcript is frozen ONLY once it is COMMITTED + signed — 'sealed' or
|
|
7713
8603
|
// 'seal_interrupted_pending' (the bilateral seal commitment) — because a later FROST
|
|
7714
8604
|
// notarization attests that exact root; a late leaf would diverge from it.
|
|
@@ -7727,8 +8617,67 @@ export class SessionNodeManager {
|
|
|
7727
8617
|
// papered that in with senderPubkey="unknown". Refuse loudly instead; the content stays
|
|
7728
8618
|
// un-acked, so a live sender redelivers once the session actually exists. After D3
|
|
7729
8619
|
// (DOD-INBOUND-GUARD-1) this path is unreachable from the wire — a fail-loud assertion.
|
|
8620
|
+
/**
|
|
8621
|
+
* DOD-M15-REFUSEDEVIDENCE-1 — HOISTED from below the hash cross-check, so that every refusal
|
|
8622
|
+
* above that point can retain the bytes under it. Same expression, earlier.
|
|
8623
|
+
*
|
|
8624
|
+
* It is the SENDER'S CLAIM at this point — nothing has checked it yet, and on a
|
|
8625
|
+
* `content_hash_mismatch` it provably does not describe these bytes. The quarantine read
|
|
8626
|
+
* recomputes its own hash over what was retained rather than reprinting this one.
|
|
8627
|
+
*/
|
|
8628
|
+
const contentHashHex = Buffer.from(contentHash).toString("hex");
|
|
7730
8629
|
if (!record) {
|
|
7731
|
-
|
|
8630
|
+
/**
|
|
8631
|
+
* RETAINED FIRST, because the triage below now tells the operator whether there is an artifact
|
|
8632
|
+
* to report — and that claim has to be made after the write, never before it (023 review F3).
|
|
8633
|
+
*
|
|
8634
|
+
* This is the case retention matters most for. A message for a session this daemon has no
|
|
8635
|
+
* record of is the least explicable thing that can arrive, so it is the thing an operator has
|
|
8636
|
+
* the least other way to show anyone. There is no `sessions` row and no counterparty, so no
|
|
8637
|
+
* tier — `#quarantineRefusedContent` bounds it at UNKNOWN and files it at a negative position,
|
|
8638
|
+
* outside the chain it never joined.
|
|
8639
|
+
*/
|
|
8640
|
+
const keptOrphan = this.#quarantineRefusedContent(agentName, sessionId, "session_orphaned", content, contentHashHex, { correlationId });
|
|
8641
|
+
/**
|
|
8642
|
+
* 024-ORPHANTRIAGE — TWO ACTIONS EXIST AND THE EVIDENCE DECIDES WHICH.
|
|
8643
|
+
*
|
|
8644
|
+
* The advice here used to be *"ask the counterparty to start a NEW session."* When the message
|
|
8645
|
+
* is a stranger probing a peer id, obeying that advice is the probe succeeding: it confirms
|
|
8646
|
+
* somebody is home and that this agent answers, from a message that was refused.
|
|
8647
|
+
*
|
|
8648
|
+
* All three signals are read from things the sender does not control — their signature is
|
|
8649
|
+
* checked against the key inside their own signed bytes, "known" comes from OUR address book,
|
|
8650
|
+
* and "ongoing" comes from OUR transcript rows rather than the sequence number they chose.
|
|
8651
|
+
*/
|
|
8652
|
+
const evidence = this.#orphanEvidence(agentName, sessionId, verifiedSignerUnmatched);
|
|
8653
|
+
const triage = triageOrphanedContent(evidence, retentionSentence(sessionId, keptOrphan));
|
|
8654
|
+
/**
|
|
8655
|
+
* BOTH SURFACES, per Invariant 2. The log is the durable forensic record and carries the
|
|
8656
|
+
* signals structurally — this is where an investigation days later reads what was known and
|
|
8657
|
+
* when. The notice below is the control: it is what the agent actually reads and acts on.
|
|
8658
|
+
*/
|
|
8659
|
+
this.#logger.warn("session.content.orphaned", {
|
|
8660
|
+
agentName, sessionId, correlationId,
|
|
8661
|
+
signerPubkey: evidence.signerPubkeyHex ?? "(no verifiable signature)",
|
|
8662
|
+
signatureVerified: evidence.signerPubkeyHex !== null,
|
|
8663
|
+
// Review F6: `"not_checked"` where nothing was measured, never a `false` that reads as a
|
|
8664
|
+
// reading. An investigator filtering this event is the only person who will ever ask.
|
|
8665
|
+
knownContact: evidence.knownContact,
|
|
8666
|
+
ongoingConversation: evidence.ongoingConversation,
|
|
8667
|
+
action: triage.action,
|
|
8668
|
+
// 023: whether the evidence the triage points at actually exists.
|
|
8669
|
+
retained: keptOrphan !== null,
|
|
8670
|
+
impact: triage.impact,
|
|
8671
|
+
});
|
|
8672
|
+
// DOD-M15-NO-SILENT-REFUSAL-1. The notice is written even though there is no session row —
|
|
8673
|
+
// the store is keyed (agent_id, session_id) and holds no foreign key to `sessions` precisely
|
|
8674
|
+
// so this case can be recorded. A refusal for a session that does not exist here is the one
|
|
8675
|
+
// the operator has the least other way to learn about.
|
|
8676
|
+
this.noteContentRefusal(agentName, sessionId, "session_orphaned", {
|
|
8677
|
+
kind: REFUSAL_KINDS.REFUSED,
|
|
8678
|
+
impact: triage.impact,
|
|
8679
|
+
guidance: triage.guidance,
|
|
8680
|
+
});
|
|
7732
8681
|
return { ok: false, reason: "session_orphaned" };
|
|
7733
8682
|
}
|
|
7734
8683
|
// DOD-TERMINAL-WAKE-1 (review F1): `abandoned` belongs here too. It is terminal and, unlike
|
|
@@ -7750,6 +8699,21 @@ export class SessionNodeManager {
|
|
|
7750
8699
|
currentStatus: record.status,
|
|
7751
8700
|
correlationId,
|
|
7752
8701
|
});
|
|
8702
|
+
// DOD-M15-REFUSEDEVIDENCE-1 — RETAINED. A post-seal straggler on the DIRECT path kept nothing
|
|
8703
|
+
// before this: `sealed_session_annex` covers the park-drain and held-drift routes, not this
|
|
8704
|
+
// exit. Something arriving into a signed, closed conversation is exactly the kind of thing an
|
|
8705
|
+
// operator later wants to produce.
|
|
8706
|
+
this.#quarantineRefusedContent(agentName, sessionId, "session_committed", content, contentHashHex, {
|
|
8707
|
+
senderPubkeyHex: record.counterparty_pubkey ?? null, correlationId,
|
|
8708
|
+
});
|
|
8709
|
+
// DOD-M15-NO-SILENT-REFUSAL-1. `currentStatus` on the log line carries the REAL status —
|
|
8710
|
+
// sealed, seal_interrupted_pending or abandoned — and the notice must not flatten those into
|
|
8711
|
+
// one claim, so it names the record as frozen rather than asserting which way it ended.
|
|
8712
|
+
this.noteContentRefusal(agentName, sessionId, "session_committed", {
|
|
8713
|
+
kind: REFUSAL_KINDS.REFUSED,
|
|
8714
|
+
impact: `This conversation is closed (it ended as "${record.status}"), so the message could not be delivered and neither can anything else they send to it. A closed conversation is signed and cannot be added to — that is what closing it means. Nothing is wrong on your side.`,
|
|
8715
|
+
guidance: "There is nothing to repair here. If they still have something to say, ask them to start a NEW conversation — a closed one cannot be reopened, and it is worth telling them, because they may not realise it ended. Read what was said before it closed with cello_transcript.",
|
|
8716
|
+
});
|
|
7753
8717
|
return { ok: false, reason: "session_committed" };
|
|
7754
8718
|
}
|
|
7755
8719
|
/**
|
|
@@ -7791,10 +8755,17 @@ export class SessionNodeManager {
|
|
|
7791
8755
|
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.",
|
|
7792
8756
|
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.",
|
|
7793
8757
|
});
|
|
8758
|
+
// DOD-M15-REFUSEDEVIDENCE-1 — RETAINED. The algorithm name is an unsigned claim by whoever
|
|
8759
|
+
// sent the frame, so this branch is reachable by crafting as well as by version skew, and the
|
|
8760
|
+
// crafted case is one to be able to show someone.
|
|
8761
|
+
this.#quarantineRefusedContent(agentName, sessionId, "content_hash_alg_unknown", content, contentHashHex, {
|
|
8762
|
+
senderPubkeyHex: record.counterparty_pubkey ?? null, correlationId,
|
|
8763
|
+
});
|
|
7794
8764
|
// DOD-M15-REFUSED-INBOUND-SILENT-1: the SAME strings the log just carried, to the operator.
|
|
7795
8765
|
// This reason is a version skew, so it affects every message from that counterparty — without
|
|
7796
8766
|
// this the conversation goes permanently quiet and they conclude the peer stopped replying.
|
|
7797
8767
|
this.noteContentRefusal(agentName, sessionId, "content_hash_alg_unknown", {
|
|
8768
|
+
kind: REFUSAL_KINDS.REFUSED,
|
|
7798
8769
|
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.",
|
|
7799
8770
|
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.",
|
|
7800
8771
|
});
|
|
@@ -7837,6 +8808,12 @@ export class SessionNodeManager {
|
|
|
7837
8808
|
// purpose.
|
|
7838
8809
|
guidance: "Look for session.salt.discarded first: if it is there, this side dropped its salt because the counterparty said it could never hold one, the agreement did complete and was deliberately undone, and a new session is the repair. Otherwise look for session.salt.adoption.refused: 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 four 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.",
|
|
7839
8810
|
});
|
|
8811
|
+
// DOD-M15-REFUSEDEVIDENCE-1 — RETAINED. We could not check it, which is precisely why the
|
|
8812
|
+
// bytes have to survive: the question of what they actually were stays open, and a hash we
|
|
8813
|
+
// could not verify answers none of it.
|
|
8814
|
+
this.#quarantineRefusedContent(agentName, sessionId, "content_hash_salt_unavailable", content, contentHashHex, {
|
|
8815
|
+
senderPubkeyHex: record.counterparty_pubkey ?? null, correlationId,
|
|
8816
|
+
});
|
|
7840
8817
|
// DOD-M15-REFUSED-INBOUND-SILENT-1 — and this branch needed it MORE than the two that had it.
|
|
7841
8818
|
//
|
|
7842
8819
|
// It was refused, logged with a full impact and guidance, not ingested, not shown — and the
|
|
@@ -7852,12 +8829,12 @@ export class SessionNodeManager {
|
|
|
7852
8829
|
// The guidance is passed by reference to the log's own text rather than duplicated: a second
|
|
7853
8830
|
// copy is a second thing to keep true, and the log's version is the one that gets maintained.
|
|
7854
8831
|
this.noteContentRefusal(agentName, sessionId, "content_hash_salt_unavailable", {
|
|
8832
|
+
kind: REFUSAL_KINDS.REFUSED,
|
|
7855
8833
|
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.",
|
|
7856
8834
|
guidance: "If session.salt.discarded is present, this side dropped its salt on purpose because the counterparty said it could never hold one — a new session is the repair. 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.discarded and session.salt.adoption.refused in the log to tell which. The transcript up to here is intact either way.",
|
|
7857
8835
|
});
|
|
7858
8836
|
return { ok: false, reason: "content_hash_salt_unavailable" };
|
|
7859
8837
|
}
|
|
7860
|
-
const contentHashHex = Buffer.from(contentHash).toString("hex");
|
|
7861
8838
|
if (Buffer.from(computed).toString("hex") !== contentHashHex) {
|
|
7862
8839
|
this.#logger.warn("session.content.cross_check.failed", {
|
|
7863
8840
|
sessionId,
|
|
@@ -7871,9 +8848,26 @@ export class SessionNodeManager {
|
|
|
7871
8848
|
// auto-acknowledge gate must never auto-co-sign it. The session stays alive (DOD-MSG-7),
|
|
7872
8849
|
// but the responder seal now requires the agent's explicit decision, not an auto-ack.
|
|
7873
8850
|
this.#markContentUnverifiable(agentName, sessionId, "tampered");
|
|
8851
|
+
/**
|
|
8852
|
+
* DOD-M15-REFUSEDEVIDENCE-1 — RETAINED, and this is the highest-value row in the table.
|
|
8853
|
+
*
|
|
8854
|
+
* A tampered frame is the one case where the message and the sender's commitment PROVABLY
|
|
8855
|
+
* disagree, and the proof only exists while both halves do. Before this, the bytes went on the
|
|
8856
|
+
* floor and all that survived was a hash of something nobody still had.
|
|
8857
|
+
*
|
|
8858
|
+
* `verifiedAuthorship` is stored when the caller verified a signature over the sender's own
|
|
8859
|
+
* bytes. That is what makes the row evidence rather than a note: the signature is checked
|
|
8860
|
+
* against the key inside the sender's signed bytes, not against anything this side chose.
|
|
8861
|
+
*/
|
|
8862
|
+
this.#quarantineRefusedContent(agentName, sessionId, "content_hash_mismatch", content, contentHashHex, {
|
|
8863
|
+
senderPubkeyHex: this.#activeNodes.get(this.#k(agentName, sessionId))?.counterpartyPubkey ?? record.counterparty_pubkey ?? null,
|
|
8864
|
+
...(verifiedAuthorship ? { authorship: verifiedAuthorship } : {}),
|
|
8865
|
+
correlationId,
|
|
8866
|
+
});
|
|
7874
8867
|
// DOD-M15-REFUSED-INBOUND-SILENT-1. Deliberately does NOT include the content or the hashes:
|
|
7875
8868
|
// it failed verification, and showing it is the injection path this cross-check closes.
|
|
7876
8869
|
this.noteContentRefusal(agentName, sessionId, "content_hash_mismatch", {
|
|
8870
|
+
kind: REFUSAL_KINDS.REFUSED,
|
|
7877
8871
|
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.",
|
|
7878
8872
|
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.",
|
|
7879
8873
|
});
|
|
@@ -7886,6 +8880,49 @@ export class SessionNodeManager {
|
|
|
7886
8880
|
// counterparty_pubkey NOT NULL, so this is unreachable unless a row was hand-crafted empty.
|
|
7887
8881
|
// Either way, "unknown" is never written to a transcript row — refuse instead.
|
|
7888
8882
|
this.#logger.warn("session.content.sender_unresolved", { sessionId, agentName, correlationId });
|
|
8883
|
+
// DOD-M15-REFUSEDEVIDENCE-1 — RETAINED, with NO sender key, because there is none and that
|
|
8884
|
+
// absence is the evidence. The guidance below says to report this; this is the artifact there
|
|
8885
|
+
// is to report. Bounded at the UNKNOWN tier — there is no contact to look a tier up on, which
|
|
8886
|
+
// is the same fact that made it unattributable.
|
|
8887
|
+
const keptUnresolved = this.#quarantineRefusedContent(agentName, sessionId, "sender_unresolved", content, contentHashHex, { correlationId });
|
|
8888
|
+
this.noteContentRefusal(agentName, sessionId, "sender_unresolved", {
|
|
8889
|
+
kind: REFUSAL_KINDS.REFUSED,
|
|
8890
|
+
impact: "A message arrived that this daemon could not attribute to anyone, so it was not delivered. This conversation's record does not say who the other party is, which a conversation opened normally always does. TREAT THIS AS HOSTILE: a message that cannot be tied to a sender is far more likely to be a probe or an attack than a fault.",
|
|
8891
|
+
/**
|
|
8892
|
+
* ⚠️ NO "WHEN IN DOUBT" HERE — Andre, 2026-09-03: *"This message has no sender, the chances
|
|
8893
|
+
* that it is hostile are very high. When in doubt? No. Just report it."*
|
|
8894
|
+
*
|
|
8895
|
+
* That hedge belongs on the ambiguous branch in `024-ORPHANTRIAGE`, where a verified
|
|
8896
|
+
* signature from a known contact leaves a real judgement to make. There is no judgement
|
|
8897
|
+
* here. Softening it would teach the operator to weigh a case that does not need weighing.
|
|
8898
|
+
*
|
|
8899
|
+
* ⚠️ IT NAMES NO REPORTING DESTINATION, and that is still true — but HALF of the reason has
|
|
8900
|
+
* gone, so the sentence is rewritten rather than left to read as though nothing changed.
|
|
8901
|
+
*
|
|
8902
|
+
* It used to rest on two facts: `CELLO_Reporting` does not exist (`DOD-M15-ORPHANTRIAGE-1`,
|
|
8903
|
+
* still open) and **the message itself is not retained**. The second is no longer true —
|
|
8904
|
+
* `DOD-M15-REFUSEDEVIDENCE-1` retains it, and the guidance below now says so and names where
|
|
8905
|
+
* it is. Telling an operator to report something while keeping nothing to report was the
|
|
8906
|
+
* gap; naming a destination nobody can reach would be Invariant 4's failure. So: the
|
|
8907
|
+
* artifact is named now, the destination when 024 lands.
|
|
8908
|
+
*
|
|
8909
|
+
* ⚠️ THE ROTATION ADVICE IS MEASURED, NOT ASSUMED. `#startReceiverNode` mints the standing
|
|
8910
|
+
* receiver's transport key with `randomBytes(32)` and never persists it, so a logout/login
|
|
8911
|
+
* genuinely yields a NEW peer id and fresh directory connections. **And the bound is stated
|
|
8912
|
+
* in the same breath:** session nodes DO persist their seed (`DOD-M12B-SESSION-SEED-1`, so a
|
|
8913
|
+
* revived conversation keeps its address), so this rotates the front door and not the doors
|
|
8914
|
+
* already open. Telling an operator to rotate without that bound would have them believe
|
|
8915
|
+
* they had closed something they had not.
|
|
8916
|
+
*/
|
|
8917
|
+
guidance:
|
|
8918
|
+
// "That is the artifact to show someone" is NOT appended: it would be false on the branch
|
|
8919
|
+
// where nothing was retained, which is the branch this sentence exists to be honest about.
|
|
8920
|
+
"Report this. " + retentionSentence(sessionId, keptUnresolved) +
|
|
8921
|
+
"Do not try to reply — there is no one to reply to, and answering an unattributable message is what a probe is looking for. " +
|
|
8922
|
+
"Then rotate your address: run cello logout followed by cello login. Your standing receiver's network identity is generated fresh each time it starts and is never stored, so this gives you a new one and rebuilds your connections to the directory — anyone holding the old address is left talking to something that no longer answers. " +
|
|
8923
|
+
"It does NOT change the addresses of conversations you already have open: those identities are kept on purpose so an interrupted conversation can resume. " +
|
|
8924
|
+
"This conversation cannot be repaired: close it with cello_close_session, and open a new one yourself if you were expecting someone. See session.content.sender_unresolved in the daemon log.",
|
|
8925
|
+
});
|
|
7889
8926
|
return { ok: false, reason: "sender_unresolved" };
|
|
7890
8927
|
}
|
|
7891
8928
|
// DOD-MSG-5: a content_hash satisfies AT MOST ONE Merkle leaf, exactly once. If this hash is
|
|
@@ -8019,6 +9056,7 @@ export class SessionNodeManager {
|
|
|
8019
9056
|
tier: senderTier,
|
|
8020
9057
|
correlationId,
|
|
8021
9058
|
});
|
|
9059
|
+
this.#noteSizeCapRefusal(agentName, sessionId, cap, senderTier);
|
|
8022
9060
|
return { ok: false, reason: "session_size_limit_exceeded" };
|
|
8023
9061
|
}
|
|
8024
9062
|
}
|
|
@@ -8100,8 +9138,29 @@ export class SessionNodeManager {
|
|
|
8100
9138
|
correlationId,
|
|
8101
9139
|
});
|
|
8102
9140
|
}
|
|
9141
|
+
/**
|
|
9142
|
+
* DOD-M15-REFUSEDEVIDENCE-1 — **A TRANSIENT BLOCK RETAINS NOTHING, and nothing is lost by
|
|
9143
|
+
* that.** Nothing was recorded and, decisively, nothing was ACKNOWLEDGED: the message is still
|
|
9144
|
+
* with the sender, whose daemon redelivers it. When the gateway recovers the same bytes are
|
|
9145
|
+
* screened, and if they are blocked they are retained then, under the detector's own reason.
|
|
9146
|
+
*
|
|
9147
|
+
* Retaining here would file a copy of a message that is coming back — a duplicate, not
|
|
9148
|
+
* evidence — and it would do so for content nothing has yet judged, once per redelivery
|
|
9149
|
+
* attempt, for as long as the gateway stays down.
|
|
9150
|
+
*/
|
|
9151
|
+
// DOD-M15-NO-SILENT-REFUSAL-1 — a TRANSIENT block, and saying which it is, is the whole
|
|
9152
|
+
// value of the notice. Nothing was recorded and nothing was acked, so the sender's daemon
|
|
9153
|
+
// redelivers on its own. An operator who reads the silence as delivery, or who asks the
|
|
9154
|
+
// counterparty to resend, is acting on the opposite of what happened.
|
|
9155
|
+
this.noteContentRefusal(agentName, sessionId, inboundVerdict.reason ?? "inbound_screen_blocked", {
|
|
9156
|
+
kind: REFUSAL_KINDS.DEFERRED,
|
|
9157
|
+
impact: "the screener could not reach a verdict on an inbound message, so it was NOT ingested and NOT shown. Nothing was recorded and nothing was acknowledged — the message is still with the sender and their daemon will redeliver it once screening works again. Do not read this silence as delivery.",
|
|
9158
|
+
guidance: "TRANSIENT — do not ask the counterparty to resend, and do not close the session. Get the local screening gateway healthy and the backlog comes through on its own: look for security.gateway.timeout, security.gateway.unavailable and security.gateway.inbound.blocked in the daemon log — the third is what an internal screen_error logs, and naming only the first two sends you looking for lines that will not be there. While it stays down, every message from every counterparty takes this path.",
|
|
9159
|
+
});
|
|
8103
9160
|
return { ok: false, reason: inboundVerdict.reason ?? "inbound_screen_blocked" };
|
|
8104
9161
|
}
|
|
9162
|
+
// Assigned only on the terminal-block branch and invoked beside each retention attempt below.
|
|
9163
|
+
let noteTerminalBlock;
|
|
8105
9164
|
if (terminalBlock) {
|
|
8106
9165
|
this.#logger.warn("security.gateway.inbound.terminal_block", {
|
|
8107
9166
|
sessionId,
|
|
@@ -8109,6 +9168,78 @@ export class SessionNodeManager {
|
|
|
8109
9168
|
reason: inboundVerdict.reason,
|
|
8110
9169
|
correlationId,
|
|
8111
9170
|
});
|
|
9171
|
+
/**
|
|
9172
|
+
* DOD-M15-REFUSEDEVIDENCE-1 — retention for a terminal block happens where its LEAF happens,
|
|
9173
|
+
* not here. Two sites below (the hold branch and the in-order append), each writing the
|
|
9174
|
+
* quarantine row at the same index as the leaf it accompanies.
|
|
9175
|
+
*
|
|
9176
|
+
* Not here, deliberately: this point is upstream of the post-screen dedup re-check and the
|
|
9177
|
+
* size-cap re-check, either of which can still refuse. Retaining above them would file
|
|
9178
|
+
* evidence for a message this call then reports as capped — and the cap path is the one that
|
|
9179
|
+
* is ruled NOT to retain.
|
|
9180
|
+
*/
|
|
9181
|
+
/**
|
|
9182
|
+
* DOD-M15-NO-SILENT-REFUSAL-1 — **the moment the product catches the attack it exists to
|
|
9183
|
+
* catch, and until now the operator was told nothing about it.**
|
|
9184
|
+
*
|
|
9185
|
+
* This path is not an error path, which is exactly why it had no notice: the block leafs the
|
|
9186
|
+
* original content hash at its canonical position and acknowledges the sender, so nothing
|
|
9187
|
+
* fails and nothing loops. The message is simply never handed to the agent. From the
|
|
9188
|
+
* operator's chair a message they were expecting never arrives and the record shows a leaf
|
|
9189
|
+
* with nothing in it.
|
|
9190
|
+
*
|
|
9191
|
+
* The notice NEVER carries the blocked content — a screener that can be talked into surfacing
|
|
9192
|
+
* what it blocked is not a screener.
|
|
9193
|
+
*
|
|
9194
|
+
* ⚠️ **THE GUIDANCE USED TO SAY "DO NOT ASK FOR THE ORIGINAL TEXT", AND THAT IS NOW WRONG.**
|
|
9195
|
+
* Rewritten rather than deleted, per the claim-comment rule, because the reasoning is what
|
|
9196
|
+
* changed and not just the sentence. It rested on the content being unavailable; under
|
|
9197
|
+
* `DOD-M15-REFUSEDEVIDENCE-1` it is retained and there is a route that returns it FRAMED. And
|
|
9198
|
+
* the friction was never protection: Andre, 2026-09-03 — *"eventually the LLM is going to go
|
|
9199
|
+
* searching for it, because human beings are going to direct their LLMs to find it, and it's
|
|
9200
|
+
* going to come back and say 'Hey, I found it here, the message says…' — which is far
|
|
9201
|
+
* worse."* Withholding the route removes the WARNING from the read, not the read.
|
|
9202
|
+
*
|
|
9203
|
+
* What survives unchanged: do not turn screening off. That is still the one action that makes
|
|
9204
|
+
* things worse, and it is the one the guidance still refuses.
|
|
9205
|
+
*/
|
|
9206
|
+
/**
|
|
9207
|
+
* ⚠️ THE DETECTOR'S OWN REASON SURVIVES — `inbound_screen_blocked` is only the fallback.
|
|
9208
|
+
*
|
|
9209
|
+
* Invariant 3: a downstream handler must not replace an upstream descriptive error with a
|
|
9210
|
+
* generic one. The verdict already says WHICH detector fired — `inbound_language_blocked` and
|
|
9211
|
+
* an injection block are different problems with different remedies, and one of them has an
|
|
9212
|
+
* operator command that fixes it. Flattening both to `inbound_screen_blocked` would also
|
|
9213
|
+
* deduplicate them together, so the second kind would be silent for the life of the session.
|
|
9214
|
+
*
|
|
9215
|
+
* The gateway's own `guidance` is appended when it has one, for the same reason: it is the
|
|
9216
|
+
* half that names the actual command.
|
|
9217
|
+
*/
|
|
9218
|
+
// `?? "inbound_screen_blocked"` is a floor, not a live branch: every verdict producer in the
|
|
9219
|
+
// tree sets `reason`, so today it never fires. It stays because `reason` is optional on the
|
|
9220
|
+
// type, and a notice keyed on `undefined` would collapse every future detector into one row.
|
|
9221
|
+
/**
|
|
9222
|
+
* ⚠️ **DEFERRED UNTIL THE RETENTION HAS ACTUALLY RUN — review F3.** The notice used to be
|
|
9223
|
+
* written here, above both append sites, and claimed the message was kept before anything had
|
|
9224
|
+
* tried to keep it. It is now a closure invoked beside each `#quarantineRefusedContent` call,
|
|
9225
|
+
* carrying that call's own answer.
|
|
9226
|
+
*
|
|
9227
|
+
* Two paths between here and there deliberately write NO notice now, and both are the better
|
|
9228
|
+
* answer: a post-screen dedup means this exact message was already noticed the first time, and
|
|
9229
|
+
* a size-cap refusal writes `#noteSizeCapRefusal` instead — which is what actually happened,
|
|
9230
|
+
* where before the operator got both stories at once.
|
|
9231
|
+
*/
|
|
9232
|
+
noteTerminalBlock = (stored) => {
|
|
9233
|
+
this.noteContentRefusal(agentName, sessionId, inboundVerdict.reason ?? "inbound_screen_blocked", {
|
|
9234
|
+
kind: REFUSAL_KINDS.BLOCKED,
|
|
9235
|
+
impact: "the screener blocked an inbound message: its content matched a detector this agent runs on everything that arrives. It was NOT shown to the agent. It IS recorded in the hash chain at its position and the sender was acknowledged, so they will not resend it and they were not told it was blocked.",
|
|
9236
|
+
guidance: "This is the protection doing its job, and nothing is required of you. If you were expecting something from this counterparty around now, tell them it was blocked and ask them to say it differently. " +
|
|
9237
|
+
retentionSentence(sessionId, stored) +
|
|
9238
|
+
(stored === null ? "" : "There is no reason to read it unless you need to show someone, or judge whether this was an attack. ") +
|
|
9239
|
+
"Do NOT turn screening off to read it: that is the one action here that makes things worse. security.gateway.inbound.terminal_block in the daemon log names which detector fired." +
|
|
9240
|
+
(inboundVerdict.guidance !== undefined ? ` The detector says: ${inboundVerdict.guidance}` : ""),
|
|
9241
|
+
});
|
|
9242
|
+
};
|
|
8112
9243
|
}
|
|
8113
9244
|
// M9-IN-001: a `redact` verdict (inbound sanitization) DELIVERS the sanitized text to the agent,
|
|
8114
9245
|
// while the Merkle leaf still binds the ORIGINAL content hash below — the transcript records what
|
|
@@ -8178,6 +9309,7 @@ export class SessionNodeManager {
|
|
|
8178
9309
|
correlationId,
|
|
8179
9310
|
recheck: true,
|
|
8180
9311
|
});
|
|
9312
|
+
this.#noteSizeCapRefusal(agentName, sessionId, cap, senderTier);
|
|
8181
9313
|
return { ok: false, reason: "session_size_limit_exceeded" };
|
|
8182
9314
|
}
|
|
8183
9315
|
}
|
|
@@ -8217,6 +9349,18 @@ export class SessionNodeManager {
|
|
|
8217
9349
|
// DOD-M12B-STRAND-1: and to disk, before we answer. The in-memory Map is the working copy;
|
|
8218
9350
|
// this row is the one that survives the teardown that used to destroy it.
|
|
8219
9351
|
this.#persistHeldContent(agentName, sessionId, canonicalSeq, deliverContent, content, contentHashHex, terminalBlock === true, correlationId);
|
|
9352
|
+
// DOD-M15-REFUSEDEVIDENCE-1 (site 1 of 2 for a terminal block): a block held behind an
|
|
9353
|
+
// ordering gap. `#releaseHeld` appends its leaf later WITHOUT re-entering this method, so
|
|
9354
|
+
// retaining at release is not available — it is retained here, at the position the leaf will
|
|
9355
|
+
// take. `held_content` is not a substitute: that row is deleted the moment the gap fills.
|
|
9356
|
+
if (terminalBlock) {
|
|
9357
|
+
const keptHeld = this.#quarantineRefusedContent(agentName, sessionId, inboundVerdict.reason ?? "inbound_screen_blocked", content, contentHashHex, {
|
|
9358
|
+
senderPubkeyHex: senderPubkey, canonicalSeq,
|
|
9359
|
+
...(verifiedAuthorship ? { authorship: verifiedAuthorship } : {}),
|
|
9360
|
+
correlationId,
|
|
9361
|
+
});
|
|
9362
|
+
noteTerminalBlock?.(keptHeld);
|
|
9363
|
+
}
|
|
8220
9364
|
this.#logger.info("session.content.held", {
|
|
8221
9365
|
sessionId,
|
|
8222
9366
|
canonicalSeq,
|
|
@@ -8249,6 +9393,50 @@ export class SessionNodeManager {
|
|
|
8249
9393
|
const leafIndex = terminalBlock
|
|
8250
9394
|
? this.appendSessionLeaf(agentName, sessionId, "msg", contentHashHex, correlationId).leafIndex
|
|
8251
9395
|
: this.#appendVerifiedContent(agentName, sessionId, deliverContent, contentHashHex, senderPubkey, correlationId, content, verifiedAuthorship).leafIndex;
|
|
9396
|
+
/**
|
|
9397
|
+
* DOD-M15-REFUSEDEVIDENCE-1 (site 2 of 2) — **the moment the product catches the attack it
|
|
9398
|
+
* exists to catch, and until now it kept only the hash.**
|
|
9399
|
+
*
|
|
9400
|
+
* The terminal-block branch above takes `appendSessionLeaf`, not `#appendVerifiedContent`, so
|
|
9401
|
+
* the row carrying the plaintext, the sender's key and the sender's signature was never written.
|
|
9402
|
+
* A hash proves a message you still hold has not changed; it proves nothing about one you threw
|
|
9403
|
+
* away — and this is precisely the message an operator would most want to produce.
|
|
9404
|
+
*
|
|
9405
|
+
* At `leafIndex`, so the leaf and the evidence describe one event and DoD 7's leaf placement is
|
|
9406
|
+
* untouched. The ORIGINAL bytes, never the sanitized `deliverContent`: evidence is what they
|
|
9407
|
+
* sent, not what a filter made of it.
|
|
9408
|
+
*/
|
|
9409
|
+
if (terminalBlock) {
|
|
9410
|
+
const keptBlocked = this.#quarantineRefusedContent(agentName, sessionId, inboundVerdict.reason ?? "inbound_screen_blocked", content, contentHashHex, {
|
|
9411
|
+
senderPubkeyHex: senderPubkey, canonicalSeq: leafIndex,
|
|
9412
|
+
...(verifiedAuthorship ? { authorship: verifiedAuthorship } : {}),
|
|
9413
|
+
correlationId,
|
|
9414
|
+
});
|
|
9415
|
+
noteTerminalBlock?.(keptBlocked);
|
|
9416
|
+
/**
|
|
9417
|
+
* ⚠️ **DROP THE WITNESS — A BLOCKED MESSAGE MADE THE SESSION PERMANENTLY UNSEALABLE.**
|
|
9418
|
+
*
|
|
9419
|
+
* THE THIRD INSTANCE of the shape already fixed for document frames at `:10593`, found by the
|
|
9420
|
+
* first journey that ever sealed a session after a screener block.
|
|
9421
|
+
*
|
|
9422
|
+
* `sealReadiness` derives `missingLeaves` from `#witnessedSeq.size` — every position the
|
|
9423
|
+
* ordering authority committed that this tree has not appended. The entry is dropped where the
|
|
9424
|
+
* leaf is credited, and that drop lives inside `#appendVerifiedContent`. A terminal block does
|
|
9425
|
+
* not go through it: the branch above takes `appendSessionLeaf` directly, so the leaf WAS
|
|
9426
|
+
* committed and the witness was never retired.
|
|
9427
|
+
*
|
|
9428
|
+
* **From the operator's chair:** their screener catches one hostile message, and from that
|
|
9429
|
+
* moment `cello_close_session` answers `session_incomplete` forever — *"waiting on an earlier
|
|
9430
|
+
* message from the counterparty that has not arrived"* — about a message that arrived, was
|
|
9431
|
+
* judged, and is sitting in the chain. The only exit is a force-abandon, which forfeits the
|
|
9432
|
+
* notarized receipt. Measured live: `treeSize 3, highWaterSeq 2, missingLeaves 1`.
|
|
9433
|
+
*
|
|
9434
|
+
* Not introduced by `DOD-M15-REFUSEDEVIDENCE-1` — it is older than this unit and simply had no
|
|
9435
|
+
* test that both blocked a message and then sealed. It is fixed here because this unit's own
|
|
9436
|
+
* DoD requires that session to seal.
|
|
9437
|
+
*/
|
|
9438
|
+
this.#witnessedSeq.get(key)?.delete(contentHashHex);
|
|
9439
|
+
}
|
|
8252
9440
|
// DOD-COATTEND-1 (review F2): the plaintext failed to reach the transcript, and since Tier 1 the
|
|
8253
9441
|
// transcript IS the delivery path — so this message can never be handed to any session. Report
|
|
8254
9442
|
// the ingest as failed. Reporting `ok: true` here is what let a local SQLCipher failure surface,
|
|
@@ -8259,6 +9447,37 @@ export class SessionNodeManager {
|
|
|
8259
9447
|
// tidy up a reporting problem would corrupt the frontier the counterparty already co-signs
|
|
8260
9448
|
// against. The hole is now crossable by delivery (F1), so it costs a gap, not a stall.
|
|
8261
9449
|
if (!terminalBlock && this.getUndeliverableSeqs(agentName, sessionId).includes(leafIndex)) {
|
|
9450
|
+
/**
|
|
9451
|
+
* DOD-M15-REFUSEDEVIDENCE-1 — **THIS PATH CANNOT RETAIN, because the storage layer is what
|
|
9452
|
+
* just failed.** The write that would keep the evidence is the same `INSERT` into the same
|
|
9453
|
+
* table that has already thrown for this message. Attempting it produces a second error line
|
|
9454
|
+
* and no evidence. Named here rather than left to be rediscovered as a missing case.
|
|
9455
|
+
*/
|
|
9456
|
+
// DOD-M15-NO-SILENT-REFUSAL-1. `#appendVerifiedContent` already noted `content_undeliverable`
|
|
9457
|
+
// at the point the write failed; this is the INGEST's own refusal, and it is a different fact
|
|
9458
|
+
// — the sender is told the ingest failed, so it will redeliver, and every redelivery of the
|
|
9459
|
+
// same hash now dedups against a leaf whose plaintext is not there. Two reasons, because a
|
|
9460
|
+
// reader fixing the disk fault needs to know both that the text is gone and that the sender
|
|
9461
|
+
// is retrying into a hole.
|
|
9462
|
+
this.noteContentRefusal(agentName, sessionId, "transcript_write_failed", {
|
|
9463
|
+
kind: REFUSAL_KINDS.LOST,
|
|
9464
|
+
impact: "A message reached this agent, was verified, and was committed to the conversation's record — and then its text could not be written to local storage, so it can never be delivered. There is a permanent gap in your copy of this conversation. This is a fault on THIS machine; the counterparty did nothing wrong and cannot fix it.",
|
|
9465
|
+
/**
|
|
9466
|
+
* ⚠️ THE READER IS USUALLY ALREADY IN A CODING AGENT, so the guidance says GO AND LOOK
|
|
9467
|
+
* rather than listing symptoms. Andre, 2026-09-03: *"The message should mention to try and
|
|
9468
|
+
* figure out why you cannot store it — it is likely a local machine problem. But if you
|
|
9469
|
+
* truly cannot figure this out using a coding agent, then we advise reaching out to
|
|
9470
|
+
* CELLO_Support."*
|
|
9471
|
+
*
|
|
9472
|
+
* That ordering matters: this is a machine fault with an ordinary cause, and an operator
|
|
9473
|
+
* sent straight to support for a full disk has been wasted. Support is the exit, not the
|
|
9474
|
+
* first step.
|
|
9475
|
+
*/
|
|
9476
|
+
guidance: "Find out why the write failed — this is almost always something ordinary on this machine. " +
|
|
9477
|
+
"If you are reading this through a coding agent, have it check: free disk space, the permissions on ~/.cello, whether the database file is readable and writable, and transcript.message.record.failed in the daemon log, which carries the underlying error. " +
|
|
9478
|
+
"Waiting cannot recover the message. Once the fault is fixed, ask them to resend — the text is gone and only its hash remains. " +
|
|
9479
|
+
"If you genuinely cannot work out the cause, reach out to CELLO_Support.",
|
|
9480
|
+
});
|
|
8262
9481
|
return { ok: false, reason: "transcript_write_failed" };
|
|
8263
9482
|
}
|
|
8264
9483
|
// NO relay witness for this hash. We appended it anyway — refusing would make the relay a hard
|
|
@@ -8592,25 +9811,34 @@ export class SessionNodeManager {
|
|
|
8592
9811
|
if (!own)
|
|
8593
9812
|
return "none";
|
|
8594
9813
|
try {
|
|
8595
|
-
// Canonical Structure 1 is [version, content_hash, sender_pubkey, session_id, last_seen_seq,
|
|
8596
|
-
|
|
8597
|
-
|
|
8598
|
-
|
|
9814
|
+
// Canonical Structure 1 is [version, content_hash, sender_pubkey, session_id, last_seen_seq,
|
|
9815
|
+
// timestamp], plus last_seen_hash at index 6 on a v2 claim (020-ACKHASH). content_hash is
|
|
9816
|
+
// index 1 in both.
|
|
9817
|
+
const s1 = decodeStructure1(own.structure1Cbor);
|
|
9818
|
+
if (!s1.ok) {
|
|
8599
9819
|
this.#logger.warn("session.seal.leaf.recover.failed", {
|
|
8600
|
-
|
|
9820
|
+
// NAMED AT ITS CAUSE — review F2. This read `structure1_content_hash_missing`, which was
|
|
9821
|
+
// accurate when the only check was `contentHash instanceof Uint8Array`. It now fires for an
|
|
9822
|
+
// unknown layout, undecodable CBOR and a malformed field too, and sends an operator to
|
|
9823
|
+
// audit a content hash when the layout is what disagreed. `structure1Reason` carries which.
|
|
9824
|
+
sessionId, agentName, reason: "structure1_decode_failed", structure1Reason: s1.reason,
|
|
8601
9825
|
impact: "cannot tell whether a SEAL ctrl leaf was already posted, so the close refuses rather than risk a second one",
|
|
8602
9826
|
});
|
|
8603
9827
|
return "unknown";
|
|
8604
9828
|
}
|
|
8605
|
-
const contentHashHex = Buffer.from(contentHash).toString("hex");
|
|
9829
|
+
const contentHashHex = Buffer.from(s1.fields.contentHash).toString("hex");
|
|
8606
9830
|
return {
|
|
8607
9831
|
reportedRootHex: this.getSessionTree(agentName, sessionId).rootWithAppendedHex(contentHashHex),
|
|
8608
9832
|
sequenceNumber: own.sequenceNumber,
|
|
8609
9833
|
};
|
|
8610
9834
|
}
|
|
8611
9835
|
catch (err) {
|
|
9836
|
+
// NOT a decode failure — review F3. `decodeStructure1` never throws, so the only thrower left
|
|
9837
|
+
// inside this try is the tree derivation below it. Calling this `structure1_decode_failed`
|
|
9838
|
+
// pointed at CBOR for a fault in `rootWithAppendedHex`, and made one reason string mean two
|
|
9839
|
+
// unrelated things in the same log event.
|
|
8612
9840
|
this.#logger.warn("session.seal.leaf.recover.failed", {
|
|
8613
|
-
sessionId, agentName, reason: "
|
|
9841
|
+
sessionId, agentName, reason: "seal_root_derivation_threw",
|
|
8614
9842
|
error: err instanceof Error ? err.message : String(err),
|
|
8615
9843
|
impact: "cannot tell whether a SEAL ctrl leaf was already posted, so the close refuses rather than risk a second one",
|
|
8616
9844
|
});
|
|
@@ -8780,6 +10008,17 @@ export class SessionNodeManager {
|
|
|
8780
10008
|
this.#undeliverableSeqs.set(recvKey, lost);
|
|
8781
10009
|
}
|
|
8782
10010
|
lost.add(leafIndex);
|
|
10011
|
+
// DOD-M15-NO-SILENT-REFUSAL-1: noted HERE, where the write actually fails, and not on the
|
|
10012
|
+
// cello_receive exit that reports it. `#undeliverableSeqs` is in memory, so the receive exit
|
|
10013
|
+
// stops being able to say this after a restart while the transcript hole stays permanent —
|
|
10014
|
+
// and the exit only runs if somebody is attending, which is the case this whole line is for.
|
|
10015
|
+
this.noteContentRefusal(agentName, sessionId, "content_undeliverable", {
|
|
10016
|
+
kind: REFUSAL_KINDS.LOST,
|
|
10017
|
+
impact: `a message arrived and was committed to the hash chain at sequence ${leafIndex}, and then its text could not be written to the local transcript. Delivery reads the transcript, so that message can never be handed to any session — it is a permanent hole in this side's copy of the conversation.`,
|
|
10018
|
+
guidance: "This is a fault on THIS machine; the counterparty did nothing wrong. Find out why the write failed — it is almost always something ordinary. " +
|
|
10019
|
+
"If you are reading this through a coding agent, have it check free disk space, the permissions on ~/.cello, and transcript.message.record.failed in the daemon log, which carries the underlying error. " +
|
|
10020
|
+
"Waiting cannot recover it. Once the fault is fixed, ask them to resend. If you genuinely cannot work out the cause, reach out to CELLO_Support.",
|
|
10021
|
+
});
|
|
8783
10022
|
}
|
|
8784
10023
|
// Review finding #6: the witness for this hash has done its ordering job once the leaf is
|
|
8785
10024
|
// appended — drop it so #witnessedSeq stays proportional to held/pending content, not the whole
|
|
@@ -9502,6 +10741,11 @@ export class SessionNodeManager {
|
|
|
9502
10741
|
}
|
|
9503
10742
|
else if (entry.screenedOut) {
|
|
9504
10743
|
this.appendSessionLeaf(agentName, sessionId, "msg", entry.contentHashHex, entry.correlationId);
|
|
10744
|
+
// The SAME witness leak as the immediate-append terminal-block branch (see the block comment
|
|
10745
|
+
// at the `if (terminalBlock)` append in `ingestReceivedContent`), on the held path. This
|
|
10746
|
+
// branch also bypasses `#appendVerifiedContent`, where the drop lives — so a blocked message
|
|
10747
|
+
// that arrived out of order left `missingLeaves` stuck at 1 and the session unsealable.
|
|
10748
|
+
this.#witnessedSeq.get(key)?.delete(entry.contentHashHex);
|
|
9505
10749
|
}
|
|
9506
10750
|
else {
|
|
9507
10751
|
this.#appendVerifiedContent(agentName, sessionId, entry.content, entry.contentHashHex, senderPubkey, entry.correlationId, entry.originalContent);
|
|
@@ -12208,16 +13452,26 @@ export class SessionNodeManager {
|
|
|
12208
13452
|
}
|
|
12209
13453
|
#recordFrameOrdering(agentName, sessionId, structure1Cbor, structure2Cbor, contentHash, correlationId, source = "content_frame") {
|
|
12210
13454
|
try {
|
|
12211
|
-
|
|
13455
|
+
// Structure 1 content_hash is index 1 and sender_pubkey index 2 in BOTH layouts — 020-ACKHASH
|
|
13456
|
+
// appended last_seen_hash at 6 rather than inserting it, so neither read moved. A v2 claim
|
|
13457
|
+
// decodes here exactly as a v1 one does; its hash is not consulted, because this unit ships
|
|
13458
|
+
// reading and not enforcing.
|
|
13459
|
+
const s1 = decodeStructure1(structure1Cbor);
|
|
12212
13460
|
const s2 = decode(structure2Cbor);
|
|
12213
|
-
const s1Hash = s1
|
|
12214
|
-
const s1Pubkey = s1
|
|
13461
|
+
const s1Hash = s1.ok ? s1.fields.contentHash : undefined;
|
|
13462
|
+
const s1Pubkey = s1.ok ? s1.fields.senderPubkey : undefined;
|
|
12215
13463
|
const seq = typeof s2?.[0] === "number" ? s2[0] : -1;
|
|
12216
13464
|
const s2Sig = s2?.[3];
|
|
12217
13465
|
if (!(s1Hash instanceof Uint8Array) || !(s1Pubkey instanceof Uint8Array) || !(s2Sig instanceof Uint8Array) || seq < 1) {
|
|
12218
13466
|
// SOFT: we could not read the record, so we learned nothing about the signer either way.
|
|
12219
13467
|
// Position falls back to the witness stream, exactly as an absent record does.
|
|
12220
|
-
|
|
13468
|
+
// The Structure 1 reason is carried so an unreadable RECORD and an unnamed LAYOUT are
|
|
13469
|
+
// distinguishable in the log — they arrive at the same soft outcome by different routes.
|
|
13470
|
+
this.#logger.warn("session.content.ordering.malformed", {
|
|
13471
|
+
sessionId,
|
|
13472
|
+
correlationId,
|
|
13473
|
+
...(s1.ok ? {} : { structure1Reason: s1.reason }),
|
|
13474
|
+
});
|
|
12221
13475
|
return { seq: null };
|
|
12222
13476
|
}
|
|
12223
13477
|
// The framed ordering record must bind to THIS content (its hash) — else it orders the wrong bytes.
|
|
@@ -12271,7 +13525,21 @@ export class SessionNodeManager {
|
|
|
12271
13525
|
*/
|
|
12272
13526
|
const reason = counterparty ? "signer_not_counterparty" : "counterparty_unknown";
|
|
12273
13527
|
this.#logger.warn("session.content.ordering.wrong_signer", { sessionId, reason, correlationId });
|
|
12274
|
-
|
|
13528
|
+
/**
|
|
13529
|
+
* 024-ORPHANTRIAGE — THE VERIFIED SIGNER SURVIVES THE SESSION LOOKUP NOW.
|
|
13530
|
+
*
|
|
13531
|
+
* The `verify(...)` three lines above has already passed: whoever produced this record holds
|
|
13532
|
+
* the private key for the key inside their own signed bytes. That is session-independent —
|
|
13533
|
+
* it needs no `sessions` row and never did. The soft branch then threw it away, so the
|
|
13534
|
+
* orphan branch downstream had nothing to go on and told the operator to go and talk to
|
|
13535
|
+
* whoever sent it.
|
|
13536
|
+
*
|
|
13537
|
+
* Carried out only on the `counterparty_unknown` half. The fatal half needs nothing: the
|
|
13538
|
+
* session freezes and no triage runs.
|
|
13539
|
+
*/
|
|
13540
|
+
return counterparty
|
|
13541
|
+
? { seq: null, fatal: { reason } }
|
|
13542
|
+
: { seq: null, verifiedSignerUnmatched: s1Pubkey };
|
|
12275
13543
|
}
|
|
12276
13544
|
// Verified — record the relay-assigned canonical sequence (1-based → 0-based leaf index) for the gate.
|
|
12277
13545
|
this.recordWitnessedSequence(agentName, sessionId, contentHashHex, seq - 1);
|
|
@@ -12595,6 +13863,12 @@ export class SessionNodeManager {
|
|
|
12595
13863
|
* and the transcript row must say so rather than imply a proof it does not have.
|
|
12596
13864
|
*/
|
|
12597
13865
|
let verifiedAuthorship;
|
|
13866
|
+
/**
|
|
13867
|
+
* 024-ORPHANTRIAGE: the signer when the signature verified but there was no counterparty to
|
|
13868
|
+
* match it against. Its ONLY consumer is the orphan branch inside ingest — everywhere else a
|
|
13869
|
+
* session record exists, so this stays `undefined` and nothing reads it.
|
|
13870
|
+
*/
|
|
13871
|
+
let verifiedSignerUnmatched;
|
|
12598
13872
|
if (s1Cbor instanceof Uint8Array && s2Cbor instanceof Uint8Array) {
|
|
12599
13873
|
const ordering = this.#recordFrameOrdering(agentName, sessionId, s1Cbor, s2Cbor, contentHash, correlationId);
|
|
12600
13874
|
if (ordering.fatal) {
|
|
@@ -12605,6 +13879,7 @@ export class SessionNodeManager {
|
|
|
12605
13879
|
if (ordering.senderPubkey !== undefined && ordering.senderSig !== undefined) {
|
|
12606
13880
|
verifiedAuthorship = { senderPubkey: ordering.senderPubkey, senderSig: ordering.senderSig };
|
|
12607
13881
|
}
|
|
13882
|
+
verifiedSignerUnmatched = ordering.verifiedSignerUnmatched;
|
|
12608
13883
|
}
|
|
12609
13884
|
else {
|
|
12610
13885
|
/**
|
|
@@ -12641,7 +13916,7 @@ export class SessionNodeManager {
|
|
|
12641
13916
|
const ingest = await this.ingestReceivedContent(
|
|
12642
13917
|
// THE DECRYPTED body — everything downstream (the hash cross-check, the leaf, the transcript,
|
|
12643
13918
|
// the delivery buffer) works on plaintext, exactly as it did before this layer existed.
|
|
12644
|
-
agentName, sessionId, plaintextBody, contentHash, correlationId, framedSeq ?? undefined, declaredAlg === undefined ? undefined : declaredAlg, verifiedAuthorship);
|
|
13919
|
+
agentName, sessionId, plaintextBody, contentHash, correlationId, framedSeq ?? undefined, declaredAlg === undefined ? undefined : declaredAlg, verifiedAuthorship, verifiedSignerUnmatched);
|
|
12645
13920
|
// AC-001: after the content is durably ingested AND its hash cross-check
|
|
12646
13921
|
// succeeds, emit an unsigned `persisted` delivery ACK back to the sender. A
|
|
12647
13922
|
// rejected ingest (tamper / not-active) produces NO ACK, so the sender's TTF
|