@cello-protocol/daemon 0.0.166 → 0.0.168

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.
Files changed (40) hide show
  1. package/dist/daemon.d.ts.map +1 -1
  2. package/dist/daemon.js +52 -5
  3. package/dist/daemon.js.map +1 -1
  4. package/dist/delivery-session-suspects.d.ts +56 -0
  5. package/dist/delivery-session-suspects.d.ts.map +1 -0
  6. package/dist/delivery-session-suspects.js +89 -0
  7. package/dist/delivery-session-suspects.js.map +1 -0
  8. package/dist/delivery-sweep-bound.d.ts +79 -0
  9. package/dist/delivery-sweep-bound.d.ts.map +1 -0
  10. package/dist/delivery-sweep-bound.js +109 -0
  11. package/dist/delivery-sweep-bound.js.map +1 -0
  12. package/dist/document-control-notifier.d.ts +17 -0
  13. package/dist/document-control-notifier.d.ts.map +1 -1
  14. package/dist/document-control-notifier.js +40 -1
  15. package/dist/document-control-notifier.js.map +1 -1
  16. package/dist/document-delivery-transport.d.ts +2 -0
  17. package/dist/document-delivery-transport.d.ts.map +1 -1
  18. package/dist/document-delivery-transport.js +78 -3
  19. package/dist/document-delivery-transport.js.map +1 -1
  20. package/dist/document-delivery.d.ts +30 -4
  21. package/dist/document-delivery.d.ts.map +1 -1
  22. package/dist/document-delivery.js +294 -1
  23. package/dist/document-delivery.js.map +1 -1
  24. package/dist/document-handlers.d.ts.map +1 -1
  25. package/dist/document-handlers.js +209 -60
  26. package/dist/document-handlers.js.map +1 -1
  27. package/dist/document-layer.d.ts.map +1 -1
  28. package/dist/document-layer.js +16 -1
  29. package/dist/document-layer.js.map +1 -1
  30. package/dist/document-lifecycle.d.ts.map +1 -1
  31. package/dist/document-lifecycle.js +10 -3
  32. package/dist/document-lifecycle.js.map +1 -1
  33. package/dist/document-store.d.ts +90 -0
  34. package/dist/document-store.d.ts.map +1 -1
  35. package/dist/document-store.js +311 -0
  36. package/dist/document-store.js.map +1 -1
  37. package/dist/session-node-manager.d.ts.map +1 -1
  38. package/dist/session-node-manager.js +14 -2
  39. package/dist/session-node-manager.js.map +1 -1
  40. package/package.json +1 -1
@@ -26,6 +26,7 @@
26
26
  import { randomBytes, randomUUID } from "node:crypto";
27
27
  import * as Y from "yjs";
28
28
  import { encodeDocumentProposal, deriveArrangement, documentGovernancePolicy, arrangementGenesisFromProposal, documentAmendmentHash, buildDocumentMultisigTbs, encodeDocumentAmendment, buildDocumentJoinOfferTbs, encodeDocumentJoinOffer, validateDocumentJoinOffer, encodeDocumentUpdateEnvelope, DOCUMENT_UPDATE_ENCODING_V1, 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
+ import { DELIVERY_ACK_TIMEOUT_MS } from "./document-delivery.js";
29
30
  import { lineHunks, isSupportedDocumentType, SUPPORTED_DOCUMENT_TYPES } from "./document-write-path.js";
30
31
  import { openingNoticeFor, rootForDocumentType } from "./document-types.js";
31
32
  import { projectDocumentText, parseJsonDocument, applyJsonToMap } from "./document-json.js";
@@ -35,6 +36,10 @@ import { profileViolation } from "./document-profile.js";
35
36
  import { screenText, SCREEN_RULE_ID } from "./document-screen.js";
36
37
  /** Document types the notification/diff path understands. Anything else is stored, not diffed. */
37
38
  const DEFAULT_DOCUMENT_TYPE = "markdown";
39
+ /** Ends a fragment with a full stop unless it already ends in one — see the refusal below. */
40
+ function withStop(text) {
41
+ return /[.!?]$/.test(text.trimEnd()) ? text : `${text.trimEnd()}.`;
42
+ }
38
43
  export function registerDocumentHandlers(deps) {
39
44
  const { handlers, logger, layer, publish } = deps;
40
45
  /**
@@ -579,6 +584,67 @@ export function registerDocumentHandlers(deps) {
579
584
  : {}),
580
585
  };
581
586
  });
587
+ /**
588
+ * DOD-MP-INVITE-FANOUT-1 — fan a governance amendment to the CURRENT holders, durably.
589
+ *
590
+ * ONE implementation for every site that fans one. There were four — invite, re-invite, remove,
591
+ * and remove's re-send — each with its own copy of the same best-effort loop, and the review found
592
+ * that wiring durability into one of them left the other three losing membership changes exactly
593
+ * as before. The re-invite is the verb the tool's own guidance tells an operator to run when a
594
+ * holder is out of step, so it carrying the defect meant the documented cure did nothing.
595
+ *
596
+ * RECORD THE DEBT FIRST. The send below is a fast path, never the guarantee: a daemon that dies
597
+ * between here and the send still owes the amendment on restart.
598
+ *
599
+ * A successful send is recorded as SENT, not acked — their daemon received the frame, and whether
600
+ * it RECORDED it is a separate fact it can refuse. The row settles for real when that holder acks
601
+ * any envelope at this epoch or later, which proves they applied it.
602
+ */
603
+ const fanOutAmendment = async (args) => {
604
+ layer.store.seedAmendmentDeliveries(args.ownerAgentId, args.documentId, args.amendmentHashHex, args.holders, deps.now());
605
+ const told = {};
606
+ for (const holder of args.holders) {
607
+ try {
608
+ const sent = await deps.transportFor(args.agentName).sendBytes({
609
+ peerAgentId: holder,
610
+ documentId: args.documentId,
611
+ bytes: args.amendmentBytes,
612
+ correlationId: randomUUID(),
613
+ });
614
+ // PARKED IS NOT NOTIFIED. The relay took it because the holder had no live counterparty.
615
+ const landed = sent.ok && sent.parked !== true;
616
+ told[holder] = landed;
617
+ if (landed) {
618
+ layer.store.markAmendmentSent(args.ownerAgentId, args.documentId, args.amendmentHashHex, holder, deps.now(), DELIVERY_ACK_TIMEOUT_MS);
619
+ }
620
+ else {
621
+ // NAMED, NOT JUST COUNTED. This used to record `false` with no log line anywhere, so the
622
+ // only trace of a lost membership change was a boolean inside an `ok: true` response.
623
+ logger.warn("document.amendment.holder_unnotified", {
624
+ documentId: args.documentId,
625
+ holderAgentId: holder,
626
+ verb: args.verb,
627
+ reason: sent.ok ? "relay_parked" : sent.reason,
628
+ detail: sent.ok
629
+ ? "the relay is holding it — the holder had no live counterparty"
630
+ : sent.detail,
631
+ });
632
+ }
633
+ }
634
+ catch (err) {
635
+ told[holder] = false;
636
+ // A throw used to be swallowed whole.
637
+ logger.warn("document.amendment.holder_unnotified", {
638
+ documentId: args.documentId,
639
+ holderAgentId: holder,
640
+ verb: args.verb,
641
+ reason: "amendment_send_threw",
642
+ detail: err instanceof Error ? err.message : String(err),
643
+ });
644
+ }
645
+ }
646
+ return told;
647
+ };
582
648
  /**
583
649
  * M14B / DOD-MP-JOIN-1 — invite a third party into an existing document.
584
650
  *
@@ -670,24 +736,20 @@ export function registerDocumentHandlers(deps) {
670
736
  // re-sent the amendment. The re-invite is the healing verb: it re-fans the admitting
671
737
  // amendment to every other current holder, best-effort, reported per holder.
672
738
  const admittingBytes = outgoing.offer.amendments[outgoing.offer.amendments.length - 1];
673
- const holdersNotified = {};
674
- if (admittingBytes !== undefined) {
675
- for (const holder of derived.arrangement.participants) {
676
- if (holder === who.ownerAgentId || holder === invitee)
677
- continue;
678
- try {
679
- const sentAmend = await deps.transportFor(who.agentName).sendBytes({
680
- peerAgentId: holder,
681
- documentId,
682
- bytes: new Uint8Array(admittingBytes),
683
- correlationId: randomUUID(),
684
- });
685
- holdersNotified[holder] = sentAmend.ok;
686
- }
687
- catch {
688
- holdersNotified[holder] = false;
689
- }
690
- }
739
+ let holdersNotified = {};
740
+ // `priorHash` is non-null whenever `outgoing` is — the lookup is keyed by it — but the
741
+ // narrowing does not survive the branch, and an amendment seeded under a null key would be
742
+ // owed to a row nothing can ever join.
743
+ if (admittingBytes !== undefined && priorHash !== null) {
744
+ holdersNotified = await fanOutAmendment({
745
+ agentName: who.agentName,
746
+ ownerAgentId: who.ownerAgentId,
747
+ documentId,
748
+ amendmentHashHex: priorHash,
749
+ amendmentBytes: new Uint8Array(admittingBytes),
750
+ holders: [...derived.arrangement.participants].filter((holder) => holder !== who.ownerAgentId && holder !== invitee),
751
+ verb: "re-invite",
752
+ });
691
753
  }
692
754
  return {
693
755
  ok: true,
@@ -793,20 +855,25 @@ export function registerDocumentHandlers(deps) {
793
855
  documentId, error: err instanceof Error ? err.message : String(err),
794
856
  });
795
857
  }
796
- const holdersTold = {};
797
- for (const holder of derived.arrangement.participants) {
798
- if (holder === who.ownerAgentId || holder === invitee)
799
- continue;
800
- try {
801
- const sent = await deps.transportFor(who.agentName).sendBytes({
802
- peerAgentId: holder, documentId, bytes: amendmentBytes, correlationId: randomUUID(),
803
- });
804
- holdersTold[holder] = sent.ok;
805
- }
806
- catch {
807
- holdersTold[holder] = false;
808
- }
809
- }
858
+ // DOD-MP-INVITE-FANOUT-1 RECORD WHAT IS OWED BEFORE TRYING TO SEND IT.
859
+ //
860
+ // The loop below is a fast path, not the guarantee. It used to be both, and that is the whole
861
+ // defect: one failed send lost a membership change permanently, because nothing remained owing
862
+ // anywhere. A content edit has always had a pending row, a retry schedule and restart survival;
863
+ // the governance act that decides who is a party to the document had none of them.
864
+ //
865
+ // Seeding first also makes the crash window safe: a daemon that dies between here and the send
866
+ // still owes the amendment on restart.
867
+ const owedHolders = [...derived.arrangement.participants].filter((holder) => holder !== who.ownerAgentId && holder !== invitee);
868
+ const holdersTold = await fanOutAmendment({
869
+ agentName: who.agentName,
870
+ ownerAgentId: who.ownerAgentId,
871
+ documentId,
872
+ amendmentHashHex: amendHashHex,
873
+ amendmentBytes,
874
+ holders: owedHolders,
875
+ verb: "invite",
876
+ });
810
877
  logger.info("document.join.invited", { documentId, invitee, epochId: body.epoch_id, offerSent });
811
878
  return {
812
879
  ok: true,
@@ -879,20 +946,31 @@ export function registerDocumentHandlers(deps) {
879
946
  const removal = chain.find((e) => e.body.kind === "remove_holder" &&
880
947
  e.body.subject_agent_id === holder &&
881
948
  e.body.epoch_id === membership.epochId);
882
- const resendTold = {};
949
+ let resendTold = {};
883
950
  if (removal) {
884
951
  const bytes = new Uint8Array(encodeDocumentAmendment(removal));
885
- const targets = new Set([...derived.arrangement.participants, holder]);
886
- targets.delete(who.ownerAgentId);
887
- for (const member of targets) {
952
+ const remaining = [...derived.arrangement.participants].filter((m) => m !== who.ownerAgentId && m !== holder);
953
+ // The healing re-send is durable for the holders who REMAIN, for the same reason the
954
+ // re-invite is: it is the verb an operator runs precisely because someone is out of step,
955
+ // so it must not be the one that gives up quietest.
956
+ resendTold = await fanOutAmendment({
957
+ agentName: who.agentName,
958
+ ownerAgentId: who.ownerAgentId,
959
+ documentId,
960
+ amendmentHashHex: Buffer.from(documentAmendmentHash(removal.body)).toString("hex"),
961
+ amendmentBytes: bytes,
962
+ holders: remaining,
963
+ verb: "remove-resend",
964
+ });
965
+ if (holder !== who.ownerAgentId) {
888
966
  try {
889
967
  const sent = await deps.transportFor(who.agentName).sendBytes({
890
- peerAgentId: member, documentId, bytes, correlationId: randomUUID(),
968
+ peerAgentId: holder, documentId, bytes, correlationId: randomUUID(),
891
969
  });
892
- resendTold[member] = sent.ok;
970
+ resendTold[holder] = sent.ok && sent.parked !== true;
893
971
  }
894
972
  catch {
895
- resendTold[member] = false;
973
+ resendTold[holder] = false;
896
974
  }
897
975
  }
898
976
  }
@@ -957,29 +1035,43 @@ export function registerDocumentHandlers(deps) {
957
1035
  // The amendment travels to EVERY current holder INCLUDING the removed one — being told is
958
1036
  // how their daemon surfaces the removal to their operator. Best-effort at P1, per holder,
959
1037
  // reported never assumed.
960
- const holdersTold = {};
961
- for (const member of withNew.arrangement.participants) {
962
- if (member === who.ownerAgentId)
963
- continue;
964
- try {
965
- const sent = await deps.transportFor(who.agentName).sendBytes({
966
- peerAgentId: member, documentId, bytes: amendmentBytes, correlationId: randomUUID(),
967
- });
968
- holdersTold[member] = sent.ok;
969
- }
970
- catch {
971
- holdersTold[member] = false;
972
- }
973
- }
1038
+ // DOD-MP-INVITE-FANOUT-1 the REMAINING holders get the durable fan-out. A holder who misses
1039
+ // a removal keeps delivering to, and accepting edits from, someone the chain has removed —
1040
+ // silently and permanently, which is the same defect the invite had and is worse, because here
1041
+ // the stale holder keeps honouring a membership that has been revoked.
1042
+ const holdersTold = await fanOutAmendment({
1043
+ agentName: who.agentName,
1044
+ ownerAgentId: who.ownerAgentId,
1045
+ documentId,
1046
+ amendmentHashHex: Buffer.from(amendHash).toString("hex"),
1047
+ amendmentBytes,
1048
+ holders: [...withNew.arrangement.participants].filter((m) => m !== who.ownerAgentId),
1049
+ verb: "remove",
1050
+ });
1051
+ // THE REMOVED HOLDER IS TOLD ONCE, and is deliberately NOT owed a durable retry: delivery to
1052
+ // them stopping is what removal MEANS, so a queue that kept redialling them would contradict
1053
+ // the act it is announcing. Forward-only cuts both ways — we tell them, we do not pursue them.
974
1054
  if (holder !== who.ownerAgentId) {
975
1055
  try {
976
1056
  const sent = await deps.transportFor(who.agentName).sendBytes({
977
1057
  peerAgentId: holder, documentId, bytes: amendmentBytes, correlationId: randomUUID(),
978
1058
  });
979
- holdersTold[holder] = sent.ok;
1059
+ holdersTold[holder] = sent.ok && sent.parked !== true;
1060
+ if (!holdersTold[holder]) {
1061
+ logger.warn("document.amendment.holder_unnotified", {
1062
+ documentId, holderAgentId: holder, verb: "remove-subject",
1063
+ reason: sent.ok ? "relay_parked" : sent.reason,
1064
+ detail: sent.ok ? "the relay is holding it" : sent.detail,
1065
+ });
1066
+ }
980
1067
  }
981
- catch {
1068
+ catch (err) {
982
1069
  holdersTold[holder] = false;
1070
+ logger.warn("document.amendment.holder_unnotified", {
1071
+ documentId, holderAgentId: holder, verb: "remove-subject",
1072
+ reason: "amendment_send_threw",
1073
+ detail: err instanceof Error ? err.message : String(err),
1074
+ });
983
1075
  }
984
1076
  }
985
1077
  logger.info("document.holder_removed", {
@@ -1113,8 +1205,47 @@ export function registerDocumentHandlers(deps) {
1113
1205
  // actions.
1114
1206
  const proposal = layer.handshake.get(who.ownerAgentId, d.documentId);
1115
1207
  const peerAnswer = layer.handshake.peerAnswer(who.ownerAgentId, d.documentId);
1208
+ const arrangement = arrangementFor(d.documentId, proposal);
1209
+ // DOD-MP-REMOVE-FEEDBACK-1 — the SENTENCE for a fact the row already carried.
1210
+ //
1211
+ // The row has shipped `removed: true` since REMOVE-1. What was missing is that a bare flag
1212
+ // is not feedback: it does not say when, it does not say the copy is still yours, and it
1213
+ // does not say what actually stopped. So this completes the existing signal rather than
1214
+ // adding a second name for it computed by a second walk of the same chain.
1215
+ //
1216
+ // NAMED `yourStanding`, NOT `yourAccess`: your access to the copy did not change — reading
1217
+ // it still works and the file is still on disk, which the sentence itself says. A surface
1218
+ // that renders a badge from the key alone would show "access: removed", which is the
1219
+ // confiscation reading FORWARD-ONLY-REMOVAL exists to forbid.
1220
+ //
1221
+ // ALWAYS PRESENT, exactly as `participants` is: an absent key is read as "fine", so on a
1222
+ // chain this build cannot decode — where `removedFromArrangement` honestly cannot tell —
1223
+ // it says `unknown` rather than going quiet and rendering a removed holder as a holder.
1224
+ const standing = arrangement["arrangementUnavailable"] !== undefined
1225
+ ? "unknown"
1226
+ : d.removed === true
1227
+ ? "removed"
1228
+ : "holder";
1229
+ const removedAtEpoch = d.removedAtEpoch;
1116
1230
  return {
1117
1231
  ...d,
1232
+ yourStanding: standing,
1233
+ ...(standing === "removed"
1234
+ ? {
1235
+ standingGuidance: `You are no longer a holder of this document` +
1236
+ (removedAtEpoch === undefined ? `. ` : `, as of epoch ${removedAtEpoch}. `) +
1237
+ `Your copy and its full history remain yours, and you can still read it here or ` +
1238
+ `open the file. What changed is only the flow of edits: yours no longer publish ` +
1239
+ `to the other holders, and theirs no longer reach you.`,
1240
+ }
1241
+ : {}),
1242
+ ...(standing === "unknown"
1243
+ ? {
1244
+ standingGuidance: `This daemon cannot read this document's amendment chain, so it cannot tell ` +
1245
+ `whether you are still a holder. Nothing here should be taken as confirmation ` +
1246
+ `that you are.`,
1247
+ }
1248
+ : {}),
1118
1249
  proposedByUs: proposal?.proposerAgentId === who.ownerAgentId,
1119
1250
  // THE PEER'S OWN SIGNED ANSWER — true accepted, false refused, null not yet heard. This
1120
1251
  // replaced an inference ("they have published into it") that could not tell refused from
@@ -1133,7 +1264,7 @@ export function registerDocumentHandlers(deps) {
1133
1264
  // WHO HOLDS IT AND WHO GOVERNS IT — derived from THIS daemon's own chain (G0).
1134
1265
  // `proposal` is passed rather than re-fetched: it is the same SQL read and the same
1135
1266
  // CBOR decode of the same bytes, already in hand.
1136
- ...arrangementFor(d.documentId, proposal),
1267
+ ...arrangement,
1137
1268
  // DID OUR OFFER LEAVE? Only meaningful for a document WE proposed — for one we accepted
1138
1269
  // there is no offer of ours to have sent. Without this, `peerAccepted: null` meant both
1139
1270
  // "they are thinking" and "they were never asked", and the shipped guidance said WAIT,
@@ -1582,7 +1713,11 @@ export function registerDocumentHandlers(deps) {
1582
1713
  return {
1583
1714
  ok: false,
1584
1715
  reason: publishable.reason,
1585
- guidance: `${publishable.detail ?? "This document can no longer accept writes."} Nothing was ` +
1716
+ guidance:
1717
+ // PUNCTUATED. `detail` comes from several producers and not all of them end in a stop,
1718
+ // so the two sentences ran together — "…no longer publish to the other holders Nothing
1719
+ // was changed locally…" — on the one line this DoD calls actionable.
1720
+ `${withStop(publishable.detail ?? "This document can no longer accept writes.")} Nothing was ` +
1586
1721
  `changed locally — an edit applied here could never be published or recovered, and would ` +
1587
1722
  `disappear the next time the daemon restarted.`,
1588
1723
  };
@@ -1723,9 +1858,16 @@ export function registerDocumentHandlers(deps) {
1723
1858
  * network.
1724
1859
  */
1725
1860
  function notifyGuidance(verb, reason, detail, holdersNotified, holderFailures) {
1861
+ // DOD-MP-CONTROL-DURABLE-1 — this said "A close is not retried", and that stopped being true
1862
+ // when the ending became durable. A sentence telling the operator to do by hand what the daemon
1863
+ // now does for them is not merely stale: it invites a second close for no reason, and it
1864
+ // undersells the one case that still needs them — a holder we never manage to confirm.
1726
1865
  const tail = verb === "close"
1727
- ? `A close is not retried run cello_doc_close again once this is cleared, or cello_doc_kill if you need it over now.`
1728
- : `The kill stands locally either way; run cello_doc_kill again once this is cleared so they stop editing.`;
1866
+ ? `The ending is owed to anyone who did not take it and is re-sent when they return; you do ` +
1867
+ `not need to run this again. If it still cannot be confirmed the daemon says so, naming ` +
1868
+ `them — that one needs you.`
1869
+ : `The kill stands locally either way, and is re-sent to anyone who did not take it until ` +
1870
+ `they do or the daemon reports that it gave up on them.`;
1729
1871
  if (reason === "document_control_unsigned") {
1730
1872
  return (`Your ${verb} was recorded, but this agent's signing key could not be loaded, so nothing ` +
1731
1873
  `could be sent to the peer${detail ? ` (${detail})` : ""}. Waiting will not help — the ` +
@@ -1778,7 +1920,14 @@ export function registerDocumentHandlers(deps) {
1778
1920
  // Waiting cannot reopen a sealed session. The cause is known per holder; it travels.
1779
1921
  const causes = [...new Set(Object.values(holderFailures ?? {}))].filter(Boolean);
1780
1922
  const because = causes.length > 0 ? ` (${causes.join(", ")})` : "";
1781
- const sealed = causes.some((c) => c.includes("sealed"));
1923
+ // THE WHOLE FAMILY, not one substring. This tested `includes("sealed")`, so `session_sealed`
1924
+ // got the useful sentence and every other way a session's record can be over —
1925
+ // `relay_session_gone` (the relay restarted or swept it) and `session_not_found` — fell to
1926
+ // generic wait-for-them advice, which is the one thing that cannot work when there is nothing
1927
+ // left to wait for.
1928
+ const sealed = causes.some((c) => c.includes("sealed") ||
1929
+ c.includes("relay_session_gone") ||
1930
+ c.includes("session_not_found"));
1782
1931
  return (`Your ${verb} was recorded but reached none of the ${missed.length} other ` +
1783
1932
  `holder${missed.length > 1 ? "s" : ""} (${missed.join(", ")})${because}, so the document ` +
1784
1933
  `cannot settle until they hear it. ` +