@cello-protocol/daemon 0.0.227 → 0.0.229
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-admin-handlers.d.ts +56 -0
- package/dist/agent-admin-handlers.d.ts.map +1 -0
- package/dist/agent-admin-handlers.js +73 -0
- package/dist/agent-admin-handlers.js.map +1 -0
- package/dist/agent-handlers.d.ts +72 -0
- package/dist/agent-handlers.d.ts.map +1 -0
- package/dist/agent-handlers.js +604 -0
- package/dist/agent-handlers.js.map +1 -0
- package/dist/agent-id-migration.d.ts +70 -0
- package/dist/agent-id-migration.d.ts.map +1 -0
- package/dist/agent-id-migration.js +468 -0
- package/dist/agent-id-migration.js.map +1 -0
- package/dist/agent-loader.d.ts +37 -0
- package/dist/agent-loader.d.ts.map +1 -0
- package/dist/agent-loader.js +37 -0
- package/dist/agent-loader.js.map +1 -0
- package/dist/agent-selection-root.d.ts +29 -0
- package/dist/agent-selection-root.d.ts.map +1 -0
- package/dist/agent-selection-root.js +126 -0
- package/dist/agent-selection-root.js.map +1 -0
- package/dist/agent-selection.d.ts +73 -0
- package/dist/agent-selection.d.ts.map +1 -0
- package/dist/agent-selection.js +80 -0
- package/dist/agent-selection.js.map +1 -0
- package/dist/agent-settings-keys.d.ts +52 -0
- package/dist/agent-settings-keys.d.ts.map +1 -0
- package/dist/agent-settings-keys.js +114 -0
- package/dist/agent-settings-keys.js.map +1 -0
- package/dist/agent-state.d.ts +67 -0
- package/dist/agent-state.d.ts.map +1 -0
- package/dist/agent-state.js +66 -0
- package/dist/agent-state.js.map +1 -0
- package/dist/assignment-verify.d.ts +90 -0
- package/dist/assignment-verify.d.ts.map +1 -0
- package/dist/assignment-verify.js +301 -0
- package/dist/assignment-verify.js.map +1 -0
- package/dist/attendance-wiring.d.ts +23 -0
- package/dist/attendance-wiring.d.ts.map +1 -0
- package/dist/attendance-wiring.js +248 -0
- package/dist/attendance-wiring.js.map +1 -0
- package/dist/attestation-body.d.ts +47 -0
- package/dist/attestation-body.d.ts.map +1 -0
- package/dist/attestation-body.js +71 -0
- package/dist/attestation-body.js.map +1 -0
- package/dist/authorship-verification.d.ts +91 -0
- package/dist/authorship-verification.d.ts.map +1 -0
- package/dist/authorship-verification.js +532 -0
- package/dist/authorship-verification.js.map +1 -0
- package/dist/away-detection.d.ts +139 -0
- package/dist/away-detection.d.ts.map +1 -0
- package/dist/away-detection.js +186 -0
- package/dist/away-detection.js.map +1 -0
- package/dist/away-inbox-oneshot.d.ts +40 -0
- package/dist/away-inbox-oneshot.d.ts.map +1 -0
- package/dist/away-inbox-oneshot.js +311 -0
- package/dist/away-inbox-oneshot.js.map +1 -0
- package/dist/backup-restore-handlers.d.ts +10 -0
- package/dist/backup-restore-handlers.d.ts.map +1 -0
- package/dist/backup-restore-handlers.js +82 -0
- package/dist/backup-restore-handlers.js.map +1 -0
- package/dist/backup-restore.d.ts +102 -0
- package/dist/backup-restore.d.ts.map +1 -0
- package/dist/backup-restore.js +341 -0
- package/dist/backup-restore.js.map +1 -0
- package/dist/bin/cello-daemon.d.ts +13 -0
- package/dist/bin/cello-daemon.d.ts.map +1 -0
- package/dist/bin/cello-daemon.js.map +1 -0
- package/dist/boot-agents.d.ts +64 -0
- package/dist/boot-agents.d.ts.map +1 -0
- package/dist/boot-agents.js +241 -0
- package/dist/boot-agents.js.map +1 -0
- package/dist/boot-connection-state.d.ts +38 -0
- package/dist/boot-connection-state.d.ts.map +1 -0
- package/dist/boot-connection-state.js +95 -0
- package/dist/boot-connection-state.js.map +1 -0
- package/dist/boot-core.d.ts +36 -0
- package/dist/boot-core.d.ts.map +1 -0
- package/dist/boot-core.js +293 -0
- package/dist/boot-core.js.map +1 -0
- package/dist/boot-parked-content.d.ts +34 -0
- package/dist/boot-parked-content.d.ts.map +1 -0
- package/dist/boot-parked-content.js +528 -0
- package/dist/boot-parked-content.js.map +1 -0
- package/dist/boot-sweeps.d.ts +22 -0
- package/dist/boot-sweeps.d.ts.map +1 -0
- package/dist/boot-sweeps.js +51 -0
- package/dist/boot-sweeps.js.map +1 -0
- package/dist/bundled-consortium-manifest.d.ts +47 -0
- package/dist/bundled-consortium-manifest.d.ts.map +1 -0
- package/dist/bundled-consortium-manifest.js +116 -0
- package/dist/bundled-consortium-manifest.js.map +1 -0
- package/dist/cbor-blob-migration.d.ts +8 -0
- package/dist/cbor-blob-migration.d.ts.map +1 -0
- package/dist/cbor-blob-migration.js +116 -0
- package/dist/cbor-blob-migration.js.map +1 -0
- package/dist/challenge-verifier.d.ts +12 -0
- package/dist/challenge-verifier.d.ts.map +1 -0
- package/dist/challenge-verifier.js +11 -0
- package/dist/challenge-verifier.js.map +1 -0
- package/dist/channel-log-store.d.ts +80 -0
- package/dist/channel-log-store.d.ts.map +1 -0
- package/dist/channel-log-store.js +290 -0
- package/dist/channel-log-store.js.map +1 -0
- package/dist/close-commitment.d.ts +67 -0
- package/dist/close-commitment.d.ts.map +1 -0
- package/dist/close-commitment.js +78 -0
- package/dist/close-commitment.js.map +1 -0
- package/dist/close-session-handler.d.ts +94 -0
- package/dist/close-session-handler.d.ts.map +1 -0
- package/dist/close-session-handler.js +1418 -0
- package/dist/close-session-handler.js.map +1 -0
- package/dist/co-attendance.d.ts +29 -0
- package/dist/co-attendance.d.ts.map +1 -0
- package/dist/co-attendance.js +32 -0
- package/dist/co-attendance.js.map +1 -0
- package/dist/column-birth.d.ts +40 -0
- package/dist/column-birth.d.ts.map +1 -0
- package/dist/column-birth.js +61 -0
- package/dist/column-birth.js.map +1 -0
- package/dist/connect-or-start.d.ts +25 -0
- package/dist/connect-or-start.d.ts.map +1 -0
- package/dist/connect-or-start.js +175 -0
- package/dist/connect-or-start.js.map +1 -0
- package/dist/connection-agents.d.ts +16 -0
- package/dist/connection-agents.d.ts.map +1 -0
- package/dist/connection-agents.js +68 -0
- package/dist/connection-agents.js.map +1 -0
- package/dist/consent-migration.d.ts +49 -0
- package/dist/consent-migration.d.ts.map +1 -0
- package/dist/consent-migration.js +128 -0
- package/dist/consent-migration.js.map +1 -0
- package/dist/consortium-bootstrap.d.ts +138 -0
- package/dist/consortium-bootstrap.d.ts.map +1 -0
- package/dist/consortium-bootstrap.js +339 -0
- package/dist/consortium-bootstrap.js.map +1 -0
- package/dist/consortium-fingerprint.d.ts +115 -0
- package/dist/consortium-fingerprint.d.ts.map +1 -0
- package/dist/consortium-fingerprint.js +175 -0
- package/dist/consortium-fingerprint.js.map +1 -0
- package/dist/contact-handlers.d.ts +59 -0
- package/dist/contact-handlers.d.ts.map +1 -0
- package/dist/contact-handlers.js +343 -0
- package/dist/contact-handlers.js.map +1 -0
- 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 +136 -0
- package/dist/contact-pubkey-case.js.map +1 -0
- package/dist/contacts-tier-migration.d.ts +90 -0
- package/dist/contacts-tier-migration.d.ts.map +1 -0
- package/dist/contacts-tier-migration.js +150 -0
- package/dist/contacts-tier-migration.js.map +1 -0
- package/dist/content-encryption-status.d.ts +111 -0
- package/dist/content-encryption-status.d.ts.map +1 -0
- package/dist/content-encryption-status.js +158 -0
- package/dist/content-encryption-status.js.map +1 -0
- package/dist/content-park-client.d.ts +90 -0
- package/dist/content-park-client.d.ts.map +1 -0
- package/dist/content-park-client.js +362 -0
- package/dist/content-park-client.js.map +1 -0
- package/dist/content-park.d.ts +52 -0
- package/dist/content-park.d.ts.map +1 -0
- package/dist/content-park.js +1309 -0
- package/dist/content-park.js.map +1 -0
- package/dist/cross-node-negotiation.d.ts +44 -0
- package/dist/cross-node-negotiation.d.ts.map +1 -0
- package/dist/cross-node-negotiation.js +34 -0
- package/dist/cross-node-negotiation.js.map +1 -0
- package/dist/daemon-handle.d.ts +57 -0
- package/dist/daemon-handle.d.ts.map +1 -0
- package/dist/daemon-handle.js +2 -0
- package/dist/daemon-handle.js.map +1 -0
- package/dist/daemon-status-report.d.ts +50 -0
- package/dist/daemon-status-report.d.ts.map +1 -0
- package/dist/daemon-status-report.js +81 -0
- package/dist/daemon-status-report.js.map +1 -0
- package/dist/daemon.d.ts +45 -0
- package/dist/daemon.d.ts.map +1 -0
- package/dist/daemon.js +1177 -0
- package/dist/daemon.js.map +1 -0
- package/dist/db-identity-store.d.ts +159 -0
- package/dist/db-identity-store.d.ts.map +1 -0
- package/dist/db-identity-store.js +502 -0
- package/dist/db-identity-store.js.map +1 -0
- package/dist/delivery-open-registry.d.ts +92 -0
- package/dist/delivery-open-registry.d.ts.map +1 -0
- package/dist/delivery-open-registry.js +121 -0
- package/dist/delivery-open-registry.js.map +1 -0
- package/dist/delivery-session-suspects.d.ts +56 -0
- package/dist/delivery-session-suspects.d.ts.map +1 -0
- package/dist/delivery-session-suspects.js +94 -0
- package/dist/delivery-session-suspects.js.map +1 -0
- package/dist/directory-auth-posture.d.ts +87 -0
- package/dist/directory-auth-posture.d.ts.map +1 -0
- package/dist/directory-auth-posture.js +134 -0
- package/dist/directory-auth-posture.js.map +1 -0
- package/dist/directory-bootstrap.d.ts +310 -0
- package/dist/directory-bootstrap.d.ts.map +1 -0
- package/dist/directory-bootstrap.js +557 -0
- package/dist/directory-bootstrap.js.map +1 -0
- package/dist/directory-connect.d.ts +27 -0
- package/dist/directory-connect.d.ts.map +1 -0
- package/dist/directory-connect.js +106 -0
- package/dist/directory-connect.js.map +1 -0
- package/dist/disconnect-cleanup.d.ts +51 -0
- package/dist/disconnect-cleanup.d.ts.map +1 -0
- package/dist/disconnect-cleanup.js +72 -0
- package/dist/disconnect-cleanup.js.map +1 -0
- package/dist/document-amendment-store.d.ts +120 -0
- package/dist/document-amendment-store.d.ts.map +1 -0
- package/dist/document-amendment-store.js +266 -0
- package/dist/document-amendment-store.js.map +1 -0
- package/dist/document-delivery-transport.d.ts +168 -0
- package/dist/document-delivery-transport.d.ts.map +1 -0
- package/dist/document-delivery-transport.js +206 -0
- package/dist/document-delivery-transport.js.map +1 -0
- package/dist/document-engine.d.ts +134 -0
- package/dist/document-engine.d.ts.map +1 -0
- package/dist/document-engine.js +282 -0
- package/dist/document-engine.js.map +1 -0
- package/dist/document-flag.d.ts +58 -0
- package/dist/document-flag.d.ts.map +1 -0
- package/dist/document-flag.js +70 -0
- package/dist/document-flag.js.map +1 -0
- package/dist/document-frame-router.d.ts +245 -0
- package/dist/document-frame-router.d.ts.map +1 -0
- package/dist/document-frame-router.js +397 -0
- package/dist/document-frame-router.js.map +1 -0
- package/dist/document-gate-wiring.d.ts +61 -0
- package/dist/document-gate-wiring.d.ts.map +1 -0
- package/dist/document-gate-wiring.js +125 -0
- package/dist/document-gate-wiring.js.map +1 -0
- package/dist/document-gate.d.ts +149 -0
- package/dist/document-gate.d.ts.map +1 -0
- package/dist/document-gate.js +509 -0
- package/dist/document-gate.js.map +1 -0
- package/dist/document-handlers.d.ts +47 -0
- package/dist/document-handlers.d.ts.map +1 -0
- package/dist/document-handlers.js +2203 -0
- package/dist/document-handlers.js.map +1 -0
- package/dist/document-handshake.d.ts +176 -0
- package/dist/document-handshake.d.ts.map +1 -0
- package/dist/document-handshake.js +452 -0
- package/dist/document-handshake.js.map +1 -0
- package/dist/document-inbound.d.ts +162 -0
- package/dist/document-inbound.d.ts.map +1 -0
- package/dist/document-inbound.js +530 -0
- package/dist/document-inbound.js.map +1 -0
- package/dist/document-json.d.ts +120 -0
- package/dist/document-json.d.ts.map +1 -0
- package/dist/document-json.js +191 -0
- package/dist/document-json.js.map +1 -0
- package/dist/document-layer.d.ts +215 -0
- package/dist/document-layer.d.ts.map +1 -0
- package/dist/document-layer.js +1025 -0
- package/dist/document-layer.js.map +1 -0
- package/dist/document-lifecycle.d.ts +52 -0
- package/dist/document-lifecycle.d.ts.map +1 -0
- package/dist/document-lifecycle.js +134 -0
- package/dist/document-lifecycle.js.map +1 -0
- package/dist/document-live-docs.d.ts +58 -0
- package/dist/document-live-docs.d.ts.map +1 -0
- package/dist/document-live-docs.js +126 -0
- package/dist/document-live-docs.js.map +1 -0
- package/dist/document-notify.d.ts +228 -0
- package/dist/document-notify.d.ts.map +1 -0
- package/dist/document-notify.js +580 -0
- package/dist/document-notify.js.map +1 -0
- package/dist/document-profile.d.ts +61 -0
- package/dist/document-profile.d.ts.map +1 -0
- package/dist/document-profile.js +112 -0
- package/dist/document-profile.js.map +1 -0
- package/dist/document-publish.d.ts +87 -0
- package/dist/document-publish.d.ts.map +1 -0
- package/dist/document-publish.js +173 -0
- package/dist/document-publish.js.map +1 -0
- package/dist/document-reachability.d.ts +42 -0
- package/dist/document-reachability.d.ts.map +1 -0
- package/dist/document-reachability.js +80 -0
- package/dist/document-reachability.js.map +1 -0
- package/dist/document-reconcile-engine.d.ts +66 -0
- package/dist/document-reconcile-engine.d.ts.map +1 -0
- package/dist/document-reconcile-engine.js +225 -0
- package/dist/document-reconcile-engine.js.map +1 -0
- package/dist/document-reconcile-scheduler.d.ts +163 -0
- package/dist/document-reconcile-scheduler.d.ts.map +1 -0
- package/dist/document-reconcile-scheduler.js +303 -0
- package/dist/document-reconcile-scheduler.js.map +1 -0
- package/dist/document-rejection.d.ts +251 -0
- package/dist/document-rejection.d.ts.map +1 -0
- package/dist/document-rejection.js +435 -0
- package/dist/document-rejection.js.map +1 -0
- package/dist/document-screen.d.ts +114 -0
- package/dist/document-screen.d.ts.map +1 -0
- package/dist/document-screen.js +223 -0
- package/dist/document-screen.js.map +1 -0
- package/dist/document-store.d.ts +372 -0
- package/dist/document-store.d.ts.map +1 -0
- package/dist/document-store.js +931 -0
- package/dist/document-store.js.map +1 -0
- package/dist/document-surface.d.ts +32 -0
- package/dist/document-surface.d.ts.map +1 -0
- package/dist/document-surface.js +151 -0
- package/dist/document-surface.js.map +1 -0
- package/dist/document-types.d.ts +94 -0
- package/dist/document-types.d.ts.map +1 -0
- package/dist/document-types.js +90 -0
- package/dist/document-types.js.map +1 -0
- package/dist/document-watch.d.ts +69 -0
- package/dist/document-watch.d.ts.map +1 -0
- package/dist/document-watch.js +108 -0
- package/dist/document-watch.js.map +1 -0
- package/dist/document-wiring.d.ts +53 -0
- package/dist/document-wiring.d.ts.map +1 -0
- package/dist/document-wiring.js +323 -0
- package/dist/document-wiring.js.map +1 -0
- package/dist/document-write-guard.d.ts +66 -0
- package/dist/document-write-guard.d.ts.map +1 -0
- package/dist/document-write-guard.js +98 -0
- package/dist/document-write-guard.js.map +1 -0
- package/dist/document-write-path.d.ts +94 -0
- package/dist/document-write-path.d.ts.map +1 -0
- package/dist/document-write-path.js +530 -0
- package/dist/document-write-path.js.map +1 -0
- package/dist/error-message.d.ts +7 -0
- package/dist/error-message.d.ts.map +1 -0
- package/dist/error-message.js +19 -0
- package/dist/error-message.js.map +1 -0
- package/dist/file-manifest-provider.d.ts +37 -0
- package/dist/file-manifest-provider.d.ts.map +1 -0
- package/dist/file-manifest-provider.js +105 -0
- package/dist/file-manifest-provider.js.map +1 -0
- package/dist/frame-values.d.ts +3 -0
- package/dist/frame-values.d.ts.map +1 -0
- package/dist/frame-values.js +84 -0
- package/dist/frame-values.js.map +1 -0
- package/dist/frontier-mismatch.d.ts +73 -0
- package/dist/frontier-mismatch.d.ts.map +1 -0
- package/dist/frontier-mismatch.js +89 -0
- package/dist/frontier-mismatch.js.map +1 -0
- package/dist/gateway-config-handlers.d.ts +26 -0
- package/dist/gateway-config-handlers.d.ts.map +1 -0
- package/dist/gateway-config-handlers.js +454 -0
- package/dist/gateway-config-handlers.js.map +1 -0
- package/dist/held-content.d.ts +145 -0
- package/dist/held-content.d.ts.map +1 -0
- package/dist/held-content.js +389 -0
- package/dist/held-content.js.map +1 -0
- package/dist/http-manifest-poll.d.ts +67 -0
- package/dist/http-manifest-poll.d.ts.map +1 -0
- package/dist/http-manifest-poll.js +150 -0
- package/dist/http-manifest-poll.js.map +1 -0
- package/dist/identity-migration.d.ts +40 -0
- package/dist/identity-migration.d.ts.map +1 -0
- package/dist/identity-migration.js +461 -0
- package/dist/identity-migration.js.map +1 -0
- package/dist/inbound-refusals.d.ts +283 -0
- package/dist/inbound-refusals.d.ts.map +1 -0
- package/dist/inbound-refusals.js +919 -0
- package/dist/inbound-refusals.js.map +1 -0
- package/dist/inbound-seal-request.d.ts +32 -0
- package/dist/inbound-seal-request.d.ts.map +1 -0
- package/dist/inbound-seal-request.js +228 -0
- package/dist/inbound-seal-request.js.map +1 -0
- package/dist/inbound-sessions.d.ts +291 -0
- package/dist/inbound-sessions.d.ts.map +1 -0
- package/dist/inbound-sessions.js +1609 -0
- package/dist/inbound-sessions.js.map +1 -0
- package/dist/inclusion-proof-handlers.d.ts +43 -0
- package/dist/inclusion-proof-handlers.d.ts.map +1 -0
- package/dist/inclusion-proof-handlers.js +565 -0
- package/dist/inclusion-proof-handlers.js.map +1 -0
- package/dist/inclusion-proof.d.ts +151 -0
- package/dist/inclusion-proof.d.ts.map +1 -0
- package/dist/inclusion-proof.js +228 -0
- package/dist/inclusion-proof.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js.map +1 -0
- package/dist/initiate-session-handler.d.ts +55 -0
- package/dist/initiate-session-handler.d.ts.map +1 -0
- package/dist/initiate-session-handler.js +310 -0
- package/dist/initiate-session-handler.js.map +1 -0
- package/dist/ipc-client.d.ts +31 -0
- package/dist/ipc-client.d.ts.map +1 -0
- package/dist/ipc-client.js +113 -0
- package/dist/ipc-client.js.map +1 -0
- package/dist/ipc-server.d.ts +63 -0
- package/dist/ipc-server.d.ts.map +1 -0
- package/dist/ipc-server.js +429 -0
- package/dist/ipc-server.js.map +1 -0
- package/dist/ipc-surface.d.ts +44 -0
- package/dist/ipc-surface.d.ts.map +1 -0
- package/dist/ipc-surface.js +107 -0
- package/dist/ipc-surface.js.map +1 -0
- package/dist/line-lcs.d.ts +51 -0
- package/dist/line-lcs.d.ts.map +1 -0
- package/dist/line-lcs.js +71 -0
- package/dist/line-lcs.js.map +1 -0
- package/dist/lock-file.d.ts +39 -0
- package/dist/lock-file.d.ts.map +1 -0
- package/dist/lock-file.js +120 -0
- package/dist/lock-file.js.map +1 -0
- package/dist/log-collapse.d.ts +66 -0
- package/dist/log-collapse.d.ts.map +1 -0
- package/dist/log-collapse.js +244 -0
- package/dist/log-collapse.js.map +1 -0
- package/dist/log-rotate.d.ts +67 -0
- package/dist/log-rotate.d.ts.map +1 -0
- package/dist/log-rotate.js +134 -0
- package/dist/log-rotate.js.map +1 -0
- package/dist/manifest-deps.d.ts +25 -0
- package/dist/manifest-deps.d.ts.map +1 -0
- package/dist/manifest-deps.js +151 -0
- package/dist/manifest-deps.js.map +1 -0
- package/dist/manifest-poll-scheduler.d.ts +31 -0
- package/dist/manifest-poll-scheduler.d.ts.map +1 -0
- package/dist/manifest-poll-scheduler.js +59 -0
- package/dist/manifest-poll-scheduler.js.map +1 -0
- package/dist/manifest-validity.d.ts +153 -0
- package/dist/manifest-validity.d.ts.map +1 -0
- package/dist/manifest-validity.js +268 -0
- package/dist/manifest-validity.js.map +1 -0
- package/dist/manifest-version-store-db.d.ts +24 -0
- package/dist/manifest-version-store-db.d.ts.map +1 -0
- package/dist/manifest-version-store-db.js +58 -0
- package/dist/manifest-version-store-db.js.map +1 -0
- package/dist/manifest-version-store.d.ts +16 -0
- package/dist/manifest-version-store.d.ts.map +1 -0
- package/dist/manifest-version-store.js +15 -0
- package/dist/manifest-version-store.js.map +1 -0
- package/dist/network-directory-node.d.ts +136 -0
- package/dist/network-directory-node.d.ts.map +1 -0
- package/dist/network-directory-node.js +810 -0
- package/dist/network-directory-node.js.map +1 -0
- package/dist/nonce-dedup.d.ts +68 -0
- package/dist/nonce-dedup.d.ts.map +1 -0
- package/dist/nonce-dedup.js +205 -0
- package/dist/nonce-dedup.js.map +1 -0
- package/dist/notification-dispatcher.d.ts +92 -0
- package/dist/notification-dispatcher.d.ts.map +1 -0
- package/dist/notification-dispatcher.js +210 -0
- package/dist/notification-dispatcher.js.map +1 -0
- package/dist/notification-handlers.d.ts +53 -0
- package/dist/notification-handlers.d.ts.map +1 -0
- package/dist/notification-handlers.js +461 -0
- package/dist/notification-handlers.js.map +1 -0
- package/dist/onboarding-guidance.d.ts +79 -0
- package/dist/onboarding-guidance.d.ts.map +1 -0
- package/dist/onboarding-guidance.js +95 -0
- package/dist/onboarding-guidance.js.map +1 -0
- package/dist/operator-guidance.d.ts +25 -0
- package/dist/operator-guidance.d.ts.map +1 -0
- package/dist/operator-guidance.js +50 -0
- package/dist/operator-guidance.js.map +1 -0
- 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/outbound-sessions.d.ts +126 -0
- package/dist/outbound-sessions.d.ts.map +1 -0
- package/dist/outbound-sessions.js +1052 -0
- package/dist/outbound-sessions.js.map +1 -0
- package/dist/park-envelope.d.ts +329 -0
- package/dist/park-envelope.d.ts.map +1 -0
- package/dist/park-envelope.js +509 -0
- package/dist/park-envelope.js.map +1 -0
- package/dist/park-recovery.d.ts +273 -0
- package/dist/park-recovery.d.ts.map +1 -0
- package/dist/park-recovery.js +717 -0
- package/dist/park-recovery.js.map +1 -0
- package/dist/park-refusals.d.ts +147 -0
- package/dist/park-refusals.d.ts.map +1 -0
- package/dist/park-refusals.js +331 -0
- package/dist/park-refusals.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/reconnect-drain.d.ts +22 -0
- package/dist/reconnect-drain.d.ts.map +1 -0
- package/dist/reconnect-drain.js +65 -0
- package/dist/reconnect-drain.js.map +1 -0
- package/dist/recovered-position.d.ts +14 -0
- package/dist/recovered-position.d.ts.map +1 -0
- package/dist/recovered-position.js +59 -0
- package/dist/recovered-position.js.map +1 -0
- package/dist/refusal-notices.d.ts +196 -0
- package/dist/refusal-notices.d.ts.map +1 -0
- package/dist/refusal-notices.js +516 -0
- package/dist/refusal-notices.js.map +1 -0
- package/dist/refusal-reasons.d.ts +245 -0
- package/dist/refusal-reasons.d.ts.map +1 -0
- package/dist/refusal-reasons.js +363 -0
- package/dist/refusal-reasons.js.map +1 -0
- package/dist/register-handler.d.ts +36 -0
- package/dist/register-handler.d.ts.map +1 -0
- package/dist/register-handler.js +285 -0
- package/dist/register-handler.js.map +1 -0
- package/dist/registration-context.d.ts +72 -0
- package/dist/registration-context.d.ts.map +1 -0
- package/dist/registration-context.js +126 -0
- package/dist/registration-context.js.map +1 -0
- package/dist/registration-manager.d.ts +94 -0
- package/dist/registration-manager.d.ts.map +1 -0
- package/dist/registration-manager.js +585 -0
- package/dist/registration-manager.js.map +1 -0
- package/dist/registration-persistence.d.ts +183 -0
- package/dist/registration-persistence.d.ts.map +1 -0
- package/dist/registration-persistence.js +263 -0
- package/dist/registration-persistence.js.map +1 -0
- package/dist/registry-poll.d.ts +52 -0
- package/dist/registry-poll.d.ts.map +1 -0
- package/dist/registry-poll.js +140 -0
- package/dist/registry-poll.js.map +1 -0
- package/dist/registry-version-store-db.d.ts +22 -0
- package/dist/registry-version-store-db.d.ts.map +1 -0
- package/dist/registry-version-store-db.js +50 -0
- package/dist/registry-version-store-db.js.map +1 -0
- package/dist/relay-endpoints.d.ts +18 -0
- package/dist/relay-endpoints.d.ts.map +1 -0
- package/dist/relay-endpoints.js +9 -0
- package/dist/relay-endpoints.js.map +1 -0
- package/dist/relay-only.d.ts +140 -0
- package/dist/relay-only.d.ts.map +1 -0
- package/dist/relay-only.js +193 -0
- package/dist/relay-only.js.map +1 -0
- package/dist/relay-receipt-store.d.ts +155 -0
- package/dist/relay-receipt-store.d.ts.map +1 -0
- package/dist/relay-receipt-store.js +284 -0
- package/dist/relay-receipt-store.js.map +1 -0
- package/dist/relay-reconnect.d.ts +32 -0
- package/dist/relay-reconnect.d.ts.map +1 -0
- package/dist/relay-reconnect.js +29 -0
- package/dist/relay-reconnect.js.map +1 -0
- package/dist/reply-lag.d.ts +11 -0
- package/dist/reply-lag.d.ts.map +1 -0
- package/dist/reply-lag.js +46 -0
- package/dist/reply-lag.js.map +1 -0
- package/dist/resolve-named-agent.d.ts +49 -0
- package/dist/resolve-named-agent.d.ts.map +1 -0
- package/dist/resolve-named-agent.js +77 -0
- package/dist/resolve-named-agent.js.map +1 -0
- package/dist/restart-seal-resolver.d.ts +110 -0
- package/dist/restart-seal-resolver.d.ts.map +1 -0
- package/dist/restart-seal-resolver.js +353 -0
- package/dist/restart-seal-resolver.js.map +1 -0
- package/dist/resume-last-seen.d.ts +15 -0
- package/dist/resume-last-seen.d.ts.map +1 -0
- package/dist/resume-last-seen.js +44 -0
- package/dist/resume-last-seen.js.map +1 -0
- package/dist/retry-queue.d.ts +203 -0
- package/dist/retry-queue.d.ts.map +1 -0
- package/dist/retry-queue.js +701 -0
- package/dist/retry-queue.js.map +1 -0
- package/dist/roster-freshness.d.ts +160 -0
- package/dist/roster-freshness.d.ts.map +1 -0
- package/dist/roster-freshness.js +250 -0
- package/dist/roster-freshness.js.map +1 -0
- package/dist/screening-status.d.ts +15 -0
- package/dist/screening-status.d.ts.map +1 -0
- package/dist/screening-status.js +65 -0
- package/dist/screening-status.js.map +1 -0
- package/dist/seal-carried-close.d.ts +37 -0
- package/dist/seal-carried-close.d.ts.map +1 -0
- package/dist/seal-carried-close.js +179 -0
- package/dist/seal-carried-close.js.map +1 -0
- package/dist/seal-certificate-pull.d.ts +79 -0
- package/dist/seal-certificate-pull.d.ts.map +1 -0
- package/dist/seal-certificate-pull.js +186 -0
- package/dist/seal-certificate-pull.js.map +1 -0
- package/dist/seal-certified-root-check.d.ts +37 -0
- package/dist/seal-certified-root-check.d.ts.map +1 -0
- package/dist/seal-certified-root-check.js +127 -0
- package/dist/seal-certified-root-check.js.map +1 -0
- package/dist/seal-coordinator.d.ts +118 -0
- package/dist/seal-coordinator.d.ts.map +1 -0
- package/dist/seal-coordinator.js +927 -0
- package/dist/seal-coordinator.js.map +1 -0
- package/dist/seal-escalation.d.ts +80 -0
- package/dist/seal-escalation.d.ts.map +1 -0
- package/dist/seal-escalation.js +283 -0
- package/dist/seal-escalation.js.map +1 -0
- package/dist/seal-evidence-root-check.d.ts +70 -0
- package/dist/seal-evidence-root-check.d.ts.map +1 -0
- package/dist/seal-evidence-root-check.js +199 -0
- package/dist/seal-evidence-root-check.js.map +1 -0
- package/dist/seal-failure-store.d.ts +139 -0
- package/dist/seal-failure-store.d.ts.map +1 -0
- package/dist/seal-failure-store.js +207 -0
- package/dist/seal-failure-store.js.map +1 -0
- package/dist/seal-flows.d.ts +107 -0
- package/dist/seal-flows.d.ts.map +1 -0
- package/dist/seal-flows.js +638 -0
- package/dist/seal-flows.js.map +1 -0
- package/dist/seal-frontier-verify.d.ts +103 -0
- package/dist/seal-frontier-verify.d.ts.map +1 -0
- package/dist/seal-frontier-verify.js +143 -0
- package/dist/seal-frontier-verify.js.map +1 -0
- package/dist/seal-leaf.d.ts +58 -0
- package/dist/seal-leaf.d.ts.map +1 -0
- package/dist/seal-leaf.js +112 -0
- package/dist/seal-leaf.js.map +1 -0
- package/dist/seal-legibility-tbs.d.ts +25 -0
- package/dist/seal-legibility-tbs.d.ts.map +1 -0
- package/dist/seal-legibility-tbs.js +77 -0
- package/dist/seal-legibility-tbs.js.map +1 -0
- package/dist/seal-local-terminus.d.ts +41 -0
- package/dist/seal-local-terminus.d.ts.map +1 -0
- package/dist/seal-local-terminus.js +170 -0
- package/dist/seal-local-terminus.js.map +1 -0
- package/dist/seal-receipt-upgrade.d.ts +30 -0
- package/dist/seal-receipt-upgrade.d.ts.map +1 -0
- package/dist/seal-receipt-upgrade.js +49 -0
- package/dist/seal-receipt-upgrade.js.map +1 -0
- package/dist/seal-relay-silence.d.ts +51 -0
- package/dist/seal-relay-silence.d.ts.map +1 -0
- package/dist/seal-relay-silence.js +76 -0
- package/dist/seal-relay-silence.js.map +1 -0
- package/dist/seal-settle.d.ts +38 -0
- package/dist/seal-settle.d.ts.map +1 -0
- package/dist/seal-settle.js +33 -0
- package/dist/seal-settle.js.map +1 -0
- package/dist/seal-upgrade.d.ts +107 -0
- package/dist/seal-upgrade.d.ts.map +1 -0
- package/dist/seal-upgrade.js +200 -0
- package/dist/seal-upgrade.js.map +1 -0
- package/dist/sealed-conversation.d.ts +36 -0
- package/dist/sealed-conversation.d.ts.map +1 -0
- package/dist/sealed-conversation.js +171 -0
- package/dist/sealed-conversation.js.map +1 -0
- package/dist/sealed-leaf-set.d.ts +92 -0
- package/dist/sealed-leaf-set.d.ts.map +1 -0
- package/dist/sealed-leaf-set.js +122 -0
- package/dist/sealed-leaf-set.js.map +1 -0
- package/dist/send-claims.d.ts +56 -0
- package/dist/send-claims.d.ts.map +1 -0
- package/dist/send-claims.js +50 -0
- package/dist/send-claims.js.map +1 -0
- package/dist/session-assignment-parser.d.ts +104 -0
- package/dist/session-assignment-parser.d.ts.map +1 -0
- package/dist/session-assignment-parser.js +345 -0
- package/dist/session-assignment-parser.js.map +1 -0
- package/dist/session-category.d.ts +19 -0
- package/dist/session-category.d.ts.map +1 -0
- package/dist/session-category.js +14 -0
- package/dist/session-category.js.map +1 -0
- package/dist/session-ceremony.d.ts +297 -0
- package/dist/session-ceremony.d.ts.map +1 -0
- package/dist/session-ceremony.js +964 -0
- package/dist/session-ceremony.js.map +1 -0
- package/dist/session-closed.d.ts +79 -0
- package/dist/session-closed.d.ts.map +1 -0
- package/dist/session-closed.js +189 -0
- package/dist/session-closed.js.map +1 -0
- package/dist/session-connection-gater.d.ts +148 -0
- package/dist/session-connection-gater.d.ts.map +1 -0
- package/dist/session-connection-gater.js +332 -0
- package/dist/session-connection-gater.js.map +1 -0
- package/dist/session-content-context.d.ts +162 -0
- package/dist/session-content-context.d.ts.map +1 -0
- package/dist/session-content-context.js +2 -0
- package/dist/session-content-context.js.map +1 -0
- package/dist/session-content-handlers.d.ts +82 -0
- package/dist/session-content-handlers.d.ts.map +1 -0
- package/dist/session-content-handlers.js +1382 -0
- package/dist/session-content-handlers.js.map +1 -0
- package/dist/session-content-ingest.d.ts +206 -0
- package/dist/session-content-ingest.d.ts.map +1 -0
- package/dist/session-content-ingest.js +2167 -0
- package/dist/session-content-ingest.js.map +1 -0
- package/dist/session-content-send.d.ts +191 -0
- package/dist/session-content-send.d.ts.map +1 -0
- package/dist/session-content-send.js +1360 -0
- package/dist/session-content-send.js.map +1 -0
- package/dist/session-delivery-acks.d.ts +169 -0
- package/dist/session-delivery-acks.d.ts.map +1 -0
- package/dist/session-delivery-acks.js +569 -0
- package/dist/session-delivery-acks.js.map +1 -0
- package/dist/session-ephemerals.d.ts +279 -0
- package/dist/session-ephemerals.d.ts.map +1 -0
- package/dist/session-ephemerals.js +591 -0
- package/dist/session-ephemerals.js.map +1 -0
- package/dist/session-leaf-records.d.ts +159 -0
- package/dist/session-leaf-records.d.ts.map +1 -0
- package/dist/session-leaf-records.js +408 -0
- package/dist/session-leaf-records.js.map +1 -0
- package/dist/session-lifecycle.d.ts +303 -0
- package/dist/session-lifecycle.d.ts.map +1 -0
- package/dist/session-lifecycle.js +1679 -0
- package/dist/session-lifecycle.js.map +1 -0
- package/dist/session-liveness.d.ts +135 -0
- package/dist/session-liveness.d.ts.map +1 -0
- package/dist/session-liveness.js +347 -0
- package/dist/session-liveness.js.map +1 -0
- package/dist/session-name.d.ts +35 -0
- package/dist/session-name.d.ts.map +1 -0
- package/dist/session-name.js +60 -0
- package/dist/session-name.js.map +1 -0
- package/dist/session-node-factory.d.ts +18 -0
- package/dist/session-node-factory.d.ts.map +1 -0
- package/dist/session-node-factory.js +182 -0
- package/dist/session-node-factory.js.map +1 -0
- package/dist/session-node-manager.d.ts +877 -0
- package/dist/session-node-manager.d.ts.map +1 -0
- package/dist/session-node-manager.js +2998 -0
- package/dist/session-node-manager.js.map +1 -0
- package/dist/session-node-types.d.ts +1057 -0
- package/dist/session-node-types.d.ts.map +1 -0
- package/dist/session-node-types.js +657 -0
- package/dist/session-node-types.js.map +1 -0
- package/dist/session-notify.d.ts +46 -0
- package/dist/session-notify.d.ts.map +1 -0
- package/dist/session-notify.js +116 -0
- package/dist/session-notify.js.map +1 -0
- package/dist/session-own-chain-store.d.ts +65 -0
- package/dist/session-own-chain-store.d.ts.map +1 -0
- package/dist/session-own-chain-store.js +75 -0
- package/dist/session-own-chain-store.js.map +1 -0
- package/dist/session-queries.d.ts +476 -0
- package/dist/session-queries.d.ts.map +1 -0
- package/dist/session-queries.js +1007 -0
- package/dist/session-queries.js.map +1 -0
- package/dist/session-read-handlers.d.ts +87 -0
- package/dist/session-read-handlers.d.ts.map +1 -0
- package/dist/session-read-handlers.js +689 -0
- package/dist/session-read-handlers.js.map +1 -0
- package/dist/session-records.d.ts +341 -0
- package/dist/session-records.d.ts.map +1 -0
- package/dist/session-records.js +858 -0
- package/dist/session-records.js.map +1 -0
- package/dist/session-relay-client.d.ts +659 -0
- package/dist/session-relay-client.d.ts.map +1 -0
- package/dist/session-relay-client.js +2876 -0
- package/dist/session-relay-client.js.map +1 -0
- package/dist/session-relay.d.ts +397 -0
- package/dist/session-relay.d.ts.map +1 -0
- package/dist/session-relay.js +1636 -0
- package/dist/session-relay.js.map +1 -0
- package/dist/session-salt-agreement.d.ts +331 -0
- package/dist/session-salt-agreement.d.ts.map +1 -0
- package/dist/session-salt-agreement.js +472 -0
- package/dist/session-salt-agreement.js.map +1 -0
- package/dist/session-salts.d.ts +432 -0
- package/dist/session-salts.d.ts.map +1 -0
- package/dist/session-salts.js +1540 -0
- package/dist/session-salts.js.map +1 -0
- package/dist/session-schema.d.ts +30 -0
- package/dist/session-schema.d.ts.map +1 -0
- package/dist/session-schema.js +877 -0
- package/dist/session-schema.js.map +1 -0
- package/dist/session-seal-leaf-store.d.ts +70 -0
- package/dist/session-seal-leaf-store.d.ts.map +1 -0
- package/dist/session-seal-leaf-store.js +105 -0
- package/dist/session-seal-leaf-store.js.map +1 -0
- package/dist/session-seal.d.ts +334 -0
- package/dist/session-seal.d.ts.map +1 -0
- package/dist/session-seal.js +1017 -0
- package/dist/session-seal.js.map +1 -0
- package/dist/session-terminal-refusal.d.ts +65 -0
- package/dist/session-terminal-refusal.d.ts.map +1 -0
- package/dist/session-terminal-refusal.js +87 -0
- package/dist/session-terminal-refusal.js.map +1 -0
- package/dist/session-tree.d.ts +110 -0
- package/dist/session-tree.d.ts.map +1 -0
- package/dist/session-tree.js +144 -0
- package/dist/session-tree.js.map +1 -0
- package/dist/session-views.d.ts +47 -0
- package/dist/session-views.d.ts.map +1 -0
- package/dist/session-views.js +278 -0
- package/dist/session-views.js.map +1 -0
- package/dist/signal-handlers.d.ts +63 -0
- package/dist/signal-handlers.d.ts.map +1 -0
- package/dist/signal-handlers.js +980 -0
- package/dist/signal-handlers.js.map +1 -0
- package/dist/signal-requirement-policy.d.ts +51 -0
- package/dist/signal-requirement-policy.d.ts.map +1 -0
- package/dist/signal-requirement-policy.js +89 -0
- package/dist/signal-requirement-policy.js.map +1 -0
- package/dist/signal-revocability.d.ts +49 -0
- package/dist/signal-revocability.d.ts.map +1 -0
- package/dist/signal-revocability.js +93 -0
- package/dist/signal-revocability.js.map +1 -0
- package/dist/signal-submission.d.ts +181 -0
- package/dist/signal-submission.d.ts.map +1 -0
- package/dist/signal-submission.js +368 -0
- package/dist/signal-submission.js.map +1 -0
- package/dist/signaling-connect.d.ts +118 -0
- package/dist/signaling-connect.d.ts.map +1 -0
- package/dist/signaling-connect.js +538 -0
- package/dist/signaling-connect.js.map +1 -0
- package/dist/signaling-wiring.d.ts +105 -0
- package/dist/signaling-wiring.d.ts.map +1 -0
- package/dist/signaling-wiring.js +393 -0
- package/dist/signaling-wiring.js.map +1 -0
- package/dist/singleton-lock.d.ts +85 -0
- package/dist/singleton-lock.d.ts.map +1 -0
- package/dist/singleton-lock.js +219 -0
- package/dist/singleton-lock.js.map +1 -0
- package/dist/sqlcipher-db.d.ts +139 -0
- package/dist/sqlcipher-db.d.ts.map +1 -0
- package/dist/sqlcipher-db.js +357 -0
- package/dist/sqlcipher-db.js.map +1 -0
- package/dist/standing-receivers.d.ts +319 -0
- package/dist/standing-receivers.d.ts.map +1 -0
- package/dist/standing-receivers.js +1295 -0
- package/dist/standing-receivers.js.map +1 -0
- package/dist/start-agent.d.ts +64 -0
- package/dist/start-agent.d.ts.map +1 -0
- package/dist/start-agent.js +136 -0
- package/dist/start-agent.js.map +1 -0
- package/dist/status-handler.d.ts +54 -0
- package/dist/status-handler.d.ts.map +1 -0
- package/dist/status-handler.js +61 -0
- package/dist/status-handler.js.map +1 -0
- package/dist/submission-retry.d.ts +208 -0
- package/dist/submission-retry.d.ts.map +1 -0
- package/dist/submission-retry.js +506 -0
- package/dist/submission-retry.js.map +1 -0
- package/dist/telegram-bot-client.d.ts +34 -0
- package/dist/telegram-bot-client.d.ts.map +1 -0
- package/dist/telegram-bot-client.js +36 -0
- package/dist/telegram-bot-client.js.map +1 -0
- package/dist/telegram-doorbell.d.ts +38 -0
- package/dist/telegram-doorbell.d.ts.map +1 -0
- package/dist/telegram-doorbell.js +140 -0
- package/dist/telegram-doorbell.js.map +1 -0
- package/dist/test-handlers.d.ts +62 -0
- package/dist/test-handlers.d.ts.map +1 -0
- package/dist/test-handlers.js +240 -0
- package/dist/test-handlers.js.map +1 -0
- package/dist/testing.d.ts +10 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +10 -0
- package/dist/testing.js.map +1 -0
- package/dist/transport-composition.d.ts +31 -0
- package/dist/transport-composition.d.ts.map +1 -0
- package/dist/transport-composition.js +55 -0
- package/dist/transport-composition.js.map +1 -0
- package/dist/transport-selector.d.ts +202 -0
- package/dist/transport-selector.d.ts.map +1 -0
- package/dist/transport-selector.js +196 -0
- package/dist/transport-selector.js.map +1 -0
- package/dist/trust-signal-pickup-listener.d.ts +43 -0
- package/dist/trust-signal-pickup-listener.d.ts.map +1 -0
- package/dist/trust-signal-pickup-listener.js +48 -0
- package/dist/trust-signal-pickup-listener.js.map +1 -0
- package/dist/trust-signal-store.d.ts +406 -0
- package/dist/trust-signal-store.d.ts.map +1 -0
- package/dist/trust-signal-store.js +939 -0
- package/dist/trust-signal-store.js.map +1 -0
- package/dist/trust-signal-sweep-tick.d.ts +60 -0
- package/dist/trust-signal-sweep-tick.d.ts.map +1 -0
- package/dist/trust-signal-sweep-tick.js +101 -0
- package/dist/trust-signal-sweep-tick.js.map +1 -0
- package/dist/trust-signal-sweep.d.ts +94 -0
- package/dist/trust-signal-sweep.d.ts.map +1 -0
- package/dist/trust-signal-sweep.js +147 -0
- package/dist/trust-signal-sweep.js.map +1 -0
- package/dist/type-registry.d.ts +42 -0
- package/dist/type-registry.d.ts.map +1 -0
- package/dist/type-registry.js +37 -0
- package/dist/type-registry.js.map +1 -0
- package/dist/types.d.ts +746 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +20 -0
- package/dist/types.js.map +1 -0
- package/dist/unresolved-nodes-report.d.ts +14 -0
- package/dist/unresolved-nodes-report.d.ts.map +1 -0
- package/dist/unresolved-nodes-report.js +96 -0
- package/dist/unresolved-nodes-report.js.map +1 -0
- package/dist/vocabulary.d.ts +150 -0
- package/dist/vocabulary.d.ts.map +1 -0
- package/dist/vocabulary.js +386 -0
- package/dist/vocabulary.js.map +1 -0
- package/dist/who-label.d.ts +28 -0
- package/dist/who-label.d.ts.map +1 -0
- package/dist/who-label.js +31 -0
- package/dist/who-label.js.map +1 -0
- package/dist/who-resolver.d.ts +15 -0
- package/dist/who-resolver.d.ts.map +1 -0
- package/dist/who-resolver.js +47 -0
- package/dist/who-resolver.js.map +1 -0
- package/dist/wire-content-hash.d.ts +93 -0
- package/dist/wire-content-hash.d.ts.map +1 -0
- package/dist/wire-content-hash.js +116 -0
- package/dist/wire-content-hash.js.map +1 -0
- package/dist/withheld-content.d.ts +13 -0
- package/dist/withheld-content.d.ts.map +1 -0
- package/dist/withheld-content.js +46 -0
- package/dist/withheld-content.js.map +1 -0
- package/dist/witness-alerts.d.ts +40 -0
- package/dist/witness-alerts.d.ts.map +1 -0
- package/dist/witness-alerts.js +102 -0
- package/dist/witness-alerts.js.map +1 -0
- package/package.json +5 -5
|
@@ -0,0 +1,2167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CELLO Daemon — TAKING A MESSAGE IN
|
|
3
|
+
*
|
|
4
|
+
* The inbound half of the content path, split out of `session-node-manager.ts` with its outbound
|
|
5
|
+
* counterpart in `session-content-send.ts`. Everything between a frame arriving on the wire and a
|
|
6
|
+
* row appearing in the transcript: the stream that carries it, the ordering record that places it,
|
|
7
|
+
* the hash, the salt, the authorship proof, the screening gateway, the dedupe, the size bound, and
|
|
8
|
+
* the ten distinct reasons a message can be refused.
|
|
9
|
+
*
|
|
10
|
+
* **Moved verbatim, comments included.** The comments here are the record of why each guard
|
|
11
|
+
* exists, several of them recording a defect that was reintroduced once already. They are the
|
|
12
|
+
* asset; they moved with the code they describe and none was summarised.
|
|
13
|
+
*
|
|
14
|
+
* `ingestReceivedContent` alone is ~1,000 lines and forty top-level statements, and it is not
|
|
15
|
+
* decomposed here: every guard reads locals the guards above it declared, so lifting a phase out
|
|
16
|
+
* needs either a state object or a signature nobody can read. Moving it intact preserves behaviour
|
|
17
|
+
* exactly, which is the point of this pass; taking it apart is a separate piece of work.
|
|
18
|
+
*/
|
|
19
|
+
import * as lp from "it-length-prefixed";
|
|
20
|
+
import { decode } from "cbor-x";
|
|
21
|
+
import { decodeStructure1 } from "@cello-protocol/protocol-types";
|
|
22
|
+
import { openSessionContent } from "@cello-protocol/crypto";
|
|
23
|
+
import { CELLO_CONTENT_PROTOCOL_ID } from "@cello-protocol/transport";
|
|
24
|
+
import { GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT } from "@cello-protocol/gateway";
|
|
25
|
+
import { contentHashFor, resolveContentHashAlg } from "./wire-content-hash.js";
|
|
26
|
+
import { SALT_ADOPTION_LABEL_MAX } from "./session-salt-agreement.js";
|
|
27
|
+
import { CONTENT_ENCRYPTION_INBOUND_GUIDANCE, SESSION_CONTENT_ENCRYPTION_V1 } from "./content-encryption-status.js";
|
|
28
|
+
import { REFUSAL_KINDS } from "./refusal-reasons.js";
|
|
29
|
+
import { refuseIfSessionClosed } from "./session-closed.js";
|
|
30
|
+
import { triageOrphanedContent } from "./orphan-triage.js";
|
|
31
|
+
import { extractErrorMessage } from "./error-message.js";
|
|
32
|
+
import { retentionSentence } from "./quarantine-framing.js";
|
|
33
|
+
import { LEAF_KIND_CTRL } from "./session-relay-client.js";
|
|
34
|
+
import { ACK_HASH_REASONS, AUTHORSHIP_SELF_CHAIN_MISMATCH, AUTHORSHIP_SESSION_MISMATCH, CONTENT_MAX_INBOUND_STREAMS, CONTENT_STREAM_LINGER_MS, REFUSAL_MAY_STILL_ARRIVE, REFUSAL_NO_OTHER_ROUTE } from "./session-node-types.js";
|
|
35
|
+
// DOD-M15-DELIVERYACK-1: the acknowledgement is its own subject — signed, verified against the
|
|
36
|
+
// session's recorded counterparty key, and kept as evidence. It lives in its own module.
|
|
37
|
+
import { sendDeliveryAck, onDeliveryAck } from "./session-delivery-acks.js";
|
|
38
|
+
export class SessionContentIngest {
|
|
39
|
+
#ctx;
|
|
40
|
+
/**
|
|
41
|
+
* The outbound half — reached for exactly ONE thing, and the reason is worth stating.
|
|
42
|
+
*
|
|
43
|
+
* The counterparty's delivery acknowledgement arrives on the SAME stream this class is already
|
|
44
|
+
* reading, so the receiver is the only code positioned to notice it; what it settles, though, is
|
|
45
|
+
* a message WE sent, whose timer and parked copy belong to the sender. Rather than give both
|
|
46
|
+
* halves a claim on that state, the frame is handed across this one edge.
|
|
47
|
+
*/
|
|
48
|
+
#send;
|
|
49
|
+
constructor(ctx, send) {
|
|
50
|
+
this.#ctx = ctx;
|
|
51
|
+
this.#send = send;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* DOD-M12B-REVIVE-RELAY-1 — the relay witness leaf handler, shared by establishment and revival.
|
|
55
|
+
*
|
|
56
|
+
* Extracted because a REVIVED session must register the same handler. It was inline in
|
|
57
|
+
* `#connectSessionRelay`, so revival — which never called that at all — had no live inbound path:
|
|
58
|
+
* every message fell back to the five-minute mailbox poll, which is why a reconnected session took
|
|
59
|
+
* three minutes to deliver what a fresh one delivers in seconds, and why doorbells stopped firing.
|
|
60
|
+
*
|
|
61
|
+
* A revived session that behaves differently from a fresh one is the defect. This is one of the
|
|
62
|
+
* two halves of making them the same.
|
|
63
|
+
*/
|
|
64
|
+
relayLeafHandler(agentName, sessionId, correlationId) {
|
|
65
|
+
return (frame) => {
|
|
66
|
+
// The counterparty's witnessed leaf arrived with its canonical sequence. The
|
|
67
|
+
// plaintext is delivered separately over the direct content stream; this is the
|
|
68
|
+
// ordering/witness signal. Full canonical-sequence reconciliation against the
|
|
69
|
+
// local tree is MSG-001-3b (J-CONTENT).
|
|
70
|
+
this.#ctx.logger.info("session.relay.leaf.delivered", {
|
|
71
|
+
sessionId,
|
|
72
|
+
sequenceNumber: frame.sequence_number,
|
|
73
|
+
leafKind: frame.leaf_kind,
|
|
74
|
+
correlationId,
|
|
75
|
+
});
|
|
76
|
+
// DOD-MSG-4 (strict in-order): record the relay-witnessed canonical sequence for the
|
|
77
|
+
// counterparty's MSG leaves. The relay is the ordering authority; structure1_cbor =
|
|
78
|
+
// [version, content_hash(32), sender_pubkey, session_id, last_seen_seq, ts] (+ last_seen_hash
|
|
79
|
+
// at index 6 on a v2 claim — 020-ACKHASH; content_hash stays at 1). The relay sequence
|
|
80
|
+
// is 1-based and global per session; the daemon tree is 0-based — normalize with -1. Only
|
|
81
|
+
// COUNTERPARTY leaves (the ones B will ingest); our own echoed leaf already lands via the
|
|
82
|
+
// send path. The gate (ingestReceivedContent) reads this map to hold out-of-order arrivals.
|
|
83
|
+
if (!frame.authored_by_us && frame.leaf_kind !== LEAF_KIND_CTRL) {
|
|
84
|
+
const s1 = decodeStructure1(frame.structure1_cbor);
|
|
85
|
+
if (s1.ok) {
|
|
86
|
+
if (frame.sequence_number > 0) {
|
|
87
|
+
this.recordWitnessedSequence(agentName, sessionId, Buffer.from(s1.fields.contentHash).toString("hex"), frame.sequence_number - 1);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
// `structure1Reason`, not `error` — review F6. This is a named refusal code, and putting
|
|
92
|
+
// it in a field called `error` reads as an exception message to anyone scanning logs.
|
|
93
|
+
// The old `try` here also wrapped `recordWitnessedSequence`, so a throw from THAT was
|
|
94
|
+
// reported as a decode failure; the decode no longer throws, and the split is deliberate.
|
|
95
|
+
this.#ctx.logger.warn("session.relay.leaf.witness.decode.failed", {
|
|
96
|
+
sessionId,
|
|
97
|
+
structure1Reason: s1.reason,
|
|
98
|
+
correlationId,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// M7-UPGRADE-002: auto-acknowledge close. When the COUNTERPARTY's SEAL ctrl leaf (0x02)
|
|
103
|
+
// arrives and B has verified the content, B's OWN node auto-co-signs the responder SEAL
|
|
104
|
+
// leaf — no agent prompt — so the bilateral seal completes promptly instead of degrading
|
|
105
|
+
// to unilateral on a slow/busy/crashed agent. Never auto-ack our OWN echoed ctrl leaf.
|
|
106
|
+
if (frame.leaf_kind === LEAF_KIND_CTRL && !frame.authored_by_us) {
|
|
107
|
+
this.#ctx.maybeAutoAcknowledgeSeal(agentName, sessionId, correlationId);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* DOD-M12B-ABANDON-NOTIFY-1 — drive the REAL inbound content handler with one framed message and
|
|
113
|
+
* a claimed peer identity.
|
|
114
|
+
*
|
|
115
|
+
* The handler is registered on a live libp2p node, so without this the only way to reach its
|
|
116
|
+
* branches is a full two-node transport fixture — which is why the session-abandoned branch and
|
|
117
|
+
* its peer pinning had no coverage at all. This feeds the same function the protocol handler
|
|
118
|
+
* calls, including the authentication check, rather than a copy of its logic.
|
|
119
|
+
*/
|
|
120
|
+
async handleContentFrameForTest(agentName, sessionId, framedBytes, remotePeerId) {
|
|
121
|
+
const source = {
|
|
122
|
+
async *[Symbol.asyncIterator]() { yield framedBytes; },
|
|
123
|
+
close: async () => { },
|
|
124
|
+
abort: () => { },
|
|
125
|
+
status: "closed",
|
|
126
|
+
};
|
|
127
|
+
await this.#handleContentStream(agentName, sessionId, source, remotePeerId);
|
|
128
|
+
}
|
|
129
|
+
async ingestReceivedContent(agentName, sessionId, content, contentHash, correlationId,
|
|
130
|
+
/**
|
|
131
|
+
* DOD-FRONTIER-STRAND-1 AC1: the relay-assigned canonical position for THIS message, taken from
|
|
132
|
+
* the verified ordering record by the caller. Passed EXPLICITLY rather than recovered from
|
|
133
|
+
* `#witnessedSeq`, because that map is keyed by content hash — so two byte-identical messages
|
|
134
|
+
* collapse in it before dedup is ever consulted, which is the whole defect. Absent when the
|
|
135
|
+
* session has no relay witness (relay-degraded): see the announced fallback below.
|
|
136
|
+
*/
|
|
137
|
+
canonicalSeqIn,
|
|
138
|
+
/**
|
|
139
|
+
* DOD-M15-SEALWIRE-1 part B1 — the algorithm the SENDER named on the frame, verbatim.
|
|
140
|
+
*
|
|
141
|
+
* `undefined` means the frame carried no name, which is a peer that predates the field and is
|
|
142
|
+
* the one case we may safely assume `sha256` for. It is threaded through rather than read off
|
|
143
|
+
* the session, because whether a hash is salted is a fact about the FRAME and its sender, never
|
|
144
|
+
* about what this side happens to hold.
|
|
145
|
+
*/
|
|
146
|
+
contentHashAlgIn,
|
|
147
|
+
/**
|
|
148
|
+
* DOD-M15-SEALWIRE-1 bullet 5: the VERIFIED authorship proof for this message, when the caller
|
|
149
|
+
* has one. The caller is the only place that has it — `#verifyAuthorshipClaim` verifies the
|
|
150
|
+
* signature the frame carries beside the sender's own signed bytes, against the key inside those
|
|
151
|
+
* bytes, and matches the signer to this session's counterparty. That result reaches here or
|
|
152
|
+
* nowhere.
|
|
153
|
+
*
|
|
154
|
+
* ⚠️ IT USED TO NAME `#recordFrameOrdering`, and that was accurate until
|
|
155
|
+
* `DOD-M15-AUTHORSHIP-ABSENT-1`: the signature arrived only inside the RELAY's Structure 2, so
|
|
156
|
+
* checking authorship needed a relay record. It does not now, and the old name sends a reader to
|
|
157
|
+
* a method that answers a different question. Rewritten, not deleted — that dependence is the
|
|
158
|
+
* defect the unit removed.
|
|
159
|
+
*
|
|
160
|
+
* Optional, because the PARK route ingests without it: recovered mail proves its sender by the
|
|
161
|
+
* mailbox envelope instead. The row records which it was, so absence is never silent.
|
|
162
|
+
*/
|
|
163
|
+
verifiedAuthorship,
|
|
164
|
+
/**
|
|
165
|
+
* 024-ORPHANTRIAGE — the key whose signature VERIFIED on a frame we could not tie to a session.
|
|
166
|
+
*
|
|
167
|
+
* Read by the orphan branch below and NOWHERE ELSE. It exists because the daemon establishes,
|
|
168
|
+
* cryptographically, that the sender holds a private key — and then discarded that the instant
|
|
169
|
+
* the session lookup came back empty, leaving the operator advised to go and make contact with
|
|
170
|
+
* whoever sent a message for a conversation that does not exist.
|
|
171
|
+
*
|
|
172
|
+
* Absent on the park-recovery caller, which cannot reach the orphan branch at all:
|
|
173
|
+
* `authenticateParkedEntry` refuses `counterparty_unknown` from the same missing record first.
|
|
174
|
+
*/
|
|
175
|
+
verifiedSignerUnmatched) {
|
|
176
|
+
// The transcript is frozen ONLY once it is COMMITTED + signed — 'sealed' or
|
|
177
|
+
// 'seal_interrupted_pending' (the bilateral seal commitment) — because a later FROST
|
|
178
|
+
// notarization attests that exact root; a late leaf would diverge from it.
|
|
179
|
+
//
|
|
180
|
+
// MSG-001-3b recovery: a merely 'interrupted' session is NOT yet committed. The
|
|
181
|
+
// counterparty's last message(s) may have been parked while this party was offline, so its
|
|
182
|
+
// local transcript is INCOMPLETE (not frozen-final). Recovering that parked content COMPLETES
|
|
183
|
+
// the local view to match the counterparty BEFORE the bilateral seal — it is not a resumption
|
|
184
|
+
// (no new activity, no re-accept) and its root was never committed. So allow 'active' AND
|
|
185
|
+
// 'interrupted'; reject only the two committed states.
|
|
186
|
+
const record = this.#ctx.queries.getSessionRecord(agentName, sessionId);
|
|
187
|
+
// DOD-UNREAD-1 D4a: NEVER record content you cannot attribute. With no sessions row there is
|
|
188
|
+
// no counterparty — the transcript has no counterparty column, so a row written here is
|
|
189
|
+
// unattributable forever, counted unread by getUnreadSummary, and unreadable by cello_receive
|
|
190
|
+
// (the phantom-session residue). The old "(No DB row = test-only path, allowed.)" fallback
|
|
191
|
+
// papered that in with senderPubkey="unknown". Refuse loudly instead; the content stays
|
|
192
|
+
// un-acked, so a live sender redelivers once the session actually exists. After D3
|
|
193
|
+
// (DOD-INBOUND-GUARD-1) this path is unreachable from the wire — a fail-loud assertion.
|
|
194
|
+
/**
|
|
195
|
+
* DOD-M15-REFUSEDEVIDENCE-1 — HOISTED from below the hash cross-check, so that every refusal
|
|
196
|
+
* above that point can retain the bytes under it. Same expression, earlier.
|
|
197
|
+
*
|
|
198
|
+
* It is the SENDER'S CLAIM at this point — nothing has checked it yet, and on a
|
|
199
|
+
* `content_hash_mismatch` it provably does not describe these bytes. The quarantine read
|
|
200
|
+
* recomputes its own hash over what was retained rather than reprinting this one.
|
|
201
|
+
*/
|
|
202
|
+
const contentHashHex = Buffer.from(contentHash).toString("hex");
|
|
203
|
+
if (!record) {
|
|
204
|
+
/**
|
|
205
|
+
* RETAINED FIRST, because the triage below now tells the operator whether there is an artifact
|
|
206
|
+
* to report — and that claim has to be made after the write, never before it (023 review F3).
|
|
207
|
+
*
|
|
208
|
+
* This is the case retention matters most for. A message for a session this daemon has no
|
|
209
|
+
* record of is the least explicable thing that can arrive, so it is the thing an operator has
|
|
210
|
+
* the least other way to show anyone. There is no `sessions` row and no counterparty, so no
|
|
211
|
+
* tier — `#quarantineRefusedContent` bounds it at UNKNOWN and files it at a negative position,
|
|
212
|
+
* outside the chain it never joined.
|
|
213
|
+
*/
|
|
214
|
+
const keptOrphan = this.#ctx.refusals.quarantineRefusedContent(agentName, sessionId, "session_orphaned", content, contentHashHex, { correlationId });
|
|
215
|
+
/**
|
|
216
|
+
* 024-ORPHANTRIAGE — TWO ACTIONS EXIST AND THE EVIDENCE DECIDES WHICH.
|
|
217
|
+
*
|
|
218
|
+
* The advice here used to be *"ask the counterparty to start a NEW session."* When the message
|
|
219
|
+
* is a stranger probing a peer id, obeying that advice is the probe succeeding: it confirms
|
|
220
|
+
* somebody is home and that this agent answers, from a message that was refused.
|
|
221
|
+
*
|
|
222
|
+
* All three signals are read from things the sender does not control — their signature is
|
|
223
|
+
* checked against the key inside their own signed bytes, "known" comes from OUR address book,
|
|
224
|
+
* and "ongoing" comes from OUR transcript rows rather than the sequence number they chose.
|
|
225
|
+
*/
|
|
226
|
+
const evidence = this.#ctx.refusals.orphanEvidence(agentName, sessionId, verifiedSignerUnmatched);
|
|
227
|
+
const triage = triageOrphanedContent(evidence, retentionSentence(sessionId, keptOrphan));
|
|
228
|
+
/**
|
|
229
|
+
* BOTH SURFACES, per Invariant 2. The log is the durable forensic record and carries the
|
|
230
|
+
* signals structurally — this is where an investigation days later reads what was known and
|
|
231
|
+
* when. The notice below is the control: it is what the agent actually reads and acts on.
|
|
232
|
+
*/
|
|
233
|
+
this.#ctx.logger.warn("session.content.orphaned", {
|
|
234
|
+
agentName, sessionId, correlationId,
|
|
235
|
+
signerPubkey: evidence.signerPubkeyHex ?? "(no verifiable signature)",
|
|
236
|
+
signatureVerified: evidence.signerPubkeyHex !== null,
|
|
237
|
+
// Review F6: `"not_checked"` where nothing was measured, never a `false` that reads as a
|
|
238
|
+
// reading. An investigator filtering this event is the only person who will ever ask.
|
|
239
|
+
knownContact: evidence.knownContact,
|
|
240
|
+
ongoingConversation: evidence.ongoingConversation,
|
|
241
|
+
action: triage.action,
|
|
242
|
+
// 023: whether the evidence the triage points at actually exists.
|
|
243
|
+
retained: keptOrphan !== null,
|
|
244
|
+
impact: triage.impact,
|
|
245
|
+
});
|
|
246
|
+
// DOD-M15-NO-SILENT-REFUSAL-1. The notice is written even though there is no session row —
|
|
247
|
+
// the store is keyed (agent_id, session_id) and holds no foreign key to `sessions` precisely
|
|
248
|
+
// so this case can be recorded. A refusal for a session that does not exist here is the one
|
|
249
|
+
// the operator has the least other way to learn about.
|
|
250
|
+
this.#ctx.notices.noteContentRefusal(agentName, sessionId, "session_orphaned", {
|
|
251
|
+
kind: REFUSAL_KINDS.REFUSED,
|
|
252
|
+
impact: triage.impact,
|
|
253
|
+
guidance: triage.guidance,
|
|
254
|
+
});
|
|
255
|
+
return { ok: false, reason: "session_orphaned" };
|
|
256
|
+
}
|
|
257
|
+
// The PARK-recovery route's door into the closed check. `session-closed.ts` holds the whole
|
|
258
|
+
// reasoning — the three statuses, why `abandoned` is one of them, and what the refusal retains.
|
|
259
|
+
const closed = refuseIfSessionClosed(this.#ctx, agentName, sessionId, content, contentHashHex, correlationId);
|
|
260
|
+
if (closed.refused)
|
|
261
|
+
return { ok: false, reason: "session_committed", retained: closed.retained };
|
|
262
|
+
/**
|
|
263
|
+
* DOD-M15-SEALWIRE-1 part B1 — VERIFY UNDER THE ALGORITHM THE SENDER NAMED.
|
|
264
|
+
*
|
|
265
|
+
* Three outcomes and they must stay apart, because two of them are version differences and only
|
|
266
|
+
* the third is evidence of tampering. Collapsing them is how a routine skew becomes a security
|
|
267
|
+
* incident in the operator's log, and how a real tamper gets dismissed as a skew.
|
|
268
|
+
*/
|
|
269
|
+
/**
|
|
270
|
+
* ⚠️ `content_hash_alg` IS NOT COVERED BY ANY SIGNATURE — review F1, and it shapes both branches
|
|
271
|
+
* below.
|
|
272
|
+
*
|
|
273
|
+
* The sender's signature is over `structure1_cbor`, which binds `content_hash`. It does NOT bind
|
|
274
|
+
* the frame envelope, so this field is an unauthenticated CLAIM by whoever sent the frame. That
|
|
275
|
+
* is fine for choosing how to verify — a wrong choice simply fails — but it means neither branch
|
|
276
|
+
* may state, as fact, anything it learned only from this field.
|
|
277
|
+
*
|
|
278
|
+
* It also means both branches MUST mark the session unverifiable. Before B1 every frame that
|
|
279
|
+
* failed the cross-check reached `#contentDesynced`, which gates auto-co-signing and unilateral
|
|
280
|
+
* ratification. Returning early here would have let a sender bypass the tamper detector by
|
|
281
|
+
* appending one unsigned string: sign hash H, send different bytes, add an unreadable algorithm
|
|
282
|
+
* name, and the receiver refuses politely, records nothing, and auto-co-signs at seal time.
|
|
283
|
+
*/
|
|
284
|
+
const algResolved = resolveContentHashAlg(contentHashAlgIn);
|
|
285
|
+
if (!algResolved.ok) {
|
|
286
|
+
// A NAME WE CANNOT READ. Not a legacy peer — an unreadable one. There is no value to compare
|
|
287
|
+
// against, so `content_hash_mismatch` here would be an exit-point label standing in for
|
|
288
|
+
// "their build is newer than ours" (Invariant 2). Refused by its own name instead.
|
|
289
|
+
this.#ctx.markContentUnverifiable(agentName, sessionId, "unverifiable");
|
|
290
|
+
this.#ctx.refusals.noteUnreadableAlgFrame(agentName, sessionId, contentHash, algResolved.value);
|
|
291
|
+
this.#ctx.logger.error("session.content.cross_check.failed", {
|
|
292
|
+
sessionId, correlationId,
|
|
293
|
+
reason: "content_hash_alg_unknown",
|
|
294
|
+
declaredAlg: algResolved.value,
|
|
295
|
+
// States only what is KNOWN. The old wording said "nothing was altered and nobody did
|
|
296
|
+
// anything wrong" and "Do not treat this as a security event" — both inferred from the
|
|
297
|
+
// unsigned field, i.e. from the attacker in the case that matters.
|
|
298
|
+
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.",
|
|
299
|
+
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.",
|
|
300
|
+
});
|
|
301
|
+
// DOD-M15-REFUSEDEVIDENCE-1 — RETAINED. The algorithm name is an unsigned claim by whoever
|
|
302
|
+
// sent the frame, so this branch is reachable by crafting as well as by version skew, and the
|
|
303
|
+
// crafted case is one to be able to show someone.
|
|
304
|
+
this.#ctx.refusals.quarantineRefusedContent(agentName, sessionId, "content_hash_alg_unknown", content, contentHashHex, {
|
|
305
|
+
senderPubkeyHex: record.counterparty_pubkey ?? null, correlationId,
|
|
306
|
+
});
|
|
307
|
+
// DOD-M15-REFUSED-INBOUND-SILENT-1: the SAME strings the log just carried, to the operator.
|
|
308
|
+
// This reason is a version skew, so it affects every message from that counterparty — without
|
|
309
|
+
// this the conversation goes permanently quiet and they conclude the peer stopped replying.
|
|
310
|
+
this.#ctx.notices.noteContentRefusal(agentName, sessionId, "content_hash_alg_unknown", {
|
|
311
|
+
kind: REFUSAL_KINDS.REFUSED,
|
|
312
|
+
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.",
|
|
313
|
+
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.",
|
|
314
|
+
});
|
|
315
|
+
return { ok: false, reason: "content_hash_alg_unknown" };
|
|
316
|
+
}
|
|
317
|
+
let computed;
|
|
318
|
+
try {
|
|
319
|
+
computed = contentHashFor(content, {
|
|
320
|
+
alg: algResolved.alg,
|
|
321
|
+
// The salt is OURS — the sender's frame never carries one, and could not be trusted if it
|
|
322
|
+
// did. A salted frame we hold no salt for throws below and is refused by name.
|
|
323
|
+
salt: this.#ctx.salts.getSessionSalt(agentName, sessionId),
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
// Reached when the peer named the salted algorithm and this side holds no salt for the
|
|
328
|
+
// session — the agreement never completed, or its record is gone. Distinct from a mismatch
|
|
329
|
+
// for the same reason as above: nothing was tampered with, we simply cannot check it.
|
|
330
|
+
this.#ctx.markContentUnverifiable(agentName, sessionId, "unverifiable");
|
|
331
|
+
this.#ctx.logger.error("session.content.cross_check.failed", {
|
|
332
|
+
sessionId, correlationId,
|
|
333
|
+
reason: "content_hash_salt_unavailable",
|
|
334
|
+
declaredAlg: algResolved.alg,
|
|
335
|
+
detail: extractErrorMessage(err),
|
|
336
|
+
// "Nothing was altered" was the same mistake as the branch above: it is not knowable from
|
|
337
|
+
// here. What IS knowable is that we could not check.
|
|
338
|
+
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.",
|
|
339
|
+
// Review F6: `#getSessionSalt` returns null for THREE conditions and only one of them wants
|
|
340
|
+
// a close. A read failure and a corrupt row both leave us holding no salt, which is exactly
|
|
341
|
+
// what makes the agreement re-offer a contribution and repair itself on the next connect.
|
|
342
|
+
//
|
|
343
|
+
// The adoption refusal is the FOURTH, added with the Decision #8 guard, and it is the only
|
|
344
|
+
// one that does not repair: this side declined the salt permanently for this session, so
|
|
345
|
+
// waiting for a reconnect is exactly the wrong advice. Leaving it out of this list would
|
|
346
|
+
// have sent an operator to look for a read failure that is not there and never will be.
|
|
347
|
+
// DOD-M15-SALTSPLIT-1 review MEDIUM-3: `session.salt.discarded` is the FIFTH cause, and it
|
|
348
|
+
// was added by the discard without appearing in this tree. Without it an operator whose salt
|
|
349
|
+
// was deliberately dropped is sent to look for three events that will not be there and then
|
|
350
|
+
// told a fifth thing that is false — the agreement DID complete here, and was then undone on
|
|
351
|
+
// purpose.
|
|
352
|
+
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.",
|
|
353
|
+
});
|
|
354
|
+
// DOD-M15-REFUSEDEVIDENCE-1 — RETAINED. We could not check it, which is precisely why the
|
|
355
|
+
// bytes have to survive: the question of what they actually were stays open, and a hash we
|
|
356
|
+
// could not verify answers none of it.
|
|
357
|
+
this.#ctx.refusals.quarantineRefusedContent(agentName, sessionId, "content_hash_salt_unavailable", content, contentHashHex, {
|
|
358
|
+
senderPubkeyHex: record.counterparty_pubkey ?? null, correlationId,
|
|
359
|
+
});
|
|
360
|
+
// DOD-M15-REFUSED-INBOUND-SILENT-1 — and this branch needed it MORE than the two that had it.
|
|
361
|
+
//
|
|
362
|
+
// It was refused, logged with a full impact and guidance, not ingested, not shown — and the
|
|
363
|
+
// operator was told nothing. Twenty lines below the branches that were wired, in the same
|
|
364
|
+
// function, with the same shape.
|
|
365
|
+
//
|
|
366
|
+
// One of its four causes is permanent, and the guidance above says so in its own words: an
|
|
367
|
+
// adoption refusal means this side declined the salt for the life of the session and
|
|
368
|
+
// reconnecting will NOT fix it. So the failure this line exists to close — the conversation
|
|
369
|
+
// goes quiet, the explanation sits in a log nobody opens — was still live on the one branch
|
|
370
|
+
// that never repairs itself.
|
|
371
|
+
//
|
|
372
|
+
// The guidance is passed by reference to the log's own text rather than duplicated: a second
|
|
373
|
+
// copy is a second thing to keep true, and the log's version is the one that gets maintained.
|
|
374
|
+
this.#ctx.notices.noteContentRefusal(agentName, sessionId, "content_hash_salt_unavailable", {
|
|
375
|
+
kind: REFUSAL_KINDS.REFUSED,
|
|
376
|
+
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.",
|
|
377
|
+
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.",
|
|
378
|
+
});
|
|
379
|
+
return { ok: false, reason: "content_hash_salt_unavailable" };
|
|
380
|
+
}
|
|
381
|
+
if (Buffer.from(computed).toString("hex") !== contentHashHex) {
|
|
382
|
+
this.#ctx.logger.warn("session.content.cross_check.failed", {
|
|
383
|
+
sessionId,
|
|
384
|
+
reason: "content_hash_mismatch",
|
|
385
|
+
// WHICH algorithm the comparison ran under. Without it, a mismatch is unfalsifiable from the
|
|
386
|
+
// log: an operator cannot tell "the bytes were altered" from "we checked it the wrong way".
|
|
387
|
+
declaredAlg: algResolved.alg,
|
|
388
|
+
correlationId,
|
|
389
|
+
});
|
|
390
|
+
// M7-UPGRADE-002 (SI-002): a tamper makes this session's content unverifiable — the
|
|
391
|
+
// auto-acknowledge gate must never auto-co-sign it. The session stays alive (DOD-MSG-7),
|
|
392
|
+
// but the responder seal now requires the agent's explicit decision, not an auto-ack.
|
|
393
|
+
this.#ctx.markContentUnverifiable(agentName, sessionId, "tampered");
|
|
394
|
+
/**
|
|
395
|
+
* DOD-M15-REFUSEDEVIDENCE-1 — RETAINED, and this is the highest-value row in the table.
|
|
396
|
+
*
|
|
397
|
+
* A tampered frame is the one case where the message and the sender's commitment PROVABLY
|
|
398
|
+
* disagree, and the proof only exists while both halves do. Before this, the bytes went on the
|
|
399
|
+
* floor and all that survived was a hash of something nobody still had.
|
|
400
|
+
*
|
|
401
|
+
* `verifiedAuthorship` is stored when the caller verified a signature over the sender's own
|
|
402
|
+
* bytes. That is what makes the row evidence rather than a note: the signature is checked
|
|
403
|
+
* against the key inside the sender's signed bytes, not against anything this side chose.
|
|
404
|
+
*/
|
|
405
|
+
this.#ctx.refusals.quarantineRefusedContent(agentName, sessionId, "content_hash_mismatch", content, contentHashHex, {
|
|
406
|
+
senderPubkeyHex: this.#ctx.activeNodes.get(this.#ctx.sessionKey(agentName, sessionId))?.counterpartyPubkey ?? record.counterparty_pubkey ?? null,
|
|
407
|
+
...(verifiedAuthorship ? { authorship: verifiedAuthorship } : {}),
|
|
408
|
+
correlationId,
|
|
409
|
+
});
|
|
410
|
+
// DOD-M15-REFUSED-INBOUND-SILENT-1. Deliberately does NOT include the content or the hashes:
|
|
411
|
+
// it failed verification, and showing it is the injection path this cross-check closes.
|
|
412
|
+
this.#ctx.notices.noteContentRefusal(agentName, sessionId, "content_hash_mismatch", {
|
|
413
|
+
kind: REFUSAL_KINDS.REFUSED,
|
|
414
|
+
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.",
|
|
415
|
+
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.",
|
|
416
|
+
});
|
|
417
|
+
return { ok: false, reason: "content_hash_mismatch" };
|
|
418
|
+
}
|
|
419
|
+
const entry = this.#ctx.activeNodes.get(this.#ctx.sessionKey(agentName, sessionId));
|
|
420
|
+
const senderPubkey = entry?.counterpartyPubkey ?? record.counterparty_pubkey;
|
|
421
|
+
if (!senderPubkey) {
|
|
422
|
+
// DOD-UNREAD-1 D4a (AC4, supersedes the MSGWAKE-1 F1 paper-in): the schema requires
|
|
423
|
+
// counterparty_pubkey NOT NULL, so this is unreachable unless a row was hand-crafted empty.
|
|
424
|
+
// Either way, "unknown" is never written to a transcript row — refuse instead.
|
|
425
|
+
this.#ctx.logger.warn("session.content.sender_unresolved", { sessionId, agentName, correlationId });
|
|
426
|
+
// DOD-M15-REFUSEDEVIDENCE-1 — RETAINED, with NO sender key, because there is none and that
|
|
427
|
+
// absence is the evidence. The guidance below says to report this; this is the artifact there
|
|
428
|
+
// is to report. Bounded at the UNKNOWN tier — there is no contact to look a tier up on, which
|
|
429
|
+
// is the same fact that made it unattributable.
|
|
430
|
+
const keptUnresolved = this.#ctx.refusals.quarantineRefusedContent(agentName, sessionId, "sender_unresolved", content, contentHashHex, { correlationId });
|
|
431
|
+
this.#ctx.notices.noteContentRefusal(agentName, sessionId, "sender_unresolved", {
|
|
432
|
+
kind: REFUSAL_KINDS.REFUSED,
|
|
433
|
+
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.",
|
|
434
|
+
/**
|
|
435
|
+
* ⚠️ NO "WHEN IN DOUBT" HERE — Andre, 2026-09-03: *"This message has no sender, the chances
|
|
436
|
+
* that it is hostile are very high. When in doubt? No. Just report it."*
|
|
437
|
+
*
|
|
438
|
+
* That hedge belongs on the ambiguous branch in `024-ORPHANTRIAGE`, where a verified
|
|
439
|
+
* signature from a known contact leaves a real judgement to make. There is no judgement
|
|
440
|
+
* here. Softening it would teach the operator to weigh a case that does not need weighing.
|
|
441
|
+
*
|
|
442
|
+
* ⚠️ IT NAMES NO REPORTING DESTINATION, and that is still true — but HALF of the reason has
|
|
443
|
+
* gone, so the sentence is rewritten rather than left to read as though nothing changed.
|
|
444
|
+
*
|
|
445
|
+
* It used to rest on two facts: `CELLO_Reporting` does not exist (`DOD-M15-ORPHANTRIAGE-1`,
|
|
446
|
+
* still open) and **the message itself is not retained**. The second is no longer true —
|
|
447
|
+
* `DOD-M15-REFUSEDEVIDENCE-1` retains it, and the guidance below now says so and names where
|
|
448
|
+
* it is. Telling an operator to report something while keeping nothing to report was the
|
|
449
|
+
* gap; naming a destination nobody can reach would be Invariant 4's failure. So: the
|
|
450
|
+
* artifact is named now, the destination when 024 lands.
|
|
451
|
+
*
|
|
452
|
+
* ⚠️ THE ROTATION ADVICE IS MEASURED, NOT ASSUMED. `#startReceiverNode` mints the standing
|
|
453
|
+
* receiver's transport key with `randomBytes(32)` and never persists it, so a logout/login
|
|
454
|
+
* genuinely yields a NEW peer id and fresh directory connections. **And the bound is stated
|
|
455
|
+
* in the same breath:** session nodes DO persist their seed (`DOD-M12B-SESSION-SEED-1`, so a
|
|
456
|
+
* revived conversation keeps its address), so this rotates the front door and not the doors
|
|
457
|
+
* already open. Telling an operator to rotate without that bound would have them believe
|
|
458
|
+
* they had closed something they had not.
|
|
459
|
+
*/
|
|
460
|
+
guidance:
|
|
461
|
+
// "That is the artifact to show someone" is NOT appended: it would be false on the branch
|
|
462
|
+
// where nothing was retained, which is the branch this sentence exists to be honest about.
|
|
463
|
+
"Report this. " + retentionSentence(sessionId, keptUnresolved) +
|
|
464
|
+
"Do not try to reply — there is no one to reply to, and answering an unattributable message is what a probe is looking for. " +
|
|
465
|
+
"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. " +
|
|
466
|
+
"It does NOT change the addresses of conversations you already have open: those identities are kept on purpose so an interrupted conversation can resume. " +
|
|
467
|
+
"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.",
|
|
468
|
+
});
|
|
469
|
+
return { ok: false, reason: "sender_unresolved" };
|
|
470
|
+
}
|
|
471
|
+
// DOD-MSG-5: a content_hash satisfies AT MOST ONE Merkle leaf, exactly once. If this hash is
|
|
472
|
+
// already a leaf in the tree — it arrived BOTH directly and via the relay-park backstop, or it
|
|
473
|
+
// is a replay — do NOT append a second leaf and do NOT double-count it. The recipient already
|
|
474
|
+
// holds this message at its assigned sequence. (In the normal single-delivery case this find is
|
|
475
|
+
// -1, so the live/recover append paths are unchanged.)
|
|
476
|
+
// ─── DOD-FRONTIER-STRAND-1 AC1: the discriminator is the POSITION, not the content ───
|
|
477
|
+
//
|
|
478
|
+
// The old rule ("a content_hash satisfies AT MOST ONE Merkle leaf") is false whenever two
|
|
479
|
+
// genuinely distinct messages match byte-for-byte — and two instances of the same model,
|
|
480
|
+
// answering the same message with similar context, collide far more readily than humans do.
|
|
481
|
+
// That is what stranded session dbb93dfc... for a week: an away responder fired twice with
|
|
482
|
+
// identical text, the sender appended both, the receiver dropped the second as a "redelivery",
|
|
483
|
+
// and the two frontiers disagreed forever. No receipt was ever possible.
|
|
484
|
+
//
|
|
485
|
+
// The relay already assigns every submission a unique position: a REDELIVERY carries the same
|
|
486
|
+
// position, a genuinely new identical message carries a NEW one. So a duplicate is the same
|
|
487
|
+
// hash AT THE SAME POSITION -- never the same hash anywhere.
|
|
488
|
+
const tree = this.#ctx.getSessionTree(agentName, sessionId);
|
|
489
|
+
let existingIdx;
|
|
490
|
+
if (canonicalSeqIn !== undefined && canonicalSeqIn >= 0 && tree.hashAt(canonicalSeqIn) === contentHashHex) {
|
|
491
|
+
// The relay position holds exactly this content: a redelivery.
|
|
492
|
+
existingIdx = canonicalSeqIn;
|
|
493
|
+
}
|
|
494
|
+
else if (canonicalSeqIn !== undefined && canonicalSeqIn >= 0 && canonicalSeqIn >= tree.size()) {
|
|
495
|
+
// The position is at or beyond the frontier, so it cannot be a leaf we already hold. A
|
|
496
|
+
// genuinely new message — including one byte-identical to an earlier leaf, which is the whole
|
|
497
|
+
// point of AC1.
|
|
498
|
+
existingIdx = -1;
|
|
499
|
+
}
|
|
500
|
+
else if (canonicalSeqIn !== undefined && canonicalSeqIn >= 0) {
|
|
501
|
+
// ─── POSITION DRIFT (review F2, a regression this fix introduced and this branch repairs) ───
|
|
502
|
+
//
|
|
503
|
+
// `canonicalSeqIn < tree.size()` yet that slot holds different content, so **leaf index is no
|
|
504
|
+
// longer the relay position** and the position cannot be used as an index into the tree. That
|
|
505
|
+
// is §7a's drift: a first message whose relay submit failed is appended locally and never
|
|
506
|
+
// counted by the relay, leaving the local record permanently one ahead.
|
|
507
|
+
//
|
|
508
|
+
// Using the position as an index here made a TRUE REDELIVERY append a second leaf — measured:
|
|
509
|
+
// tree size 3 where the pre-fix code correctly gave 2. That is the "too permissive" direction,
|
|
510
|
+
// and it inflates this side's tree against the counterparty's: the strand, from the other end.
|
|
511
|
+
//
|
|
512
|
+
// So under drift, fall back to the content-hash rule. It is weaker — it still cannot tell two
|
|
513
|
+
// identical messages apart — but it is CORRECT about redelivery, which is the failure actually
|
|
514
|
+
// reachable here, and it is exactly the pre-existing behavior, so this is not a regression in
|
|
515
|
+
// either direction. Loudly announced, because the ambiguity is real and the drift is the thing
|
|
516
|
+
// that should be fixed (DOD-FIRSTMSG-WITNESS-1 closes the producer).
|
|
517
|
+
existingIdx = tree.indexOfHash(contentHashHex);
|
|
518
|
+
// Announce only when the fallback actually DECIDED something (it found a duplicate). When it
|
|
519
|
+
// finds nothing the message simply appends, `session.content.sequence_behind_tree` already
|
|
520
|
+
// reports the drift itself, and a second warn on every message of a drifted session would
|
|
521
|
+
// bury the case that matters. A signal that fires on the normal case is not a signal.
|
|
522
|
+
if (existingIdx >= 0)
|
|
523
|
+
this.#ctx.logger.warn("session.content.dedup.position_drifted", {
|
|
524
|
+
sessionId,
|
|
525
|
+
agentName,
|
|
526
|
+
contentHashHex,
|
|
527
|
+
canonicalSeq: canonicalSeqIn,
|
|
528
|
+
treeSize: tree.size(),
|
|
529
|
+
dedupedAt: existingIdx,
|
|
530
|
+
reason: "leaf_index_is_not_relay_position_fell_back_to_content_hash",
|
|
531
|
+
correlationId,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
// RELAY-DEGRADED: no witness, so no discriminator exists and the content-hash rule is all
|
|
536
|
+
// there is. Keeping it preserves today's protection against real redelivery and today's blind
|
|
537
|
+
// spot for identical messages -- the strand can still form on this path. Section 5a permits
|
|
538
|
+
// proceeding rather than refusing (losing content is worse than mis-ordering it), but only
|
|
539
|
+
// ANNOUNCED: a silent fallback is exactly how this went a week unnoticed. Fires only when the
|
|
540
|
+
// hash actually matches, so it marks a real decision rather than every unwitnessed message.
|
|
541
|
+
existingIdx = tree.indexOfHash(contentHashHex);
|
|
542
|
+
// Gated exactly as its sibling `session.content.unwitnessed` is (see :3933): a session with NO
|
|
543
|
+
// RELAY ATTACHED has no witness BY DESIGN, so warning there would fire on every message of a
|
|
544
|
+
// normal no-relay session and bury the case that means something. A signal that fires on the
|
|
545
|
+
// normal case is not a signal. The reason distinguishes the two shapes rather than asserting
|
|
546
|
+
// the relay is absent — the position can also be missing because this particular frame carried
|
|
547
|
+
// no ordering record while the relay is perfectly healthy.
|
|
548
|
+
if (existingIdx >= 0 && this.#ctx.activeNodes.get(this.#ctx.sessionKey(agentName, sessionId))?.relayClient) {
|
|
549
|
+
this.#ctx.logger.warn("session.content.dedup.unwitnessed", {
|
|
550
|
+
sessionId,
|
|
551
|
+
agentName,
|
|
552
|
+
contentHashHex,
|
|
553
|
+
sequenceNumber: existingIdx,
|
|
554
|
+
reason: "no_ordering_record_deduped_on_content_hash",
|
|
555
|
+
correlationId,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (existingIdx >= 0) {
|
|
560
|
+
this.#ctx.logger.info("session.content.deduplicated", {
|
|
561
|
+
sessionId,
|
|
562
|
+
contentHashHex,
|
|
563
|
+
sequenceNumber: existingIdx,
|
|
564
|
+
witnessed: canonicalSeqIn !== undefined,
|
|
565
|
+
correlationId,
|
|
566
|
+
});
|
|
567
|
+
// appendedCount 0 — a dedup appends NO new leaf, so a recover that re-pulls an already-ingested
|
|
568
|
+
// entry (e.g. after auto-recover already drained it) must not count it as a fresh recovery.
|
|
569
|
+
return { ok: true, leafIndex: existingIdx, sequenceNumber: existingIdx, appendedCount: 0 };
|
|
570
|
+
}
|
|
571
|
+
// M8C-ABUSE-1 (reviewer HIGH fix, D18): per-session total-size cap (anti-drip-feed) —
|
|
572
|
+
// "whitelisted senders bounded only by disk" (DoD), so a known contact is exempt entirely.
|
|
573
|
+
// MUST run BEFORE the hold-branch below — the original placement (after it) let a
|
|
574
|
+
// non-contact sender drip-feed unbounded bytes by making every message arrive "out of order"
|
|
575
|
+
// relative to the relay witness (held content skipped the cap entirely, then #releaseHeld
|
|
576
|
+
// appended it later with no re-check). Accounts for bytes already committed AND bytes
|
|
577
|
+
// currently sitting in the hold buffer (multiple held chunks could otherwise each individually
|
|
578
|
+
// pass the check while cumulatively exceeding it once released). Runs BEFORE the M9 screening
|
|
579
|
+
// seam below (cheap + synchronous — fail fast on volume before spending gateway compute on
|
|
580
|
+
// content headed for rejection anyway); both gates are independent and either rejects on its
|
|
581
|
+
// own criteria, so ordering between them does not change correctness.
|
|
582
|
+
{
|
|
583
|
+
// DOD-TIER-2 AC2: the per-session byte cap is the sender's TIER cap (DEFAULT_TIER_BOUNDS),
|
|
584
|
+
// applied to EVERY sender — no tier is unbounded (INV-TIER-BOUND), so a contact is no longer
|
|
585
|
+
// "exempt entirely". A stranger (no row → UNKNOWN) keeps the 25 MB cap; KNOWN+ get more.
|
|
586
|
+
const senderTier = this.#ctx.records.getTier(agentName, senderPubkey);
|
|
587
|
+
const cap = this.#ctx.records.resolveTierBound(agentName, senderTier, "max_bytes");
|
|
588
|
+
const priorTotal = this.#ctx.queries.getReceivedBytesTotal(agentName, sessionId);
|
|
589
|
+
const heldTotal = this.#ctx.held.getHeldBytesTotal(agentName, sessionId);
|
|
590
|
+
if (priorTotal + heldTotal + content.length > cap) {
|
|
591
|
+
this.#ctx.logger.warn("session.content.abuse_bound.session_size_exceeded", {
|
|
592
|
+
sessionId,
|
|
593
|
+
agentName,
|
|
594
|
+
senderPubkey,
|
|
595
|
+
priorTotal,
|
|
596
|
+
heldTotal,
|
|
597
|
+
incoming: content.length,
|
|
598
|
+
cap,
|
|
599
|
+
tier: senderTier,
|
|
600
|
+
correlationId,
|
|
601
|
+
});
|
|
602
|
+
this.#ctx.notices.noteSizeCapRefusal(agentName, sessionId, cap, senderTier);
|
|
603
|
+
return { ok: false, reason: "session_size_limit_exceeded" };
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
// M9-CORE-001: the inbound screening seam (INV-5). Screen here — after the content is proven
|
|
607
|
+
// authentic (hash cross-check) and confirmed not a duplicate, before it is either held for
|
|
608
|
+
// ordering or appended to the agent-facing buffer. This is the SINGLE inbound funnel: direct
|
|
609
|
+
// arrivals, recovered/parked content (daemon recover → here), and held-then-released content
|
|
610
|
+
// (held below, screened now, released already-screened) all pass this point. A non-allow
|
|
611
|
+
// verdict means the content is NOT delivered to the agent: it is not held, not buffered, and
|
|
612
|
+
// no leaf is appended — the message stays un-acked so the sender's TTF/park/retry redelivers
|
|
613
|
+
// it once the gateway is reachable again (DB-001 fail-closed: hold, never expose ungated).
|
|
614
|
+
// DOD-DOC-SCREEN-CLASSIFY-1: a DOCUMENT frame skips the gateway's content screen HERE, and is
|
|
615
|
+
// screened later on text instead of bytes. Every content step is inert or worse for one at this
|
|
616
|
+
// point — the sanitizer's rewrites are deliberately discarded by the funnel below (rewriting a
|
|
617
|
+
// signed envelope destroys it), and language/injection judge a UTF-8 decode of binary. Size stays
|
|
618
|
+
// bounded twice (MAX_DOCUMENT_FRAME_BYTES at classify, the gate's own cap).
|
|
619
|
+
//
|
|
620
|
+
// WHAT IS TRADED, stated plainly: the screen skipped here is fail-CLOSED (a gateway that is down
|
|
621
|
+
// returns a transient block, and the frame is held un-acked for redelivery). Its replacement —
|
|
622
|
+
// the gate's in-process rules, then the semantic screen at `document-inbound.ts` step 7a-bis —
|
|
623
|
+
// is fail-OPEN on that same condition, because holding document convergence hostage to an
|
|
624
|
+
// optional layer breaks a layer that degrades by design. That degradation is LOGGED BY NAME
|
|
625
|
+
// there (`document.inbound.screen.unavailable`); it is not silent, and it is not free.
|
|
626
|
+
//
|
|
627
|
+
// Logged by name so the skip is visible rather than assumed.
|
|
628
|
+
const isDocFrame = this.#ctx.isDocumentFrame?.(content) === true;
|
|
629
|
+
if (isDocFrame) {
|
|
630
|
+
this.#ctx.logger.info("session.content.screen.skipped_document_frame", {
|
|
631
|
+
sessionId,
|
|
632
|
+
agentName,
|
|
633
|
+
correlationId,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
const inboundVerdict = isDocFrame
|
|
637
|
+
? { disposition: "allow", content }
|
|
638
|
+
: await this.#ctx.securityGateway.screenInbound(content, {
|
|
639
|
+
direction: "inbound",
|
|
640
|
+
agentName,
|
|
641
|
+
sessionId,
|
|
642
|
+
correlationId,
|
|
643
|
+
});
|
|
644
|
+
// M9 terminal-vs-transient split. A TERMINAL block (inboundVerdict.terminal) is a detector
|
|
645
|
+
// rejecting the CONTENT itself — a confident non-allowlisted language (IN-003), a high-score
|
|
646
|
+
// injection (IN-002), or an oversized payload (IN-001). The identical bytes would be rejected
|
|
647
|
+
// identically on redelivery, so holding them un-acked would loop the sender forever. Instead a
|
|
648
|
+
// terminal block is `screenedOut`: it records a leaf binding the ORIGINAL content hash and is
|
|
649
|
+
// acknowledged (the sender stops), but is NEVER buffered for the agent (cello_receive never sees
|
|
650
|
+
// it). The leaf is REQUIRED, not cosmetic: the sender appended this leaf at its CANONICAL position
|
|
651
|
+
// on send, so a terminal block must take the SAME strict-in-order path as a delivered message —
|
|
652
|
+
// record the leaf at its canonical index, not in arrival order — or the two parties' hash chains
|
|
653
|
+
// diverge by POSITION and the bilateral seal cross-check mismatches (code-review HIGH-1). The only
|
|
654
|
+
// difference from a normal message is that it leafs WITHOUT buffering. A TRANSIENT block (a
|
|
655
|
+
// fail-closed gateway_unavailable / governance_timeout) records nothing and is not acked.
|
|
656
|
+
const terminalBlock = inboundVerdict.disposition === "block" && inboundVerdict.terminal === true;
|
|
657
|
+
if (inboundVerdict.disposition !== "allow" && inboundVerdict.disposition !== "redact" && !terminalBlock) {
|
|
658
|
+
// TRANSIENT block / warn HOLD (do not deliver, do not leaf, do not ack). The message stays
|
|
659
|
+
// un-acked so the sender's TTF/park/retry redelivers and re-screens it once the gateway recovers.
|
|
660
|
+
// (If we committed a leaf, dedup would later swallow the redelivery and the agent would never
|
|
661
|
+
// receive it.)
|
|
662
|
+
if (inboundVerdict.reason === GOVERNANCE_TIMEOUT) {
|
|
663
|
+
this.#ctx.logger.error("security.gateway.timeout", {
|
|
664
|
+
sessionId,
|
|
665
|
+
reason: inboundVerdict.reason,
|
|
666
|
+
correlationId,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
else if (inboundVerdict.reason === GATEWAY_UNAVAILABLE) {
|
|
670
|
+
this.#ctx.logger.error("security.gateway.unavailable", {
|
|
671
|
+
direction: "inbound",
|
|
672
|
+
reason: inboundVerdict.reason,
|
|
673
|
+
correlationId,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
this.#ctx.logger.warn("security.gateway.inbound.blocked", {
|
|
678
|
+
sessionId,
|
|
679
|
+
disposition: inboundVerdict.disposition,
|
|
680
|
+
reason: inboundVerdict.reason,
|
|
681
|
+
correlationId,
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* DOD-M15-REFUSEDEVIDENCE-1 — **A TRANSIENT BLOCK RETAINS NOTHING, and nothing is lost by
|
|
686
|
+
* that.** Nothing was recorded and, decisively, nothing was ACKNOWLEDGED: the message is still
|
|
687
|
+
* with the sender, whose daemon redelivers it. When the gateway recovers the same bytes are
|
|
688
|
+
* screened, and if they are blocked they are retained then, under the detector's own reason.
|
|
689
|
+
*
|
|
690
|
+
* Retaining here would file a copy of a message that is coming back — a duplicate, not
|
|
691
|
+
* evidence — and it would do so for content nothing has yet judged, once per redelivery
|
|
692
|
+
* attempt, for as long as the gateway stays down.
|
|
693
|
+
*/
|
|
694
|
+
// DOD-M15-NO-SILENT-REFUSAL-1 — a TRANSIENT block, and saying which it is, is the whole
|
|
695
|
+
// value of the notice. Nothing was recorded and nothing was acked, so the sender's daemon
|
|
696
|
+
// redelivers on its own. An operator who reads the silence as delivery, or who asks the
|
|
697
|
+
// counterparty to resend, is acting on the opposite of what happened.
|
|
698
|
+
this.#ctx.notices.noteContentRefusal(agentName, sessionId, inboundVerdict.reason ?? "inbound_screen_blocked", {
|
|
699
|
+
kind: REFUSAL_KINDS.DEFERRED,
|
|
700
|
+
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.",
|
|
701
|
+
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.",
|
|
702
|
+
});
|
|
703
|
+
return { ok: false, reason: inboundVerdict.reason ?? "inbound_screen_blocked" };
|
|
704
|
+
}
|
|
705
|
+
// Assigned only on the terminal-block branch and invoked beside each retention attempt below.
|
|
706
|
+
let noteTerminalBlock;
|
|
707
|
+
if (terminalBlock) {
|
|
708
|
+
this.#ctx.logger.warn("security.gateway.inbound.terminal_block", {
|
|
709
|
+
sessionId,
|
|
710
|
+
disposition: inboundVerdict.disposition,
|
|
711
|
+
reason: inboundVerdict.reason,
|
|
712
|
+
correlationId,
|
|
713
|
+
});
|
|
714
|
+
/**
|
|
715
|
+
* DOD-M15-REFUSEDEVIDENCE-1 — retention for a terminal block happens where its LEAF happens,
|
|
716
|
+
* not here. Two sites below (the hold branch and the in-order append), each writing the
|
|
717
|
+
* quarantine row at the same index as the leaf it accompanies.
|
|
718
|
+
*
|
|
719
|
+
* Not here, deliberately: this point is upstream of the post-screen dedup re-check and the
|
|
720
|
+
* size-cap re-check, either of which can still refuse. Retaining above them would file
|
|
721
|
+
* evidence for a message this call then reports as capped — and the cap path is the one that
|
|
722
|
+
* is ruled NOT to retain.
|
|
723
|
+
*/
|
|
724
|
+
/**
|
|
725
|
+
* DOD-M15-NO-SILENT-REFUSAL-1 — **the moment the product catches the attack it exists to
|
|
726
|
+
* catch, and until now the operator was told nothing about it.**
|
|
727
|
+
*
|
|
728
|
+
* This path is not an error path, which is exactly why it had no notice: the block leafs the
|
|
729
|
+
* original content hash at its canonical position and acknowledges the sender, so nothing
|
|
730
|
+
* fails and nothing loops. The message is simply never handed to the agent. From the
|
|
731
|
+
* operator's chair a message they were expecting never arrives and the record shows a leaf
|
|
732
|
+
* with nothing in it.
|
|
733
|
+
*
|
|
734
|
+
* The notice NEVER carries the blocked content — a screener that can be talked into surfacing
|
|
735
|
+
* what it blocked is not a screener.
|
|
736
|
+
*
|
|
737
|
+
* ⚠️ **THE GUIDANCE USED TO SAY "DO NOT ASK FOR THE ORIGINAL TEXT", AND THAT IS NOW WRONG.**
|
|
738
|
+
* Rewritten rather than deleted, per the claim-comment rule, because the reasoning is what
|
|
739
|
+
* changed and not just the sentence. It rested on the content being unavailable; under
|
|
740
|
+
* `DOD-M15-REFUSEDEVIDENCE-1` it is retained and there is a route that returns it FRAMED. And
|
|
741
|
+
* the friction was never protection: Andre, 2026-09-03 — *"eventually the LLM is going to go
|
|
742
|
+
* searching for it, because human beings are going to direct their LLMs to find it, and it's
|
|
743
|
+
* going to come back and say 'Hey, I found it here, the message says…' — which is far
|
|
744
|
+
* worse."* Withholding the route removes the WARNING from the read, not the read.
|
|
745
|
+
*
|
|
746
|
+
* What survives unchanged: do not turn screening off. That is still the one action that makes
|
|
747
|
+
* things worse, and it is the one the guidance still refuses.
|
|
748
|
+
*/
|
|
749
|
+
/**
|
|
750
|
+
* ⚠️ THE DETECTOR'S OWN REASON SURVIVES — `inbound_screen_blocked` is only the fallback.
|
|
751
|
+
*
|
|
752
|
+
* Invariant 3: a downstream handler must not replace an upstream descriptive error with a
|
|
753
|
+
* generic one. The verdict already says WHICH detector fired — `inbound_language_blocked` and
|
|
754
|
+
* an injection block are different problems with different remedies, and one of them has an
|
|
755
|
+
* operator command that fixes it. Flattening both to `inbound_screen_blocked` would also
|
|
756
|
+
* deduplicate them together, so the second kind would be silent for the life of the session.
|
|
757
|
+
*
|
|
758
|
+
* The gateway's own `guidance` is appended when it has one, for the same reason: it is the
|
|
759
|
+
* half that names the actual command.
|
|
760
|
+
*/
|
|
761
|
+
// `?? "inbound_screen_blocked"` is a floor, not a live branch: every verdict producer in the
|
|
762
|
+
// tree sets `reason`, so today it never fires. It stays because `reason` is optional on the
|
|
763
|
+
// type, and a notice keyed on `undefined` would collapse every future detector into one row.
|
|
764
|
+
/**
|
|
765
|
+
* ⚠️ **DEFERRED UNTIL THE RETENTION HAS ACTUALLY RUN — review F3.** The notice used to be
|
|
766
|
+
* written here, above both append sites, and claimed the message was kept before anything had
|
|
767
|
+
* tried to keep it. It is now a closure invoked beside each `#quarantineRefusedContent` call,
|
|
768
|
+
* carrying that call's own answer.
|
|
769
|
+
*
|
|
770
|
+
* Two paths between here and there deliberately write NO notice now, and both are the better
|
|
771
|
+
* answer: a post-screen dedup means this exact message was already noticed the first time, and
|
|
772
|
+
* a size-cap refusal writes `#noteSizeCapRefusal` instead — which is what actually happened,
|
|
773
|
+
* where before the operator got both stories at once.
|
|
774
|
+
*/
|
|
775
|
+
noteTerminalBlock = (stored) => {
|
|
776
|
+
this.#ctx.notices.noteContentRefusal(agentName, sessionId, inboundVerdict.reason ?? "inbound_screen_blocked", {
|
|
777
|
+
kind: REFUSAL_KINDS.BLOCKED,
|
|
778
|
+
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.",
|
|
779
|
+
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. " +
|
|
780
|
+
retentionSentence(sessionId, stored) +
|
|
781
|
+
(stored === null ? "" : "There is no reason to read it unless you need to show someone, or judge whether this was an attack. ") +
|
|
782
|
+
"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." +
|
|
783
|
+
(inboundVerdict.guidance !== undefined ? ` The detector says: ${inboundVerdict.guidance}` : ""),
|
|
784
|
+
});
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
// M9-IN-001: a `redact` verdict (inbound sanitization) DELIVERS the sanitized text to the agent,
|
|
788
|
+
// while the Merkle leaf still binds the ORIGINAL content hash below — the transcript records what
|
|
789
|
+
// the peer actually sent; the agent sees the sanitized form. `allow` leaves the content unchanged.
|
|
790
|
+
// A terminal block carries the original bytes here only so its leaf binds the right hash; it is
|
|
791
|
+
// never delivered (the screenedOut flag below routes it to a leaf-without-buffer).
|
|
792
|
+
const deliverContent = inboundVerdict.disposition === "redact" && inboundVerdict.content !== undefined
|
|
793
|
+
? inboundVerdict.content
|
|
794
|
+
: content;
|
|
795
|
+
// DOD-M9C-SCREENPASSIVE-1: the log says what the AGENT was told. A wrap and a removal change
|
|
796
|
+
// what the recipient reads, and until now neither left a trace anywhere — an operator asking
|
|
797
|
+
// "why did my agent see a warning on that message?" had nothing to read. `correlationId` is the
|
|
798
|
+
// ingest's own, so the finding, the leaf and the delivery are one story.
|
|
799
|
+
if (inboundVerdict.disposition === "redact") {
|
|
800
|
+
const findings = (inboundVerdict.events ?? [])
|
|
801
|
+
.filter((e) => String(e.category).startsWith("injection:"))
|
|
802
|
+
.map((e) => String(e.category));
|
|
803
|
+
const removals = (inboundVerdict.events ?? [])
|
|
804
|
+
.filter((e) => e.disposition === "redact")
|
|
805
|
+
.map((e) => String(e.category));
|
|
806
|
+
this.#ctx.logger.info("security.screen.inbound.annotated", {
|
|
807
|
+
sessionId, contentHashHex, correlationId,
|
|
808
|
+
findings, removals,
|
|
809
|
+
deliveredBytes: deliverContent.length,
|
|
810
|
+
originalBytes: content.length,
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
// screenInbound above is the ONLY suspension point in this method, and it splits the dedup check
|
|
814
|
+
// (indexOfHash, above) from the leaf append (below). Across that await, two concurrent ingests of
|
|
815
|
+
// the SAME content hash — e.g. a direct retry and a park-recovery racing on reconnect — can BOTH
|
|
816
|
+
// pass the first dedup check before either appends, producing two leaves for one hash
|
|
817
|
+
// (DOD-MSG-5 break → leafIndex≠canonicalSeq → root divergence). So re-check dedup on resume.
|
|
818
|
+
// Everything from here to the append is synchronous (atomic under Node's single thread): the
|
|
819
|
+
// first to resume appends, and the second sees its leaf and dedups.
|
|
820
|
+
//
|
|
821
|
+
// Adding any further await between here and the append reopens the window.
|
|
822
|
+
// DOD-FRONTIER-STRAND-1 AC1: this re-check must use the SAME discriminator as the first one.
|
|
823
|
+
// Left keyed on the content hash it silently re-created the whole defect one branch later --
|
|
824
|
+
// the pre-screen check would correctly let a second identical-but-distinct message through, and
|
|
825
|
+
// then this one would drop it anyway. The race it exists to close is unaffected: two concurrent
|
|
826
|
+
// ingests of a true redelivery share a position, so the second still sees the first's leaf.
|
|
827
|
+
const treeAfterScreen = this.#ctx.getSessionTree(agentName, sessionId);
|
|
828
|
+
const dedupAfterScreen = canonicalSeqIn !== undefined && canonicalSeqIn >= 0
|
|
829
|
+
? (treeAfterScreen.hashAt(canonicalSeqIn) === contentHashHex ? canonicalSeqIn : -1)
|
|
830
|
+
: treeAfterScreen.indexOfHash(contentHashHex);
|
|
831
|
+
if (dedupAfterScreen >= 0) {
|
|
832
|
+
this.#ctx.logger.info("session.content.deduplicated", {
|
|
833
|
+
sessionId,
|
|
834
|
+
contentHashHex,
|
|
835
|
+
sequenceNumber: dedupAfterScreen,
|
|
836
|
+
witnessed: canonicalSeqIn !== undefined,
|
|
837
|
+
phase: "post_screen",
|
|
838
|
+
correlationId,
|
|
839
|
+
});
|
|
840
|
+
return { ok: true, leafIndex: dedupAfterScreen, sequenceNumber: dedupAfterScreen, appendedCount: 0, ...(terminalBlock ? { screenedOut: true } : {}) };
|
|
841
|
+
}
|
|
842
|
+
// M8C-ABUSE-1 (cello-unit-reviewer HIGH fix, post-M9INT-1 merge): re-check the size cap here,
|
|
843
|
+
// in the SAME synchronous window as the dedup re-check above. The original check (before the
|
|
844
|
+
// screenInbound await) used totals that can go stale: two concurrent ingests for the same
|
|
845
|
+
// non-contact session — e.g. a live direct arrival racing a recoverParkedFromRelay pull —
|
|
846
|
+
// could each independently pass the pre-await check using the SAME stale totals, then both
|
|
847
|
+
// append/hold, jointly exceeding the cap. Symmetric to the dedup fix: everything from here to
|
|
848
|
+
// the append/hold branch is synchronous, so whichever call resumes first appends/holds before
|
|
849
|
+
// the second's re-check runs, and the second's freshly-recomputed totals correctly include the
|
|
850
|
+
// first's contribution.
|
|
851
|
+
{
|
|
852
|
+
// DOD-TIER-2 AC2 (re-check): the SAME tier cap as the primary gate above, recomputed in this
|
|
853
|
+
// synchronous window (the totals can go stale across the screenInbound await). Applied to EVERY
|
|
854
|
+
// sender — a contact is no longer exempt (INV-TIER-BOUND). Must mirror the primary gate exactly
|
|
855
|
+
// so a sender can never pass one and fail the other.
|
|
856
|
+
const senderTier = this.#ctx.records.getTier(agentName, senderPubkey);
|
|
857
|
+
const cap = this.#ctx.records.resolveTierBound(agentName, senderTier, "max_bytes");
|
|
858
|
+
const priorTotal = this.#ctx.queries.getReceivedBytesTotal(agentName, sessionId);
|
|
859
|
+
const heldTotal = this.#ctx.held.getHeldBytesTotal(agentName, sessionId);
|
|
860
|
+
if (priorTotal + heldTotal + content.length > cap) {
|
|
861
|
+
this.#ctx.logger.warn("session.content.abuse_bound.session_size_exceeded", {
|
|
862
|
+
sessionId,
|
|
863
|
+
agentName,
|
|
864
|
+
senderPubkey,
|
|
865
|
+
priorTotal,
|
|
866
|
+
heldTotal,
|
|
867
|
+
incoming: content.length,
|
|
868
|
+
cap,
|
|
869
|
+
tier: senderTier,
|
|
870
|
+
correlationId,
|
|
871
|
+
recheck: true,
|
|
872
|
+
});
|
|
873
|
+
this.#ctx.notices.noteSizeCapRefusal(agentName, sessionId, cap, senderTier);
|
|
874
|
+
return { ok: false, reason: "session_size_limit_exceeded" };
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
// DOD-MSG-4 (strict in-order gate): the RELAY is the ordering authority. If B holds the
|
|
878
|
+
// canonical sequence for this hash (witnessed via leaf_deliver) and it is AHEAD of the next
|
|
879
|
+
// expected leaf, HOLD the content rather than append it out of order. The missing in-between
|
|
880
|
+
// sequence(s) are recovered from the relay mailbox; #releaseHeld then drains the held entries
|
|
881
|
+
// in canonical order. This keeps the daemon-owned leaf index === the canonical sequence by
|
|
882
|
+
// construction, so two parties' roots match even when direct delivery and park-recovery
|
|
883
|
+
// interleave. With NO witness for this hash (relay-degraded) B falls back to arrival-order
|
|
884
|
+
// append — the pre-MSG-4 behavior (no ordering signal available).
|
|
885
|
+
const key = this.#ctx.sessionKey(agentName, sessionId);
|
|
886
|
+
// Prefer the position the CALLER verified for this specific message over the hash-keyed map.
|
|
887
|
+
// The map cannot distinguish two identical messages (AC1) -- it holds one entry per hash, so the
|
|
888
|
+
// second firing overwrites the first's position. The explicit value is per-message and correct;
|
|
889
|
+
// the map remains the fallback for paths that have no ordering record.
|
|
890
|
+
const canonicalSeq = canonicalSeqIn !== undefined && canonicalSeqIn >= 0
|
|
891
|
+
? canonicalSeqIn
|
|
892
|
+
: this.#ctx.witnessedSeq.get(key)?.get(contentHashHex);
|
|
893
|
+
const nextExpected = this.#ctx.getSessionTree(agentName, sessionId).size();
|
|
894
|
+
if (canonicalSeq !== undefined && canonicalSeq > nextExpected) {
|
|
895
|
+
this.#ctx.held.ensureHeldRestored(agentName, sessionId);
|
|
896
|
+
let held = this.#ctx.heldContent.get(key);
|
|
897
|
+
if (!held) {
|
|
898
|
+
held = new Map();
|
|
899
|
+
this.#ctx.heldContent.set(key, held);
|
|
900
|
+
}
|
|
901
|
+
// A terminal block out of canonical order is held WITHOUT delivery (screenedOut): #releaseHeld
|
|
902
|
+
// leafs it at its canonical index when the gap fills, but never buffers it for the agent. This
|
|
903
|
+
// keeps leafIndex === canonicalSeq for screened-out content too (code-review HIGH-1).
|
|
904
|
+
// THE PEER'S RAW BYTES RIDE ALONG. Classification (document frame vs conversation) reads
|
|
905
|
+
// byte 0, and `deliverContent` is the SCREENED copy — for a CBOR frame that is no longer a
|
|
906
|
+
// map header, so a held document frame was released into the CONVERSATION path: transcript,
|
|
907
|
+
// doorbell, and `cello_receive` handing an agent raw CBOR as though a person typed it.
|
|
908
|
+
// The in-order path has always passed these bytes; only the held path dropped them.
|
|
909
|
+
held.set(canonicalSeq, { content: deliverContent, originalContent: content, contentHashHex, correlationId, ...(terminalBlock ? { screenedOut: true } : {}) });
|
|
910
|
+
// DOD-M12B-STRAND-1: and to disk, before we answer. The in-memory Map is the working copy;
|
|
911
|
+
// this row is the one that survives the teardown that used to destroy it.
|
|
912
|
+
this.#ctx.queries.persistHeldContent(agentName, sessionId, canonicalSeq, deliverContent, content, contentHashHex, terminalBlock === true, correlationId);
|
|
913
|
+
// DOD-M15-REFUSEDEVIDENCE-1 (site 1 of 2 for a terminal block): a block held behind an
|
|
914
|
+
// ordering gap. `#releaseHeld` appends its leaf later WITHOUT re-entering this method, so
|
|
915
|
+
// retaining at release is not available — it is retained here, at the position the leaf will
|
|
916
|
+
// take. `held_content` is not a substitute: that row is deleted the moment the gap fills.
|
|
917
|
+
if (terminalBlock) {
|
|
918
|
+
const keptHeld = this.#ctx.refusals.quarantineRefusedContent(agentName, sessionId, inboundVerdict.reason ?? "inbound_screen_blocked", content, contentHashHex, {
|
|
919
|
+
senderPubkeyHex: senderPubkey, canonicalSeq,
|
|
920
|
+
...(verifiedAuthorship ? { authorship: verifiedAuthorship } : {}),
|
|
921
|
+
correlationId,
|
|
922
|
+
});
|
|
923
|
+
noteTerminalBlock?.(keptHeld);
|
|
924
|
+
}
|
|
925
|
+
this.#ctx.logger.info("session.content.held", {
|
|
926
|
+
sessionId,
|
|
927
|
+
canonicalSeq,
|
|
928
|
+
nextExpected,
|
|
929
|
+
gap: canonicalSeq - nextExpected,
|
|
930
|
+
screenedOut: terminalBlock,
|
|
931
|
+
correlationId,
|
|
932
|
+
});
|
|
933
|
+
// Held content is NOT yet a durable leaf, so it is deliberately NOT acknowledged `persisted`
|
|
934
|
+
// (the caller checks `held`). The sender's TTF→park backstop and the recover/dedup path
|
|
935
|
+
// guarantee eventual delivery; B never claims persisted for content it only holds in memory.
|
|
936
|
+
return { ok: true, leafIndex: canonicalSeq, sequenceNumber: canonicalSeq, held: true, ...(terminalBlock ? { screenedOut: true } : {}) };
|
|
937
|
+
}
|
|
938
|
+
if (canonicalSeq !== undefined && canonicalSeq < nextExpected) {
|
|
939
|
+
// Contradiction (review finding #2): the witness says this hash belongs BEHIND the current
|
|
940
|
+
// tree, yet the dedup scan above found no existing leaf for it — so it is neither a duplicate
|
|
941
|
+
// nor in canonical order. This is only reachable via the accepted content-before-witness /
|
|
942
|
+
// relay-degraded interleaving (the next sub-increment's pending-witness buffer closes it). Log
|
|
943
|
+
// it loudly (the leaf-index===sequence invariant is at risk) and append rather than DROP the
|
|
944
|
+
// message — losing content is worse than a transient mis-order the seal cross-check will catch.
|
|
945
|
+
this.#ctx.logger.warn("session.content.sequence_behind_tree", {
|
|
946
|
+
sessionId,
|
|
947
|
+
canonicalSeq,
|
|
948
|
+
nextExpected,
|
|
949
|
+
correlationId,
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
// In-order append. A terminal block leafs the ORIGINAL content hash WITHOUT buffering it for the
|
|
953
|
+
// agent (screenedOut); a delivered message buffers + leafs via #appendVerifiedContent.
|
|
954
|
+
const leafIndex = terminalBlock
|
|
955
|
+
? this.#ctx.appendSessionLeaf(agentName, sessionId, "msg", contentHashHex, correlationId).leafIndex
|
|
956
|
+
: this.appendVerifiedContent(agentName, sessionId, deliverContent, contentHashHex, senderPubkey, correlationId, content, verifiedAuthorship).leafIndex;
|
|
957
|
+
/**
|
|
958
|
+
* DOD-M15-REFUSEDEVIDENCE-1 (site 2 of 2) — **the moment the product catches the attack it
|
|
959
|
+
* exists to catch, and until now it kept only the hash.**
|
|
960
|
+
*
|
|
961
|
+
* The terminal-block branch above takes `appendSessionLeaf`, not `#appendVerifiedContent`, so
|
|
962
|
+
* the row carrying the plaintext, the sender's key and the sender's signature was never written.
|
|
963
|
+
* A hash proves a message you still hold has not changed; it proves nothing about one you threw
|
|
964
|
+
* away — and this is precisely the message an operator would most want to produce.
|
|
965
|
+
*
|
|
966
|
+
* At `leafIndex`, so the leaf and the evidence describe one event and DoD 7's leaf placement is
|
|
967
|
+
* untouched. The ORIGINAL bytes, never the sanitized `deliverContent`: evidence is what they
|
|
968
|
+
* sent, not what a filter made of it.
|
|
969
|
+
*/
|
|
970
|
+
if (terminalBlock) {
|
|
971
|
+
const keptBlocked = this.#ctx.refusals.quarantineRefusedContent(agentName, sessionId, inboundVerdict.reason ?? "inbound_screen_blocked", content, contentHashHex, {
|
|
972
|
+
senderPubkeyHex: senderPubkey, canonicalSeq: leafIndex,
|
|
973
|
+
...(verifiedAuthorship ? { authorship: verifiedAuthorship } : {}),
|
|
974
|
+
correlationId,
|
|
975
|
+
});
|
|
976
|
+
noteTerminalBlock?.(keptBlocked);
|
|
977
|
+
/**
|
|
978
|
+
* ⚠️ **DROP THE WITNESS — A BLOCKED MESSAGE MADE THE SESSION PERMANENTLY UNSEALABLE.**
|
|
979
|
+
*
|
|
980
|
+
* THE THIRD INSTANCE of the shape already fixed for document frames at `:10593`, found by the
|
|
981
|
+
* first journey that ever sealed a session after a screener block.
|
|
982
|
+
*
|
|
983
|
+
* `sealReadiness` derives `missingLeaves` from `#witnessedSeq.size` — every position the
|
|
984
|
+
* ordering authority committed that this tree has not appended. The entry is dropped where the
|
|
985
|
+
* leaf is credited, and that drop lives inside `#appendVerifiedContent`. A terminal block does
|
|
986
|
+
* not go through it: the branch above takes `appendSessionLeaf` directly, so the leaf WAS
|
|
987
|
+
* committed and the witness was never retired.
|
|
988
|
+
*
|
|
989
|
+
* **From the operator's chair:** their screener catches one hostile message, and from that
|
|
990
|
+
* moment `cello_close_session` answers `session_incomplete` forever — *"waiting on an earlier
|
|
991
|
+
* message from the counterparty that has not arrived"* — about a message that arrived, was
|
|
992
|
+
* judged, and is sitting in the chain. The only exit is a force-abandon, which forfeits the
|
|
993
|
+
* notarized receipt. Measured live: `treeSize 3, highWaterSeq 2, missingLeaves 1`.
|
|
994
|
+
*
|
|
995
|
+
* Not introduced by `DOD-M15-REFUSEDEVIDENCE-1` — it is older than this unit and simply had no
|
|
996
|
+
* test that both blocked a message and then sealed. It is fixed here because this unit's own
|
|
997
|
+
* DoD requires that session to seal.
|
|
998
|
+
*/
|
|
999
|
+
this.#ctx.witnessedSeq.get(key)?.delete(contentHashHex);
|
|
1000
|
+
}
|
|
1001
|
+
// DOD-COATTEND-1 (review F2): the plaintext failed to reach the transcript, and since Tier 1 the
|
|
1002
|
+
// transcript IS the delivery path — so this message can never be handed to any session. Report
|
|
1003
|
+
// the ingest as failed. Reporting `ok: true` here is what let a local SQLCipher failure surface,
|
|
1004
|
+
// 30 seconds later and one subsystem away, as "no content arrived — keep waiting": the operator
|
|
1005
|
+
// is sent to debug a counterparty who did nothing wrong.
|
|
1006
|
+
//
|
|
1007
|
+
// The leaf STAYS. It is genuinely committed to the hash chain, and unwinding a committed leaf to
|
|
1008
|
+
// tidy up a reporting problem would corrupt the frontier the counterparty already co-signs
|
|
1009
|
+
// against. The hole is now crossable by delivery (F1), so it costs a gap, not a stall.
|
|
1010
|
+
if (!terminalBlock && this.getUndeliverableSeqs(agentName, sessionId).includes(leafIndex)) {
|
|
1011
|
+
/**
|
|
1012
|
+
* DOD-M15-REFUSEDEVIDENCE-1 — **THIS PATH CANNOT RETAIN, because the storage layer is what
|
|
1013
|
+
* just failed.** The write that would keep the evidence is the same `INSERT` into the same
|
|
1014
|
+
* table that has already thrown for this message. Attempting it produces a second error line
|
|
1015
|
+
* and no evidence. Named here rather than left to be rediscovered as a missing case.
|
|
1016
|
+
*/
|
|
1017
|
+
// DOD-M15-NO-SILENT-REFUSAL-1. `#appendVerifiedContent` already noted `content_undeliverable`
|
|
1018
|
+
// at the point the write failed; this is the INGEST's own refusal, and it is a different fact
|
|
1019
|
+
// — the sender is told the ingest failed, so it will redeliver, and every redelivery of the
|
|
1020
|
+
// same hash now dedups against a leaf whose plaintext is not there. Two reasons, because a
|
|
1021
|
+
// reader fixing the disk fault needs to know both that the text is gone and that the sender
|
|
1022
|
+
// is retrying into a hole.
|
|
1023
|
+
this.#ctx.notices.noteContentRefusal(agentName, sessionId, "transcript_write_failed", {
|
|
1024
|
+
kind: REFUSAL_KINDS.LOST,
|
|
1025
|
+
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.",
|
|
1026
|
+
/**
|
|
1027
|
+
* ⚠️ THE READER IS USUALLY ALREADY IN A CODING AGENT, so the guidance says GO AND LOOK
|
|
1028
|
+
* rather than listing symptoms. Andre, 2026-09-03: *"The message should mention to try and
|
|
1029
|
+
* figure out why you cannot store it — it is likely a local machine problem. But if you
|
|
1030
|
+
* truly cannot figure this out using a coding agent, then we advise reaching out to
|
|
1031
|
+
* CELLO_Support."*
|
|
1032
|
+
*
|
|
1033
|
+
* That ordering matters: this is a machine fault with an ordinary cause, and an operator
|
|
1034
|
+
* sent straight to support for a full disk has been wasted. Support is the exit, not the
|
|
1035
|
+
* first step.
|
|
1036
|
+
*/
|
|
1037
|
+
guidance: "Find out why the write failed — this is almost always something ordinary on this machine. " +
|
|
1038
|
+
"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. " +
|
|
1039
|
+
"Waiting cannot recover the message. Once the fault is fixed, ask them to resend — the text is gone and only its hash remains. " +
|
|
1040
|
+
"If you genuinely cannot work out the cause, reach out to CELLO_Support.",
|
|
1041
|
+
});
|
|
1042
|
+
return { ok: false, reason: "transcript_write_failed" };
|
|
1043
|
+
}
|
|
1044
|
+
// NO relay witness for this hash. We appended it anyway — refusing would make the relay a hard
|
|
1045
|
+
// precondition for reading mail, so a relay outage would render the inbox unreadable, and the
|
|
1046
|
+
// direct path and park backstop exist precisely to survive that. But this append is a WEAKER
|
|
1047
|
+
// guarantee and must not masquerade as the stronger one: with a witness, the received content is
|
|
1048
|
+
// checked against a hash the sender committed to a third party; without one, the only available
|
|
1049
|
+
// hash rode in the same frame as the content, so the check is the sender's claim against the
|
|
1050
|
+
// sender's own claim. Say so. A sender who simply never submits to the relay is otherwise
|
|
1051
|
+
// indistinguishable from one the relay merely has not witnessed YET.
|
|
1052
|
+
// The relay witness is an INDEPENDENT attestation: a (content_hash → sequence) binding derived
|
|
1053
|
+
// from the sender's own signed leaf. Holding one, we check received content against a hash the
|
|
1054
|
+
// sender committed to a THIRD PARTY. Holding none, the only hash available rode in the same frame
|
|
1055
|
+
// as the content — the sender's claim checked against the sender's claim.
|
|
1056
|
+
//
|
|
1057
|
+
// Unwitnessed content is still ingested. Refusing it would make the relay a precondition for
|
|
1058
|
+
// READING mail, so a relay outage would render the inbox unreadable — the redundancy the direct
|
|
1059
|
+
// path and the park backstop exist to provide.
|
|
1060
|
+
//
|
|
1061
|
+
// Warn ONLY when a witness was EXPECTED. A session with no relay attached has no witness BY
|
|
1062
|
+
// DESIGN, and warning on every message there would bury the one case that means something —
|
|
1063
|
+
// a relay IS attached, so the sender's leaf should have been submitted and witnessed, and it
|
|
1064
|
+
// was not. A signal that fires on the normal case is not a signal.
|
|
1065
|
+
if (canonicalSeq === undefined && this.#ctx.activeNodes.get(key)?.relayClient) {
|
|
1066
|
+
this.#ctx.logger.warn("session.content.unwitnessed", {
|
|
1067
|
+
agentName,
|
|
1068
|
+
sessionId,
|
|
1069
|
+
leafIndex,
|
|
1070
|
+
contentHash: contentHashHex,
|
|
1071
|
+
correlationId,
|
|
1072
|
+
guidance: "a relay is attached to this session but no witness bound this content hash — it was ingested with no independent commitment from the sender",
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
// A just-appended leaf may unblock held out-of-order arrivals whose turn is now next.
|
|
1076
|
+
// appendedCount = this leaf + any held leaves released by it, so a caller (recover) can tally the
|
|
1077
|
+
// leaves ACTUALLY written, not just the directly-ingested one (review #3).
|
|
1078
|
+
/**
|
|
1079
|
+
* ─── THE SENDER'S SELF CHAIN ADVANCES HERE, WHERE EVERY ROUTE PASSES ─────────────────────────
|
|
1080
|
+
*
|
|
1081
|
+
* `noteReceivedFromCounterparty` records "the last message we accepted from them", which is what
|
|
1082
|
+
* `#verifySenderSelfChain` compares their NEXT message's `prevOwnHash` against.
|
|
1083
|
+
*
|
|
1084
|
+
* It used to be called by the live content-frame caller only, so a message recovered from the
|
|
1085
|
+
* relay mailbox was ingested without advancing it. Measured live 2026-09-16: a machine came back
|
|
1086
|
+
* from a blackout, took both parked messages, and the SECOND one named the first as its
|
|
1087
|
+
* predecessor — which this side had just accepted but not recorded. The chain check called it a
|
|
1088
|
+
* predecessor we do not hold, the ordering record was thrown away, and the seal answer told the
|
|
1089
|
+
* operator their copy did not match a seal that was correct.
|
|
1090
|
+
*
|
|
1091
|
+
* Here it is on the one path every route ends at, and only for a leaf actually appended: a HELD
|
|
1092
|
+
* frame returns earlier (it is not accepted yet), and so does a DEDUP hit — which is also a fix,
|
|
1093
|
+
* because advancing on a duplicate would rewind the head to an older message when a late copy
|
|
1094
|
+
* arrives. Both callers verify the chain BEFORE they call this, so the comparison still happens
|
|
1095
|
+
* against the previous message and never against this one.
|
|
1096
|
+
*/
|
|
1097
|
+
this.#ctx.authorship.noteReceivedFromCounterparty(agentName, sessionId, contentHash);
|
|
1098
|
+
const released = this.#ctx.held.releaseHeld(agentName, sessionId, senderPubkey);
|
|
1099
|
+
return { ok: true, leafIndex, sequenceNumber: leafIndex, appendedCount: 1 + released, ...(terminalBlock ? { screenedOut: true } : {}) };
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* DOD-MSG-4: record the relay-witnessed canonical sequence for a content hash. The relay is the
|
|
1103
|
+
* ordering authority (Structure 2): it assigns each message a sequence from its hash and delivers
|
|
1104
|
+
* B the (content_hash -> sequence) binding via leaf_deliver. The strict-in-order gate orders the
|
|
1105
|
+
* transcript by THIS — never a sender-stamped field. Also advances the per-session high-water mark
|
|
1106
|
+
* (the largest witnessed sequence) reserved for the future catch-up-before-live increment. Idempotent.
|
|
1107
|
+
*/
|
|
1108
|
+
recordWitnessedSequence(agentName, sessionId, contentHashHex, sequenceNumber) {
|
|
1109
|
+
if (sequenceNumber < 0)
|
|
1110
|
+
return;
|
|
1111
|
+
const key = this.#ctx.sessionKey(agentName, sessionId);
|
|
1112
|
+
// DOD-M12B-SEAL-STUCK-1: this process has now seen this session's ordering state, so an empty
|
|
1113
|
+
// witness map for it means "no gap" rather than "never looked".
|
|
1114
|
+
this.#ctx.orderingObserved.add(key);
|
|
1115
|
+
const hw = this.#ctx.highWaterSeq.get(key) ?? -1;
|
|
1116
|
+
if (sequenceNumber > hw)
|
|
1117
|
+
this.#ctx.highWaterSeq.set(key, sequenceNumber);
|
|
1118
|
+
// ALREADY PLACED: the message beat the relay's copy on the direct path. The entry is retired
|
|
1119
|
+
// when a leaf is appended, so recording it now would never be retired, and every close would
|
|
1120
|
+
// answer `session_incomplete` until force-abandon — live on `0a534851…`, 2026-09-13.
|
|
1121
|
+
if (this.#ctx.getSessionTree(agentName, sessionId).hashAt(sequenceNumber) === contentHashHex)
|
|
1122
|
+
return;
|
|
1123
|
+
let map = this.#ctx.witnessedSeq.get(key);
|
|
1124
|
+
if (!map) {
|
|
1125
|
+
map = new Map();
|
|
1126
|
+
this.#ctx.witnessedSeq.set(key, map);
|
|
1127
|
+
}
|
|
1128
|
+
map.set(contentHashHex, sequenceNumber);
|
|
1129
|
+
/**
|
|
1130
|
+
* DOD-M12B-LEAF-TRIGGERS-FETCH-1 — A LEAF WE CANNOT READ IS A FETCH ORDER.
|
|
1131
|
+
*
|
|
1132
|
+
* MEASURED LIVE 2026-08-18: the relay delivered this leaf one second after the counterparty
|
|
1133
|
+
* sent. We had the hash and the sequence, the bytes were parked at that same relay, and the
|
|
1134
|
+
* plaintext arrived 102 seconds later on a background sweep. Nothing connected the two facts —
|
|
1135
|
+
* this method recorded the sequence and stopped.
|
|
1136
|
+
*
|
|
1137
|
+
* The witness leaf and the plaintext are separate deliveries: the leaf comes over the relay, the
|
|
1138
|
+
* bytes over the direct content stream. After an interruption the two session nodes have no
|
|
1139
|
+
* direct connection, so the bytes go to the park instead and only a timer ever finds them.
|
|
1140
|
+
*
|
|
1141
|
+
* The grace window is what keeps this off the hot path. On a healthy session the direct content
|
|
1142
|
+
* lands within milliseconds of its leaf, so fetching immediately would mean a relay round trip
|
|
1143
|
+
* for every message in every session. We give the direct path its two seconds first.
|
|
1144
|
+
*/
|
|
1145
|
+
this.#scheduleLeafFetchIfUnresolved(agentName, sessionId, contentHashHex);
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Cancel a pending leaf fetch for one piece of content. The TIMERS stay the manager's — it has
|
|
1149
|
+
* three users and only one of them moved — so `#markContentTerminallyRefused`, now in
|
|
1150
|
+
* `inbound-refusals.ts`, asks for the cancellation instead of reaching into the map.
|
|
1151
|
+
*/
|
|
1152
|
+
cancelLeafFetch(key, contentHashHex) {
|
|
1153
|
+
const timerKey = `${key}::${contentHashHex}`;
|
|
1154
|
+
const t = this.#ctx.leafFetchTimers.get(timerKey);
|
|
1155
|
+
if (t !== undefined) {
|
|
1156
|
+
clearTimeout(t);
|
|
1157
|
+
this.#ctx.leafFetchTimers.delete(timerKey);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
/** DOD-M12B-LEAF-TRIGGERS-FETCH-1: this content is here — no fetch is owed for it, and any
|
|
1161
|
+
* pending one is cancelled. Called wherever content actually lands. */
|
|
1162
|
+
markContentResolved(agentName, sessionId, contentHashHex) {
|
|
1163
|
+
const key = this.#ctx.sessionKey(agentName, sessionId);
|
|
1164
|
+
let set = this.#ctx.resolvedContent.get(key);
|
|
1165
|
+
if (!set) {
|
|
1166
|
+
set = new Set();
|
|
1167
|
+
this.#ctx.resolvedContent.set(key, set);
|
|
1168
|
+
}
|
|
1169
|
+
set.add(contentHashHex);
|
|
1170
|
+
const timerKey = `${key}::${contentHashHex}`;
|
|
1171
|
+
const t = this.#ctx.leafFetchTimers.get(timerKey);
|
|
1172
|
+
if (t !== undefined) {
|
|
1173
|
+
clearTimeout(t);
|
|
1174
|
+
this.#ctx.leafFetchTimers.delete(timerKey);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
#scheduleLeafFetchIfUnresolved(agentName, sessionId, contentHashHex) {
|
|
1178
|
+
const key = this.#ctx.sessionKey(agentName, sessionId);
|
|
1179
|
+
if (this.#ctx.resolvedContent.get(key)?.has(contentHashHex))
|
|
1180
|
+
return;
|
|
1181
|
+
// DOD-M15-REFUSALTERMINAL-1: a refusal nothing can get past is the end of the work, not a
|
|
1182
|
+
// reason to come back in two seconds.
|
|
1183
|
+
if (this.#ctx.refusals.isTerminallyRefused(agentName, sessionId, contentHashHex))
|
|
1184
|
+
return;
|
|
1185
|
+
const timerKey = `${key}::${contentHashHex}`;
|
|
1186
|
+
// ONE fetch per content hash. The relay redelivers, and a redelivery carries the same sequence —
|
|
1187
|
+
// scheduling per redelivery turns a slow relay into a storm against itself.
|
|
1188
|
+
if (this.#ctx.leafFetchTimers.has(timerKey))
|
|
1189
|
+
return;
|
|
1190
|
+
const timer = setTimeout(() => {
|
|
1191
|
+
this.#ctx.leafFetchTimers.delete(timerKey);
|
|
1192
|
+
if (this.#ctx.resolvedContent.get(key)?.has(contentHashHex))
|
|
1193
|
+
return; // the direct path won
|
|
1194
|
+
if (this.#ctx.shuttingDown)
|
|
1195
|
+
return;
|
|
1196
|
+
this.#ctx.logger.info("session.content.leaf_unresolved.fetch", {
|
|
1197
|
+
agentName,
|
|
1198
|
+
sessionId,
|
|
1199
|
+
contentHash: contentHashHex,
|
|
1200
|
+
graceMs: this.#ctx.leafFetchGraceMs,
|
|
1201
|
+
impact: "the relay told us this message exists and its plaintext never arrived directly — "
|
|
1202
|
+
+ "fetching it now instead of waiting for the periodic sweep",
|
|
1203
|
+
});
|
|
1204
|
+
this.#ctx.park.fireParkedDrain(agentName, "witnessed_leaf_unresolved");
|
|
1205
|
+
}, this.#ctx.leafFetchGraceMs);
|
|
1206
|
+
timer.unref?.();
|
|
1207
|
+
this.#ctx.leafFetchTimers.set(timerKey, timer);
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* DOD-MSG-4: the relay's high-water canonical sequence for this session (largest witnessed leaf),
|
|
1211
|
+
* or -1 if none. The relay is the ordering authority, so this is the outside view of how far the
|
|
1212
|
+
* session has actually progressed — which is why it is the right input to a catch-up-before-live
|
|
1213
|
+
* gate. Consumed by `sealReadiness` (M12-P14) for REPORTING only: the missing-leaf decision is made
|
|
1214
|
+
* from `#witnessedSeq`, because this counts the relay's sequence space (which includes ctrl leaves)
|
|
1215
|
+
* and the tree does not. Maintained by `recordWitnessedSequence`.
|
|
1216
|
+
*/
|
|
1217
|
+
/**
|
|
1218
|
+
* DOD-COATTEND-1 (review F2): leaf sequences whose plaintext failed to reach the transcript and
|
|
1219
|
+
* are therefore undeliverable. Empty is the overwhelmingly normal case.
|
|
1220
|
+
*/
|
|
1221
|
+
getUndeliverableSeqs(agentName, sessionId) {
|
|
1222
|
+
return [...(this.#ctx.undeliverableSeqs.get(this.#ctx.sessionKey(agentName, sessionId)) ?? [])];
|
|
1223
|
+
}
|
|
1224
|
+
/** DOD-MSG-4 / DAEMON-004: append a verified message leaf and buffer it for cello_receive. */
|
|
1225
|
+
appendVerifiedContent(agentName, sessionId, content, contentHashHex, senderPubkey, correlationId,
|
|
1226
|
+
/**
|
|
1227
|
+
* The bytes as the PEER SENT THEM, before inbound sanitization — for the document classifier
|
|
1228
|
+
* only. Defaults to `content` for callers that never screened (the held-release path).
|
|
1229
|
+
*
|
|
1230
|
+
* A `redact` verdict rewrites `content` for the agent's benefit, and that is right for
|
|
1231
|
+
* conversation: the operator sees the sanitized form while the leaf still binds the original.
|
|
1232
|
+
* It is WRONG for a document frame, and not marginally. Rewriting bytes inside a signed CBOR
|
|
1233
|
+
* envelope does not sanitize it — it destroys it. The frame stops decoding, stops being
|
|
1234
|
+
* recognised as document traffic at all, and falls through to the conversation path, where it
|
|
1235
|
+
* is recorded as something a person said and handed to the agent by `cello_receive`.
|
|
1236
|
+
*
|
|
1237
|
+
* Measured live: roughly half of proposals vanished this way. Intermittent because a proposal
|
|
1238
|
+
* carries a random 16-byte nonce, so whether its bytes trip a sanitizer rule varies per run —
|
|
1239
|
+
* which is why it read as flakiness rather than as a rule firing.
|
|
1240
|
+
*
|
|
1241
|
+
* Documents are NOT unscreened as a result. They are screened by `DocumentGate`, which is built
|
|
1242
|
+
* for them and REFUSES rather than mutates (§16.7) — because mutating one party's replica of a
|
|
1243
|
+
* CRDT is not a false positive, it is permanent divergence that both sides converge on and
|
|
1244
|
+
* neither can see.
|
|
1245
|
+
*/
|
|
1246
|
+
originalContent,
|
|
1247
|
+
/**
|
|
1248
|
+
* DOD-M15-SEALWIRE-1 bullet 5: threaded from `ingestReceivedContent`, which is the only place
|
|
1249
|
+
* that has it — `#verifyAuthorshipClaim` verified this signature (carried on the frame beside
|
|
1250
|
+
* the bytes it signs) against the pubkey inside those bytes, and matched the signer to this
|
|
1251
|
+
* session's counterparty. It reaches the transcript row from here or not at all.
|
|
1252
|
+
*
|
|
1253
|
+
* ⚠️ IT USED TO NAME `#recordFrameOrdering`, true until `DOD-M15-AUTHORSHIP-ABSENT-1` moved the
|
|
1254
|
+
* check off the relay's record and onto the frame's own signature. Rewritten rather than
|
|
1255
|
+
* deleted: the old name is the evidence of what authorship used to depend on.
|
|
1256
|
+
*
|
|
1257
|
+
* Undefined on the held-release and soft-fallback paths; the row records that as
|
|
1258
|
+
* `local_session_state` rather than leaving it indistinguishable from a proven one.
|
|
1259
|
+
*/
|
|
1260
|
+
verifiedAuthorship) {
|
|
1261
|
+
// M14 / DOD-DOC-INBOUND-2 — DOCUMENT FRAMES DIVERGE HERE, and the three-way split is the whole
|
|
1262
|
+
// contract:
|
|
1263
|
+
//
|
|
1264
|
+
// LEAF yes, and as `doc` (0x04) rather than `msg` (0x00). The seal covers document traffic
|
|
1265
|
+
// — that is what makes the exchange provable — but it is not conversation, and the
|
|
1266
|
+
// leaf kind is what a verifier renders it by.
|
|
1267
|
+
// TRANSCRIPT no. Recording CRDT bytes as a received message puts them in the operator's
|
|
1268
|
+
// conversation history, where `cello_receive` hands them to an agent as something a
|
|
1269
|
+
// person said.
|
|
1270
|
+
// DOORBELL no (§11.3). A collaborator typing produces a stream of updates; a doorbell each
|
|
1271
|
+
// time would interrupt the operator's agent continuously for something with no
|
|
1272
|
+
// deadline.
|
|
1273
|
+
//
|
|
1274
|
+
// The hook is injected and absent by default, so a daemon without the document layer behaves
|
|
1275
|
+
// exactly as before — this cannot change the conversation path by being unwired.
|
|
1276
|
+
const routed = this.#ctx.onDocumentFrame?.(agentName, sessionId,
|
|
1277
|
+
// THE PEER'S BYTES, not the sanitized ones. See `originalContent` above.
|
|
1278
|
+
originalContent ?? content, senderPubkey, correlationId);
|
|
1279
|
+
if (routed?.consumed === true) {
|
|
1280
|
+
const { leafIndex } = this.#ctx.appendSessionLeaf(agentName, sessionId, "doc", contentHashHex, correlationId);
|
|
1281
|
+
// DROP THE WITNESS, exactly as the conversation branch does once its leaf is appended. The
|
|
1282
|
+
// witness has done its ordering job either way — the leaf IS committed here.
|
|
1283
|
+
//
|
|
1284
|
+
// This branch returns early and so never reached that cleanup, and every inbound document
|
|
1285
|
+
// frame left a permanent entry behind. Harmless until `sealReadiness` started deriving
|
|
1286
|
+
// `missingLeaves` from the size of that map (M12-P14): from then on a session that carried
|
|
1287
|
+
// ANY document traffic could never seal, because the ordering authority was recorded as
|
|
1288
|
+
// having committed leaves this tree had — but had not been credited with. The refusal is
|
|
1289
|
+
// `session_incomplete`, whose only escape is a force-abandon with no notarized receipt.
|
|
1290
|
+
//
|
|
1291
|
+
// Two correct changes, each fine alone, that break where they meet. Caught by running the
|
|
1292
|
+
// live enforcers straight after merging main rather than trusting a green unit suite.
|
|
1293
|
+
this.#ctx.witnessedSeq.get(this.#ctx.sessionKey(agentName, sessionId))?.delete(contentHashHex);
|
|
1294
|
+
/**
|
|
1295
|
+
* ⚠️ THIS LINE USED TO LOG `ok: routed.ok` AND `reason: routed.reason`, AND NEITHER CAN EVER
|
|
1296
|
+
* BE PRESENT HERE. Removed rather than left, because their absence was read as evidence.
|
|
1297
|
+
*
|
|
1298
|
+
* The producer is `DocumentFrameRouter.routeSync`, and it has four returns — `unshaped`,
|
|
1299
|
+
* `undecodable`, `owner_unresolved`, and the normal path — **none of which sets either
|
|
1300
|
+
* field.** It cannot: the normal path is `void this.#enqueue(...)`, fire-and-forget, so at the
|
|
1301
|
+
* instant this line is written the frame has been CLASSIFIED and QUEUED and nothing has yet
|
|
1302
|
+
* decided whether it will be accepted. The verdict is genuinely not knowable here.
|
|
1303
|
+
*
|
|
1304
|
+
* **What that cost:** `j-stale-session` reported `framesReceived=3 inbound=0`, and the
|
|
1305
|
+
* investigation recorded that `ok` and `reason` were "ABSENT from every line in the run — so
|
|
1306
|
+
* the router returned neither, which is itself the next thread to pull: a routing result that
|
|
1307
|
+
* reports no outcome cannot say whether it accepted or dropped the frame." That thread leads
|
|
1308
|
+
* nowhere. The router did not fail to report an outcome; **it has no outcome to report at this
|
|
1309
|
+
* point in the flow**, and a JSON logger omits an `undefined` field, so a structural absence
|
|
1310
|
+
* looked exactly like a fault. A field that can never be populated is worse than no field.
|
|
1311
|
+
*
|
|
1312
|
+
* **Where the verdict actually lands**, named here so the next reader does not have to find it
|
|
1313
|
+
* the hard way: a refusal is `document.frame.refused` (warn, carrying `kind` + `reason`,
|
|
1314
|
+
* emitted from `#enqueue`'s continuation under the same `correlationId`). Acceptance is
|
|
1315
|
+
* silent on this event. So "was this frame ingested?" is answered by joining on
|
|
1316
|
+
* `correlationId`, never by reading this line alone.
|
|
1317
|
+
*/
|
|
1318
|
+
this.#ctx.logger.info("session.document.received", {
|
|
1319
|
+
sessionId,
|
|
1320
|
+
senderPubkey,
|
|
1321
|
+
contentHashHex,
|
|
1322
|
+
sequenceNumber: leafIndex,
|
|
1323
|
+
kind: routed.kind,
|
|
1324
|
+
// The verdict is asynchronous. Stated positively so absence is not mistaken for silence.
|
|
1325
|
+
dispatch: "queued",
|
|
1326
|
+
verdictEvent: "document.frame.refused",
|
|
1327
|
+
correlationId,
|
|
1328
|
+
});
|
|
1329
|
+
return { leafIndex };
|
|
1330
|
+
}
|
|
1331
|
+
const { leafIndex } = this.#ctx.appendSessionLeaf(agentName, sessionId, "msg", contentHashHex, correlationId);
|
|
1332
|
+
// DOD-LOG-1: persist the readable RECEIVED plaintext to the durable transcript, keyed by the
|
|
1333
|
+
// canonical leaf sequence so it joins the committed hash chain (survives restart; INV-3 — the
|
|
1334
|
+
// relay/directory never see this plaintext, only the hash).
|
|
1335
|
+
const durable = this.#ctx.records.recordTranscriptMessage(agentName, sessionId, leafIndex, "received", content, correlationId,
|
|
1336
|
+
// DOD-M15-SEALWIRE-1 bullet 5: present only when the ordering record verified AND the signer
|
|
1337
|
+
// matched this session's counterparty. Undefined on the soft fallback, which the row records
|
|
1338
|
+
// as `local_session_state` rather than leaving indistinguishable.
|
|
1339
|
+
verifiedAuthorship);
|
|
1340
|
+
const recvKey = this.#ctx.sessionKey(agentName, sessionId);
|
|
1341
|
+
if (!durable) {
|
|
1342
|
+
// The leaf is committed and the plaintext is not. Delivery reads the transcript, so this
|
|
1343
|
+
// message is now unreachable by every session — record it so the receive path can SAY that
|
|
1344
|
+
// rather than time out wearing the quiet-counterparty answer (review F2).
|
|
1345
|
+
let lost = this.#ctx.undeliverableSeqs.get(recvKey);
|
|
1346
|
+
if (!lost) {
|
|
1347
|
+
lost = new Set();
|
|
1348
|
+
this.#ctx.undeliverableSeqs.set(recvKey, lost);
|
|
1349
|
+
}
|
|
1350
|
+
lost.add(leafIndex);
|
|
1351
|
+
// DOD-M15-NO-SILENT-REFUSAL-1: noted HERE, where the write actually fails, and not on the
|
|
1352
|
+
// cello_receive exit that reports it. `#undeliverableSeqs` is in memory, so the receive exit
|
|
1353
|
+
// stops being able to say this after a restart while the transcript hole stays permanent —
|
|
1354
|
+
// and the exit only runs if somebody is attending, which is the case this whole line is for.
|
|
1355
|
+
this.#ctx.notices.noteContentRefusal(agentName, sessionId, "content_undeliverable", {
|
|
1356
|
+
kind: REFUSAL_KINDS.LOST,
|
|
1357
|
+
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.`,
|
|
1358
|
+
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. " +
|
|
1359
|
+
"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. " +
|
|
1360
|
+
"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.",
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
// Review finding #6: the witness for this hash has done its ordering job once the leaf is
|
|
1364
|
+
// appended — drop it so #witnessedSeq stays proportional to held/pending content, not the whole
|
|
1365
|
+
// transcript. A later replay of the same hash is still caught by the dedup leaf-scan, which is
|
|
1366
|
+
// independent of the witness map.
|
|
1367
|
+
this.#ctx.witnessedSeq.get(recvKey)?.delete(contentHashHex);
|
|
1368
|
+
// DOD-M12B-LEAF-TRIGGERS-FETCH-1: the bytes are here, so cancel any fetch the witness leaf
|
|
1369
|
+
// scheduled. On a healthy session this is the branch that runs — the direct path beats the
|
|
1370
|
+
// grace window and the relay is never asked, which is what keeps a fetch off the hot path of
|
|
1371
|
+
// every message.
|
|
1372
|
+
this.markContentResolved(agentName, sessionId, contentHashHex);
|
|
1373
|
+
/**
|
|
1374
|
+
* THE ARRIVAL BUFFER USED TO BE FILLED HERE, and nothing read it.
|
|
1375
|
+
*
|
|
1376
|
+
* Every verified message was copied into an in-memory list as plaintext, capped at the last 32
|
|
1377
|
+
* per session and held for the life of the daemon process. `cello_receive` stopped draining it
|
|
1378
|
+
* at DOD-COATTEND-1, when delivery moved onto the durable transcript written a few lines above;
|
|
1379
|
+
* the away responder's peek at its tail was the last reader, and DOD-M15-AWAYSCOPE-1 deleted
|
|
1380
|
+
* that. What was left was the plaintext of every conversation, kept in memory, for nobody.
|
|
1381
|
+
*
|
|
1382
|
+
* The transcript row IS the delivery record. There is nothing to keep alongside it.
|
|
1383
|
+
*/
|
|
1384
|
+
this.#ctx.logger.info("session.content.received", {
|
|
1385
|
+
sessionId,
|
|
1386
|
+
senderPubkey,
|
|
1387
|
+
contentHashHex,
|
|
1388
|
+
sequenceNumber: leafIndex,
|
|
1389
|
+
correlationId,
|
|
1390
|
+
});
|
|
1391
|
+
// M8C-MSGWAKE-1: content is now buffered and drainable — fire the doorbell AFTER the push so a
|
|
1392
|
+
// woken cello_receive finds the message. Content-free (agent/session/senderPubkey only). Never
|
|
1393
|
+
// let a listener error escape the content path.
|
|
1394
|
+
try {
|
|
1395
|
+
this.#ctx.onContentArrived?.(agentName, sessionId, senderPubkey);
|
|
1396
|
+
}
|
|
1397
|
+
catch (err) {
|
|
1398
|
+
this.#ctx.logger.warn("notification.cello_message.dispatch.failed", {
|
|
1399
|
+
sessionId, agentName, reason: extractErrorMessage(err),
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
return { leafIndex };
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* DAEMON-004: register the /cello/content/1.0.0 handler on a session node so
|
|
1406
|
+
* inbound content_frames are decoded, cross-checked, and ingested.
|
|
1407
|
+
*/
|
|
1408
|
+
// Awaited by createSessionNode / acceptSession so the /cello/content/1.0.0 handler
|
|
1409
|
+
// is provably registered before the caller returns (and thus before any peer sends
|
|
1410
|
+
// content). libp2p registers the protocol synchronously today, but awaiting removes
|
|
1411
|
+
// the fragile dependency on that internal timing (review L4).
|
|
1412
|
+
async registerContentHandler(agentName, sessionId, node, _counterpartyPubkey) {
|
|
1413
|
+
try {
|
|
1414
|
+
await node.handle(CELLO_CONTENT_PROTOCOL_ID, (stream, remotePeerId) => {
|
|
1415
|
+
// `.catch` is not decoration: the handler builds its length-prefixed decoder before its own
|
|
1416
|
+
// try, and a throw there would otherwise become an unhandled rejection that takes the
|
|
1417
|
+
// daemon down for one malformed inbound stream.
|
|
1418
|
+
void this.#handleContentStream(agentName, sessionId, stream, remotePeerId).catch((err) => {
|
|
1419
|
+
this.#ctx.logger.warn("session.content.stream.handler.failed", {
|
|
1420
|
+
sessionId,
|
|
1421
|
+
error: extractErrorMessage(err),
|
|
1422
|
+
});
|
|
1423
|
+
});
|
|
1424
|
+
}, { maxInboundStreams: CONTENT_MAX_INBOUND_STREAMS });
|
|
1425
|
+
}
|
|
1426
|
+
catch (err) {
|
|
1427
|
+
this.#ctx.logger.error("session.content.handler.register.failed", {
|
|
1428
|
+
sessionId,
|
|
1429
|
+
error: extractErrorMessage(err),
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* Hand the relay a leaf this agent RECEIVED whose author never submitted it — 034-CARRYLEAF.
|
|
1435
|
+
*
|
|
1436
|
+
* **The attack this closes:** somebody sends you something, declines to have it witnessed, and
|
|
1437
|
+
* seals one message short. The relay's account really does end before their last message, so your
|
|
1438
|
+
* receipt does too — every leaf validly signed, nothing false, the last thing said simply absent.
|
|
1439
|
+
*
|
|
1440
|
+
* **Why this is admissible and not a forgery:** the bytes are theirs, the signature over them is
|
|
1441
|
+
* theirs, and `#verifyAuthorshipClaim` verified it against this session's counterparty before a
|
|
1442
|
+
* word of it was ingested. The relay verifies it again against the directory-signed assignment.
|
|
1443
|
+
* Nothing here is asserted by us except that we received it.
|
|
1444
|
+
*
|
|
1445
|
+
* ⚠️ BEST-EFFORT, AND ITS FAILURE IS NOT SILENT. If the relay cannot be reached, the message is
|
|
1446
|
+
* still delivered and read — refusing it would make the relay a precondition for reading mail,
|
|
1447
|
+
* which is the thing every unit on this path has been careful not to do. What is lost is only the
|
|
1448
|
+
* guarantee that it can enter a receipt, and that surfaces where the operator can act on it: the
|
|
1449
|
+
* seal's own pre-flight refuses a gapped chain by name (`seal_carry_noncontiguous`) with guidance,
|
|
1450
|
+
* so the consequence reaches them at the moment it matters rather than as a log line here.
|
|
1451
|
+
*/
|
|
1452
|
+
witnessReceivedLeaf(agentName, sessionId, contentHash, structure1Cbor, senderSignature,
|
|
1453
|
+
/** The domain the AUTHOR assigned this leaf, read off their frame — never guessed (review F5). */
|
|
1454
|
+
leafKind, correlationId) {
|
|
1455
|
+
const entry = this.#ctx.activeNodes.get(this.#ctx.sessionKey(agentName, sessionId));
|
|
1456
|
+
if (!entry?.relayClient || !entry.relaySessionIdBytes) {
|
|
1457
|
+
this.#ctx.logger.warn("session.content.witness_received.unavailable", {
|
|
1458
|
+
agentName, sessionId, correlationId,
|
|
1459
|
+
impact: "a message arrived that its sender never had witnessed, and this side has no relay client " +
|
|
1460
|
+
"for the session, so it could not be witnessed here either. It is delivered and readable; " +
|
|
1461
|
+
"it cannot enter a notarized receipt until some party witnesses it.",
|
|
1462
|
+
});
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
void entry.relayClient
|
|
1466
|
+
.witnessReceivedLeaf(entry.node, entry.relaySessionIdBytes, contentHash, leafKind, {
|
|
1467
|
+
structure1Cbor,
|
|
1468
|
+
senderSignature,
|
|
1469
|
+
})
|
|
1470
|
+
.then((res) => {
|
|
1471
|
+
if (res.ok) {
|
|
1472
|
+
this.#ctx.logger.info("session.content.witness_received", {
|
|
1473
|
+
agentName, sessionId, correlationId, relaySequence: res.sequence_number,
|
|
1474
|
+
impact: "this side witnessed a message its SENDER did not. The leaf now holds a canonical " +
|
|
1475
|
+
"position, so it can appear in a receipt whatever the sender does next.",
|
|
1476
|
+
});
|
|
1477
|
+
// It has a position now, so it can be acknowledged like any other received message.
|
|
1478
|
+
this.noteAcknowledgeable(agentName, sessionId, res.sequence_number - 1, contentHash);
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* `counter_submit_duplicate` is NOT a failure and must not be logged as one: it means this
|
|
1483
|
+
* relay already holds the leaf, which is the outcome we wanted. It fires on the ordinary
|
|
1484
|
+
* race where the sender's own submit lands while ours is in flight.
|
|
1485
|
+
*/
|
|
1486
|
+
if (res.reason === "counter_submit_duplicate") {
|
|
1487
|
+
this.#ctx.logger.info("session.content.witness_received.already_held", {
|
|
1488
|
+
agentName, sessionId, correlationId,
|
|
1489
|
+
impact: "the relay already held this leaf — its sender witnessed it after all, or in parallel with us.",
|
|
1490
|
+
});
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
this.#ctx.logger.error("session.content.witness_received.failed", {
|
|
1494
|
+
agentName, sessionId, correlationId, reason: res.reason,
|
|
1495
|
+
...(res.detail === undefined ? {} : { detail: res.detail }),
|
|
1496
|
+
impact: "a message arrived that its sender never had witnessed, and this side could not witness " +
|
|
1497
|
+
"it either. It is delivered and readable. What is at risk is the RECEIPT: if this " +
|
|
1498
|
+
"message is still unwitnessed when the conversation is sealed, the seal will refuse a " +
|
|
1499
|
+
"gapped chain by name rather than quietly leaving it out.",
|
|
1500
|
+
});
|
|
1501
|
+
})
|
|
1502
|
+
.catch((err) => {
|
|
1503
|
+
this.#ctx.logger.error("session.content.witness_received.threw", {
|
|
1504
|
+
agentName, sessionId, correlationId, error: extractErrorMessage(err),
|
|
1505
|
+
});
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* Record that a message ARRIVED and was accepted at a known canonical position — 033-ACKEMIT
|
|
1510
|
+
* review F1.
|
|
1511
|
+
*
|
|
1512
|
+
* The one writer for both copies of the acknowledgement, so the claim this daemon signs says what
|
|
1513
|
+
* it actually received rather than what the relay got round to delivering back to it.
|
|
1514
|
+
*
|
|
1515
|
+
* Monotonic, and it must be: a re-delivery or a recovered park of an EARLIER message must not walk
|
|
1516
|
+
* the acknowledgement backwards, and must not swap the hash under an unchanged position.
|
|
1517
|
+
*/
|
|
1518
|
+
noteAcknowledgeable(agentName, sessionId, canonicalSeq, contentHash) {
|
|
1519
|
+
// Relay sequences are 1-based; a canonical leaf index is 0-based. The claim carries the relay's
|
|
1520
|
+
// number, because the relay is what checks it.
|
|
1521
|
+
const relaySeq = canonicalSeq + 1;
|
|
1522
|
+
if (relaySeq < 1)
|
|
1523
|
+
return;
|
|
1524
|
+
const key = this.#ctx.sessionKey(agentName, sessionId);
|
|
1525
|
+
const prev = this.#ctx.lastAck.get(key);
|
|
1526
|
+
if (prev && relaySeq <= prev.seq)
|
|
1527
|
+
return;
|
|
1528
|
+
this.#ctx.lastAck.set(key, { seq: relaySeq, hash: Uint8Array.from(contentHash) });
|
|
1529
|
+
this.#ctx.records.recordLastAck(agentName, sessionId, relaySeq, contentHash);
|
|
1530
|
+
const entry = this.#ctx.activeNodes.get(key);
|
|
1531
|
+
const sessionIdHex = entry?.relaySessionIdBytes
|
|
1532
|
+
? Buffer.from(entry.relaySessionIdBytes).toString("hex")
|
|
1533
|
+
: sessionId;
|
|
1534
|
+
entry?.relayClient?.noteReceivedLeaf(sessionIdHex, relaySeq, contentHash);
|
|
1535
|
+
}
|
|
1536
|
+
#refuseInboundContent(agentName, sessionId, reason, contentHash, detail, correlationId) {
|
|
1537
|
+
// The sentence about the other route is chosen HERE, from what this machine can actually do —
|
|
1538
|
+
// never written into a caller's literal, where it would be a promise nobody re-checked.
|
|
1539
|
+
const guidance = `${detail.guidance} ${this.#ctx.mailboxRouteAvailable(agentName) ? REFUSAL_MAY_STILL_ARRIVE : REFUSAL_NO_OTHER_ROUTE}`;
|
|
1540
|
+
this.#ctx.logger.error("session.content.refused", { agentName, sessionId, correlationId, reason, ...detail, guidance });
|
|
1541
|
+
this.#ctx.notices.noteContentRefusal(agentName, sessionId, reason, {
|
|
1542
|
+
kind: REFUSAL_KINDS.REFUSED, impact: detail.impact, guidance,
|
|
1543
|
+
});
|
|
1544
|
+
/**
|
|
1545
|
+
* Review F4 — A PROMISE MADE HERE IS CLOSED IN `recoverParkedEntry`, not left standing.
|
|
1546
|
+
*
|
|
1547
|
+
* The guidance above tells the operator the message may arrive by the mailbox. Both sibling
|
|
1548
|
+
* refusals on this path already arm a memo so the recovery can say the refusal did not hold;
|
|
1549
|
+
* this one armed nothing, so a delivered message would have left a permanent alarm sitting in
|
|
1550
|
+
* `cello_check_notifications` saying it had been turned away.
|
|
1551
|
+
*
|
|
1552
|
+
* Armed AFTER the notice is filed, so the memo can never claim a refusal that did not happen.
|
|
1553
|
+
*/
|
|
1554
|
+
this.#ctx.refusals.noteRefusedOnDirectPath(agentName, sessionId, contentHash);
|
|
1555
|
+
}
|
|
1556
|
+
async #handleContentStream(agentName, sessionId, stream, remotePeerId) {
|
|
1557
|
+
// CLOSING THIS STREAM IS WHAT KEEPS THE SESSION ALIVE PAST ITS 33RD MESSAGE.
|
|
1558
|
+
//
|
|
1559
|
+
// Every content frame and every delivery ACK opens a fresh /cello/content/1.0.0 stream on the
|
|
1560
|
+
// one muxed connection the session holds, and libp2p caps INBOUND streams per protocol per
|
|
1561
|
+
// connection. It enforces that cap AFTER multistream-select has answered, so an over-cap stream
|
|
1562
|
+
// negotiates fine and is reset an instant later, and the SENDER's next `stream.send(...)`
|
|
1563
|
+
// throws "Cannot write to a stream that is closed" — an error that names the exit point and not
|
|
1564
|
+
// one thing about the cause.
|
|
1565
|
+
//
|
|
1566
|
+
// A stream leaves the muxer's set only on its `close` event, and closing our write end triggers
|
|
1567
|
+
// that only once the peer has closed its end too. So a handler that reads its frame and returns
|
|
1568
|
+
// leaves the stream half-open for the life of the connection and the count only ever rises.
|
|
1569
|
+
// Measured on a live daemon: 115 failures over 3.5 hours, with EXACTLY 32 successful streams
|
|
1570
|
+
// before the first one on both affected sessions (M12B Entry 10).
|
|
1571
|
+
//
|
|
1572
|
+
// The decoder is built INSIDE the try so a malformed stream cannot throw past the close below.
|
|
1573
|
+
let iter;
|
|
1574
|
+
try {
|
|
1575
|
+
iter = lp.decode(stream)[Symbol.asyncIterator]();
|
|
1576
|
+
const result = await iter.next();
|
|
1577
|
+
if (result.done || result.value === undefined)
|
|
1578
|
+
return;
|
|
1579
|
+
const bytes = result.value instanceof Uint8Array ? result.value
|
|
1580
|
+
: Buffer.isBuffer(result.value) ? new Uint8Array(result.value)
|
|
1581
|
+
: result.value.slice();
|
|
1582
|
+
const frame = decode(bytes);
|
|
1583
|
+
const correlationId = typeof frame["correlation_id"] === "string" ? frame["correlation_id"] : undefined;
|
|
1584
|
+
const frameType = typeof frame["type"] === "string" ? frame["type"] : "(absent)";
|
|
1585
|
+
/**
|
|
1586
|
+
* DOD-M15-FRAME-1 — ONE GATE, BEFORE THE DISPATCH, FOR EVERY FRAME ON THIS PROTOCOL.
|
|
1587
|
+
*
|
|
1588
|
+
* A stranger could dial an agent's standing receiver (it admitted everyone until DOD-M15-ASSIGN-1), hold
|
|
1589
|
+
* the connection open through promotion — libp2p's gater runs only at connection
|
|
1590
|
+
* establishment, so narrowing it does not evict anyone already attached — and then speak the
|
|
1591
|
+
* content protocol the moment it activated. The frame was ingested, leafed, transcribed, and
|
|
1592
|
+
* attributed to the legitimate counterparty, because attribution is read from local session
|
|
1593
|
+
* state rather than from anything the frame proved.
|
|
1594
|
+
*
|
|
1595
|
+
* DELIBERATELY SHARED RATHER THAN COPIED INTO EACH BRANCH. `session_abandoned_notice` already
|
|
1596
|
+
* had both checks, correct and complete, twenty lines below — and the other two frame types
|
|
1597
|
+
* did not. Copying the pattern a third and fourth time would fix today's three and leave the
|
|
1598
|
+
* fifth frame type, added later by someone who did not read this comment, unguarded again.
|
|
1599
|
+
* Placing it above the dispatch makes the guard the DEFAULT: a new frame type is protected by
|
|
1600
|
+
* construction and has to opt OUT visibly rather than opt in silently.
|
|
1601
|
+
*
|
|
1602
|
+
* Verified safe for all three current types by enumeration, not assumption — `content_frame`
|
|
1603
|
+
* (:5169), `session_abandoned_notice` (:6663) and `content_delivery_ack` (:7439) are the only
|
|
1604
|
+
* senders on `CELLO_CONTENT_PROTOCOL_ID`, and all three put `session_id` in the frame.
|
|
1605
|
+
*
|
|
1606
|
+
* MISSING, MALFORMED AND MISMATCHED TAKE ONE PATH. An attacker evading a mismatch check does
|
|
1607
|
+
* not send a wrong value — it sends no value, and a guard that only fires on a present-and-
|
|
1608
|
+
* wrong field is a guard that is trivially skipped. That is exactly what the old
|
|
1609
|
+
* `content_frame` check did: `typeof x === "string" && x !== sessionId`.
|
|
1610
|
+
*/
|
|
1611
|
+
const expectedPeer = this.#ctx.activeNodes.get(this.#ctx.sessionKey(agentName, sessionId))?.counterpartySessionPeerId;
|
|
1612
|
+
if (!remotePeerId || !expectedPeer || remotePeerId !== expectedPeer) {
|
|
1613
|
+
// Loud in the LOG — there is no caller to answer on an inbound stream, so this is the whole
|
|
1614
|
+
// surface. Neutral wording: this is an observation, not a verdict about intent. The same
|
|
1615
|
+
// signal comes from a real impersonation attempt and from our own fallback paths
|
|
1616
|
+
// mishandling a reconnect, and nothing here can tell them apart.
|
|
1617
|
+
this.#ctx.logger.warn("session.content.peer_mismatch", {
|
|
1618
|
+
agentName, sessionId, frameType,
|
|
1619
|
+
remotePeerId: remotePeerId ?? "(absent)", expected: expectedPeer ?? "(unknown)",
|
|
1620
|
+
impact: "a frame arrived on this session's content protocol from a peer that is not its counterparty; it was refused — not ingested, not attributed, not recorded — and the peer was disconnected",
|
|
1621
|
+
});
|
|
1622
|
+
/**
|
|
1623
|
+
* PEER-ENDING, NOT SESSION-ENDING — and the difference is a deliberate deviation from the
|
|
1624
|
+
* DoD clause (review F2).
|
|
1625
|
+
*
|
|
1626
|
+
* The clause says the refusal is session-ending. Applied HERE that would be a worse hole
|
|
1627
|
+
* than the one it closes: a pre-positioned stranger could kill any session on the machine
|
|
1628
|
+
* with a single frame, trading an injection hole for a denial-of-service hole. The
|
|
1629
|
+
* session-ending response belongs where the evidence is about the SESSION's counterparty —
|
|
1630
|
+
* `#freezeOnIdentityFailure`, reached when a party that IS the peer we dialled signs with a
|
|
1631
|
+
* key that is not theirs.
|
|
1632
|
+
*
|
|
1633
|
+
* Here the evidence is about the PEER: they are not party to this session at all. So the
|
|
1634
|
+
* connection goes and the session is untouched. Without this the stranger stayed attached
|
|
1635
|
+
* for the life of the session and the gate re-refused each frame forever — and the eviction
|
|
1636
|
+
* sweep's own fallback ("the frame gate still refuses anything this peer sends") only closes
|
|
1637
|
+
* the loop if the frame gate does something about the connection.
|
|
1638
|
+
*
|
|
1639
|
+
* Fire-and-forget: a hang-up that fails must not turn a successful refusal into a thrown
|
|
1640
|
+
* handler, and the refusal above has already done the load-bearing work.
|
|
1641
|
+
*/
|
|
1642
|
+
if (remotePeerId) {
|
|
1643
|
+
const entry = this.#ctx.activeNodes.get(this.#ctx.sessionKey(agentName, sessionId));
|
|
1644
|
+
void entry?.node.hangUp(remotePeerId).catch((err) => {
|
|
1645
|
+
this.#ctx.logger.debug("session.content.peer_mismatch.hangup_failed", {
|
|
1646
|
+
sessionId, peerId: remotePeerId, error: extractErrorMessage(err),
|
|
1647
|
+
});
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
const claimedSessionId = frame["session_id"];
|
|
1653
|
+
if (typeof claimedSessionId !== "string" || claimedSessionId !== sessionId) {
|
|
1654
|
+
this.#ctx.logger.warn("session.content.session_mismatch", {
|
|
1655
|
+
agentName, sessionId, frameType,
|
|
1656
|
+
claimedSessionId: typeof claimedSessionId === "string" ? claimedSessionId : "(absent)",
|
|
1657
|
+
impact: "the frame does not name the session whose stream it arrived on; it was refused rather than routed, because the authenticated stream is the better authority for where content belongs",
|
|
1658
|
+
});
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
// CELLO-M7-MSG-001 (AC-001/AC-002): a `persisted` delivery ACK arriving on the
|
|
1662
|
+
// same /cello/content/1.0.0 protocol resolves the sender's awaiting-ACK timer.
|
|
1663
|
+
// The protocol acts on `persisted` ONLY — any other level leaves the timer armed.
|
|
1664
|
+
if (frame["type"] === "content_delivery_ack") {
|
|
1665
|
+
const ackHash = frame["content_hash"];
|
|
1666
|
+
const level = frame["level"];
|
|
1667
|
+
if (ackHash instanceof Uint8Array && level === "persisted") {
|
|
1668
|
+
onDeliveryAck(this.#ctx, (a, sid, h) => this.#send.resolveAwaitingAck(a, sid, h), agentName, sessionId, ackHash, frame["ack_sig"], correlationId);
|
|
1669
|
+
}
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
// DOD-M12B-ABANDON-NOTIFY-1: the counterparty force-abandoned. Handled here, on the same
|
|
1673
|
+
// authenticated stream the delivery acknowledgement rides, and AFTER the session-id check
|
|
1674
|
+
// below cannot be skipped — the frame names its session and the handler is bound to one.
|
|
1675
|
+
if (frame["type"] === "session_abandoned_notice") {
|
|
1676
|
+
// DOD-M15-FRAME-1: the peer and session checks that used to live here now run above, for
|
|
1677
|
+
// EVERY frame type, unchanged in substance — this branch was where they were written first
|
|
1678
|
+
// and correctly, and it is the reference the shared gate was lifted from. Its comment is
|
|
1679
|
+
// preserved there, including the reason the transport being authenticated is not enough.
|
|
1680
|
+
// Left as a bare dispatch on purpose: a second copy of a guard is a second thing to keep in
|
|
1681
|
+
// step, and the one that drifts is the one nobody is reading.
|
|
1682
|
+
void this.#ctx.retireOnCounterpartyAbandon(agentName, sessionId, correlationId);
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
/**
|
|
1686
|
+
* DOD-M15-SEALWIRE-1 bullet 6 (part A) — the salt agreement.
|
|
1687
|
+
*
|
|
1688
|
+
* Placed BELOW the shared peer/session gate deliberately, which is the whole reason that gate
|
|
1689
|
+
* was lifted above the dispatch: a new frame type is protected by construction rather than
|
|
1690
|
+
* having to remember to opt in. A stranger's salt frame is refused before it reaches here, so
|
|
1691
|
+
* nothing about this session's salt can be steered by a peer that is not its counterparty.
|
|
1692
|
+
*
|
|
1693
|
+
* The fields are read defensively into the frame shape rather than cast: an inbound value is
|
|
1694
|
+
* whatever a peer chose to encode, and `onPeerSaltFrame` refuses both-fields and neither-field
|
|
1695
|
+
* by name — so a non-Uint8Array in either slot must arrive at that function as ABSENT, not as
|
|
1696
|
+
* a present-but-wrong value it would then try to use.
|
|
1697
|
+
*/
|
|
1698
|
+
/**
|
|
1699
|
+
* 007-CRYPTO — the peer's SIGNED ephemeral.
|
|
1700
|
+
*
|
|
1701
|
+
* Fields are read defensively rather than cast, exactly like the salt frame below: an inbound
|
|
1702
|
+
* value is whatever a peer chose to encode, and `verifySessionEphemeral` refuses a missing or
|
|
1703
|
+
* wrong-width one BY NAME — so a non-`Uint8Array` must arrive there as ABSENT rather than as a
|
|
1704
|
+
* present-but-wrong value it would try to use.
|
|
1705
|
+
*/
|
|
1706
|
+
if (frame["type"] === "session_key_agreement") {
|
|
1707
|
+
const ephemeralPublic = frame["ephemeral_public"];
|
|
1708
|
+
const signature = frame["ephemeral_sig"];
|
|
1709
|
+
await this.#ctx.ephemerals.handleEphemeralFrame(agentName, sessionId, {
|
|
1710
|
+
...(ephemeralPublic instanceof Uint8Array ? { ephemeralPublic } : {}),
|
|
1711
|
+
...(signature instanceof Uint8Array ? { signature } : {}),
|
|
1712
|
+
}, correlationId);
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
if (frame["type"] === "session_salt_agreement") {
|
|
1716
|
+
const contribution = frame["contribution"];
|
|
1717
|
+
const fingerprint = frame["fingerprint"];
|
|
1718
|
+
const adoptionClosed = frame["adoption_closed"];
|
|
1719
|
+
await this.#ctx.salts.handleSaltFrame(agentName, sessionId, {
|
|
1720
|
+
...(contribution instanceof Uint8Array ? { contribution } : {}),
|
|
1721
|
+
...(fingerprint instanceof Uint8Array ? { fingerprint } : {}),
|
|
1722
|
+
// A non-string stays ABSENT rather than being coerced, exactly like the other two: the
|
|
1723
|
+
// decision function refuses a shape it cannot read, and must never be handed a `"42"`.
|
|
1724
|
+
//
|
|
1725
|
+
// TRUNCATED AT THE BOUNDARY — 006-CRYPTO finding 6. Every label CELLO sends is under
|
|
1726
|
+
// twenty characters, and this one is chosen entirely by the peer. Cutting it here means
|
|
1727
|
+
// no unbounded peer string is stored, logged or rendered anywhere downstream; the
|
|
1728
|
+
// rendering that keeps it out of our own sentences is `renderPeerAdoptionLabel`.
|
|
1729
|
+
...(typeof adoptionClosed === "string" && adoptionClosed.length > 0
|
|
1730
|
+
? { adoptionClosed: adoptionClosed.slice(0, SALT_ADOPTION_LABEL_MAX) }
|
|
1731
|
+
: {}),
|
|
1732
|
+
}, correlationId);
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
if (frame["type"] !== "content_frame") {
|
|
1736
|
+
// LOGGED, not silently dropped. This handler is bound to one session, and a frame it does
|
|
1737
|
+
// not understand arriving on that stream is either a peer speaking a newer protocol or a
|
|
1738
|
+
// bug on our side — both worth a line, and neither distinguishable from "nothing arrived"
|
|
1739
|
+
// when the return is silent.
|
|
1740
|
+
this.#ctx.logger.warn("session.content.frame_unknown_type", {
|
|
1741
|
+
sessionId,
|
|
1742
|
+
type: typeof frame["type"] === "string" ? String(frame["type"]) : "(absent)",
|
|
1743
|
+
});
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
// DOD-M15-FRAME-1: the session-id check moved to the shared gate above, and its `&&` became
|
|
1747
|
+
// `||` on the way. It read `typeof x === "string" && x !== sessionId` — firing only when the
|
|
1748
|
+
// field was PRESENT and wrong, so omitting it passed. Its own sibling twenty lines up already
|
|
1749
|
+
// refused absence, with a comment saying treating a missing field as agreement is how a guard
|
|
1750
|
+
// stops guarding. Same file, same switch, opposite conclusion.
|
|
1751
|
+
// Review F4: hand the DECODED frame to a test observer before anything consumes it. Absent in
|
|
1752
|
+
// production — the field is null unless a test installs one.
|
|
1753
|
+
this.#ctx.inboundFrameObserver?.(frame);
|
|
1754
|
+
const contentBytes = frame["content_bytes"];
|
|
1755
|
+
const contentHash = frame["content_hash"];
|
|
1756
|
+
if (!(contentBytes instanceof Uint8Array) || !(contentHash instanceof Uint8Array)) {
|
|
1757
|
+
// Same reasoning as the unknown type above: a malformed frame that vanishes without a trace
|
|
1758
|
+
// is indistinguishable, from the operator's side, from a counterparty who never sent
|
|
1759
|
+
// anything.
|
|
1760
|
+
this.#ctx.logger.warn("session.content.frame_malformed", {
|
|
1761
|
+
sessionId,
|
|
1762
|
+
hasContent: contentBytes instanceof Uint8Array,
|
|
1763
|
+
hasHash: contentHash instanceof Uint8Array,
|
|
1764
|
+
});
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
/**
|
|
1768
|
+
* 🚨 DECRYPT BEFORE ANYTHING ELSE READS THE BODY — `DOD-M15-EPHEMERAL-AUTH-1`.
|
|
1769
|
+
*
|
|
1770
|
+
* `content_hash` is over the PLAINTEXT, so the hash check, the transcript, the seal and the
|
|
1771
|
+
* salted hash all keep meaning exactly what they mean today — but only if the body is put back
|
|
1772
|
+
* before any of them run.
|
|
1773
|
+
*
|
|
1774
|
+
* ⚠️ ABSENT IS NOT A PASS. A frame with no `content_encryption` is refused rather than read as
|
|
1775
|
+
* plaintext. There is no unencrypted sender to be compatible with, and treating a missing
|
|
1776
|
+
* marker as "this one is in the clear" is precisely the downgrade an attacker asks for: strip
|
|
1777
|
+
* one field and the receiver reads the body raw. Missing and unknown take the same path as a
|
|
1778
|
+
* failed decrypt, for the reason that runs through this whole unit — a check lenient about an
|
|
1779
|
+
* absent proof is a check that gets skipped.
|
|
1780
|
+
*/
|
|
1781
|
+
const declaredEncryption = frame["content_encryption"];
|
|
1782
|
+
const encState = this.#ctx.ephemerals.contentEncryptionState(agentName, sessionId);
|
|
1783
|
+
let plaintextBody;
|
|
1784
|
+
if (declaredEncryption !== SESSION_CONTENT_ENCRYPTION_V1) {
|
|
1785
|
+
this.#refuseInboundContent(agentName, sessionId, "content_encryption_absent_or_unknown", contentHash, {
|
|
1786
|
+
declared: typeof declaredEncryption === "string" ? declaredEncryption : "(absent)",
|
|
1787
|
+
impact: "the frame did not say it was encrypted under this session's key, so it was refused unread — nothing was shown and this copy was not kept.",
|
|
1788
|
+
guidance: "STOPPED ON PURPOSE. A message arrived that was not encrypted under this session's key. " +
|
|
1789
|
+
"This build never sends one, so either something between you rewrote the frame, or your " +
|
|
1790
|
+
"counterparty is running something that is not CELLO. Confirm with them OUT OF BAND " +
|
|
1791
|
+
"before opening another session.",
|
|
1792
|
+
}, correlationId);
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
if (encState.key === null) {
|
|
1796
|
+
this.#refuseInboundContent(agentName, sessionId, "no_session_key", contentHash, {
|
|
1797
|
+
detail: encState.reason,
|
|
1798
|
+
impact: "an encrypted message arrived and this side has no agreed key to open it, so it was refused unread rather than shown as garbage.",
|
|
1799
|
+
// Review F6: the RECEIVE-side wording. The send-side table explains what became of a
|
|
1800
|
+
// message this operator sent, which is the wrong direction entirely for a message they
|
|
1801
|
+
// cannot open.
|
|
1802
|
+
guidance: CONTENT_ENCRYPTION_INBOUND_GUIDANCE[encState.reason],
|
|
1803
|
+
}, correlationId);
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
const opened = openSessionContent(encState.key, contentBytes);
|
|
1807
|
+
if (opened === null) {
|
|
1808
|
+
// GCM's tag is the only thing separating "not for us" from "modified in flight", and this
|
|
1809
|
+
// side must not branch on which — that would be branching on attacker-controlled input.
|
|
1810
|
+
this.#refuseInboundContent(agentName, sessionId, "decrypt_failed", contentHash, {
|
|
1811
|
+
impact: "the message did not decrypt under this session's agreed key — it was modified in flight, or it was encrypted under a different key. Refused unread.",
|
|
1812
|
+
guidance: "STOPPED ON PURPOSE. Nothing was shown and this copy was not kept. A message that fails " +
|
|
1813
|
+
"this check has either been altered on its way to you or was not encrypted for this " +
|
|
1814
|
+
"session. Confirm with your counterparty OUT OF BAND, then start a new session.",
|
|
1815
|
+
}, correlationId);
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
plaintextBody = opened;
|
|
1819
|
+
/**
|
|
1820
|
+
* ─── THE CONVERSATION IS OVER, AND THAT OUTRANKS EVERY QUESTION BELOW IT ──────────────────
|
|
1821
|
+
* `DOD-M15-CLOSEDSESSION-1`.
|
|
1822
|
+
*
|
|
1823
|
+
* Placed HERE — after the decrypt, before the authorship claim — for two reasons, and both
|
|
1824
|
+
* are load-bearing:
|
|
1825
|
+
*
|
|
1826
|
+
* - AFTER the decrypt, because refusing retains the bytes, and the bytes worth retaining are
|
|
1827
|
+
* the message. Refusing a line earlier would quarantine ciphertext nobody can read.
|
|
1828
|
+
* - BEFORE the authorship claim, because that is what was answering. A message composed
|
|
1829
|
+
* after the seal acknowledges content our frozen record does not hold, so
|
|
1830
|
+
* `verifyAuthorshipClaim` returned `ack_hash_unknown_content` and the status was never
|
|
1831
|
+
* consulted at all. Measured live on session `9d253bce…`.
|
|
1832
|
+
*
|
|
1833
|
+
* ⚠️ THIS IS A REORDER, NOT A REMOVAL. The acknowledgement check still runs, and still
|
|
1834
|
+
* refuses, for every session that is not closed — which is the state it exists for. What it
|
|
1835
|
+
* no longer does is describe a hash where the situation is that there is nothing left to
|
|
1836
|
+
* acknowledge.
|
|
1837
|
+
*
|
|
1838
|
+
* The peer gate above has already established this frame came from THIS session's
|
|
1839
|
+
* counterparty peer, and the session key opened it. So nothing is attributed on the strength
|
|
1840
|
+
* of an unverified signature: the quarantine row records the counterparty from local session
|
|
1841
|
+
* state, exactly as the park route's refusal does.
|
|
1842
|
+
*/
|
|
1843
|
+
if (refuseIfSessionClosed(this.#ctx, agentName, sessionId, plaintextBody, Buffer.from(contentHash).toString("hex"), correlationId).refused) {
|
|
1844
|
+
/**
|
|
1845
|
+
* ⚠️ ONE SIGNAL IS DELIBERATELY GIVEN UP, AND IT IS NAMED RATHER THAN LOST — review F4. A
|
|
1846
|
+
* frame signed by a key that is NOT the counterparty's used to reach
|
|
1847
|
+
* `session.content.authorship.refuted` here. The FREEZE it triggers is moot on a session
|
|
1848
|
+
* that is already terminal, and verifying a signature purely to log it is work an attacker
|
|
1849
|
+
* can ask for by volume — so what survives is whether a proof was carried at all. The bytes
|
|
1850
|
+
* are retained either way, so the signature is still there to be examined.
|
|
1851
|
+
*/
|
|
1852
|
+
this.#ctx.logger.info("session.content.closed.frame_proof", {
|
|
1853
|
+
agentName, sessionId, correlationId,
|
|
1854
|
+
hasStructure1: frame["structure1_cbor"] instanceof Uint8Array,
|
|
1855
|
+
hasSenderSignature: frame["sender_signature"] instanceof Uint8Array,
|
|
1856
|
+
impact: "the message was refused because this conversation is closed, so its authorship proof was NOT verified and no identity verdict was reached about it. The bytes are retained and readable.",
|
|
1857
|
+
});
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
// DOD-MSG-4 (self-ordering content frame): if the frame carries the relay's signed ordering
|
|
1861
|
+
// record, verify the sender signature and record the canonical sequence FROM THE FRAME, BEFORE
|
|
1862
|
+
// ingest — so the strict-in-order gate has the position without waiting on the separate
|
|
1863
|
+
// leaf_deliver witness (removes the content-before-witness race).
|
|
1864
|
+
//
|
|
1865
|
+
// DOD-M15-FRAME-1 — POSITION MAY BE SOFT; IDENTITY MAY NOT. The old comment here read "A
|
|
1866
|
+
// bad/absent record is non-fatal: the content still ingests", and it was accurate: a
|
|
1867
|
+
// signature that failed to verify, and a signature by a key that is NOT this session's
|
|
1868
|
+
// counterparty, both returned null and the content was ingested and attributed anyway. An
|
|
1869
|
+
// ABSENT record stays soft — that is the documented relay-degraded path and refusing it would
|
|
1870
|
+
// make the relay a precondition for reading mail. A record that is PRESENT and REFUTED is a
|
|
1871
|
+
// different fact, and it is now refused.
|
|
1872
|
+
const s1Cbor = frame["structure1_cbor"];
|
|
1873
|
+
const s2Cbor = frame["structure2_cbor"];
|
|
1874
|
+
/**
|
|
1875
|
+
* `DOD-M15-AUTHORSHIP-ABSENT-1` — the sender's own signature, carried BESIDE the bytes it
|
|
1876
|
+
* signs, exactly as `hash_submit` has always carried it. This field is why identity no longer
|
|
1877
|
+
* depends on the relay: it arrives whether or not a relay witnessed the message.
|
|
1878
|
+
*/
|
|
1879
|
+
const senderSig = frame["sender_signature"];
|
|
1880
|
+
let framedSeq = null;
|
|
1881
|
+
/**
|
|
1882
|
+
* DOD-M15-SEALWIRE-1 bullet 5. Set ONLY when the ordering record verified — the signature
|
|
1883
|
+
* checked against the pubkey inside the sender's own signed bytes AND the signer matched this
|
|
1884
|
+
* session's counterparty. It is deliberately NOT set on the two soft paths below (no record
|
|
1885
|
+
* supplied; decode failed), because on those the author is attested by local session state
|
|
1886
|
+
* and the transcript row must say so rather than imply a proof it does not have.
|
|
1887
|
+
*/
|
|
1888
|
+
let verifiedAuthorship;
|
|
1889
|
+
/**
|
|
1890
|
+
* 024-ORPHANTRIAGE: the signer when the signature verified but there was no counterparty to
|
|
1891
|
+
* match it against. Its ONLY consumer is the orphan branch inside ingest — everywhere else a
|
|
1892
|
+
* session record exists, so this stays `undefined` and nothing reads it.
|
|
1893
|
+
*/
|
|
1894
|
+
let verifiedSignerUnmatched;
|
|
1895
|
+
/**
|
|
1896
|
+
* ─── NO PASSPORT, NO ENTRY — `DOD-M15-AUTHORSHIP-ABSENT-1` ───────────────────────────────
|
|
1897
|
+
*
|
|
1898
|
+
* ⚠️ **THIS COMMENT USED TO SAY THE OPPOSITE, AND THE SENTENCE IT REPLACES IS THE DEFECT.**
|
|
1899
|
+
* It read: *"it means the per-message signer check is **opt-in for the sender** — a party that
|
|
1900
|
+
* passed the peer gate and wants to avoid the comparison simply omits the proof."* That was an
|
|
1901
|
+
* accurate description of the code, which is why it is rewritten here rather than deleted: it
|
|
1902
|
+
* is the sentence a reader with a coding agent finds, and it must now describe what the code
|
|
1903
|
+
* does. A frame that supplies nothing checkable is REFUSED. Omitting the proof buys the sender
|
|
1904
|
+
* nothing except a message that does not arrive.
|
|
1905
|
+
*
|
|
1906
|
+
* The old reasoning was sound as far as it went — the signature was only ever DELIVERED inside
|
|
1907
|
+
* the relay's Structure 2, so refusing on its absence would have made the relay a precondition
|
|
1908
|
+
* for reading mail. It stopped one field short: the signature travels beside the bytes it
|
|
1909
|
+
* signs now, on every content frame, so identity no longer needs the relay and position still
|
|
1910
|
+
* does not require identity.
|
|
1911
|
+
*
|
|
1912
|
+
* ⚠️ REFUSED, NOT FROZEN. A frozen session is only cleared by opening a new one, and the
|
|
1913
|
+
* overwhelmingly likely cause of an absent proof is a counterparty on an older build. The
|
|
1914
|
+
* freeze is for a proof that FAILED (below, and in `#recordFrameOrdering`) — a positive fact
|
|
1915
|
+
* about their key.
|
|
1916
|
+
*/
|
|
1917
|
+
if (!(s1Cbor instanceof Uint8Array) || !(senderSig instanceof Uint8Array)) {
|
|
1918
|
+
this.#ctx.refusals.refuseUnprovenAuthorship(agentName, sessionId, "authorship_proof_absent", contentHash, {
|
|
1919
|
+
// WHICH half is missing. A sender on an older build supplies neither; a stripped frame is
|
|
1920
|
+
// likelier to be missing one, and an investigator should not have to guess which.
|
|
1921
|
+
hasStructure1: s1Cbor instanceof Uint8Array,
|
|
1922
|
+
hasSenderSignature: senderSig instanceof Uint8Array,
|
|
1923
|
+
}, correlationId);
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
const authorship = this.#ctx.authorship.verifyAuthorshipClaim(agentName, sessionId, s1Cbor, senderSig, contentHash);
|
|
1927
|
+
if (authorship.verdict === "refuted") {
|
|
1928
|
+
/**
|
|
1929
|
+
* THE FORENSIC LINE, BEFORE THE FREEZE. `session.content.identity.frozen` records that a
|
|
1930
|
+
* session was stopped; this records WHICH check stopped it and on WHICH proof — the frame's
|
|
1931
|
+
* own signature, not the relay's copy of it. The two used to be the same event because there
|
|
1932
|
+
* was only one place a signer was checked; there are two now, and an investigation that
|
|
1933
|
+
* cannot tell them apart is looking at the wrong half of the wire.
|
|
1934
|
+
*/
|
|
1935
|
+
this.#ctx.logger.warn("session.content.authorship.refuted", {
|
|
1936
|
+
agentName, sessionId, correlationId, reason: authorship.reason,
|
|
1937
|
+
impact: "a message arrived with a proof of authorship that FAILED — it does not verify, or it is signed by a key that is not this session's counterparty. Nothing was ingested and the session is being frozen.",
|
|
1938
|
+
});
|
|
1939
|
+
await this.#ctx.freezeOnIdentityFailure(agentName, sessionId, authorship.reason, correlationId);
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
if (authorship.verdict === "unusable") {
|
|
1943
|
+
// A replayed claim gets its own name on BOTH surfaces, not just in the log context: it is
|
|
1944
|
+
// the one `unusable` cause that may be adversarial, and it is the one the operator can act
|
|
1945
|
+
// on. The others are a peer whose build or bytes we could not read.
|
|
1946
|
+
/**
|
|
1947
|
+
* 033-ACKEMIT — AND THE THREE ACKNOWLEDGEMENT CAUSES GET THEIR OWN SURFACE REASON, for the
|
|
1948
|
+
* same argument that gave the replay one: `authorship_proof_unusable` tells the operator the
|
|
1949
|
+
* proof was "unreadable, or signed over different content", and for these it is neither.
|
|
1950
|
+
* The proof is perfect; what it CLAIMS TO HAVE SEEN is wrong. Filing them under the generic
|
|
1951
|
+
* name would send someone to audit a decoder, and would spend the operator's attention
|
|
1952
|
+
* asking their counterparty about a version number that is not the question.
|
|
1953
|
+
*/
|
|
1954
|
+
this.#ctx.refusals.refuseUnprovenAuthorship(agentName, sessionId, authorship.reason === AUTHORSHIP_SESSION_MISMATCH
|
|
1955
|
+
? "authorship_wrong_conversation"
|
|
1956
|
+
/**
|
|
1957
|
+
* `DOD-M15-SELFCHAIN-1` — its own name on the surface the operator reads, not only in a
|
|
1958
|
+
* log field. See the sentences in `#refuseUnprovenAuthorship`.
|
|
1959
|
+
*/
|
|
1960
|
+
: authorship.reason === AUTHORSHIP_SELF_CHAIN_MISMATCH
|
|
1961
|
+
? AUTHORSHIP_SELF_CHAIN_MISMATCH
|
|
1962
|
+
: ACK_HASH_REASONS.has(authorship.reason)
|
|
1963
|
+
/**
|
|
1964
|
+
* ⚠️ THE SPECIFIC CAUSE, NOT THE CLASS — review F5, and the diff's own comment on
|
|
1965
|
+
* `ACK_HASH_REASONS` had already said why: "the operator's next move differs for
|
|
1966
|
+
* each." It then collapsed all three into ONE surface reason carrying ONE sentence,
|
|
1967
|
+
* so the three names survived only in a log field nobody reads. For an absent
|
|
1968
|
+
* acknowledgement the shared impact was flatly false — there is no part that "does
|
|
1969
|
+
* not match", because there is no part — and for the other two the shared guidance
|
|
1970
|
+
* sent the reader to ask about a build version that cannot be the cause.
|
|
1971
|
+
*/
|
|
1972
|
+
? authorship.reason
|
|
1973
|
+
: "authorship_proof_unusable", contentHash, { detail: authorship.reason }, correlationId);
|
|
1974
|
+
/**
|
|
1975
|
+
* ─── AND THE SESSION FREEZES, EVENTUALLY — `DOD-M15-SELFCHAIN-1` + `-GAP-1` ──────────────
|
|
1976
|
+
*
|
|
1977
|
+
* ⚠️ ONLY THIS ONE OF THE `unusable` CAUSES FREEZES. The acknowledgement causes say the
|
|
1978
|
+
* sender is wrong about what WE said, which a drifted record produces honestly. This one
|
|
1979
|
+
* says they are wrong about what THEY said, so continuing writes a disputed order into the
|
|
1980
|
+
* receipt. The freeze is what makes the refusal an ESCALATION rather than a dropped frame.
|
|
1981
|
+
*
|
|
1982
|
+
* ⚠️ **BUT IT IS NO LONGER IMMEDIATE.** "A party cannot be honestly mistaken about what
|
|
1983
|
+
* they said" is false on the relay path — a message routinely arrives before its own
|
|
1984
|
+
* predecessor, which is still in the mailbox. The decision, the grace and its log line live
|
|
1985
|
+
* with the gap state, in `AuthorshipVerifier.noteSelfChainGapAndShouldFreeze`.
|
|
1986
|
+
*/
|
|
1987
|
+
if (authorship.reason === AUTHORSHIP_SELF_CHAIN_MISMATCH
|
|
1988
|
+
&& this.#ctx.authorship.noteSelfChainGapAndShouldFreeze(agentName, sessionId, correlationId)) {
|
|
1989
|
+
await this.#ctx.freezeOnIdentityFailure(agentName, sessionId, authorship.reason, correlationId);
|
|
1990
|
+
}
|
|
1991
|
+
return;
|
|
1992
|
+
}
|
|
1993
|
+
if (authorship.verdict === "verified") {
|
|
1994
|
+
verifiedAuthorship = { senderPubkey: authorship.senderPubkey, senderSig: authorship.senderSig };
|
|
1995
|
+
}
|
|
1996
|
+
else {
|
|
1997
|
+
verifiedSignerUnmatched = authorship.senderPubkey;
|
|
1998
|
+
}
|
|
1999
|
+
if (s2Cbor instanceof Uint8Array) {
|
|
2000
|
+
const ordering = this.#ctx.refusals.recordFrameOrdering(agentName, sessionId, s1Cbor, s2Cbor, contentHash, correlationId);
|
|
2001
|
+
if (ordering.fatal) {
|
|
2002
|
+
await this.#ctx.freezeOnIdentityFailure(agentName, sessionId, ordering.fatal.reason, correlationId);
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
framedSeq = ordering.seq;
|
|
2006
|
+
}
|
|
2007
|
+
else {
|
|
2008
|
+
/**
|
|
2009
|
+
* POSITION IS THE ONLY THING THAT CAN BE ABSENT NOW, and this event is about position.
|
|
2010
|
+
*
|
|
2011
|
+
* It fires on the relay-degraded path, where the sender had no witnessed record to stamp
|
|
2012
|
+
* on. The message is ingested — its author is proven, above, by the frame's own signature —
|
|
2013
|
+
* and only its place in the canonical sequence falls back to the witness stream. Refusing
|
|
2014
|
+
* here would make the relay a precondition for reading mail, which is the thing this unit
|
|
2015
|
+
* was careful NOT to do.
|
|
2016
|
+
*/
|
|
2017
|
+
this.#ctx.logger.info("session.content.ordering.absent", {
|
|
2018
|
+
agentName, sessionId, correlationId,
|
|
2019
|
+
impact: "this frame carried no relay ordering record, so its POSITION in the canonical sequence is not known from the frame and falls back to the witness stream. Its AUTHOR was verified from the frame's own signature.",
|
|
2020
|
+
});
|
|
2021
|
+
}
|
|
2022
|
+
// AC-001: carry the sender's correlationId from the frame into the receive
|
|
2023
|
+
// path so both sides log the same flow id (never re-minted on receipt).
|
|
2024
|
+
/**
|
|
2025
|
+
* DOD-M15-SEALWIRE-1 part B1 — the algorithm the sender named, taken from the FRAME.
|
|
2026
|
+
*
|
|
2027
|
+
* Read as `unknown` and passed through verbatim, deliberately: `resolveContentHashAlg` is the
|
|
2028
|
+
* one place that decides what a value means, and it distinguishes ABSENT (a peer predating
|
|
2029
|
+
* the field — verify as `sha256`) from a non-string or an unreadable name (refuse by name).
|
|
2030
|
+
* Coercing here would collapse that distinction and turn a version skew into a tamper report.
|
|
2031
|
+
*/
|
|
2032
|
+
const declaredAlg = frame["content_hash_alg"];
|
|
2033
|
+
const ingest = await this.ingestReceivedContent(
|
|
2034
|
+
// THE DECRYPTED body — everything downstream (the hash cross-check, the leaf, the transcript,
|
|
2035
|
+
// the delivery buffer) works on plaintext, exactly as it did before this layer existed.
|
|
2036
|
+
agentName, sessionId, plaintextBody, contentHash, correlationId, framedSeq ?? undefined, declaredAlg === undefined ? undefined : declaredAlg, verifiedAuthorship, verifiedSignerUnmatched);
|
|
2037
|
+
// AC-001: after the content is durably ingested AND its hash cross-check
|
|
2038
|
+
// succeeds, emit an unsigned `persisted` delivery ACK back to the sender. A
|
|
2039
|
+
// rejected ingest (tamper / not-active) produces NO ACK, so the sender's TTF
|
|
2040
|
+
// path can park / recover.
|
|
2041
|
+
// DOD-MSG-4: a HELD (out-of-order) frame is NOT yet a durable leaf, so it is NOT
|
|
2042
|
+
// acknowledged `persisted` — the sender's TTF→park backstop then guarantees the
|
|
2043
|
+
// missing-earlier message is fetchable, and dedup absorbs the redundant copy.
|
|
2044
|
+
if (ingest.ok && !ingest.held) {
|
|
2045
|
+
/**
|
|
2046
|
+
* ─── THE SELF CHAIN ADVANCES INSIDE `ingestReceivedContent`, NOT HERE — `DOD-M15-SELFCHAIN-1`
|
|
2047
|
+
*
|
|
2048
|
+
* It was called here, and this is the live content-frame path only: a message recovered from
|
|
2049
|
+
* the relay mailbox never reached it. It moved down into the ingest's durable-append point,
|
|
2050
|
+
* which every route ends at — see the comment there for what that cost live.
|
|
2051
|
+
*
|
|
2052
|
+
* The reason it is not inside `#noteAcknowledgeable` still stands and is why it did not move
|
|
2053
|
+
* there: the acknowledgement is a (POSITION, content) pair needing the relay's number, so on
|
|
2054
|
+
* a session the relay never witnessed it is never written, while the self link needs no
|
|
2055
|
+
* position at all — it is one party's hash chain over their own messages.
|
|
2056
|
+
*/
|
|
2057
|
+
/**
|
|
2058
|
+
* 033-ACKEMIT review F1 — ACKNOWLEDGE WHAT ARRIVED, HERE, not when the relay gets round to
|
|
2059
|
+
* delivering its copy back to us.
|
|
2060
|
+
*
|
|
2061
|
+
* Placed after a successful, non-held ingest deliberately: a HELD frame is not yet a durable
|
|
2062
|
+
* leaf and is not acknowledged `persisted` either, so claiming to have seen it would put a
|
|
2063
|
+
* position in our signed claim that our own record does not yet hold.
|
|
2064
|
+
*
|
|
2065
|
+
* `framedSeq` is the relay's canonical position taken from the sender's own signed ordering
|
|
2066
|
+
* record and verified before it got here. When it is absent the message arrived with no
|
|
2067
|
+
* ordering record — the withheld-submit case — and there is no position to acknowledge,
|
|
2068
|
+
* whatever we hold of the content. That limit is structural to a (position, content) pair
|
|
2069
|
+
* and it is what the carried-leaf follow-on closes.
|
|
2070
|
+
*/
|
|
2071
|
+
if (framedSeq !== null) {
|
|
2072
|
+
this.noteAcknowledgeable(agentName, sessionId, framedSeq, contentHash);
|
|
2073
|
+
}
|
|
2074
|
+
else {
|
|
2075
|
+
/**
|
|
2076
|
+
* ─── WITNESS WHAT THEY DID NOT — 034-CARRYLEAF, and this is the line that closes
|
|
2077
|
+
* `DOD-M15-WITHHOLD-SEAL-1` ────────────────────────────────────────────────────────────
|
|
2078
|
+
*
|
|
2079
|
+
* No ordering record means the sender never asked the relay to witness this message. Two
|
|
2080
|
+
* things look identical from here: their relay was briefly unreachable, or they are
|
|
2081
|
+
* withholding it on purpose so it cannot appear in the receipt. **We do not need to tell
|
|
2082
|
+
* those apart, and that is the point** — the same action repairs both, and it costs the
|
|
2083
|
+
* honest case nothing.
|
|
2084
|
+
*
|
|
2085
|
+
* We hold their signature over their own bytes. So we hand it to the relay ourselves.
|
|
2086
|
+
*/
|
|
2087
|
+
/**
|
|
2088
|
+
* ─── THE KIND COMES OFF THE FRAME, AND A FRAME WITHOUT ONE IS REFUSED ─────────────────
|
|
2089
|
+
*
|
|
2090
|
+
* A leaf kind selects a HASH DOMAIN — documents and rejection envelopes ride this same
|
|
2091
|
+
* frame — so witnessing under a guessed domain would put a wrong statement in the
|
|
2092
|
+
* canonical record.
|
|
2093
|
+
*
|
|
2094
|
+
* ⚠️ **THIS USED TO DECLINE TO WITNESS AND DELIVER THE MESSAGE ANYWAY, "because a peer
|
|
2095
|
+
* too old to send the field should be left alone". THAT SENTENCE WAS INHERITED, NOT
|
|
2096
|
+
* DERIVED, AND IT LEFT THE WHOLE ATTACK OPEN.** CELLO is alpha with no users; there is no
|
|
2097
|
+
* older peer to protect. What the leniency actually bought was an opt-out: emit the shape
|
|
2098
|
+
* a 2026-09-04 build emitted, and your message is delivered AND cannot be witnessed —
|
|
2099
|
+
* which is precisely the withholding this line exists to stop, reachable by anyone
|
|
2100
|
+
* willing to modify their client.
|
|
2101
|
+
*
|
|
2102
|
+
* So it is refused. Missing, malformed and mismatched take one path (§5), and a peer that
|
|
2103
|
+
* cannot say which domain its own leaf belongs to has supplied an unusable proof.
|
|
2104
|
+
*/
|
|
2105
|
+
const framedKind = frame["leaf_kind"];
|
|
2106
|
+
if (typeof framedKind !== "number") {
|
|
2107
|
+
this.#ctx.refusals.refuseUnprovenAuthorship(agentName, sessionId, "authorship_proof_unusable", contentHash, {
|
|
2108
|
+
detail: "leaf_kind_absent",
|
|
2109
|
+
}, correlationId);
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
void this.witnessReceivedLeaf(agentName, sessionId, contentHash, s1Cbor, senderSig, framedKind, correlationId);
|
|
2113
|
+
}
|
|
2114
|
+
void sendDeliveryAck(this.#ctx, agentName, sessionId, contentHash, correlationId);
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
catch (err) {
|
|
2118
|
+
this.#ctx.logger.warn("session.content.stream.read.failed", {
|
|
2119
|
+
sessionId,
|
|
2120
|
+
error: extractErrorMessage(err),
|
|
2121
|
+
});
|
|
2122
|
+
}
|
|
2123
|
+
finally {
|
|
2124
|
+
// `close()` waits only for OUR write buffer, which is empty here, so this cannot stall the
|
|
2125
|
+
// handler; it runs on every exit above, and there are several early returns.
|
|
2126
|
+
try {
|
|
2127
|
+
await stream.close();
|
|
2128
|
+
}
|
|
2129
|
+
catch (err) {
|
|
2130
|
+
// NOT SILENT. A close that fails here is the signature of the cap biting from the other
|
|
2131
|
+
// side, and it was the absence of exactly this line that turned the original diagnosis
|
|
2132
|
+
// into a 6,451-record log measurement.
|
|
2133
|
+
this.#ctx.logger.warn("session.content.stream.close.failed", {
|
|
2134
|
+
sessionId,
|
|
2135
|
+
error: extractErrorMessage(err),
|
|
2136
|
+
});
|
|
2137
|
+
try {
|
|
2138
|
+
stream.abort(err instanceof Error ? err : new Error(String(err)));
|
|
2139
|
+
}
|
|
2140
|
+
catch { /* already gone */ }
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
// OUR CLOSE ALONE DOES NOT FREE THE SLOT — the peer has to close its end too, and a peer
|
|
2144
|
+
// owns its own daemon. Without this, someone who opens content streams and never closes them
|
|
2145
|
+
// pins every inbound slot we have and puts us straight back into the defect above, with the
|
|
2146
|
+
// same unreadable error. `abort` resets unilaterally, so it works regardless of the peer;
|
|
2147
|
+
// the delay is what keeps it from landing while a well-behaved sender is still inside its
|
|
2148
|
+
// own `close()`. Unref'd so it can never hold the process open at shutdown, and tracked so
|
|
2149
|
+
// teardown can drop it.
|
|
2150
|
+
if (stream.status === "open" || stream.status === "closing") {
|
|
2151
|
+
const linger = setTimeout(() => {
|
|
2152
|
+
this.#ctx.lingeringStreams.delete(linger);
|
|
2153
|
+
if (stream.status !== "open" && stream.status !== "closing")
|
|
2154
|
+
return;
|
|
2155
|
+
this.#ctx.logger.debug("session.content.stream.linger.reset", { sessionId });
|
|
2156
|
+
try {
|
|
2157
|
+
stream.abort(new Error("inbound content stream not closed by peer"));
|
|
2158
|
+
}
|
|
2159
|
+
catch { /* already gone */ }
|
|
2160
|
+
}, CONTENT_STREAM_LINGER_MS);
|
|
2161
|
+
linger.unref?.();
|
|
2162
|
+
this.#ctx.lingeringStreams.add(linger);
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
//# sourceMappingURL=session-content-ingest.js.map
|