@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,2203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOD-DOC-TOOLS-1 — the operator's surface onto documents.
|
|
3
|
+
*
|
|
4
|
+
* Until this module existed, nothing in production called `createDocument`. Every unit below it was
|
|
5
|
+
* built and tested, and none of it was reachable: no operator could propose a document, so no
|
|
6
|
+
* document existed, so the delivery sweep was a no-op by construction and the inbound path never
|
|
7
|
+
* had anything addressed to it. The layer was complete and unreachable, which reads exactly like a
|
|
8
|
+
* layer that works.
|
|
9
|
+
*
|
|
10
|
+
* ── WHY PROPOSE AND ACCEPT ARE SEPARATE VERBS ─────────────────────────────────────────────────
|
|
11
|
+
*
|
|
12
|
+
* A document is a standing agreement to apply a counterparty's signed operations to local state.
|
|
13
|
+
* That is a larger grant than receiving a message, and §16.3 puts a human consent decision in front
|
|
14
|
+
* of it. Accepting is the moment the operator agrees; everything after is the CRDT converging
|
|
15
|
+
* without asking again. So the proposal is recorded, listed, and answered once — never inferred
|
|
16
|
+
* from the first update arriving.
|
|
17
|
+
*
|
|
18
|
+
* ── WRITE IS FULL-CONTENT, NOT A PATCH ────────────────────────────────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* `cello_doc_write` takes the document's whole new text and the engine diffs it against the live
|
|
21
|
+
* doc. An agent that emits a patch has to be right about offsets in a document its peer is
|
|
22
|
+
* concurrently editing, and a wrong offset in a CRDT is not a rejected patch — it is a permanent,
|
|
23
|
+
* silent corruption that both sides converge on. Full content moves that problem to a diff run
|
|
24
|
+
* against the state the operator actually saw.
|
|
25
|
+
*/
|
|
26
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
27
|
+
import * as Y from "yjs";
|
|
28
|
+
import { encodeDocumentProposal, deriveDocumentState, documentGovernancePolicy, arrangementGenesisFromProposal, documentAmendmentHash, buildDocumentMultisigTbs, encodeDocumentAmendment, buildDocumentProposalTbs, documentIdFromProposal, seamViolation, ASSURANCE_TIER_V1, TOPOLOGY_DEFAULT, DOCUMENT_FEATURE_VERSION, encodeDocumentProposalAck, buildDocumentProposalAckTbs, DOCUMENT_PROPOSAL_ACK_VERSION, MAX_PROPOSAL_REFUSAL_REASON_LENGTH, } from "@cello-protocol/protocol-types";
|
|
29
|
+
/**
|
|
30
|
+
* How long one holder's amendment send may be outstanding before the daemon SAYS SO —
|
|
31
|
+
* `DOD-M15-DOCACCEPT-UNBOUNDED-1`.
|
|
32
|
+
*
|
|
33
|
+
* **This is a reporting threshold, not a timeout.** Nothing is cancelled when it fires; the send is
|
|
34
|
+
* still awaited. It exists because the fan-out is sequential and unbounded, so a single unreachable
|
|
35
|
+
* holder stalls the whole operation — and until now the daemon logged nothing at all while that
|
|
36
|
+
* happened, leaving the failure loud in neither the log nor the agent response.
|
|
37
|
+
*
|
|
38
|
+
* Chosen well under the MCP client's own 60s request timeout, so the line lands **while the operator
|
|
39
|
+
* is still waiting** rather than after their tool call has already given up. A threshold that fires
|
|
40
|
+
* after the caller has gone is a line nobody reads.
|
|
41
|
+
*/
|
|
42
|
+
const HOLDER_OPENING_INFO_MS = 5_000;
|
|
43
|
+
/**
|
|
44
|
+
* When a holder's send stops being "slow" and starts being "not answering" —
|
|
45
|
+
* `DOD-M15-DOCACCEPT-UNBOUNDED-1`.
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ THE FIRST VERSION HAD ONE THRESHOLD AT 5s AND IT FIRED ON THE HEALTHY CASE. Review measured real
|
|
48
|
+
* cold-session-open latency from a 150 MB daemon log — 74 opens where a fresh session had to be
|
|
49
|
+
* dialled: **p50 12.9s, p90 25.1s, min 3.6s, and only 9 of 74 under 5s.** So a single `warn` at 5s
|
|
50
|
+
* saying *"this holder cannot currently be reached"* was **false on ~88% of the cold opens that
|
|
51
|
+
* trigger it** — the holder WAS reached, twelve seconds later. That is precisely the outcome I said I
|
|
52
|
+
* was guarding against: a signal that fires on the normal case is one operators learn to ignore, which
|
|
53
|
+
* is worse than the silence it replaced. It was also error substitution — naming *unreachability* and
|
|
54
|
+
* sending the reader to the transport when the true state is "a session is being opened".
|
|
55
|
+
*
|
|
56
|
+
* Two thresholds now. **5s is INFO and says what is actually happening**; **30s is WARN and says the
|
|
57
|
+
* holder has not answered** — above the observed p90, still under the client's own 60s request
|
|
58
|
+
* timeout so the line lands while the operator is still waiting.
|
|
59
|
+
*/
|
|
60
|
+
const HOLDER_UNANSWERED_WARN_MS = 30_000;
|
|
61
|
+
import { lineHunks, isSupportedDocumentType, SUPPORTED_DOCUMENT_TYPES } from "./document-write-path.js";
|
|
62
|
+
import { openingNoticeFor, rootForDocumentType } from "./document-types.js";
|
|
63
|
+
import { projectDocumentText, parseJsonDocument, applyJsonToMap } from "./document-json.js";
|
|
64
|
+
import { classifyRemovals } from "./document-write-guard.js";
|
|
65
|
+
import { normalizeWatchPaths } from "./document-watch.js";
|
|
66
|
+
import { profileViolation } from "./document-profile.js";
|
|
67
|
+
import { screenText, SCREEN_RULE_ID, SCREEN_GUIDANCE } from "./document-screen.js";
|
|
68
|
+
import { extractErrorMessage } from "./error-message.js";
|
|
69
|
+
/** Document types the notification/diff path understands. Anything else is stored, not diffed. */
|
|
70
|
+
const DEFAULT_DOCUMENT_TYPE = "markdown";
|
|
71
|
+
/** Ends a fragment with a full stop unless it already ends in one — see the refusal below. */
|
|
72
|
+
function withStop(text) {
|
|
73
|
+
return /[.!?]$/.test(text.trimEnd()) ? text : `${text.trimEnd()}.`;
|
|
74
|
+
}
|
|
75
|
+
export function registerDocumentHandlers(deps) {
|
|
76
|
+
const { handlers, logger, layer, publish } = deps;
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the agent AND its owner key together, because a handler that has one without the other
|
|
79
|
+
* is a handler that will pick the wrong scope. Returns a refusal the handler returns verbatim.
|
|
80
|
+
*/
|
|
81
|
+
function resolve(params, connectionId) {
|
|
82
|
+
const explicit = typeof params?.agent === "string" ? params.agent : undefined;
|
|
83
|
+
const agentName = deps.resolveAgent(connectionId, explicit);
|
|
84
|
+
if (agentName === null) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
reason: "no_current_agent",
|
|
88
|
+
guidance: "Call cello_use_agent first, or pass 'agent' to say which agent this is for.",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const ownerAgentId = deps.ownerKeyFor(agentName);
|
|
92
|
+
if (ownerAgentId === null) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
reason: "agent_identity_unavailable",
|
|
96
|
+
guidance: `Agent '${agentName}' has no signing identity loaded, so it cannot hold documents.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return { agentName, ownerAgentId };
|
|
100
|
+
}
|
|
101
|
+
const isRefusal = (r) => r.ok === false;
|
|
102
|
+
/**
|
|
103
|
+
* Documents holding an edit that was APPLIED to the live doc and never published.
|
|
104
|
+
*
|
|
105
|
+
* In memory deliberately, and it is not a shortcut: an unpublished edit exists only in the live
|
|
106
|
+
* `Y.Doc`, which is rebuilt from the envelope log on restart — so the edit and this flag are lost
|
|
107
|
+
* together. A durable flag would outlive the thing it describes and send the operator flushing
|
|
108
|
+
* something that no longer exists.
|
|
109
|
+
*/
|
|
110
|
+
const unpublishedEdits = new Set();
|
|
111
|
+
const unpublishedKey = (ownerAgentId, documentId) => `${ownerAgentId}\u0000${documentId}`;
|
|
112
|
+
/**
|
|
113
|
+
* Has the peer REFUSED this document? If so, nothing may be published into it.
|
|
114
|
+
*
|
|
115
|
+
* Without this a refused document keeps authoring envelopes: each one is signed, leafed, and
|
|
116
|
+
* delivered forever to a peer who has no such document and answers `document_unknown` every time.
|
|
117
|
+
* The operator's surface meanwhile shows `active` with a pending count that never clears — the
|
|
118
|
+
* exact shape of a collaboration that has silently stopped working, which is the failure this
|
|
119
|
+
* milestone exists to not have.
|
|
120
|
+
*
|
|
121
|
+
* Checked HERE rather than in `DocumentLifecycle.canPublish` because the consent decision lives in
|
|
122
|
+
* the handshake, and giving lifecycle a handshake dependency to answer one question would put the
|
|
123
|
+
* proposal protocol inside the status machine.
|
|
124
|
+
*
|
|
125
|
+
* UNANSWERED IS NOT REFUSED. Publishing before the peer has decided is normal and load-bearing —
|
|
126
|
+
* the update waits in the log and delivers when they accept, which is what makes proposing to an
|
|
127
|
+
* offline peer work at all.
|
|
128
|
+
*/
|
|
129
|
+
function peerRefused(who, documentId) {
|
|
130
|
+
const answer = layer.handshake.peerAnswer(who.ownerAgentId, documentId);
|
|
131
|
+
if (answer.accepted !== false)
|
|
132
|
+
return null;
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
reason: "document_peer_refused",
|
|
136
|
+
guidance: `Your peer refused this document${answer.reason ? ` — they said: "${answer.reason}"` : ""}. ` +
|
|
137
|
+
`Nothing published into it can ever reach them, so this write was not recorded. Propose a ` +
|
|
138
|
+
`new document if you want to try again with different terms.`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Write the document out as a file, or return null when no workspace is configured.
|
|
143
|
+
*
|
|
144
|
+
* Failures are LOGGED AND SWALLOWED, deliberately and only here: the file is a projection of the
|
|
145
|
+
* document, not the document. A disk problem must not fail a proposal or a consent decision that
|
|
146
|
+
* is otherwise complete and already recorded — the operator would be left with a peer who thinks
|
|
147
|
+
* they agreed and a local state that says they did not.
|
|
148
|
+
*/
|
|
149
|
+
async function materialize(ownerAgentId, documentId, documentType) {
|
|
150
|
+
if (!layer.writePath)
|
|
151
|
+
return { path: null, reason: "document_files_unavailable" };
|
|
152
|
+
try {
|
|
153
|
+
const path = await layer.writePath.materialize(ownerAgentId, documentId, documentType, layer.live.get(ownerAgentId, documentId));
|
|
154
|
+
return { path };
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
// THE REASON TRAVELS TO THE CALLER, not just to the log. `propose` and `accept` returned
|
|
158
|
+
// `filePath: null` and said nothing, while the tool description promises "returns its path" —
|
|
159
|
+
// so an operator looking for a file that is not there had no way to learn why without reading
|
|
160
|
+
// the daemon log. `cello_doc_write` was given `fileUpdated` and guidance for exactly this;
|
|
161
|
+
// these two were not.
|
|
162
|
+
const reason = err instanceof Error && "reason" in err
|
|
163
|
+
? String(err.reason)
|
|
164
|
+
: "document_file_error";
|
|
165
|
+
const detail = extractErrorMessage(err);
|
|
166
|
+
logger.warn("document.file.materialize_failed", { documentId, reason, detail });
|
|
167
|
+
return { path: null, reason, detail };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// ─── propose ──────────────────────────────────────────────────────────────────────────────
|
|
171
|
+
handlers.set("cello_doc_propose", async (params, connectionId) => {
|
|
172
|
+
const who = resolve(params, connectionId);
|
|
173
|
+
if (isRefusal(who))
|
|
174
|
+
return who;
|
|
175
|
+
// RETRY PATH. A proposal whose send failed left a real local document and an unreachable peer;
|
|
176
|
+
// proposing again would mint a fresh nonce, hence a fresh document_id, hence a SECOND document —
|
|
177
|
+
// leaving the first an orphan the operator cannot explain or clear. Given the id, the stored
|
|
178
|
+
// envelope is re-sent unchanged, so the peer sees the offer that was always meant for them.
|
|
179
|
+
const retryId = typeof params?.document_id === "string" ? params.document_id : "";
|
|
180
|
+
if (retryId.length > 0) {
|
|
181
|
+
const stored = layer.handshake.get(who.ownerAgentId, retryId);
|
|
182
|
+
if (!stored || stored.proposerAgentId !== who.ownerAgentId) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
reason: "document_proposal_not_ours",
|
|
186
|
+
guidance: `No proposal ${retryId.slice(0, 16)}… authored by this agent. Omit 'document_id' to ` +
|
|
187
|
+
`make a new proposal, or see cello_doc_list.`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const resendId = randomUUID();
|
|
191
|
+
const resent = await deps.transportFor(who.agentName).sendBytes({
|
|
192
|
+
peerAgentId: stored.envelope.peer_agent_id,
|
|
193
|
+
documentId: retryId,
|
|
194
|
+
bytes: encodeDocumentProposal(stored.envelope),
|
|
195
|
+
correlationId: resendId,
|
|
196
|
+
});
|
|
197
|
+
logger.info("document.proposal.resent", { documentId: retryId, sent: resent.ok, correlationId: resendId });
|
|
198
|
+
if (!resent.ok) {
|
|
199
|
+
return { ok: false, reason: resent.reason, guidance: resent.detail ?? "The peer is still unreachable." };
|
|
200
|
+
}
|
|
201
|
+
layer.handshake.markProposalSent(who.ownerAgentId, retryId, deps.now());
|
|
202
|
+
return { ok: true, documentId: retryId, proposalSent: true, peerAgentId: stored.envelope.peer_agent_id };
|
|
203
|
+
}
|
|
204
|
+
const peerAgentId = typeof params?.peer_pubkey === "string" ? params.peer_pubkey.toLowerCase() : "";
|
|
205
|
+
if (!/^[0-9a-f]{64}$/.test(peerAgentId)) {
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
reason: "invalid_peer_pubkey",
|
|
209
|
+
guidance: "cello_doc_propose requires 'peer_pubkey' — the counterparty's 32-byte hex K_local " +
|
|
210
|
+
"public key, which is also their agent id. See cello_contacts.",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (peerAgentId === who.ownerAgentId) {
|
|
214
|
+
// A document with yourself converges trivially and has no counterparty to consent, but every
|
|
215
|
+
// downstream unit would treat it as a real peer — including the delivery worker, which would
|
|
216
|
+
// dial the daemon it is running in.
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
reason: "document_peer_is_self",
|
|
220
|
+
guidance: "A document needs a counterparty. 'peer_pubkey' is this agent's own key.",
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const documentType = typeof params?.document_type === "string" ? params.document_type : DEFAULT_DOCUMENT_TYPE;
|
|
224
|
+
// REFUSED AT THE DOOR, because a type only some verbs can serve is worse than no support at all.
|
|
225
|
+
// `document_type` was previously unvalidated: "yaml" created a real, signed, peer-accepted
|
|
226
|
+
// document whose file materialisation threw and was swallowed, returning `filePath: null` with
|
|
227
|
+
// no explanation while the tool description promises a path. "json" was worse — genuinely
|
|
228
|
+
// supported by DocumentWritePath (map root) and not by read/write/diff (text root), so it read
|
|
229
|
+
// as empty, wrote into a root nothing projects, and diffed as unchanged forever.
|
|
230
|
+
if (!isSupportedDocumentType(documentType)) {
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
reason: "document_type_unsupported",
|
|
234
|
+
guidance: `'${documentType}' is not a document type this build can serve. Supported: ` +
|
|
235
|
+
`${[...SUPPORTED_DOCUMENT_TYPES].sort().join(", ")}. Nothing was created, so there is ` +
|
|
236
|
+
`nothing to clean up — re-propose with a supported type.`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const startingText = typeof params?.starting_content === "string" ? params.starting_content : "";
|
|
240
|
+
// SCREENED LIKE ANY OTHER CONTENT. The starting content is the largest single payload of text
|
|
241
|
+
// this path ever carries and it does not travel as an update, so the gate — which validates
|
|
242
|
+
// update envelopes — never sees it. Unscreened, an inviter could seed the affordance prefix or a
|
|
243
|
+
// turn marker into the document body and it would land in the invitee's agent context and on
|
|
244
|
+
// their disk verbatim, while the same string in an ordinary edit is refused.
|
|
245
|
+
const seedFault = startingText.length > 0 ? screenText(startingText) : null;
|
|
246
|
+
if (seedFault) {
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
reason: "document_content_refused",
|
|
250
|
+
guidance: `The starting content contains ${seedFault.codepoints.join(", ")} ` +
|
|
251
|
+
`(${seedFault.count} occurrence(s), first at character ${seedFault.offsets[0]}). ` +
|
|
252
|
+
`${SCREEN_GUIDANCE} Nothing was created, so there is nothing to clean up — remove it and ` +
|
|
253
|
+
`propose again.`,
|
|
254
|
+
detail: JSON.stringify({ rule: SCREEN_RULE_ID, ...seedFault }),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
// THE STARTING CONTENT IS A YJS UPDATE, not a string, and that is the whole reason it is on the
|
|
258
|
+
// proposal at all (§16.3 step 1). Both sides apply THESE BYTES, so epoch zero is byte-identical
|
|
259
|
+
// on both. Each side building its own doc from the same template string produces two documents
|
|
260
|
+
// that look the same and never converge — different client ids, different item ids, and every
|
|
261
|
+
// subsequent edit interleaving against a history the other has not got.
|
|
262
|
+
let startingContent = null;
|
|
263
|
+
if (startingText.length > 0) {
|
|
264
|
+
const seed = new Y.Doc();
|
|
265
|
+
// PINNED. A random client id would put the proposer's identity in the shared epoch-zero
|
|
266
|
+
// state, so the same proposal sent twice would produce different bytes and a different
|
|
267
|
+
// document_id — and the id is meant to be a function of what was proposed.
|
|
268
|
+
seed.clientID = 1;
|
|
269
|
+
// SEEDED INTO THE ROOT THE TYPE USES. A JSON document seeded into the text root would be
|
|
270
|
+
// accepted, materialize as `{}`, and read as empty on both sides — the starting content
|
|
271
|
+
// silently discarded while every surface reported success.
|
|
272
|
+
if (rootForDocumentType(documentType) === "map") {
|
|
273
|
+
const seeded = parseJsonDocument(startingText);
|
|
274
|
+
if (!seeded.ok) {
|
|
275
|
+
return {
|
|
276
|
+
ok: false,
|
|
277
|
+
reason: "document_content_unparseable",
|
|
278
|
+
guidance: `This is a JSON document and starting_content is not valid JSON (${seeded.detail}). ` +
|
|
279
|
+
`Nothing was created, so there is nothing to clean up.`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
applyJsonToMap(seed.getMap("data"), seeded.value, seed);
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
seed.getText("content").insert(0, startingText);
|
|
286
|
+
}
|
|
287
|
+
startingContent = Y.encodeStateAsUpdate(seed);
|
|
288
|
+
}
|
|
289
|
+
// THE ADMIN SET IS ALWAYS WRITTEN, NEVER SILENTLY ABSENT (GOVERN-1: the creation flow makes
|
|
290
|
+
// the choice legible). No `admins` param means EVERYONE — both genesis participants — and
|
|
291
|
+
// that default is recorded explicitly in the signed proposal rather than implied by an
|
|
292
|
+
// absent field, so the invitee consents to a stated rule, not a convention.
|
|
293
|
+
const rawAdmins = params?.admins;
|
|
294
|
+
if (rawAdmins !== undefined && (!Array.isArray(rawAdmins) || rawAdmins.length === 0)) {
|
|
295
|
+
return {
|
|
296
|
+
ok: false,
|
|
297
|
+
reason: "document_admins_invalid",
|
|
298
|
+
guidance: "admins must be a non-empty list of 64-hex pubkeys, or omitted for everyone-is-admin. " +
|
|
299
|
+
"Nothing was created.",
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
const adminSet = rawAdmins === undefined
|
|
303
|
+
? [who.ownerAgentId, peerAgentId]
|
|
304
|
+
: [...new Set(rawAdmins)];
|
|
305
|
+
for (const admin of adminSet) {
|
|
306
|
+
if (admin !== who.ownerAgentId && admin !== peerAgentId) {
|
|
307
|
+
return {
|
|
308
|
+
ok: false,
|
|
309
|
+
reason: "document_admins_invalid",
|
|
310
|
+
guidance: `${String(admin).slice(0, 16)}… is not a party to this document — a creation admin ` +
|
|
311
|
+
`must be you or the counterparty (admins are always holders). Nothing was created.`,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const properties = {
|
|
316
|
+
assurance_tier: ASSURANCE_TIER_V1,
|
|
317
|
+
schema_enforcement: false,
|
|
318
|
+
topology: TOPOLOGY_DEFAULT,
|
|
319
|
+
append_only: params?.append_only === true,
|
|
320
|
+
admin_set: adminSet,
|
|
321
|
+
};
|
|
322
|
+
const violation = seamViolation(properties);
|
|
323
|
+
if (violation) {
|
|
324
|
+
// Refused HERE rather than sent and refused by the peer: proposing something this build knows
|
|
325
|
+
// its counterparty must reject wastes a round trip and leaves a refused row on both sides.
|
|
326
|
+
return { ok: false, reason: "document_seam_violation", guidance: violation };
|
|
327
|
+
}
|
|
328
|
+
const envelope = {
|
|
329
|
+
type: "document_proposal",
|
|
330
|
+
feature_version: DOCUMENT_FEATURE_VERSION,
|
|
331
|
+
proposer_agent_id: who.ownerAgentId,
|
|
332
|
+
peer_agent_id: peerAgentId,
|
|
333
|
+
document_type: documentType,
|
|
334
|
+
properties,
|
|
335
|
+
starting_content: startingContent,
|
|
336
|
+
// Distinguishes two otherwise identical proposals. Without it, proposing the same document to
|
|
337
|
+
// the same peer twice collides on document_id and the second silently does nothing.
|
|
338
|
+
nonce: new Uint8Array(randomBytes(16)),
|
|
339
|
+
proposed_at_ms: deps.now(),
|
|
340
|
+
signature: new Uint8Array(0),
|
|
341
|
+
};
|
|
342
|
+
envelope.signature = await deps.sign(who.agentName, buildDocumentProposalTbs(envelope));
|
|
343
|
+
const documentId = documentIdFromProposal(envelope);
|
|
344
|
+
// LOCAL FIRST, then send. The reverse order loses the document if the process dies between the
|
|
345
|
+
// two, and the peer would then hold a document whose proposer has no record of proposing it —
|
|
346
|
+
// every update they send refused as `document_unknown`, with nothing on this side to explain it.
|
|
347
|
+
layer.store.createDocument({
|
|
348
|
+
documentId,
|
|
349
|
+
ownerAgentId: who.ownerAgentId,
|
|
350
|
+
peerAgentId,
|
|
351
|
+
documentType,
|
|
352
|
+
properties,
|
|
353
|
+
status: "active",
|
|
354
|
+
createdAtMs: envelope.proposed_at_ms,
|
|
355
|
+
});
|
|
356
|
+
// The envelope itself, so a failed send is recoverable rather than a dead end. See
|
|
357
|
+
// `DocumentHandshake.recordOutgoing`.
|
|
358
|
+
layer.handshake.recordOutgoing(who.ownerAgentId, envelope, envelope.proposed_at_ms);
|
|
359
|
+
if (startingContent) {
|
|
360
|
+
// Applied to OUR live doc from the same bytes the peer will apply, for the same reason they
|
|
361
|
+
// are on the wire at all.
|
|
362
|
+
Y.applyUpdate(layer.live.get(who.ownerAgentId, documentId), startingContent);
|
|
363
|
+
}
|
|
364
|
+
// THE FILE EXISTS FROM THE START. Materializing lazily would mean the first `cello_doc_publish`
|
|
365
|
+
// has no recorded projection to diff against and refuses — correct, and a bad first experience
|
|
366
|
+
// for something the operator never had to ask for.
|
|
367
|
+
const proposeFile = await materialize(who.ownerAgentId, documentId, documentType);
|
|
368
|
+
const correlationId = randomUUID();
|
|
369
|
+
const sent = await deps.transportFor(who.agentName).sendBytes({
|
|
370
|
+
peerAgentId,
|
|
371
|
+
documentId,
|
|
372
|
+
bytes: encodeDocumentProposal(envelope),
|
|
373
|
+
correlationId,
|
|
374
|
+
});
|
|
375
|
+
// `document.proposal.sent` per the DoD taxonomy. It was `document.proposed`, which reads as
|
|
376
|
+
// the same fact but does not match what an operator or a log query is told to look for.
|
|
377
|
+
logger.info("document.proposal.sent", { documentId, peerAgentId, sent: sent.ok, correlationId });
|
|
378
|
+
if (!sent.ok) {
|
|
379
|
+
// The document EXISTS and the proposal did not arrive. Both facts are reported, because
|
|
380
|
+
// reporting only the failure would hide a real local row and reporting only success would
|
|
381
|
+
// have the operator wait for a consent decision the peer was never asked to make.
|
|
382
|
+
return {
|
|
383
|
+
ok: true,
|
|
384
|
+
documentId,
|
|
385
|
+
proposalSent: false,
|
|
386
|
+
reason: sent.reason,
|
|
387
|
+
guidance: `The document was created locally but the proposal did not reach the peer (${sent.reason}). ` +
|
|
388
|
+
`Once they are online, run cello_doc_propose again with document_id='${documentId}' to ` +
|
|
389
|
+
`re-send this same offer — do not propose a new one, that would make a second document.`,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
layer.handshake.markProposalSent(who.ownerAgentId, documentId, deps.now());
|
|
393
|
+
return {
|
|
394
|
+
ok: true,
|
|
395
|
+
documentId,
|
|
396
|
+
proposalSent: true,
|
|
397
|
+
peerAgentId,
|
|
398
|
+
filePath: proposeFile.path,
|
|
399
|
+
// A file that RUNS when opened says so where the operator is handed its path — see
|
|
400
|
+
// `openingNoticeFor`. Only html carries one today, and only because a peer can write into it.
|
|
401
|
+
...(proposeFile.path !== null && openingNoticeFor(documentType) !== undefined
|
|
402
|
+
? { fileNotice: openingNoticeFor(documentType) }
|
|
403
|
+
: {}),
|
|
404
|
+
// Said out loud rather than left as a silent null against a description that promises a path.
|
|
405
|
+
...(proposeFile.path === null
|
|
406
|
+
? {
|
|
407
|
+
fileUnavailableReason: proposeFile.reason,
|
|
408
|
+
guidance: `The document exists and the offer was sent, but no file was written for it ` +
|
|
409
|
+
`(${proposeFile.reason}${proposeFile.detail ? `: ${proposeFile.detail}` : ""}). Use ` +
|
|
410
|
+
`cello_doc_read and cello_doc_write, which do not need the file.`,
|
|
411
|
+
}
|
|
412
|
+
: {}),
|
|
413
|
+
};
|
|
414
|
+
});
|
|
415
|
+
/**
|
|
416
|
+
* Tell the proposer what was decided.
|
|
417
|
+
*
|
|
418
|
+
* BEST-EFFORT, and the caller says so rather than failing the decision. Consent is local and
|
|
419
|
+
* final the moment the operator makes it — refusing to accept a document because the counterparty
|
|
420
|
+
* is momentarily unreachable would hand any network blip a veto over the operator's own choice.
|
|
421
|
+
* What an unsent ack costs is that the proposer keeps waiting, which the surface reports as
|
|
422
|
+
* unanswered rather than as a refusal.
|
|
423
|
+
*/
|
|
424
|
+
async function tellProposer(who, documentId, proposerAgentId, accepted, reason) {
|
|
425
|
+
const ack = {
|
|
426
|
+
type: "document_proposal_ack",
|
|
427
|
+
ack_version: DOCUMENT_PROPOSAL_ACK_VERSION,
|
|
428
|
+
document_id: documentId,
|
|
429
|
+
acker_agent_id: who.ownerAgentId,
|
|
430
|
+
accepted,
|
|
431
|
+
...(accepted ? {} : { refusal_reason: (reason ?? "declined").slice(0, MAX_PROPOSAL_REFUSAL_REASON_LENGTH) }),
|
|
432
|
+
decided_at_ms: deps.now(),
|
|
433
|
+
signature: new Uint8Array(0),
|
|
434
|
+
};
|
|
435
|
+
// BEST-EFFORT MEANS BEST-EFFORT FOR A THROW TOO, and it did not.
|
|
436
|
+
//
|
|
437
|
+
// The header above states the contract: consent is local and final, and an unsent ack must not
|
|
438
|
+
// fail the decision. That held only for a RETURNED failure. `sign` goes through a key provider
|
|
439
|
+
// and `sendBytes` opens a session — a directory negotiation, a dial, and a seal ceremony on its
|
|
440
|
+
// last line — any of which can throw. Nothing caught them.
|
|
441
|
+
//
|
|
442
|
+
// By the time this runs, `accept` has already committed the consent transition and created the
|
|
443
|
+
// document. So a throw here reached the IPC boundary as `internal_error` with "An unexpected
|
|
444
|
+
// error occurred", the operator re-ran accept, and got "a consent decision is made once" — which
|
|
445
|
+
// reads as a bug in the handshake rather than a send that failed after the decision stuck.
|
|
446
|
+
try {
|
|
447
|
+
ack.signature = await deps.sign(who.agentName, buildDocumentProposalAckTbs(ack));
|
|
448
|
+
const sent = await deps.transportFor(who.agentName).sendBytes({
|
|
449
|
+
peerAgentId: proposerAgentId,
|
|
450
|
+
documentId,
|
|
451
|
+
bytes: encodeDocumentProposalAck(ack),
|
|
452
|
+
correlationId: randomUUID(),
|
|
453
|
+
});
|
|
454
|
+
if (!sent.ok) {
|
|
455
|
+
logger.warn("document.proposal.ack_unsent", { documentId, accepted, reason: sent.reason });
|
|
456
|
+
}
|
|
457
|
+
return sent.ok;
|
|
458
|
+
}
|
|
459
|
+
catch (err) {
|
|
460
|
+
// Same outcome as a returned failure, and reported the same way: the peer was not told. The
|
|
461
|
+
// caller already surfaces that as `proposerNotified: false`.
|
|
462
|
+
logger.warn("document.proposal.ack_threw", {
|
|
463
|
+
documentId,
|
|
464
|
+
accepted,
|
|
465
|
+
error: extractErrorMessage(err),
|
|
466
|
+
});
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
// ─── inbox / accept / refuse ──────────────────────────────────────────────────────────────
|
|
471
|
+
handlers.set("cello_doc_inbox", async (params, connectionId) => {
|
|
472
|
+
const who = resolve(params, connectionId);
|
|
473
|
+
if (isRefusal(who))
|
|
474
|
+
return who;
|
|
475
|
+
const pending = layer.handshake.pending(who.ownerAgentId);
|
|
476
|
+
return {
|
|
477
|
+
ok: true,
|
|
478
|
+
proposals: pending.map((p) => ({
|
|
479
|
+
documentId: p.documentId,
|
|
480
|
+
proposerAgentId: p.proposerAgentId,
|
|
481
|
+
documentType: p.envelope.document_type,
|
|
482
|
+
appendOnly: p.envelope.properties.append_only,
|
|
483
|
+
hasStartingContent: p.envelope.starting_content !== null,
|
|
484
|
+
proposedAtMs: p.envelope.proposed_at_ms,
|
|
485
|
+
})),
|
|
486
|
+
// SYNC-P4 (D5 deleted): open invitations, DERIVED — a document this daemon holds whose
|
|
487
|
+
// entry set says this agent is an invited seat. The operator consents to what their own
|
|
488
|
+
// daemon computed from the signed record, not to a carried snapshot; cello_doc_accept
|
|
489
|
+
// re-derives at the moment of consequence. The inviter shown is the author of the admit
|
|
490
|
+
// entry naming this agent (the genesis proposer for a genesis-named seat).
|
|
491
|
+
joins: layer.store
|
|
492
|
+
.listDocuments(who.ownerAgentId)
|
|
493
|
+
.filter((d) => d.status === "active")
|
|
494
|
+
.flatMap((d) => {
|
|
495
|
+
const genesisRecord = layer.handshake.get(who.ownerAgentId, d.documentId);
|
|
496
|
+
if (!genesisRecord)
|
|
497
|
+
return [];
|
|
498
|
+
const derived = deriveDocumentState(arrangementGenesisFromProposal(genesisRecord.envelope), layer.amendments.chain(who.ownerAgentId, d.documentId), documentGovernancePolicy, layer.verifySignature);
|
|
499
|
+
if (!derived.ok || !derived.state.invited.has(who.ownerAgentId))
|
|
500
|
+
return [];
|
|
501
|
+
const admit = layer.amendments
|
|
502
|
+
.chain(who.ownerAgentId, d.documentId)
|
|
503
|
+
.find((e) => e.body.kind === "add_holder" && e.body.subject_agent_id === who.ownerAgentId);
|
|
504
|
+
return [
|
|
505
|
+
{
|
|
506
|
+
documentId: d.documentId,
|
|
507
|
+
inviterAgentId: admit?.body.author_agent_id ?? genesisRecord.envelope.proposer_agent_id,
|
|
508
|
+
participants: [...derived.state.participants].sort(),
|
|
509
|
+
invited: [...derived.state.invited].sort(),
|
|
510
|
+
admins: [...derived.state.admins].sort(),
|
|
511
|
+
properties: derived.state.properties,
|
|
512
|
+
assuranceTier: "authenticated",
|
|
513
|
+
documentType: d.documentType,
|
|
514
|
+
},
|
|
515
|
+
];
|
|
516
|
+
}),
|
|
517
|
+
};
|
|
518
|
+
});
|
|
519
|
+
// The join-answer frame died with D5 — the consent/refusal ENTRY is the answer, and it fans out.
|
|
520
|
+
handlers.set("cello_doc_accept", async (params, connectionId) => {
|
|
521
|
+
const who = resolve(params, connectionId);
|
|
522
|
+
if (isRefusal(who))
|
|
523
|
+
return who;
|
|
524
|
+
const documentId = typeof params?.document_id === "string" ? params.document_id : "";
|
|
525
|
+
if (documentId.length === 0) {
|
|
526
|
+
return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_inbox." };
|
|
527
|
+
}
|
|
528
|
+
// THE CURE FOR A HALF-CONSENTED DOCUMENT (P2 review F2): if this agent already holds the
|
|
529
|
+
// document but its own derivation says it is still an INVITED seat, the earlier accept
|
|
530
|
+
// recorded the decision and then failed to record the consent entry — and both decision rows
|
|
531
|
+
// are settled, so neither branch below can run again. Re-running accept authors the missing
|
|
532
|
+
// consent, which is exactly what the failure guidance promises.
|
|
533
|
+
if (layer.store.getDocument(who.ownerAgentId, documentId)) {
|
|
534
|
+
const genesisRecord = layer.handshake.get(who.ownerAgentId, documentId);
|
|
535
|
+
if (genesisRecord) {
|
|
536
|
+
const standing = deriveDocumentState(arrangementGenesisFromProposal(genesisRecord.envelope), layer.amendments.chain(who.ownerAgentId, documentId), documentGovernancePolicy, layer.verifySignature);
|
|
537
|
+
if (standing.ok && standing.state.invited.has(who.ownerAgentId)) {
|
|
538
|
+
// THE JOIN PATH (SYNC-P4, D5 deleted): the document arrived through the exchange, this
|
|
539
|
+
// agent derives as an invited seat, and accepting IS authoring the consent entry (R21)
|
|
540
|
+
// — which travels to every holder over the same carrier as everything else. Until it
|
|
541
|
+
// reaches them, their fold shows this agent invited, not participating. Idempotent by
|
|
542
|
+
// re-run: a consent that failed to record is authored on the next accept.
|
|
543
|
+
const consent = await authorConsent(who, documentId);
|
|
544
|
+
if (!consent.ok) {
|
|
545
|
+
return {
|
|
546
|
+
ok: false,
|
|
547
|
+
reason: "document_consent_unrecorded",
|
|
548
|
+
guidance: `You hold this document but your consent entry is not recorded ` +
|
|
549
|
+
`(${consent.reason}) — run cello_doc_accept again once the named condition ` +
|
|
550
|
+
`clears.`,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
const doc = layer.store.getDocument(who.ownerAgentId, documentId);
|
|
554
|
+
const joinFile = await materialize(who.ownerAgentId, documentId, doc.documentType);
|
|
555
|
+
return {
|
|
556
|
+
ok: true,
|
|
557
|
+
documentId,
|
|
558
|
+
joined: true,
|
|
559
|
+
consentEntry: consent.entryHash,
|
|
560
|
+
consentDelivered: consent.holdersNotified,
|
|
561
|
+
filePath: joinFile.path,
|
|
562
|
+
...(joinFile.path !== null && openingNoticeFor(doc.documentType) !== undefined
|
|
563
|
+
? { fileNotice: openingNoticeFor(doc.documentType) }
|
|
564
|
+
: {}),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const outcome = layer.handshake.accept(who.ownerAgentId, documentId, deps.now());
|
|
570
|
+
if (!outcome.ok)
|
|
571
|
+
return { ok: false, reason: outcome.reason, guidance: outcome.detail };
|
|
572
|
+
// THE SAME REFUSAL ON THE RECEIVING SIDE. Guarding only `propose` leaves an accepter able to
|
|
573
|
+
// take on a document it cannot serve, and the party harmed is the one who did not choose the
|
|
574
|
+
// type. A peer running an older or a different build can still offer anything.
|
|
575
|
+
if (!isSupportedDocumentType(outcome.envelope.document_type)) {
|
|
576
|
+
return {
|
|
577
|
+
ok: false,
|
|
578
|
+
reason: "document_type_unsupported",
|
|
579
|
+
guidance: `Your peer proposed a '${outcome.envelope.document_type}' document, which this build ` +
|
|
580
|
+
`cannot serve (supported: ${[...SUPPORTED_DOCUMENT_TYPES].sort().join(", ")}). The ` +
|
|
581
|
+
`proposal is left undecided rather than accepted into a document that would read as ` +
|
|
582
|
+
`empty — use cello_doc_refuse if you want it gone, and tell them which types you take.`,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
// THE CONSENT AND THE DOCUMENT ARE ONE ACT. `accept` moves the proposal row; without this the
|
|
586
|
+
// operator has agreed to a document that does not exist, and the peer's first update is refused
|
|
587
|
+
// as `document_unknown` — a refusal that names a real condition and explains nothing.
|
|
588
|
+
layer.store.createDocument({
|
|
589
|
+
documentId,
|
|
590
|
+
ownerAgentId: who.ownerAgentId,
|
|
591
|
+
peerAgentId: outcome.envelope.proposer_agent_id,
|
|
592
|
+
documentType: outcome.envelope.document_type,
|
|
593
|
+
properties: outcome.envelope.properties,
|
|
594
|
+
status: "active",
|
|
595
|
+
createdAtMs: deps.now(),
|
|
596
|
+
});
|
|
597
|
+
if (outcome.envelope.starting_content) {
|
|
598
|
+
// SCREENED ON A SHADOW, BEFORE THE LIVE DOCUMENT. The proposer's own check is ergonomics — the
|
|
599
|
+
// adversary owns their daemon and can delete it — so the receiver decides for itself, and it
|
|
600
|
+
// decides on the projected TEXT rather than the update's bytes. This is the one document body
|
|
601
|
+
// that never arrives as an update, so nothing else in the inbound path would ever look at it.
|
|
602
|
+
const shadow = new Y.Doc();
|
|
603
|
+
Y.applyUpdate(shadow, outcome.envelope.starting_content);
|
|
604
|
+
const seedFault = screenText(projectDocumentText(shadow, outcome.envelope.document_type));
|
|
605
|
+
if (seedFault) {
|
|
606
|
+
logger.warn("document.proposal.starting_content_refused", {
|
|
607
|
+
documentId,
|
|
608
|
+
proposerAgentId: outcome.envelope.proposer_agent_id,
|
|
609
|
+
codepoints: seedFault.codepoints,
|
|
610
|
+
count: seedFault.count,
|
|
611
|
+
});
|
|
612
|
+
return {
|
|
613
|
+
ok: false,
|
|
614
|
+
reason: "document_content_refused",
|
|
615
|
+
guidance: `This proposal's starting content contains ${seedFault.codepoints.join(", ")} ` +
|
|
616
|
+
`(${seedFault.count} occurrence(s), first at character ${seedFault.offsets[0]}). ` +
|
|
617
|
+
`${SCREEN_GUIDANCE} It was NOT accepted and nothing was written to disk — ask the ` +
|
|
618
|
+
`proposer to remove it and propose again.`,
|
|
619
|
+
detail: JSON.stringify({ rule: SCREEN_RULE_ID, ...seedFault }),
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
Y.applyUpdate(layer.live.get(who.ownerAgentId, documentId), outcome.envelope.starting_content);
|
|
623
|
+
}
|
|
624
|
+
const acceptFile = await materialize(who.ownerAgentId, documentId, outcome.envelope.document_type);
|
|
625
|
+
// R21: the accept IS this agent's consent entry — the proposer's fold shows this agent
|
|
626
|
+
// invited until the entry reaches them over the amendment carrier.
|
|
627
|
+
const consent = await authorConsent(who, documentId);
|
|
628
|
+
if (!consent.ok) {
|
|
629
|
+
logger.warn("document.consent.unrecorded", {
|
|
630
|
+
documentId,
|
|
631
|
+
reason: consent.reason,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
// NOT re-logged here. `DocumentHandshake` already emits `document.proposal.accepted` for this
|
|
635
|
+
// exact fact, and two events for one act make every count of "how many were accepted" wrong
|
|
636
|
+
// depending on which name the query used.
|
|
637
|
+
const told = await tellProposer(who, documentId, outcome.envelope.proposer_agent_id, true, undefined);
|
|
638
|
+
return {
|
|
639
|
+
ok: true,
|
|
640
|
+
documentId,
|
|
641
|
+
peerAgentId: outcome.envelope.proposer_agent_id,
|
|
642
|
+
proposerNotified: told,
|
|
643
|
+
...(consent.ok
|
|
644
|
+
? { consentEntry: consent.entryHash, consentDelivered: consent.holdersNotified }
|
|
645
|
+
: { consentUnrecorded: consent.reason }),
|
|
646
|
+
filePath: acceptFile.path,
|
|
647
|
+
// Matters MORE here than on propose: the type came from the PROPOSER's envelope, so the
|
|
648
|
+
// accepter is being handed an executable file they did not choose the format of.
|
|
649
|
+
...(acceptFile.path !== null && openingNoticeFor(outcome.envelope.document_type) !== undefined
|
|
650
|
+
? { fileNotice: openingNoticeFor(outcome.envelope.document_type) }
|
|
651
|
+
: {}),
|
|
652
|
+
...(acceptFile.path === null
|
|
653
|
+
? {
|
|
654
|
+
fileUnavailableReason: acceptFile.reason,
|
|
655
|
+
guidance: `You accepted the document and it is live, but no file was written for it ` +
|
|
656
|
+
`(${acceptFile.reason}${acceptFile.detail ? `: ${acceptFile.detail}` : ""}). Use ` +
|
|
657
|
+
`cello_doc_read and cello_doc_write, which do not need the file.`,
|
|
658
|
+
}
|
|
659
|
+
: {}),
|
|
660
|
+
};
|
|
661
|
+
});
|
|
662
|
+
/**
|
|
663
|
+
* DOD-MP-INVITE-FANOUT-1 — fan a governance amendment to the CURRENT holders, durably.
|
|
664
|
+
*
|
|
665
|
+
* ONE implementation for every site that fans one. There were four — invite, re-invite, remove,
|
|
666
|
+
* and remove's re-send — each with its own copy of the same best-effort loop, and the review found
|
|
667
|
+
* that wiring durability into one of them left the other three losing membership changes exactly
|
|
668
|
+
* as before. The re-invite is the verb the tool's own guidance tells an operator to run when a
|
|
669
|
+
* holder is out of step, so it carrying the defect meant the documented cure did nothing.
|
|
670
|
+
*
|
|
671
|
+
* RECORD THE DEBT FIRST. The send below is a fast path, never the guarantee: a daemon that dies
|
|
672
|
+
* between here and the send still owes the amendment on restart.
|
|
673
|
+
*
|
|
674
|
+
* A successful send is recorded as SENT, not acked — their daemon received the frame, and whether
|
|
675
|
+
* it RECORDED it is a separate fact it can refuse. The row settles for real when that holder acks
|
|
676
|
+
* any envelope at this epoch or later, which proves they applied it.
|
|
677
|
+
*/
|
|
678
|
+
const fanOutAmendment = async (args) => {
|
|
679
|
+
const told = {};
|
|
680
|
+
for (const holder of args.holders) {
|
|
681
|
+
/**
|
|
682
|
+
* ⚠️ SAY WHO WE ARE WAITING ON, WHILE WE ARE STILL WAITING — `DOD-M15-DOCACCEPT-UNBOUNDED-1`.
|
|
683
|
+
*
|
|
684
|
+
* This loop is SEQUENTIAL and `sendBytes` opens a session to the holder with **no timeout** in
|
|
685
|
+
* the whole chain (`sendBytes` → `acquireSession` → `openSession`). **One holder who cannot be
|
|
686
|
+
* reached blocks the entire operation** — `cello_doc_accept` is the caller that surfaces it, and
|
|
687
|
+
* the operator's client gives up after 60s having been told nothing at all. Measured live:
|
|
688
|
+
* every `j-multiplayer` timeout is `cello_doc_accept` at 60000ms while the failing test set
|
|
689
|
+
* reshuffles, which is what identified this loop.
|
|
690
|
+
*
|
|
691
|
+
* **The product's own journey names the invariant that breaks:** *"an absent holder blocks
|
|
692
|
+
* nobody."* An absent holder blocks everybody.
|
|
693
|
+
*
|
|
694
|
+
* ⚠️ THIS DOES NOT FIX THAT, DELIBERATELY. Bounding the wait is a behaviour change, and the
|
|
695
|
+
* right bound is a product judgement — too short falsely marks holders un-notified, which is a
|
|
696
|
+
* membership-change correctness problem; too long leaves the hang. The layer's only existing
|
|
697
|
+
* bound, `RECONCILE_INFLIGHT_BOUND_MS`, is 60s for a BACKGROUND sweep and is longer than the
|
|
698
|
+
* client's own timeout, so it cannot be borrowed. Tracked for that decision.
|
|
699
|
+
*
|
|
700
|
+
* What is fixed here is the **silence**: the daemon logged nothing while it blocked, so the
|
|
701
|
+
* failure was loud in neither the log nor the agent response. This names the holder being waited
|
|
702
|
+
* on **while the wait is still happening**, and changes nothing else — the timer is cleared on
|
|
703
|
+
* every exit path, including a throw.
|
|
704
|
+
*/
|
|
705
|
+
/**
|
|
706
|
+
* ⚠️ THE RECONCILE PROMISE IS NOT TRUE FOR EVERY VERB — review F2, the worst of the findings
|
|
707
|
+
* because it reassured the operator about permanent loss.
|
|
708
|
+
*
|
|
709
|
+
* The first version said flatly *"they are re-notified on reconcile"*. `sweepTargets` skips a
|
|
710
|
+
* document when `derived.state.ended !== null`, and when the owner is no longer among `seats`.
|
|
711
|
+
* So for **`kill`**, for the **`close`** that ends the document, and for **`refuse_join`** (where
|
|
712
|
+
* the refuser has just given up their own seat), the document is never swept again — the entry is
|
|
713
|
+
* appended locally, the send fails, and that holder keeps reading the document as ACTIVE forever.
|
|
714
|
+
* Telling them "nothing is lost" there is worse than saying nothing.
|
|
715
|
+
*/
|
|
716
|
+
const reconcileWillRetry = !["kill", "close", "refuse_join"].includes(String(args.verb));
|
|
717
|
+
const started = Date.now();
|
|
718
|
+
let announced = null;
|
|
719
|
+
const openingTimer = setTimeout(() => {
|
|
720
|
+
announced = "opening";
|
|
721
|
+
logger.info("document.amendment.holder_opening", {
|
|
722
|
+
documentId: args.documentId,
|
|
723
|
+
holderAgentId: holder,
|
|
724
|
+
verb: args.verb,
|
|
725
|
+
thresholdMs: HOLDER_OPENING_INFO_MS,
|
|
726
|
+
detail: "still working on this holder — most often a session is being opened, which measures p50 ~13s and p90 ~25s on a cold peer. NOT yet evidence of a problem.",
|
|
727
|
+
});
|
|
728
|
+
}, HOLDER_OPENING_INFO_MS);
|
|
729
|
+
const unansweredTimer = setTimeout(() => {
|
|
730
|
+
announced = "unanswered";
|
|
731
|
+
logger.warn("document.amendment.holder_unanswered", {
|
|
732
|
+
documentId: args.documentId,
|
|
733
|
+
holderAgentId: holder,
|
|
734
|
+
verb: args.verb,
|
|
735
|
+
thresholdMs: HOLDER_UNANSWERED_WARN_MS,
|
|
736
|
+
// NO TOOL NAME — review F3. This hardcoded `cello_doc_accept`, and `fanOutAmendment` has
|
|
737
|
+
// five call sites: an operator whose `cello_doc_invite` hung was told their
|
|
738
|
+
// `cello_doc_accept` returned nothing, contradicted by `verb` in the same payload.
|
|
739
|
+
impact: "this fan-out is sequential and unbounded, so every holder after this one is waiting too and the calling operation has returned nothing. The operator sees it simply hang.",
|
|
740
|
+
guidance: reconcileWillRetry
|
|
741
|
+
? "This holder has not answered well past a normal session open. The amendment is recorded locally and the reconcile sweep will carry it to them later, so it is not lost — but the calling operation will not answer until this send settles."
|
|
742
|
+
: `This holder has not answered well past a normal session open, and this verb ("${String(args.verb)}") ENDS the document or gives up this seat — so the reconcile sweep will NOT carry it. If this send never settles, that holder keeps reading the document as active, permanently.`,
|
|
743
|
+
});
|
|
744
|
+
}, HOLDER_UNANSWERED_WARN_MS);
|
|
745
|
+
try {
|
|
746
|
+
const sent = await deps.transportFor(args.agentName).sendBytes({
|
|
747
|
+
peerAgentId: holder,
|
|
748
|
+
documentId: args.documentId,
|
|
749
|
+
bytes: args.amendmentBytes,
|
|
750
|
+
correlationId: randomUUID(),
|
|
751
|
+
});
|
|
752
|
+
// PARKED IS NOT NOTIFIED. The relay took it because the holder had no live counterparty.
|
|
753
|
+
const landed = sent.ok && sent.parked !== true;
|
|
754
|
+
told[holder] = landed;
|
|
755
|
+
if (!landed) {
|
|
756
|
+
// NAMED, NOT JUST COUNTED. This used to record `false` with no log line anywhere, so the
|
|
757
|
+
// only trace of a lost membership change was a boolean inside an `ok: true` response.
|
|
758
|
+
logger.warn("document.amendment.holder_unnotified", {
|
|
759
|
+
documentId: args.documentId,
|
|
760
|
+
holderAgentId: holder,
|
|
761
|
+
verb: args.verb,
|
|
762
|
+
reason: sent.ok ? "relay_parked" : sent.reason,
|
|
763
|
+
detail: sent.ok
|
|
764
|
+
? "the relay is holding it — the holder had no live counterparty"
|
|
765
|
+
: sent.detail,
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
catch (err) {
|
|
770
|
+
told[holder] = false;
|
|
771
|
+
// A throw used to be swallowed whole.
|
|
772
|
+
logger.warn("document.amendment.holder_unnotified", {
|
|
773
|
+
documentId: args.documentId,
|
|
774
|
+
holderAgentId: holder,
|
|
775
|
+
verb: args.verb,
|
|
776
|
+
reason: "amendment_send_threw",
|
|
777
|
+
detail: extractErrorMessage(err),
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
finally {
|
|
781
|
+
// EVERY exit path, including the throw above — a timer left armed would log a slow send for a
|
|
782
|
+
// holder that already answered, which is worse than the silence it replaces.
|
|
783
|
+
clearTimeout(openingTimer);
|
|
784
|
+
clearTimeout(unansweredTimer);
|
|
785
|
+
/**
|
|
786
|
+
* ⚠️ PAIR THE ANNOUNCEMENT WITH ITS OUTCOME — review F5. The old line logged
|
|
787
|
+
* `waitedMs: <the constant>`, which is not a measurement, and nothing was written when the send
|
|
788
|
+
* settled. So the log could not distinguish *outstanding 6s then fine* from *outstanding 55s
|
|
789
|
+
* then the client gave up* — the one thing an operator reading it wants to know. Emitted only
|
|
790
|
+
* if we announced, so a healthy send stays silent.
|
|
791
|
+
*/
|
|
792
|
+
if (announced !== null) {
|
|
793
|
+
logger.info("document.amendment.holder_settled", {
|
|
794
|
+
documentId: args.documentId,
|
|
795
|
+
holderAgentId: holder,
|
|
796
|
+
verb: args.verb,
|
|
797
|
+
waitedMs: Date.now() - started,
|
|
798
|
+
announcedAs: announced,
|
|
799
|
+
notified: told[holder] === true,
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return told;
|
|
805
|
+
};
|
|
806
|
+
/**
|
|
807
|
+
* SYNC-P2 (R21/R22) — author THIS agent's consent entry for a document they were named into,
|
|
808
|
+
* append it locally, and fan it to the derived participants over the amendment carrier. Both
|
|
809
|
+
* accept branches (a bilateral proposal, a join offer) call this: consenting IS authoring your
|
|
810
|
+
* first entry, and until it reaches the others their fold shows you invited, not participating.
|
|
811
|
+
*/
|
|
812
|
+
const authorConsent = async (who, documentId, kind = "consent") => {
|
|
813
|
+
const genesisRecord = layer.handshake.get(who.ownerAgentId, documentId);
|
|
814
|
+
if (!genesisRecord)
|
|
815
|
+
return { ok: false, reason: "document_genesis_missing" };
|
|
816
|
+
const genesisArr = arrangementGenesisFromProposal(genesisRecord.envelope);
|
|
817
|
+
let chain;
|
|
818
|
+
try {
|
|
819
|
+
chain = layer.amendments.chain(who.ownerAgentId, documentId);
|
|
820
|
+
}
|
|
821
|
+
catch (err) {
|
|
822
|
+
return {
|
|
823
|
+
ok: false,
|
|
824
|
+
reason: `document_chain_undecodable: ${extractErrorMessage(err)}`,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
const derived = deriveDocumentState(genesisArr, chain, documentGovernancePolicy, layer.verifySignature);
|
|
828
|
+
if (!derived.ok)
|
|
829
|
+
return { ok: false, reason: derived.reason };
|
|
830
|
+
// R29's author-side mirror of the R30 inbound gate: an ended world takes no further entries.
|
|
831
|
+
// Every other holder would refuse this entry (their gate finds the ending in its closure), so
|
|
832
|
+
// authoring it would only fork this daemon away from the agreement everyone else has settled —
|
|
833
|
+
// the concrete case being a late kill rewriting "closed by agreement" as a unilateral end.
|
|
834
|
+
if (derived.state.ended !== null) {
|
|
835
|
+
return { ok: false, reason: derived.state.ended === "killed" ? "document_killed" : "document_closed" };
|
|
836
|
+
}
|
|
837
|
+
const body = {
|
|
838
|
+
document_id: documentId,
|
|
839
|
+
kind,
|
|
840
|
+
subject_agent_id: who.ownerAgentId,
|
|
841
|
+
// A refusal names nothing it agrees to — it is the subject's own signed no (R24).
|
|
842
|
+
property_change: kind === "consent"
|
|
843
|
+
? {
|
|
844
|
+
key: "consents_to",
|
|
845
|
+
value: `${String(derived.state.properties["assurance_tier"])}/${DOCUMENT_FEATURE_VERSION}`,
|
|
846
|
+
}
|
|
847
|
+
: null,
|
|
848
|
+
state_hash: null,
|
|
849
|
+
authored_at_ms: deps.now(),
|
|
850
|
+
author_agent_id: who.ownerAgentId,
|
|
851
|
+
author_seq: (layer.amendments.watermarks(who.ownerAgentId, documentId).get(who.ownerAgentId)?.seq ??
|
|
852
|
+
0) + 1,
|
|
853
|
+
parents: [...derived.state.frontier],
|
|
854
|
+
};
|
|
855
|
+
const entryHash = documentAmendmentHash(body);
|
|
856
|
+
const multisigTbs = buildDocumentMultisigTbs({
|
|
857
|
+
document_id: documentId,
|
|
858
|
+
subject_kind: "document_amendment",
|
|
859
|
+
subject_hash: entryHash,
|
|
860
|
+
required_signers: [who.ownerAgentId],
|
|
861
|
+
});
|
|
862
|
+
const consent = {
|
|
863
|
+
body,
|
|
864
|
+
collection: {
|
|
865
|
+
document_id: documentId,
|
|
866
|
+
subject_kind: "document_amendment",
|
|
867
|
+
subject_hash: entryHash,
|
|
868
|
+
required_signers: [who.ownerAgentId],
|
|
869
|
+
signatures: [
|
|
870
|
+
{ signer_agent_id: who.ownerAgentId, signature: await deps.sign(who.agentName, multisigTbs) },
|
|
871
|
+
],
|
|
872
|
+
},
|
|
873
|
+
};
|
|
874
|
+
// The same author-your-own-void guard every local authoring site carries.
|
|
875
|
+
const withNew = deriveDocumentState(genesisArr, [...chain, consent], documentGovernancePolicy, layer.verifySignature);
|
|
876
|
+
if (!withNew.ok)
|
|
877
|
+
return { ok: false, reason: withNew.reason };
|
|
878
|
+
const entryHex = Buffer.from(entryHash).toString("hex");
|
|
879
|
+
const inert = withNew.state.voids.find((v) => v.hash === entryHex) ??
|
|
880
|
+
withNew.state.excluded.find((e) => e.hash === entryHex);
|
|
881
|
+
if (inert)
|
|
882
|
+
return { ok: false, reason: inert.reason };
|
|
883
|
+
const bytes = new Uint8Array(encodeDocumentAmendment(consent));
|
|
884
|
+
layer.amendments.append(who.ownerAgentId, documentId, bytes, deps.now());
|
|
885
|
+
const holdersNotified = await fanOutAmendment({
|
|
886
|
+
agentName: who.agentName,
|
|
887
|
+
ownerAgentId: who.ownerAgentId,
|
|
888
|
+
documentId,
|
|
889
|
+
amendmentHashHex: entryHex,
|
|
890
|
+
amendmentBytes: bytes,
|
|
891
|
+
holders: [...withNew.state.participants, ...withNew.state.invited].filter((p) => p !== who.ownerAgentId),
|
|
892
|
+
verb: kind,
|
|
893
|
+
});
|
|
894
|
+
// SYNC-P4: the AUTHOR'S own status projection — the inbound path projects on receipt, and
|
|
895
|
+
// the authoring daemon must not read "active" for a document its own entry just ended.
|
|
896
|
+
if ((kind === "close" || kind === "kill") && withNew.state.ended !== null) {
|
|
897
|
+
layer.store.setDocumentStatus(who.ownerAgentId, documentId, withNew.state.ended === "killed" ? "killed" : "closed");
|
|
898
|
+
}
|
|
899
|
+
return { ok: true, entryHash: entryHex, holdersNotified };
|
|
900
|
+
};
|
|
901
|
+
/**
|
|
902
|
+
* M14B / DOD-MP-JOIN-1 — invite a third party into an existing document.
|
|
903
|
+
*
|
|
904
|
+
* One admin's signature authors the admitting entry; the invitee's own consent makes the
|
|
905
|
+
* join real (their accept, on their daemon). VALIDATE-THEN-APPEND: the chain including the
|
|
906
|
+
* new entry derives through the real policy before one byte lands. The invitee gets a NOTICE
|
|
907
|
+
* — a step-1 reconcile frame (SYNC-R25) — and bootstraps the world through the exchange;
|
|
908
|
+
* existing holders get the entry best-effort, with any miss repaired by their next exchange.
|
|
909
|
+
*/
|
|
910
|
+
handlers.set("cello_doc_invite", async (params, connectionId) => {
|
|
911
|
+
const who = resolve(params, connectionId);
|
|
912
|
+
if (isRefusal(who))
|
|
913
|
+
return who;
|
|
914
|
+
const documentId = typeof params?.document_id === "string" ? params.document_id : "";
|
|
915
|
+
const invitee = typeof params?.invitee_pubkey === "string" ? params.invitee_pubkey : "";
|
|
916
|
+
if (documentId.length === 0) {
|
|
917
|
+
return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_list." };
|
|
918
|
+
}
|
|
919
|
+
if (!/^[0-9a-f]{64}$/.test(invitee)) {
|
|
920
|
+
return {
|
|
921
|
+
ok: false,
|
|
922
|
+
reason: "invalid_invitee_pubkey",
|
|
923
|
+
guidance: "invitee_pubkey must be the 64-hex agent id — see cello_contacts.",
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
const doc = layer.store.getDocument(who.ownerAgentId, documentId);
|
|
927
|
+
if (!doc) {
|
|
928
|
+
return { ok: false, reason: "document_unknown", guidance: `No document ${documentId.slice(0, 16)}… for this agent.` };
|
|
929
|
+
}
|
|
930
|
+
if (doc.status !== "active") {
|
|
931
|
+
return {
|
|
932
|
+
ok: false,
|
|
933
|
+
reason: "document_not_active",
|
|
934
|
+
guidance: `This document is ${doc.status} — only an active document can admit a holder.`,
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
const genesisRecord = layer.handshake.get(who.ownerAgentId, documentId);
|
|
938
|
+
if (!genesisRecord) {
|
|
939
|
+
return {
|
|
940
|
+
ok: false,
|
|
941
|
+
reason: "document_genesis_missing",
|
|
942
|
+
guidance: "The document has a row but no stored genesis proposal to replay from — this is a local-state fault, not the peer's.",
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
const genesisArr = arrangementGenesisFromProposal(genesisRecord.envelope);
|
|
946
|
+
const chain = layer.amendments.chain(who.ownerAgentId, documentId);
|
|
947
|
+
const derived = deriveDocumentState(genesisArr, chain, documentGovernancePolicy, layer.verifySignature);
|
|
948
|
+
if (!derived.ok) {
|
|
949
|
+
return { ok: false, reason: "document_chain_invalid", guidance: derived.reason };
|
|
950
|
+
}
|
|
951
|
+
if (!derived.state.admins.has(who.ownerAgentId)) {
|
|
952
|
+
return {
|
|
953
|
+
ok: false,
|
|
954
|
+
reason: "document_not_admin",
|
|
955
|
+
guidance: `Inviting takes an admin's signature and this agent holds no admin power here. ` +
|
|
956
|
+
`Current admins: ${[...derived.state.admins].join(", ")}.`,
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
if (derived.state.participants.has(invitee)) {
|
|
960
|
+
return {
|
|
961
|
+
ok: false,
|
|
962
|
+
reason: "document_already_holder",
|
|
963
|
+
guidance: "That agent already holds this document — there is nothing to invite them to.",
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
if (derived.state.invited.has(invitee)) {
|
|
967
|
+
// A RE-RUN while the invitation is open: the admitting entry is already in the chain, so
|
|
968
|
+
// authoring afresh would only mint a void. Re-send the NOTICE (SYNC-R25) — initiating a
|
|
969
|
+
// reconcile exchange is idempotent by construction, and the invitee's empty-handed answer
|
|
970
|
+
// pulls the genesis and the whole entry set across. The other holders are re-fanned the
|
|
971
|
+
// admitting entry too: one who missed it is wedged, refusing the invitee by name.
|
|
972
|
+
const admitting = chain.find((e) => e.body.kind === "add_holder" && e.body.subject_agent_id === invitee);
|
|
973
|
+
const priorHash = admitting
|
|
974
|
+
? Buffer.from(documentAmendmentHash(admitting.body)).toString("hex")
|
|
975
|
+
: null;
|
|
976
|
+
const renotice = await layer.initiateReconcile(who.ownerAgentId, invitee, [documentId]);
|
|
977
|
+
let holdersNotified = {};
|
|
978
|
+
if (admitting && priorHash !== null) {
|
|
979
|
+
holdersNotified = await fanOutAmendment({
|
|
980
|
+
agentName: who.agentName,
|
|
981
|
+
ownerAgentId: who.ownerAgentId,
|
|
982
|
+
documentId,
|
|
983
|
+
amendmentHashHex: priorHash,
|
|
984
|
+
amendmentBytes: new Uint8Array(encodeDocumentAmendment(admitting)),
|
|
985
|
+
holders: [...derived.state.participants, ...derived.state.invited].filter((holder) => holder !== who.ownerAgentId && holder !== invitee),
|
|
986
|
+
verb: "re-invite",
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
return {
|
|
990
|
+
ok: true,
|
|
991
|
+
documentId,
|
|
992
|
+
inviteeAgentId: invitee,
|
|
993
|
+
amendmentHash: priorHash,
|
|
994
|
+
resent: true,
|
|
995
|
+
noticeSent: renotice.ok,
|
|
996
|
+
holdersNotified,
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
const body = {
|
|
1000
|
+
document_id: documentId,
|
|
1001
|
+
kind: "add_holder",
|
|
1002
|
+
subject_agent_id: invitee,
|
|
1003
|
+
property_change: null,
|
|
1004
|
+
state_hash: null,
|
|
1005
|
+
authored_at_ms: deps.now(),
|
|
1006
|
+
// SYNC-P1 — the causal fields: authored on the fold's frontier.
|
|
1007
|
+
author_agent_id: who.ownerAgentId,
|
|
1008
|
+
author_seq: (layer.amendments.watermarks(who.ownerAgentId, documentId).get(who.ownerAgentId)?.seq ??
|
|
1009
|
+
0) + 1,
|
|
1010
|
+
parents: [...derived.state.frontier],
|
|
1011
|
+
};
|
|
1012
|
+
const amendHash = documentAmendmentHash(body);
|
|
1013
|
+
const multisigTbs = buildDocumentMultisigTbs({
|
|
1014
|
+
document_id: documentId,
|
|
1015
|
+
subject_kind: "document_amendment",
|
|
1016
|
+
subject_hash: amendHash,
|
|
1017
|
+
required_signers: [who.ownerAgentId],
|
|
1018
|
+
});
|
|
1019
|
+
const amendment = {
|
|
1020
|
+
body,
|
|
1021
|
+
collection: {
|
|
1022
|
+
document_id: documentId,
|
|
1023
|
+
subject_kind: "document_amendment",
|
|
1024
|
+
subject_hash: amendHash,
|
|
1025
|
+
required_signers: [who.ownerAgentId],
|
|
1026
|
+
signatures: [
|
|
1027
|
+
{ signer_agent_id: who.ownerAgentId, signature: await deps.sign(who.agentName, multisigTbs) },
|
|
1028
|
+
],
|
|
1029
|
+
},
|
|
1030
|
+
};
|
|
1031
|
+
// VALIDATE-BEFORE-APPEND, on the exact bytes about to land.
|
|
1032
|
+
const withNew = deriveDocumentState(genesisArr, [...chain, amendment], documentGovernancePolicy, layer.verifySignature);
|
|
1033
|
+
if (!withNew.ok) {
|
|
1034
|
+
return { ok: false, reason: "document_amendment_invalid", guidance: withNew.reason };
|
|
1035
|
+
}
|
|
1036
|
+
// A locally-authored entry must TAKE EFFECT — a fold-void entry is admissible history when a
|
|
1037
|
+
// peer sends it, but authoring one ourselves would be publishing an act we already know is
|
|
1038
|
+
// inert, and the void's reason is the refusal the operator needs.
|
|
1039
|
+
{
|
|
1040
|
+
const candidateHex = Buffer.from(amendHash).toString("hex");
|
|
1041
|
+
const inert = withNew.state.voids.find((v) => v.hash === candidateHex) ??
|
|
1042
|
+
withNew.state.excluded.find((e) => e.hash === candidateHex);
|
|
1043
|
+
if (inert) {
|
|
1044
|
+
return { ok: false, reason: "document_amendment_invalid", guidance: inert.reason };
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
const amendmentBytes = new Uint8Array(encodeDocumentAmendment(amendment));
|
|
1048
|
+
layer.amendments.append(who.ownerAgentId, documentId, amendmentBytes, deps.now());
|
|
1049
|
+
const amendHashHex = Buffer.from(amendHash).toString("hex");
|
|
1050
|
+
// THE NOTICE (SYNC-R25, replacing the D5 offer): no bespoke frame carrying history — the
|
|
1051
|
+
// inviter initiates a reconcile exchange naming the document. The invitee's daemon answers
|
|
1052
|
+
// the unheld position with an empty hand, and the step-2 reply carries the genesis and the
|
|
1053
|
+
// whole entry set (the P3 bootstrap). Losing the notice strands nothing: the admission is in
|
|
1054
|
+
// the chain, and any later exchange with any holder delivers it.
|
|
1055
|
+
const notice = await layer.initiateReconcile(who.ownerAgentId, invitee, [documentId]);
|
|
1056
|
+
const offerSent = notice.ok;
|
|
1057
|
+
if (!notice.ok) {
|
|
1058
|
+
logger.warn("document.join.notice_unsent", { documentId, reason: notice.reason });
|
|
1059
|
+
}
|
|
1060
|
+
// DOD-MP-INVITE-FANOUT-1 — RECORD WHAT IS OWED BEFORE TRYING TO SEND IT.
|
|
1061
|
+
//
|
|
1062
|
+
// The loop below is a fast path, not the guarantee. It used to be both, and that is the whole
|
|
1063
|
+
// defect: one failed send lost a membership change permanently, because nothing remained owing
|
|
1064
|
+
// anywhere. A content edit has always had a pending row, a retry schedule and restart survival;
|
|
1065
|
+
// the governance act that decides who is a party to the document had none of them.
|
|
1066
|
+
//
|
|
1067
|
+
// Seeding first also makes the crash window safe: a daemon that dies between here and the send
|
|
1068
|
+
// still owes the amendment on restart.
|
|
1069
|
+
const owedHolders = [...derived.state.participants, ...derived.state.invited].filter((holder) => holder !== who.ownerAgentId && holder !== invitee);
|
|
1070
|
+
const holdersTold = await fanOutAmendment({
|
|
1071
|
+
agentName: who.agentName,
|
|
1072
|
+
ownerAgentId: who.ownerAgentId,
|
|
1073
|
+
documentId,
|
|
1074
|
+
amendmentHashHex: amendHashHex,
|
|
1075
|
+
amendmentBytes,
|
|
1076
|
+
holders: owedHolders,
|
|
1077
|
+
verb: "invite",
|
|
1078
|
+
});
|
|
1079
|
+
logger.info("document.join.invited", { documentId, invitee, noticeSent: offerSent });
|
|
1080
|
+
return {
|
|
1081
|
+
ok: true,
|
|
1082
|
+
documentId,
|
|
1083
|
+
inviteeAgentId: invitee,
|
|
1084
|
+
amendmentHash: amendHashHex,
|
|
1085
|
+
noticeSent: offerSent,
|
|
1086
|
+
holdersNotified: holdersTold,
|
|
1087
|
+
...(offerSent
|
|
1088
|
+
? {}
|
|
1089
|
+
: {
|
|
1090
|
+
guidance: "The admission is recorded and the invitation exists, but the notice did not reach " +
|
|
1091
|
+
"the invitee — they may be offline. Re-run cello_doc_invite with the same invitee " +
|
|
1092
|
+
"once they are reachable: it re-sends the notice rather than authoring a second " +
|
|
1093
|
+
"entry.",
|
|
1094
|
+
}),
|
|
1095
|
+
};
|
|
1096
|
+
});
|
|
1097
|
+
/**
|
|
1098
|
+
* DOD-MP-REMOVE-1 — remove a holder, forward-only. Two shapes, one verb: an admin removing a
|
|
1099
|
+
* non-admin holder, and a holder removing THEMSELVES (voluntary leave — always theirs, per
|
|
1100
|
+
* D3). What removal means is exactly and only: delivery to them stops and their new edits
|
|
1101
|
+
* refuse naming the removal. Their copy is theirs forever — no surface claims more. Removing
|
|
1102
|
+
* a fellow ADMIN refuses here the way the policy refuses it everywhere (demote first, under
|
|
1103
|
+
* remove_admin's all-others rule — whose cross-daemon signature gathering is a parked design
|
|
1104
|
+
* note, Entry 10).
|
|
1105
|
+
*/
|
|
1106
|
+
handlers.set("cello_doc_remove", async (params, connectionId) => {
|
|
1107
|
+
const who = resolve(params, connectionId);
|
|
1108
|
+
if (isRefusal(who))
|
|
1109
|
+
return who;
|
|
1110
|
+
const documentId = typeof params?.document_id === "string" ? params.document_id : "";
|
|
1111
|
+
const holder = typeof params?.holder_pubkey === "string" ? params.holder_pubkey : "";
|
|
1112
|
+
if (documentId.length === 0) {
|
|
1113
|
+
return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_list." };
|
|
1114
|
+
}
|
|
1115
|
+
if (!/^[0-9a-f]{64}$/.test(holder)) {
|
|
1116
|
+
return {
|
|
1117
|
+
ok: false,
|
|
1118
|
+
reason: "invalid_holder_pubkey",
|
|
1119
|
+
guidance: "holder_pubkey must be the 64-hex agent id of a current holder.",
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
const doc = layer.store.getDocument(who.ownerAgentId, documentId);
|
|
1123
|
+
if (!doc) {
|
|
1124
|
+
return { ok: false, reason: "document_unknown", guidance: `No document ${documentId.slice(0, 16)}… for this agent.` };
|
|
1125
|
+
}
|
|
1126
|
+
const genesisRecord = layer.handshake.get(who.ownerAgentId, documentId);
|
|
1127
|
+
if (!genesisRecord) {
|
|
1128
|
+
return {
|
|
1129
|
+
ok: false,
|
|
1130
|
+
reason: "document_genesis_missing",
|
|
1131
|
+
guidance: "The document has a row but no stored genesis proposal to replay from.",
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
const genesisArr = arrangementGenesisFromProposal(genesisRecord.envelope);
|
|
1135
|
+
const chain = layer.amendments.chain(who.ownerAgentId, documentId);
|
|
1136
|
+
const derived = deriveDocumentState(genesisArr, chain, documentGovernancePolicy, layer.verifySignature);
|
|
1137
|
+
if (!derived.ok) {
|
|
1138
|
+
return { ok: false, reason: "document_chain_invalid", guidance: derived.reason };
|
|
1139
|
+
}
|
|
1140
|
+
if (!derived.state.participants.has(holder) && !derived.state.invited.has(holder)) {
|
|
1141
|
+
// ALREADY REMOVED is the HEALING path, not a refusal (REMOVE-1 review F3): a holder who
|
|
1142
|
+
// was offline at removal time never learned, and no other verb can ever re-send the
|
|
1143
|
+
// removal amendment — a second cello_doc_remove is the invite-retry precedent. A subject
|
|
1144
|
+
// the chain never touched still refuses.
|
|
1145
|
+
if (layer.standingOf(who.ownerAgentId, documentId, holder) === "removed") {
|
|
1146
|
+
const removal = [...chain].reverse().find((e) => e.body.kind === "remove_holder" && e.body.subject_agent_id === holder);
|
|
1147
|
+
let resendTold = {};
|
|
1148
|
+
if (removal) {
|
|
1149
|
+
const bytes = new Uint8Array(encodeDocumentAmendment(removal));
|
|
1150
|
+
const remaining = [...derived.state.participants, ...derived.state.invited].filter((m) => m !== who.ownerAgentId && m !== holder);
|
|
1151
|
+
// The healing re-send is durable for the holders who REMAIN, for the same reason the
|
|
1152
|
+
// re-invite is: it is the verb an operator runs precisely because someone is out of step,
|
|
1153
|
+
// so it must not be the one that gives up quietest.
|
|
1154
|
+
resendTold = await fanOutAmendment({
|
|
1155
|
+
agentName: who.agentName,
|
|
1156
|
+
ownerAgentId: who.ownerAgentId,
|
|
1157
|
+
documentId,
|
|
1158
|
+
amendmentHashHex: Buffer.from(documentAmendmentHash(removal.body)).toString("hex"),
|
|
1159
|
+
amendmentBytes: bytes,
|
|
1160
|
+
holders: remaining,
|
|
1161
|
+
verb: "remove-resend",
|
|
1162
|
+
});
|
|
1163
|
+
if (holder !== who.ownerAgentId) {
|
|
1164
|
+
try {
|
|
1165
|
+
const sent = await deps.transportFor(who.agentName).sendBytes({
|
|
1166
|
+
peerAgentId: holder, documentId, bytes, correlationId: randomUUID(),
|
|
1167
|
+
});
|
|
1168
|
+
resendTold[holder] = sent.ok && sent.parked !== true;
|
|
1169
|
+
}
|
|
1170
|
+
catch {
|
|
1171
|
+
resendTold[holder] = false;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
return {
|
|
1176
|
+
ok: true,
|
|
1177
|
+
documentId,
|
|
1178
|
+
removedAgentId: holder,
|
|
1179
|
+
resent: true,
|
|
1180
|
+
holdersNotified: resendTold,
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
return {
|
|
1184
|
+
ok: false,
|
|
1185
|
+
reason: "document_not_holder",
|
|
1186
|
+
guidance: "That agent does not hold this document — there is nobody to remove.",
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
const body = {
|
|
1190
|
+
document_id: documentId,
|
|
1191
|
+
kind: "remove_holder",
|
|
1192
|
+
subject_agent_id: holder,
|
|
1193
|
+
property_change: null,
|
|
1194
|
+
state_hash: null,
|
|
1195
|
+
authored_at_ms: deps.now(),
|
|
1196
|
+
// SYNC-P1 — the causal fields: authored on the fold's frontier.
|
|
1197
|
+
author_agent_id: who.ownerAgentId,
|
|
1198
|
+
author_seq: (layer.amendments.watermarks(who.ownerAgentId, documentId).get(who.ownerAgentId)?.seq ??
|
|
1199
|
+
0) + 1,
|
|
1200
|
+
parents: [...derived.state.frontier],
|
|
1201
|
+
};
|
|
1202
|
+
const amendHash = documentAmendmentHash(body);
|
|
1203
|
+
const multisigTbs = buildDocumentMultisigTbs({
|
|
1204
|
+
document_id: documentId,
|
|
1205
|
+
subject_kind: "document_amendment",
|
|
1206
|
+
subject_hash: amendHash,
|
|
1207
|
+
required_signers: [who.ownerAgentId],
|
|
1208
|
+
});
|
|
1209
|
+
const amendment = {
|
|
1210
|
+
body,
|
|
1211
|
+
collection: {
|
|
1212
|
+
document_id: documentId,
|
|
1213
|
+
subject_kind: "document_amendment",
|
|
1214
|
+
subject_hash: amendHash,
|
|
1215
|
+
required_signers: [who.ownerAgentId],
|
|
1216
|
+
signatures: [
|
|
1217
|
+
{ signer_agent_id: who.ownerAgentId, signature: await deps.sign(who.agentName, multisigTbs) },
|
|
1218
|
+
],
|
|
1219
|
+
},
|
|
1220
|
+
};
|
|
1221
|
+
// VALIDATE-BEFORE-APPEND — the policy rules here: a non-admin removing someone else, or any
|
|
1222
|
+
// single admin trying to expel a fellow admin through the holder door, refuses with the
|
|
1223
|
+
// policy's own sentence. Voluntary self-leave passes for anyone.
|
|
1224
|
+
const withNew = deriveDocumentState(genesisArr, [...chain, amendment], documentGovernancePolicy, layer.verifySignature);
|
|
1225
|
+
if (!withNew.ok) {
|
|
1226
|
+
return { ok: false, reason: "document_amendment_invalid", guidance: withNew.reason };
|
|
1227
|
+
}
|
|
1228
|
+
// A locally-authored entry must TAKE EFFECT — a fold-void entry is admissible history when a
|
|
1229
|
+
// peer sends it, but authoring one ourselves would be publishing an act we already know is
|
|
1230
|
+
// inert, and the void's reason is the refusal the operator needs.
|
|
1231
|
+
{
|
|
1232
|
+
const candidateHex = Buffer.from(amendHash).toString("hex");
|
|
1233
|
+
const inert = withNew.state.voids.find((v) => v.hash === candidateHex) ??
|
|
1234
|
+
withNew.state.excluded.find((e) => e.hash === candidateHex);
|
|
1235
|
+
if (inert) {
|
|
1236
|
+
return { ok: false, reason: "document_amendment_invalid", guidance: inert.reason };
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
const amendmentBytes = new Uint8Array(encodeDocumentAmendment(amendment));
|
|
1240
|
+
layer.amendments.append(who.ownerAgentId, documentId, amendmentBytes, deps.now());
|
|
1241
|
+
// SYNC-P4 (R27): a removal can COMPLETE a standing agreement — everyone who remains has
|
|
1242
|
+
// agreed — and that now falls out of the DERIVATION; the author's daemon projects it here,
|
|
1243
|
+
// exactly as receiving daemons project on arrival.
|
|
1244
|
+
{
|
|
1245
|
+
const afterRemove = layer.deriveEnded(who.ownerAgentId, documentId);
|
|
1246
|
+
if (afterRemove?.ended) {
|
|
1247
|
+
layer.store.setDocumentStatus(who.ownerAgentId, documentId, afterRemove.ended === "killed" ? "killed" : "closed");
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
// The amendment travels to EVERY current holder INCLUDING the removed one — being told is
|
|
1251
|
+
// how their daemon surfaces the removal to their operator. Best-effort at P1, per holder,
|
|
1252
|
+
// reported never assumed.
|
|
1253
|
+
// DOD-MP-INVITE-FANOUT-1 — the REMAINING holders get the durable fan-out. A holder who misses
|
|
1254
|
+
// a removal keeps delivering to, and accepting edits from, someone the chain has removed —
|
|
1255
|
+
// silently and permanently, which is the same defect the invite had and is worse, because here
|
|
1256
|
+
// the stale holder keeps honouring a membership that has been revoked.
|
|
1257
|
+
const holdersTold = await fanOutAmendment({
|
|
1258
|
+
agentName: who.agentName,
|
|
1259
|
+
ownerAgentId: who.ownerAgentId,
|
|
1260
|
+
documentId,
|
|
1261
|
+
amendmentHashHex: Buffer.from(amendHash).toString("hex"),
|
|
1262
|
+
amendmentBytes,
|
|
1263
|
+
holders: [...withNew.state.participants, ...withNew.state.invited].filter((m) => m !== who.ownerAgentId),
|
|
1264
|
+
verb: "remove",
|
|
1265
|
+
});
|
|
1266
|
+
// THE REMOVED HOLDER IS TOLD ONCE, and is deliberately NOT owed a durable retry: delivery to
|
|
1267
|
+
// them stopping is what removal MEANS, so a queue that kept redialling them would contradict
|
|
1268
|
+
// the act it is announcing. Forward-only cuts both ways — we tell them, we do not pursue them.
|
|
1269
|
+
if (holder !== who.ownerAgentId) {
|
|
1270
|
+
try {
|
|
1271
|
+
const sent = await deps.transportFor(who.agentName).sendBytes({
|
|
1272
|
+
peerAgentId: holder, documentId, bytes: amendmentBytes, correlationId: randomUUID(),
|
|
1273
|
+
});
|
|
1274
|
+
holdersTold[holder] = sent.ok && sent.parked !== true;
|
|
1275
|
+
if (!holdersTold[holder]) {
|
|
1276
|
+
logger.warn("document.amendment.holder_unnotified", {
|
|
1277
|
+
documentId, holderAgentId: holder, verb: "remove-subject",
|
|
1278
|
+
reason: sent.ok ? "relay_parked" : sent.reason,
|
|
1279
|
+
detail: sent.ok ? "the relay is holding it" : sent.detail,
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
catch (err) {
|
|
1284
|
+
holdersTold[holder] = false;
|
|
1285
|
+
logger.warn("document.amendment.holder_unnotified", {
|
|
1286
|
+
documentId, holderAgentId: holder, verb: "remove-subject",
|
|
1287
|
+
reason: "amendment_send_threw",
|
|
1288
|
+
detail: extractErrorMessage(err),
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
logger.info("document.holder_removed", {
|
|
1293
|
+
documentId, holder, voluntary: holder === who.ownerAgentId,
|
|
1294
|
+
});
|
|
1295
|
+
return {
|
|
1296
|
+
ok: true,
|
|
1297
|
+
documentId,
|
|
1298
|
+
removedAgentId: holder,
|
|
1299
|
+
voluntary: holder === who.ownerAgentId,
|
|
1300
|
+
holdersNotified: holdersTold,
|
|
1301
|
+
guidance: "Removal is forward-only: their existing copy and its history remain theirs — new edits " +
|
|
1302
|
+
"simply no longer flow either way.",
|
|
1303
|
+
};
|
|
1304
|
+
});
|
|
1305
|
+
handlers.set("cello_doc_refuse", async (params, connectionId) => {
|
|
1306
|
+
const who = resolve(params, connectionId);
|
|
1307
|
+
if (isRefusal(who))
|
|
1308
|
+
return who;
|
|
1309
|
+
const documentId = typeof params?.document_id === "string" ? params.document_id : "";
|
|
1310
|
+
// TRIMMED before the emptiness test. `document-handshake.refuse` THROWS on a whitespace-only
|
|
1311
|
+
// reason, and this guard only checked length — so `{ reason: " " }` reached it and surfaced
|
|
1312
|
+
// as `internal_error`, for a refusal that is otherwise perfectly valid.
|
|
1313
|
+
const given = typeof params?.reason === "string" ? params.reason.trim() : "";
|
|
1314
|
+
const reason = given.length > 0 ? given : "declined_by_operator";
|
|
1315
|
+
if (documentId.length === 0) {
|
|
1316
|
+
return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_inbox." };
|
|
1317
|
+
}
|
|
1318
|
+
// THE NEW-MODEL INVITATION (SYNC-P3): the document arrived through the exchange and this
|
|
1319
|
+
// agent derives as an INVITED seat — declining is authoring your own signed refuse_join
|
|
1320
|
+
// entry (R24), which reaches every holder over the same carrier as everything else and
|
|
1321
|
+
// settles the inviter's surface from the record itself.
|
|
1322
|
+
if (layer.store.getDocument(who.ownerAgentId, documentId)) {
|
|
1323
|
+
const genesisRecord = layer.handshake.get(who.ownerAgentId, documentId);
|
|
1324
|
+
if (genesisRecord) {
|
|
1325
|
+
const standing = deriveDocumentState(arrangementGenesisFromProposal(genesisRecord.envelope), layer.amendments.chain(who.ownerAgentId, documentId), documentGovernancePolicy, layer.verifySignature);
|
|
1326
|
+
if (standing.ok && standing.state.invited.has(who.ownerAgentId)) {
|
|
1327
|
+
const refusal = await authorConsent(who, documentId, "refuse_join");
|
|
1328
|
+
if (!refusal.ok) {
|
|
1329
|
+
return {
|
|
1330
|
+
ok: false,
|
|
1331
|
+
reason: "document_refusal_unrecorded",
|
|
1332
|
+
guidance: `Your refusal entry could not be recorded (${refusal.reason}) — run ` +
|
|
1333
|
+
`cello_doc_refuse again once the named condition clears.`,
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
return {
|
|
1337
|
+
ok: true,
|
|
1338
|
+
documentId,
|
|
1339
|
+
joined: false,
|
|
1340
|
+
refusalEntry: refusal.entryHash,
|
|
1341
|
+
refusalDelivered: refusal.holdersNotified,
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
const proposal = layer.handshake.get(who.ownerAgentId, documentId);
|
|
1347
|
+
const outcome = layer.handshake.refuse(who.ownerAgentId, documentId, reason, deps.now());
|
|
1348
|
+
if (!outcome.ok)
|
|
1349
|
+
return { ok: false, reason: outcome.reason, guidance: outcome.detail };
|
|
1350
|
+
// The REASON travels. A refusal the proposer cannot see the reason for leaves them unable to
|
|
1351
|
+
// propose anything better, which is what makes people abandon a protocol rather than adjust.
|
|
1352
|
+
const told = proposal
|
|
1353
|
+
? await tellProposer(who, documentId, proposal.proposerAgentId, false, reason)
|
|
1354
|
+
: false;
|
|
1355
|
+
return { ok: true, documentId, proposerNotified: told };
|
|
1356
|
+
});
|
|
1357
|
+
// ─── list / read / write ──────────────────────────────────────────────────────────────────
|
|
1358
|
+
handlers.set("cello_doc_list", async (params, connectionId) => {
|
|
1359
|
+
const who = resolve(params, connectionId);
|
|
1360
|
+
if (isRefusal(who))
|
|
1361
|
+
return who;
|
|
1362
|
+
// SYNC-R45/R46 — everything here is DERIVED at the moment of asking (participants, admins,
|
|
1363
|
+
// properties, ended, own standing) or read from the per-party DISPLAY CACHE (sync,
|
|
1364
|
+
// lastSyncedAtMs — spec §9; never a correctness input, R44). R48: a row whose chain does not
|
|
1365
|
+
// derive says so BY NAME (`underivable`) with every derived key present and null — an absent
|
|
1366
|
+
// key was already misread once as "nobody holds this".
|
|
1367
|
+
// SYNC-P4 (D5 replacement): the invitation ledger IS the entry set — an open invitation is
|
|
1368
|
+
// an invited seat in the derivation; a refusal is the subject's refuse_join entry.
|
|
1369
|
+
const outgoingJoins = layer.store
|
|
1370
|
+
.listDocuments(who.ownerAgentId)
|
|
1371
|
+
.flatMap((d) => {
|
|
1372
|
+
const genesisRecord = layer.handshake.get(who.ownerAgentId, d.documentId);
|
|
1373
|
+
if (!genesisRecord)
|
|
1374
|
+
return [];
|
|
1375
|
+
let chain;
|
|
1376
|
+
try {
|
|
1377
|
+
chain = layer.amendments.chain(who.ownerAgentId, d.documentId);
|
|
1378
|
+
}
|
|
1379
|
+
catch {
|
|
1380
|
+
return [];
|
|
1381
|
+
}
|
|
1382
|
+
const derived = deriveDocumentState(arrangementGenesisFromProposal(genesisRecord.envelope), chain, documentGovernancePolicy, layer.verifySignature);
|
|
1383
|
+
if (!derived.ok)
|
|
1384
|
+
return [];
|
|
1385
|
+
// Review F1 (P5): the genesis peer can refuse BEFORE ever holding the document, so no
|
|
1386
|
+
// refuse_join entry exists anywhere — their signed answer lives on the handshake record.
|
|
1387
|
+
// Without this read, a refused proposal rendered "pending" forever while the same
|
|
1388
|
+
// daemon's publish gate knew "they said no" — two surfaces of one daemon contradicting.
|
|
1389
|
+
const open = [...derived.state.invited]
|
|
1390
|
+
.filter((seat) => seat !== who.ownerAgentId)
|
|
1391
|
+
.map((seat) => {
|
|
1392
|
+
if (seat === genesisRecord.peerAgentId &&
|
|
1393
|
+
genesisRecord.proposerAgentId === who.ownerAgentId &&
|
|
1394
|
+
genesisRecord.peerAccepted === false) {
|
|
1395
|
+
return {
|
|
1396
|
+
documentId: d.documentId,
|
|
1397
|
+
inviteeAgentId: seat,
|
|
1398
|
+
state: "refused",
|
|
1399
|
+
...(genesisRecord.peerReason ? { reason: genesisRecord.peerReason } : {}),
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
return { documentId: d.documentId, inviteeAgentId: seat, state: "pending" };
|
|
1403
|
+
});
|
|
1404
|
+
const refusedSeats = chain
|
|
1405
|
+
.filter((e) => e.body.kind === "refuse_join")
|
|
1406
|
+
.map((e) => e.body.subject_agent_id)
|
|
1407
|
+
.filter((seat) => seat !== null && seat !== who.ownerAgentId)
|
|
1408
|
+
.filter((seat) => !derived.state.participants.has(seat) && !derived.state.invited.has(seat));
|
|
1409
|
+
const refused = [...new Set(refusedSeats)].map((seat) => ({
|
|
1410
|
+
documentId: d.documentId,
|
|
1411
|
+
inviteeAgentId: seat,
|
|
1412
|
+
state: "refused",
|
|
1413
|
+
}));
|
|
1414
|
+
return [...open, ...refused];
|
|
1415
|
+
});
|
|
1416
|
+
const documents = layer.store.listDocuments(who.ownerAgentId).map((doc) => {
|
|
1417
|
+
const base = {
|
|
1418
|
+
documentId: doc.documentId,
|
|
1419
|
+
documentType: doc.documentType,
|
|
1420
|
+
};
|
|
1421
|
+
const underivable = (reason) => ({
|
|
1422
|
+
...base,
|
|
1423
|
+
underivable: reason,
|
|
1424
|
+
participants: null,
|
|
1425
|
+
admins: null,
|
|
1426
|
+
properties: null,
|
|
1427
|
+
ended: null,
|
|
1428
|
+
yourStanding: "unknown",
|
|
1429
|
+
parties: null,
|
|
1430
|
+
standingGuidance: "This daemon cannot derive this document's arrangement, so nothing here should be " +
|
|
1431
|
+
"taken as confirmation of anything — see the named reason.",
|
|
1432
|
+
});
|
|
1433
|
+
const genesisRecord = layer.handshake.get(who.ownerAgentId, doc.documentId);
|
|
1434
|
+
if (!genesisRecord)
|
|
1435
|
+
return underivable("document_genesis_missing");
|
|
1436
|
+
let chain;
|
|
1437
|
+
try {
|
|
1438
|
+
chain = layer.amendments.chain(who.ownerAgentId, doc.documentId);
|
|
1439
|
+
}
|
|
1440
|
+
catch (err) {
|
|
1441
|
+
return underivable(`document_chain_undecodable: ${extractErrorMessage(err)}`);
|
|
1442
|
+
}
|
|
1443
|
+
const derived = deriveDocumentState(arrangementGenesisFromProposal(genesisRecord.envelope), chain, documentGovernancePolicy, layer.verifySignature);
|
|
1444
|
+
if (!derived.ok)
|
|
1445
|
+
return underivable(derived.reason);
|
|
1446
|
+
const state = derived.state;
|
|
1447
|
+
const rawStanding = layer.standingOf(who.ownerAgentId, doc.documentId, who.ownerAgentId);
|
|
1448
|
+
const yourStanding = rawStanding === "stranger" || rawStanding === "unknown" ? "unknown" : rawStanding;
|
|
1449
|
+
// Sync + blockedBy come from the layer's ONE display-cache read (shared with the sweep's
|
|
1450
|
+
// believed-current suppression): "behind" means we hold something the party's last claimed
|
|
1451
|
+
// position did not cover; "unseen" means no exchange was ever recorded here.
|
|
1452
|
+
const parties = [...state.participants, ...state.invited]
|
|
1453
|
+
.filter((agentId) => agentId !== who.ownerAgentId)
|
|
1454
|
+
.sort()
|
|
1455
|
+
.map((agentId) => ({
|
|
1456
|
+
agentId,
|
|
1457
|
+
class: state.participants.has(agentId)
|
|
1458
|
+
? "participant"
|
|
1459
|
+
: "invited",
|
|
1460
|
+
...layer.partySync(who.ownerAgentId, doc.documentId, agentId),
|
|
1461
|
+
}));
|
|
1462
|
+
return {
|
|
1463
|
+
...base,
|
|
1464
|
+
participants: [...state.participants].sort(),
|
|
1465
|
+
admins: [...state.admins].sort(),
|
|
1466
|
+
properties: state.properties,
|
|
1467
|
+
ended: state.ended,
|
|
1468
|
+
yourStanding,
|
|
1469
|
+
...(yourStanding === "removed"
|
|
1470
|
+
? {
|
|
1471
|
+
standingGuidance: "You are no longer a holder of this document. Your copy and its full history " +
|
|
1472
|
+
"remain yours, and you can still read it here or open the file. What changed is " +
|
|
1473
|
+
"only the flow of edits: yours no longer publish to the other holders, and " +
|
|
1474
|
+
"theirs no longer reach you.",
|
|
1475
|
+
}
|
|
1476
|
+
: {}),
|
|
1477
|
+
parties,
|
|
1478
|
+
};
|
|
1479
|
+
});
|
|
1480
|
+
return {
|
|
1481
|
+
ok: true,
|
|
1482
|
+
...(outgoingJoins.length > 0 ? { joinOffers: outgoingJoins } : {}),
|
|
1483
|
+
documents,
|
|
1484
|
+
};
|
|
1485
|
+
});
|
|
1486
|
+
handlers.set("cello_doc_read", async (params, connectionId) => {
|
|
1487
|
+
const who = resolve(params, connectionId);
|
|
1488
|
+
if (isRefusal(who))
|
|
1489
|
+
return who;
|
|
1490
|
+
const documentId = typeof params?.document_id === "string" ? params.document_id : "";
|
|
1491
|
+
if (documentId.length === 0) {
|
|
1492
|
+
return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_list." };
|
|
1493
|
+
}
|
|
1494
|
+
const document = layer.store.getDocument(who.ownerAgentId, documentId);
|
|
1495
|
+
if (!document) {
|
|
1496
|
+
return {
|
|
1497
|
+
ok: false,
|
|
1498
|
+
reason: "document_unknown",
|
|
1499
|
+
guidance: `No document ${documentId.slice(0, 16)}… for this agent. See cello_doc_list.`,
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
// THROWS rather than returning empty when the log cannot be rebuilt — see LiveDocuments.get. An
|
|
1503
|
+
// empty document handed to an agent here would be written back over the peer's real content.
|
|
1504
|
+
const doc = layer.live.get(who.ownerAgentId, documentId);
|
|
1505
|
+
// TYPE-AWARE. A JSON document's content is in the MAP root; reading the text root would answer
|
|
1506
|
+
// `content: ""` for a full document — the exact "an empty document handed to an agent gets
|
|
1507
|
+
// written back over the peer's real content" hazard this path warns about above.
|
|
1508
|
+
const content = projectDocumentText(doc, document.documentType);
|
|
1509
|
+
// THE READ IS THE BOOKMARK. `cello_doc_diff` answers "what changed since I looked", and looking
|
|
1510
|
+
// is this call. Marking on an arriving update instead would erase the very change the diff
|
|
1511
|
+
// exists to show, silently, at the moment it arrived.
|
|
1512
|
+
layer.notifications.markRead(who.ownerAgentId, documentId, content, deps.now());
|
|
1513
|
+
// And the unread notice is cleared, because it has now been read. Leaving it would keep an
|
|
1514
|
+
// inbox entry for something the agent is holding in its hands.
|
|
1515
|
+
layer.notifications.clear(who.ownerAgentId, documentId);
|
|
1516
|
+
return {
|
|
1517
|
+
ok: true,
|
|
1518
|
+
documentId,
|
|
1519
|
+
documentType: document.documentType,
|
|
1520
|
+
peerAgentId: document.peerAgentId,
|
|
1521
|
+
status: document.status,
|
|
1522
|
+
content,
|
|
1523
|
+
};
|
|
1524
|
+
});
|
|
1525
|
+
/**
|
|
1526
|
+
* DOD-DOC-WATCH-1 — declare which paths of a document should wake this agent.
|
|
1527
|
+
*
|
|
1528
|
+
* RECEIVER-LOCAL and nothing goes on the wire. A peer cannot make this agent wake by claiming a
|
|
1529
|
+
* field matters, nor suppress a wake by omitting one; the receiver decides from what actually
|
|
1530
|
+
* changed in its own copy.
|
|
1531
|
+
*
|
|
1532
|
+
* An EMPTY list clears the watch, and clearing is the only way to stop being nudged — there is no
|
|
1533
|
+
* separate unwatch verb to fall out of step with this one.
|
|
1534
|
+
*/
|
|
1535
|
+
handlers.set("cello_doc_watch", async (params, connectionId) => {
|
|
1536
|
+
const who = resolve(params, connectionId);
|
|
1537
|
+
if (isRefusal(who))
|
|
1538
|
+
return who;
|
|
1539
|
+
const documentId = typeof params?.document_id === "string" ? params.document_id : "";
|
|
1540
|
+
if (documentId.length === 0) {
|
|
1541
|
+
return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_list." };
|
|
1542
|
+
}
|
|
1543
|
+
const document = layer.store.getDocument(who.ownerAgentId, documentId);
|
|
1544
|
+
if (!document) {
|
|
1545
|
+
return {
|
|
1546
|
+
ok: false,
|
|
1547
|
+
reason: "document_unknown",
|
|
1548
|
+
guidance: `No document ${documentId.slice(0, 16)}… for this agent. See cello_doc_list.`,
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
const raw = Array.isArray(params?.paths) ? params.paths.filter((p) => typeof p === "string") : null;
|
|
1552
|
+
if (raw === null) {
|
|
1553
|
+
// LISTED, not silently treated as "clear". A caller that omits `paths` is asking what is set,
|
|
1554
|
+
// and answering "cleared" would turn a read into a destructive act.
|
|
1555
|
+
return { ok: true, documentId, paths: layer.notifications.watches(who.ownerAgentId, documentId) };
|
|
1556
|
+
}
|
|
1557
|
+
let paths;
|
|
1558
|
+
try {
|
|
1559
|
+
paths = normalizeWatchPaths(raw);
|
|
1560
|
+
}
|
|
1561
|
+
catch (err) {
|
|
1562
|
+
return {
|
|
1563
|
+
ok: false,
|
|
1564
|
+
reason: "watch_path_invalid",
|
|
1565
|
+
guidance: extractErrorMessage(err),
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
layer.notifications.setWatches(who.ownerAgentId, documentId, paths);
|
|
1569
|
+
logger.info("document.watch.set", { documentId, paths: paths.length });
|
|
1570
|
+
return {
|
|
1571
|
+
ok: true,
|
|
1572
|
+
documentId,
|
|
1573
|
+
paths,
|
|
1574
|
+
...(paths.length === 0
|
|
1575
|
+
? { guidance: "Watch cleared — this document will no longer wake you." }
|
|
1576
|
+
: {
|
|
1577
|
+
guidance: `You will be woken once when any of these move, and not again until you read the ` +
|
|
1578
|
+
`document. Nothing was sent to your counterparty: this is local to you.`,
|
|
1579
|
+
}),
|
|
1580
|
+
};
|
|
1581
|
+
});
|
|
1582
|
+
handlers.set("cello_doc_diff", async (params, connectionId) => {
|
|
1583
|
+
const who = resolve(params, connectionId);
|
|
1584
|
+
if (isRefusal(who))
|
|
1585
|
+
return who;
|
|
1586
|
+
const documentId = typeof params?.document_id === "string" ? params.document_id : "";
|
|
1587
|
+
if (documentId.length === 0) {
|
|
1588
|
+
return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_list." };
|
|
1589
|
+
}
|
|
1590
|
+
const document = layer.store.getDocument(who.ownerAgentId, documentId);
|
|
1591
|
+
if (!document) {
|
|
1592
|
+
return {
|
|
1593
|
+
ok: false,
|
|
1594
|
+
reason: "document_unknown",
|
|
1595
|
+
guidance: `No document ${documentId.slice(0, 16)}… for this agent. See cello_doc_list.`,
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
const after = projectDocumentText(layer.live.get(who.ownerAgentId, documentId), document.documentType);
|
|
1599
|
+
const before = layer.notifications.lastSeen(who.ownerAgentId, documentId);
|
|
1600
|
+
if (before === null) {
|
|
1601
|
+
// NEVER READ is not "nothing changed", and it is not an empty before either. Diffing against
|
|
1602
|
+
// "" would render a first look at a long document as an enormous change the agent then treats
|
|
1603
|
+
// as "what just arrived" — and act on. Said plainly instead.
|
|
1604
|
+
return {
|
|
1605
|
+
ok: false,
|
|
1606
|
+
reason: "document_never_read",
|
|
1607
|
+
guidance: `You have not read ${documentId.slice(0, 16)}… yet, so there is nothing to compare against. ` +
|
|
1608
|
+
`Call cello_doc_read first; the diff answers "what changed since I looked".`,
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
const rendered = layer.notifications.diff(document.documentType, before, after, documentId);
|
|
1612
|
+
// The STATS come from the same pair of texts, so an agent branching on `overlap` is branching on
|
|
1613
|
+
// the same comparison it is being shown.
|
|
1614
|
+
// OUR OWN edited lines, so `overlap` is a computed answer rather than the reassuring null three
|
|
1615
|
+
// instruction sheets were telling agents to trust. Null here still means "not computed" — we
|
|
1616
|
+
// have not written since the read — and `diffStats` keeps that distinct from "no conflict",
|
|
1617
|
+
// which is the whole reason its parameter is required.
|
|
1618
|
+
const myEdits = layer.notifications.myEditedLines(who.ownerAgentId, documentId);
|
|
1619
|
+
const stats = layer.notifications.diffStats(documentId, before, after, myEdits, document.documentType);
|
|
1620
|
+
if (!rendered.ok) {
|
|
1621
|
+
// The stats still stand — they are structural and type-independent — so a document type this
|
|
1622
|
+
// build cannot render is not a document an agent has to read blind.
|
|
1623
|
+
// NOT `reason` ON AN `ok: true`. That key is the daemon's FAILURE convention everywhere else —
|
|
1624
|
+
// `json-out.ts` documents `ok: false` as the one failure shape — so an agent branching on
|
|
1625
|
+
// `result.reason` reads this perfectly good stats-only diff as an error and stops.
|
|
1626
|
+
return {
|
|
1627
|
+
ok: true,
|
|
1628
|
+
documentId,
|
|
1629
|
+
unchanged: before === after,
|
|
1630
|
+
diff: null,
|
|
1631
|
+
diffUnavailableReason: rendered.reason,
|
|
1632
|
+
stats,
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
return {
|
|
1636
|
+
ok: true,
|
|
1637
|
+
documentId,
|
|
1638
|
+
unchanged: before === after,
|
|
1639
|
+
diff: rendered.diff,
|
|
1640
|
+
...(rendered.fallback !== undefined ? { fallback: rendered.fallback } : {}),
|
|
1641
|
+
stats,
|
|
1642
|
+
};
|
|
1643
|
+
});
|
|
1644
|
+
handlers.set("cello_doc_write", async (params, connectionId) => {
|
|
1645
|
+
const who = resolve(params, connectionId);
|
|
1646
|
+
if (isRefusal(who))
|
|
1647
|
+
return who;
|
|
1648
|
+
const documentId = typeof params?.document_id === "string" ? params.document_id : "";
|
|
1649
|
+
if (documentId.length === 0) {
|
|
1650
|
+
return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_list." };
|
|
1651
|
+
}
|
|
1652
|
+
if (typeof params?.content !== "string") {
|
|
1653
|
+
return {
|
|
1654
|
+
ok: false,
|
|
1655
|
+
reason: "invalid_content",
|
|
1656
|
+
guidance: "cello_doc_write takes 'content' — the document's COMPLETE new text, not a patch. The " +
|
|
1657
|
+
"daemon diffs it against the current state, so offsets cannot go stale under a " +
|
|
1658
|
+
"concurrent edit by the peer.",
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
const content = params.content;
|
|
1662
|
+
const document = layer.store.getDocument(who.ownerAgentId, documentId);
|
|
1663
|
+
if (!document) {
|
|
1664
|
+
return {
|
|
1665
|
+
ok: false,
|
|
1666
|
+
reason: "document_unknown",
|
|
1667
|
+
guidance: `No document ${documentId.slice(0, 16)}… for this agent. See cello_doc_list.`,
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
const refused = peerRefused(who, documentId);
|
|
1671
|
+
if (refused)
|
|
1672
|
+
return refused;
|
|
1673
|
+
const doc = layer.live.get(who.ownerAgentId, documentId);
|
|
1674
|
+
const text = doc.getText("content");
|
|
1675
|
+
const before = projectDocumentText(doc, document.documentType);
|
|
1676
|
+
const isMapRoot = rootForDocumentType(document.documentType) === "map";
|
|
1677
|
+
// PARSED BEFORE ANYTHING IS TOUCHED. A structured document must not partially apply a write
|
|
1678
|
+
// that does not parse — the operator would be left with half their edit in a signed envelope
|
|
1679
|
+
// and no way to tell which half.
|
|
1680
|
+
const parsedWrite = isMapRoot ? parseJsonDocument(content) : null;
|
|
1681
|
+
if (parsedWrite !== null && !parsedWrite.ok) {
|
|
1682
|
+
return {
|
|
1683
|
+
ok: false,
|
|
1684
|
+
reason: "document_content_unparseable",
|
|
1685
|
+
guidance: `This is a JSON document and what you sent is not valid JSON (${parsedWrite.detail}). ` +
|
|
1686
|
+
`Nothing was changed. Send the COMPLETE document, not a fragment — cello_doc_read gives ` +
|
|
1687
|
+
`you the current text to edit.`,
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
if (before === content) {
|
|
1691
|
+
// NO TEXT CHANGE IS NOT ALWAYS NOTHING TO DO, and this returned here unconditionally — which
|
|
1692
|
+
// made an applied-but-unpublished edit UNRECOVERABLE.
|
|
1693
|
+
//
|
|
1694
|
+
// The sequence: a write applies the text to the live doc, `publish.publish` then refuses (the
|
|
1695
|
+
// platform is paused), and the handler correctly reports `changed: true, published: false`.
|
|
1696
|
+
// The edit is now in the document and in no log — and pending is derived from the log, so
|
|
1697
|
+
// nothing will ever deliver it. The operator's natural retry is to write the same text again,
|
|
1698
|
+
// which landed HERE and was answered `changed: false, published: false`: a cheerful no-op
|
|
1699
|
+
// over a permanent divergence. `cello_doc_publish` could not flush it either — it diffs the
|
|
1700
|
+
// FILE, which already matches.
|
|
1701
|
+
//
|
|
1702
|
+
// GATED ON A RECORDED FACT, not on asking `publish` whether anything is owed. Its own guard
|
|
1703
|
+
// only fires once we have published before (`lastPublishedStateVector !== null`), so on a
|
|
1704
|
+
// first write it would publish the whole document on every unchanged call — a leaf and a
|
|
1705
|
+
// delivery for nothing, which the neighbouring test pins against.
|
|
1706
|
+
//
|
|
1707
|
+
// The flag lives in memory ON PURPOSE: an unpublished edit is itself in-memory only. The live
|
|
1708
|
+
// doc is rebuilt from the envelope log, and the edit is by definition not in it, so both are
|
|
1709
|
+
// lost on restart together. A durable flag would outlive the thing it describes.
|
|
1710
|
+
if (unpublishedEdits.has(unpublishedKey(who.ownerAgentId, documentId))) {
|
|
1711
|
+
// Screened here too. A flush publishes text that was folded in on an EARLIER call, so
|
|
1712
|
+
// skipping it would be a way for content to reach the peer without ever passing the check.
|
|
1713
|
+
const stuckFault = screenText(projectDocumentText(doc, document.documentType));
|
|
1714
|
+
if (stuckFault) {
|
|
1715
|
+
return {
|
|
1716
|
+
ok: false,
|
|
1717
|
+
reason: "document_content_refused",
|
|
1718
|
+
guidance: `The edit waiting to be published contains ${stuckFault.codepoints.join(", ")}, which ` +
|
|
1719
|
+
`your peer's screening refuses. Correct the file and publish again.`,
|
|
1720
|
+
detail: JSON.stringify({ rule: SCREEN_RULE_ID, ...stuckFault }),
|
|
1721
|
+
};
|
|
1722
|
+
}
|
|
1723
|
+
const flushed = await publish.publish(who.ownerAgentId, documentId, doc, deps.now());
|
|
1724
|
+
if (flushed.ok) {
|
|
1725
|
+
unpublishedEdits.delete(unpublishedKey(who.ownerAgentId, documentId));
|
|
1726
|
+
layer.notifications.markWritten(who.ownerAgentId, documentId, content);
|
|
1727
|
+
await materialize(who.ownerAgentId, documentId, document.documentType);
|
|
1728
|
+
return {
|
|
1729
|
+
ok: true,
|
|
1730
|
+
documentId,
|
|
1731
|
+
changed: false,
|
|
1732
|
+
published: true,
|
|
1733
|
+
envelopeHash: flushed.envelopeHash,
|
|
1734
|
+
guidance: "The text was already what you sent, but an earlier edit had been applied without " +
|
|
1735
|
+
"reaching your peer. It has gone out now.",
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
return {
|
|
1739
|
+
ok: true,
|
|
1740
|
+
documentId,
|
|
1741
|
+
changed: false,
|
|
1742
|
+
published: false,
|
|
1743
|
+
reason: flushed.reason,
|
|
1744
|
+
guidance: `An earlier edit is applied locally and still has not reached your peer ` +
|
|
1745
|
+
`(${flushed.reason}). ${flushed.detail ?? ""}`.trim(),
|
|
1746
|
+
};
|
|
1747
|
+
}
|
|
1748
|
+
return { ok: true, documentId, changed: false, published: false };
|
|
1749
|
+
}
|
|
1750
|
+
// AUTHORING-SIDE PROFILE CHECK (DOD-DOC-PROFILE-1, §16.7-14). Caught where the character was
|
|
1751
|
+
// WRITTEN, so it never becomes a rejection round: the peer's gate would refuse this envelope,
|
|
1752
|
+
// the refusal would advance the retry counter, and three of those stall the document. Refusing
|
|
1753
|
+
// here costs one call and no protocol state.
|
|
1754
|
+
//
|
|
1755
|
+
// This is ERGONOMICS, not security, and the distinction is load-bearing — the receiver's gate
|
|
1756
|
+
// runs the identical check and stays authoritative, because a sender's client can be patched or
|
|
1757
|
+
// compromised while the sender themselves is a good actor. Deleting this makes CELLO more
|
|
1758
|
+
// annoying; deleting the receiver's makes it unsafe.
|
|
1759
|
+
const profileFault = profileViolation(typeof document.properties.content_profile === "string"
|
|
1760
|
+
? document.properties.content_profile
|
|
1761
|
+
: undefined, content);
|
|
1762
|
+
// AUTHORING-SIDE SCREENING (DOD-DOC-SCREEN-1, §16.6). The receiver's gate refuses these and
|
|
1763
|
+
// stays authoritative; this catches them where the character was WRITTEN so it never becomes a
|
|
1764
|
+
// rejection round. Three rejected rounds stall the document, and a stall from a character the
|
|
1765
|
+
// operator cannot see in their own editor is the worst version of that.
|
|
1766
|
+
//
|
|
1767
|
+
// FRICTION REDUCTION AMONG GOOD ACTORS, never a boundary — the sender's client can be patched
|
|
1768
|
+
// or compromised while the sender is a good actor, which is why the receiving gate exists and
|
|
1769
|
+
// why this one cannot replace it. Same function on both sides (`screenText`), so the two cannot
|
|
1770
|
+
// disagree about what is refused.
|
|
1771
|
+
// SENDER ADOPTS THE RECEIVER'S RULE (DOD-DOC-SCREEN-1, §16.7-16).
|
|
1772
|
+
//
|
|
1773
|
+
// Every codepoint THIS peer has already refused for THIS document, learned from their own signed
|
|
1774
|
+
// refusals. Rules compose toward strict: once they have said no to a character, emitting it
|
|
1775
|
+
// again spends a refusal round on an answer we have already been given — and three rounds stall
|
|
1776
|
+
// the document, so an avoidable one is expensive.
|
|
1777
|
+
//
|
|
1778
|
+
// This is what the machine-readable refusal detail was FOR. A refusal that carried only prose
|
|
1779
|
+
// could be read by an operator and adopted by nobody.
|
|
1780
|
+
const adopted = layer.store.adoptedRefusedCodepoints(who.ownerAgentId, documentId);
|
|
1781
|
+
if (adopted.size > 0) {
|
|
1782
|
+
const offenders = new Set();
|
|
1783
|
+
const offsets = [];
|
|
1784
|
+
let at = 0;
|
|
1785
|
+
for (const ch of content) {
|
|
1786
|
+
const cp = `U+${ch.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`;
|
|
1787
|
+
if (adopted.has(cp)) {
|
|
1788
|
+
offenders.add(cp);
|
|
1789
|
+
offsets.push(at);
|
|
1790
|
+
}
|
|
1791
|
+
at++;
|
|
1792
|
+
}
|
|
1793
|
+
// Markers match case-insensitively, exactly as the screen matches them. A case-sensitive
|
|
1794
|
+
// search here does not admit anything — `screenText` below still refuses — but it loses the
|
|
1795
|
+
// "your peer has already said no to this" answer and reports a first refusal instead.
|
|
1796
|
+
const lowered = content.toLowerCase();
|
|
1797
|
+
for (const marker of [...adopted].filter((c) => !c.startsWith("U+"))) {
|
|
1798
|
+
const needle = marker.toLowerCase();
|
|
1799
|
+
let idx = lowered.indexOf(needle);
|
|
1800
|
+
while (idx !== -1) {
|
|
1801
|
+
offenders.add(marker);
|
|
1802
|
+
offsets.push([...content.slice(0, idx)].length);
|
|
1803
|
+
idx = lowered.indexOf(needle, idx + needle.length);
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
if (offenders.size > 0) {
|
|
1807
|
+
offsets.sort((a, b) => a - b);
|
|
1808
|
+
return {
|
|
1809
|
+
ok: false,
|
|
1810
|
+
reason: "document_peer_rule_adopted",
|
|
1811
|
+
guidance: `Your peer has already refused ${[...offenders].join(", ")} in this document ` +
|
|
1812
|
+
`(${offsets.length} occurrence(s), first at character ${offsets[0]}). Sending it again ` +
|
|
1813
|
+
`would spend a refusal round on an answer they have given — and three of those stall ` +
|
|
1814
|
+
`the document. Remove it and write again.`,
|
|
1815
|
+
detail: JSON.stringify({ rule: SCREEN_RULE_ID, adopted: true, codepoints: [...offenders], count: offsets.length, offsets }),
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
const screenFault = screenText(content);
|
|
1820
|
+
if (screenFault) {
|
|
1821
|
+
return {
|
|
1822
|
+
ok: false,
|
|
1823
|
+
reason: "document_content_refused",
|
|
1824
|
+
guidance: `Your peer's screening will refuse ${screenFault.codepoints.join(", ")} ` +
|
|
1825
|
+
`(${screenFault.count} occurrence(s), first at character ${screenFault.offsets[0]}). ` +
|
|
1826
|
+
`${SCREEN_GUIDANCE} Remove them and write again — ` +
|
|
1827
|
+
`sending as-is costs a rejection round, and three of those stall the document.`,
|
|
1828
|
+
detail: JSON.stringify({ rule: SCREEN_RULE_ID, ...screenFault }),
|
|
1829
|
+
};
|
|
1830
|
+
}
|
|
1831
|
+
if (profileFault) {
|
|
1832
|
+
return {
|
|
1833
|
+
ok: false,
|
|
1834
|
+
reason: "document_profile_violation",
|
|
1835
|
+
guidance: `This document was agreed as '${profileFault.profile}', which does not allow ` +
|
|
1836
|
+
`${profileFault.codepoints.join(", ")} (${profileFault.count} occurrence(s), first at ` +
|
|
1837
|
+
`character ${profileFault.offsets[0]}). The profile is fixed for the life of the document ` +
|
|
1838
|
+
`— it was bound into the id when your peer accepted it — so change the text rather than ` +
|
|
1839
|
+
`the setting.`,
|
|
1840
|
+
detail: JSON.stringify(profileFault),
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
// DOD-DOC-STALE-WRITE-1 — WOULD THIS WRITE DELETE SOMETHING THE AUTHOR NEVER SAW?
|
|
1844
|
+
//
|
|
1845
|
+
// `content` is the COMPLETE text, so anything absent from it is a deletion. That contract is
|
|
1846
|
+
// right — a patch API means stale offsets, which in a CRDT is permanent corruption both sides
|
|
1847
|
+
// converge on — but it cannot distinguish "I read their paragraph and do not want it" from
|
|
1848
|
+
// "their paragraph arrived while I was typing". Both are the same bytes.
|
|
1849
|
+
//
|
|
1850
|
+
// Measured live 2026-08-09: a peer's paragraph admitted at 12:35:41.807 was destroyed by a write
|
|
1851
|
+
// published at 12:35:42.033. The lost text was the smaller harm — the signed record cannot tell
|
|
1852
|
+
// the two apart either, so the accident is permanently attributed as a deliberate rejection of
|
|
1853
|
+
// the counterparty's work.
|
|
1854
|
+
//
|
|
1855
|
+
// The read mark holds the exact text last read, durably, which is enough to tell them apart.
|
|
1856
|
+
// Refusal is conditioned on REMOVAL, never on staleness alone: a write that only ADDS is never
|
|
1857
|
+
// refused, or proposing a document and writing to it before reading would refuse for nothing.
|
|
1858
|
+
// GATED ON WHETHER THE DOCUMENT MOVED UNDER THEM. If no peer update has been admitted since
|
|
1859
|
+
// this agent last looked, its view IS current by construction and every removal in its text is
|
|
1860
|
+
// something it put there or read — nothing to protect, and refusing would be pure friction.
|
|
1861
|
+
//
|
|
1862
|
+
// This is what makes the guard quiet. The first version asked only "was this line in your last
|
|
1863
|
+
// read", which refused every EDIT of an existing line by an author who had not called read —
|
|
1864
|
+
// changing a line removes the old line's text. Two existing tests caught it.
|
|
1865
|
+
const unreadFromPeer = layer.notifications.unreadFromPeer(who.ownerAgentId, documentId);
|
|
1866
|
+
const verdict = unreadFromPeer > 0
|
|
1867
|
+
? classifyRemovals(before, content, layer.notifications.knownTexts(who.ownerAgentId, documentId))
|
|
1868
|
+
: { removed: [], deliberate: [], unseen: [], refuse: false };
|
|
1869
|
+
if (verdict.refuse) {
|
|
1870
|
+
// The diff travels WITH the refusal. This is the moment the author can still act: they are
|
|
1871
|
+
// about to destroy it, rather than the peer discovering it gone hours later — and unlike the
|
|
1872
|
+
// peer, the author can tell whether they meant it.
|
|
1873
|
+
logger.info("document.write.refused.unseen_removal", {
|
|
1874
|
+
documentId,
|
|
1875
|
+
unseen: verdict.unseen.length,
|
|
1876
|
+
unreadFromPeer,
|
|
1877
|
+
});
|
|
1878
|
+
return {
|
|
1879
|
+
ok: false,
|
|
1880
|
+
reason: "document_write_would_delete_unseen",
|
|
1881
|
+
currentContent: before,
|
|
1882
|
+
unseenRemovals: verdict.unseen,
|
|
1883
|
+
guidance: `This write would delete ${verdict.unseen.length} line(s) you have not read — your ` +
|
|
1884
|
+
`counterparty changed the document after you last looked at it. Nothing was changed. ` +
|
|
1885
|
+
`The current text is in 'currentContent' and what you would have removed is in ` +
|
|
1886
|
+
`'unseenRemovals'. Re-apply your edit on top of the current text and write again. If you ` +
|
|
1887
|
+
`DO want those lines gone, read the document first — a removal you have read is recorded ` +
|
|
1888
|
+
`as a deliberate act rather than refused.`,
|
|
1889
|
+
};
|
|
1890
|
+
}
|
|
1891
|
+
// LINE HUNKS, never a whole-text replace — and this is not a preference, it is measured.
|
|
1892
|
+
//
|
|
1893
|
+
// `delete(0, len); insert(0, content)` can only delete the items THIS side has seen. A peer's
|
|
1894
|
+
// concurrently-inserted items survive the delete and are spliced into the new text as orphan
|
|
1895
|
+
// fragments. Against yjs at this version:
|
|
1896
|
+
//
|
|
1897
|
+
// both sides full-replace "original" with "AAA" / "BBB" → "AAABBB" on BOTH sides
|
|
1898
|
+
// "Hello world", peer inserts " dear", we replace w/ "Goodbye" → " dearGoodbye"
|
|
1899
|
+
//
|
|
1900
|
+
// The first is the ORDINARY case for an API whose contract is "send back the complete text",
|
|
1901
|
+
// and it converges two whole documents concatenated — signed and published by both parties. It
|
|
1902
|
+
// is exactly the "a wrong offset in a CRDT is a permanent corruption both sides converge on"
|
|
1903
|
+
// outcome the full-content contract was chosen to avoid; the mechanism moved and the failure
|
|
1904
|
+
// did not.
|
|
1905
|
+
//
|
|
1906
|
+
// `lineHunks` touches only the lines that actually changed, so untouched regions keep their
|
|
1907
|
+
// items and a peer's concurrent edit to them merges. Same function `DocumentWritePath.#foldText`
|
|
1908
|
+
// uses, whose header records the same hazard for the file path — one folding rule, not two.
|
|
1909
|
+
// REFUSE A TERMINAL DOCUMENT BEFORE TOUCHING THE LOCAL COPY.
|
|
1910
|
+
//
|
|
1911
|
+
// `peerRefused` was checked above; the terminal statuses were not. A write into a closed, killed
|
|
1912
|
+
// or stalled document applied the hunks, failed at `canPublish`, and returned
|
|
1913
|
+
// `{ok: true, changed: true, published: false}` while adding the document to the stuck-edit set
|
|
1914
|
+
// — where no flush can ever succeed, because the document is terminal. The operator's next
|
|
1915
|
+
// `cello_doc_read` then showed text that is in no envelope log and no peer's copy, and that
|
|
1916
|
+
// vanishes on the next daemon restart when the live document is rebuilt from the log.
|
|
1917
|
+
//
|
|
1918
|
+
// Scoped to the TERMINAL statuses on purpose. `agent_platform_paused` is the recoverable case
|
|
1919
|
+
// the mutate-then-remember path was built for and keeps its behaviour: the edit is real, it is
|
|
1920
|
+
// held, and clearing the pause flushes it.
|
|
1921
|
+
const publishable = layer.lifecycle.canPublish(who.ownerAgentId, documentId);
|
|
1922
|
+
if (!publishable.ok && publishable.reason !== "agent_platform_paused") {
|
|
1923
|
+
return {
|
|
1924
|
+
ok: false,
|
|
1925
|
+
reason: publishable.reason,
|
|
1926
|
+
guidance:
|
|
1927
|
+
// PUNCTUATED. `detail` comes from several producers and not all of them end in a stop,
|
|
1928
|
+
// so the two sentences ran together — "…no longer publish to the other holders Nothing
|
|
1929
|
+
// was changed locally…" — on the one line this DoD calls actionable.
|
|
1930
|
+
`${withStop(publishable.detail ?? "This document can no longer accept writes.")} Nothing was ` +
|
|
1931
|
+
`changed locally — an edit applied here could never be published or recovered, and would ` +
|
|
1932
|
+
`disappear the next time the daemon restarted.`,
|
|
1933
|
+
};
|
|
1934
|
+
}
|
|
1935
|
+
// ONE TRANSACTION either way, so the whole edit is a single Yjs update rather than several — a
|
|
1936
|
+
// peer applying them separately would pass through states no operator ever wrote.
|
|
1937
|
+
if (parsedWrite !== null && parsedWrite.ok) {
|
|
1938
|
+
// PER KEY AND AT EVERY DEPTH, which is the whole reason a JSON document uses the map root.
|
|
1939
|
+
// Two agents editing different fields produce disjoint operations and both survive — including
|
|
1940
|
+
// two fields inside the SAME nested object, which was the defect: a nested object stored as a
|
|
1941
|
+
// plain value is one item, so two writes to it are two writes to one item and one is lost.
|
|
1942
|
+
//
|
|
1943
|
+
// Untouched keys are not rewritten at any depth. Writing a key back with an identical value is
|
|
1944
|
+
// still a CRDT operation and would clobber a peer's concurrent edit to a field this agent
|
|
1945
|
+
// never looked at.
|
|
1946
|
+
applyJsonToMap(doc.getMap("data"), parsedWrite.value, doc);
|
|
1947
|
+
}
|
|
1948
|
+
else {
|
|
1949
|
+
const hunks = lineHunks(before, content);
|
|
1950
|
+
doc.transact(() => {
|
|
1951
|
+
// Back to front, so earlier offsets stay valid as later ones are rewritten.
|
|
1952
|
+
for (const hunk of [...hunks].reverse()) {
|
|
1953
|
+
if (hunk.to > hunk.from)
|
|
1954
|
+
text.delete(hunk.from, hunk.to - hunk.from);
|
|
1955
|
+
if (hunk.insert.length > 0)
|
|
1956
|
+
text.insert(hunk.from, hunk.insert);
|
|
1957
|
+
}
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
const result = await publish.publish(who.ownerAgentId, documentId, doc, deps.now());
|
|
1961
|
+
if (!result.ok) {
|
|
1962
|
+
// REMEMBER IT, so the retry above can flush it. Without this the operator's next identical
|
|
1963
|
+
// write is indistinguishable from an ordinary no-op and the edit never leaves.
|
|
1964
|
+
unpublishedEdits.add(unpublishedKey(who.ownerAgentId, documentId));
|
|
1965
|
+
// The EDIT IS APPLIED locally and is not published. Reported as such: an operator told the
|
|
1966
|
+
// write failed would write it again, and the second write would be a no-op diff against the
|
|
1967
|
+
// text it already applied — the change silently never leaving.
|
|
1968
|
+
return {
|
|
1969
|
+
ok: true,
|
|
1970
|
+
documentId,
|
|
1971
|
+
changed: true,
|
|
1972
|
+
published: false,
|
|
1973
|
+
reason: result.reason,
|
|
1974
|
+
guidance: result.detail,
|
|
1975
|
+
};
|
|
1976
|
+
}
|
|
1977
|
+
// WHAT WE WROTE, against the current read mark. Without it `cello_doc_diff` shows our own edits
|
|
1978
|
+
// back to us as "what changed since I looked" — which the tool description frames as the
|
|
1979
|
+
// COUNTERPARTY's contribution — and `overlap` has nothing to separate mine from theirs.
|
|
1980
|
+
unpublishedEdits.delete(unpublishedKey(who.ownerAgentId, documentId));
|
|
1981
|
+
// A removal the author HAD read is the "second refusal" — a deliberate editorial act. Recorded
|
|
1982
|
+
// as one, so it is distinguishable later from the accident this guard now prevents.
|
|
1983
|
+
if (verdict.deliberate.length > 0) {
|
|
1984
|
+
logger.info("document.write.removal.deliberate", {
|
|
1985
|
+
documentId,
|
|
1986
|
+
lines: verdict.deliberate.length,
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
layer.notifications.markWritten(who.ownerAgentId, documentId, content);
|
|
1990
|
+
// AND THE FILE. `cello_doc_write` changes the document; without this the operator's own
|
|
1991
|
+
// projection on disk is stale the moment they use it, and the two surfaces disagree about the
|
|
1992
|
+
// document they both claim to show.
|
|
1993
|
+
//
|
|
1994
|
+
// Worse than cosmetic: `cello_doc_publish` diffs the FILE against the last recorded projection,
|
|
1995
|
+
// so a stale file either refuses as `document_file_stale` or — if it happened to match an older
|
|
1996
|
+
// baseline — republishes text the document has already moved past. Found on the first live
|
|
1997
|
+
// two-agent smoke, where the author's own file was missing the line she had just written.
|
|
1998
|
+
const writtenFile = await materialize(who.ownerAgentId, documentId, document.documentType);
|
|
1999
|
+
// Delivery is the worker's, not this call's — publish is fire-and-forget by design (§16.4), and
|
|
2000
|
+
// a write that blocked on an offline peer would make editing a shared document depend on the
|
|
2001
|
+
// other party being awake.
|
|
2002
|
+
return {
|
|
2003
|
+
ok: true,
|
|
2004
|
+
documentId,
|
|
2005
|
+
changed: true,
|
|
2006
|
+
published: true,
|
|
2007
|
+
envelopeHash: result.envelopeHash,
|
|
2008
|
+
// THE FILE'S FATE, SURFACED. `materialize` swallows its error — right for propose and accept,
|
|
2009
|
+
// where a disk fault must not fail a completed consent decision, and wrong here. The comment
|
|
2010
|
+
// above this call already spells out the consequence: a stale file makes the NEXT
|
|
2011
|
+
// `cello_doc_publish` refuse as `document_file_stale`, or worse, republish text the document
|
|
2012
|
+
// has moved past. The cause was a swallowed error one call earlier that only a daemon-log
|
|
2013
|
+
// reader would ever see.
|
|
2014
|
+
fileUpdated: writtenFile !== null,
|
|
2015
|
+
...(writtenFile === null
|
|
2016
|
+
? {
|
|
2017
|
+
guidance: "The change was published, but your local file could not be rewritten. Read the " +
|
|
2018
|
+
"document rather than the file, and expect cello_doc_publish to refuse as " +
|
|
2019
|
+
"document_file_stale until the file is back in step.",
|
|
2020
|
+
}
|
|
2021
|
+
: {}),
|
|
2022
|
+
};
|
|
2023
|
+
});
|
|
2024
|
+
// ─── close / kill ─────────────────────────────────────────────────────────────────────────
|
|
2025
|
+
handlers.set("cello_doc_close", async (params, connectionId) => {
|
|
2026
|
+
const who = resolve(params, connectionId);
|
|
2027
|
+
if (isRefusal(who))
|
|
2028
|
+
return who;
|
|
2029
|
+
const documentId = typeof params?.document_id === "string" ? params.document_id : "";
|
|
2030
|
+
if (documentId.length === 0) {
|
|
2031
|
+
return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_list." };
|
|
2032
|
+
}
|
|
2033
|
+
// SYNC-P4 (R26/R27): closing is authoring YOUR OWN close entry — the same self-signed act
|
|
2034
|
+
// consent is, traveling the same carrier, settling by DERIVATION when every current
|
|
2035
|
+
// participant has one. No control frame, no fan-out bookkeeping, no fire-once anything: a
|
|
2036
|
+
// close that has not reached someone yet is just a difference the next exchange closes.
|
|
2037
|
+
const closed = await authorConsent(who, documentId, "close");
|
|
2038
|
+
if (!closed.ok) {
|
|
2039
|
+
return {
|
|
2040
|
+
ok: false,
|
|
2041
|
+
reason: "document_close_unrecorded",
|
|
2042
|
+
guidance: `Your close entry could not be recorded (${closed.reason}).`,
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
2045
|
+
const derived = layer.governanceFrontierFor(who.ownerAgentId, documentId) !== null
|
|
2046
|
+
? layer.deriveEnded(who.ownerAgentId, documentId)
|
|
2047
|
+
: null;
|
|
2048
|
+
return {
|
|
2049
|
+
ok: true,
|
|
2050
|
+
documentId,
|
|
2051
|
+
closeEntry: closed.entryHash,
|
|
2052
|
+
closeDelivered: closed.holdersNotified,
|
|
2053
|
+
// DERIVED, at this instant: "closed" only when everyone seated has agreed — one party
|
|
2054
|
+
// alone is never the whole agreement, and the surface says who is still being waited on.
|
|
2055
|
+
ended: derived?.ended ?? null,
|
|
2056
|
+
waitingOn: derived?.waitingOn ?? [],
|
|
2057
|
+
};
|
|
2058
|
+
});
|
|
2059
|
+
handlers.set("cello_doc_kill", async (params, connectionId) => {
|
|
2060
|
+
const who = resolve(params, connectionId);
|
|
2061
|
+
if (isRefusal(who))
|
|
2062
|
+
return who;
|
|
2063
|
+
const documentId = typeof params?.document_id === "string" ? params.document_id : "";
|
|
2064
|
+
if (documentId.length === 0) {
|
|
2065
|
+
return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_list." };
|
|
2066
|
+
}
|
|
2067
|
+
// SYNC-P4 (R28): a kill is one admin's own signed entry — immediate and one-sided the
|
|
2068
|
+
// moment it applies anywhere, independent of anyone being reachable (a decision to stop
|
|
2069
|
+
// that depends on the other party being online is not a decision to stop). It travels the
|
|
2070
|
+
// same carrier as everything else; a holder who has not received it yet is a difference
|
|
2071
|
+
// the next exchange closes.
|
|
2072
|
+
const killed = await authorConsent(who, documentId, "kill");
|
|
2073
|
+
if (!killed.ok) {
|
|
2074
|
+
return {
|
|
2075
|
+
ok: false,
|
|
2076
|
+
reason: "document_kill_unrecorded",
|
|
2077
|
+
guidance: `Your kill entry could not be recorded (${killed.reason}).`,
|
|
2078
|
+
};
|
|
2079
|
+
}
|
|
2080
|
+
return {
|
|
2081
|
+
ok: true,
|
|
2082
|
+
documentId,
|
|
2083
|
+
killEntry: killed.entryHash,
|
|
2084
|
+
killDelivered: killed.holdersNotified,
|
|
2085
|
+
ended: "killed",
|
|
2086
|
+
};
|
|
2087
|
+
});
|
|
2088
|
+
handlers.set("cello_doc_publish", async (params, connectionId) => {
|
|
2089
|
+
const who = resolve(params, connectionId);
|
|
2090
|
+
if (isRefusal(who))
|
|
2091
|
+
return who;
|
|
2092
|
+
const documentId = typeof params?.document_id === "string" ? params.document_id : "";
|
|
2093
|
+
if (documentId.length === 0) {
|
|
2094
|
+
return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_list." };
|
|
2095
|
+
}
|
|
2096
|
+
if (!layer.writePath) {
|
|
2097
|
+
return {
|
|
2098
|
+
ok: false,
|
|
2099
|
+
reason: "document_files_unavailable",
|
|
2100
|
+
guidance: "This daemon has no document workspace configured, so there is no file to publish from.",
|
|
2101
|
+
};
|
|
2102
|
+
}
|
|
2103
|
+
const document = layer.store.getDocument(who.ownerAgentId, documentId);
|
|
2104
|
+
if (!document) {
|
|
2105
|
+
return {
|
|
2106
|
+
ok: false,
|
|
2107
|
+
reason: "document_unknown",
|
|
2108
|
+
guidance: `No document ${documentId.slice(0, 16)}… for this agent. See cello_doc_list.`,
|
|
2109
|
+
};
|
|
2110
|
+
}
|
|
2111
|
+
const refusedByPeer = peerRefused(who, documentId);
|
|
2112
|
+
if (refusedByPeer)
|
|
2113
|
+
return refusedByPeer;
|
|
2114
|
+
const doc = layer.live.get(who.ownerAgentId, documentId);
|
|
2115
|
+
let update;
|
|
2116
|
+
try {
|
|
2117
|
+
// Diffs the FILE against the last recorded projection and folds the difference in as local
|
|
2118
|
+
// operations. Refuses loudly on a stale baseline rather than diffing against something the
|
|
2119
|
+
// document has moved past — which would read a peer's admitted content as a deliberate
|
|
2120
|
+
// deletion and publish it as one.
|
|
2121
|
+
update = await layer.writePath.publish(who.ownerAgentId, documentId, document.documentType, doc);
|
|
2122
|
+
}
|
|
2123
|
+
catch (err) {
|
|
2124
|
+
const reason = err instanceof Error && "reason" in err ? String(err.reason) : "document_file_error";
|
|
2125
|
+
return {
|
|
2126
|
+
ok: false,
|
|
2127
|
+
reason,
|
|
2128
|
+
guidance: extractErrorMessage(err),
|
|
2129
|
+
};
|
|
2130
|
+
}
|
|
2131
|
+
if (update === null) {
|
|
2132
|
+
// NOTHING NEW ON DISK — but an earlier publish may have FOLDED an edit in and then failed to
|
|
2133
|
+
// publish it, in which case the file now matches the projection and there is nothing left to
|
|
2134
|
+
// diff. Without this branch that edit can never leave: the operator clears the cause, publishes
|
|
2135
|
+
// again, and gets a clean "nothing to do" over a change the peer has never seen — with
|
|
2136
|
+
// `cello_doc_list` agreeing, because pending is derived from the envelope log and no envelope
|
|
2137
|
+
// was ever written. Same recovery `cello_doc_write` has; it was missing here while the comment
|
|
2138
|
+
// below claimed parity.
|
|
2139
|
+
if (unpublishedEdits.has(unpublishedKey(who.ownerAgentId, documentId))) {
|
|
2140
|
+
const flushed = await publish.publish(who.ownerAgentId, documentId, doc, deps.now());
|
|
2141
|
+
if (!flushed.ok) {
|
|
2142
|
+
return { ok: true, documentId, changed: false, published: false, reason: flushed.reason, guidance: flushed.detail };
|
|
2143
|
+
}
|
|
2144
|
+
unpublishedEdits.delete(unpublishedKey(who.ownerAgentId, documentId));
|
|
2145
|
+
layer.notifications.markWritten(who.ownerAgentId, documentId, projectDocumentText(doc, document.documentType));
|
|
2146
|
+
return { ok: true, documentId, changed: true, published: true, envelopeHash: flushed.envelopeHash };
|
|
2147
|
+
}
|
|
2148
|
+
// A publish is an INTENT. Nothing changed on disk, so there is nothing to say, and saying it
|
|
2149
|
+
// anyway costs a leaf and a round trip.
|
|
2150
|
+
return { ok: true, documentId, changed: false, published: false };
|
|
2151
|
+
}
|
|
2152
|
+
// SCREEN WHAT THE FILE IS ABOUT TO SEND. `cello_doc_write` catches an offending character at the
|
|
2153
|
+
// keystroke so it never becomes a rejection round — and three rejection rounds stall a document
|
|
2154
|
+
// permanently. The file path ran none of it, on the surface §4.1 calls PRIMARY: a human editing
|
|
2155
|
+
// in their editor, or an agent with file tools. A zero-width space pasted from a web page is
|
|
2156
|
+
// invisible in both, and the refusal arrives later from the peer with no clue where it came from.
|
|
2157
|
+
//
|
|
2158
|
+
// Screened AFTER the fold because the fold is what produces the text; the edit is therefore
|
|
2159
|
+
// already in the local document when this refuses. The flag is deliberately NOT set: flushing
|
|
2160
|
+
// later would publish the offending bytes unscreened. Correcting the file is what removes it,
|
|
2161
|
+
// and the guidance says so.
|
|
2162
|
+
const publishScreenFault = screenText(projectDocumentText(doc, document.documentType));
|
|
2163
|
+
if (publishScreenFault) {
|
|
2164
|
+
return {
|
|
2165
|
+
ok: false,
|
|
2166
|
+
reason: "document_content_refused",
|
|
2167
|
+
guidance: `Your peer's screening will refuse ${publishScreenFault.codepoints.join(", ")} ` +
|
|
2168
|
+
`(${publishScreenFault.count} occurrence(s), first at character ` +
|
|
2169
|
+
`${publishScreenFault.offsets[0]}). These are characters that make what a reader SEES ` +
|
|
2170
|
+
`differ from what the document SAYS, or that address a reader's model rather than the ` +
|
|
2171
|
+
`reader — they are easy to paste in without seeing them. Nothing was sent. Remove them ` +
|
|
2172
|
+
`from the file and publish again; sending as-is costs a rejection round, and three of ` +
|
|
2173
|
+
`those stall the document.`,
|
|
2174
|
+
detail: JSON.stringify({ rule: SCREEN_RULE_ID, ...publishScreenFault }),
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
const result = await publish.publish(who.ownerAgentId, documentId, doc, deps.now());
|
|
2178
|
+
if (!result.ok) {
|
|
2179
|
+
// The file's edits are already FOLDED INTO the document — same shape as cello_doc_write's
|
|
2180
|
+
// applied-but-unpublished case, and reported the same way, because an operator told this
|
|
2181
|
+
// failed would edit again and the second publish would diff against a projection that already
|
|
2182
|
+
// contains their change.
|
|
2183
|
+
// REMEMBER IT, so the branch above can flush it. This is what made the comment's claim of
|
|
2184
|
+
// parity with `cello_doc_write` true rather than aspirational.
|
|
2185
|
+
unpublishedEdits.add(unpublishedKey(who.ownerAgentId, documentId));
|
|
2186
|
+
return { ok: true, documentId, changed: true, published: false, reason: result.reason, guidance: result.detail };
|
|
2187
|
+
}
|
|
2188
|
+
layer.notifications.markWritten(who.ownerAgentId, documentId, projectDocumentText(doc, document.documentType));
|
|
2189
|
+
return { ok: true, documentId, changed: true, published: true, envelopeHash: result.envelopeHash };
|
|
2190
|
+
});
|
|
2191
|
+
logger.debug("document.handlers.registered", {
|
|
2192
|
+
// DERIVED from what was actually registered, never hand-typed. The list it replaced was a
|
|
2193
|
+
// second source of truth for the one question this line exists to answer, and it could drift
|
|
2194
|
+
// from the handler map without anything noticing — which is the same four-place-lockstep
|
|
2195
|
+
// failure this surface has already had twice.
|
|
2196
|
+
// Matched on `_doc_` and logged WHOLE rather than stripped of a `cello_doc_` prefix: the
|
|
2197
|
+
// §2b source audit reads cello_* tokens out of this file to prove the daemon never names a
|
|
2198
|
+
// tool that does not exist, and a bare prefix literal reads to it as exactly that — a dead
|
|
2199
|
+
// command. The audit was right to flag it; the literal is what had to go.
|
|
2200
|
+
verbs: [...handlers.keys()].filter((k) => k.includes("_doc_")).sort(),
|
|
2201
|
+
});
|
|
2202
|
+
}
|
|
2203
|
+
//# sourceMappingURL=document-handlers.js.map
|