@cello-protocol/daemon 0.0.167 → 0.0.169

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 (80) hide show
  1. package/dist/daemon.d.ts.map +1 -1
  2. package/dist/daemon.js +115 -254
  3. package/dist/daemon.js.map +1 -1
  4. package/dist/document-amendment-store.d.ts +94 -46
  5. package/dist/document-amendment-store.d.ts.map +1 -1
  6. package/dist/document-amendment-store.js +217 -102
  7. package/dist/document-amendment-store.js.map +1 -1
  8. package/dist/document-delivery-transport.d.ts +41 -52
  9. package/dist/document-delivery-transport.d.ts.map +1 -1
  10. package/dist/document-delivery-transport.js +6 -156
  11. package/dist/document-delivery-transport.js.map +1 -1
  12. package/dist/document-frame-router.d.ts +13 -17
  13. package/dist/document-frame-router.d.ts.map +1 -1
  14. package/dist/document-frame-router.js +21 -93
  15. package/dist/document-frame-router.js.map +1 -1
  16. package/dist/document-handlers.d.ts +1 -1
  17. package/dist/document-handlers.d.ts.map +1 -1
  18. package/dist/document-handlers.js +471 -466
  19. package/dist/document-handlers.js.map +1 -1
  20. package/dist/document-handshake.d.ts +4 -1
  21. package/dist/document-handshake.d.ts.map +1 -1
  22. package/dist/document-handshake.js +3 -1
  23. package/dist/document-handshake.js.map +1 -1
  24. package/dist/document-inbound.d.ts +18 -5
  25. package/dist/document-inbound.d.ts.map +1 -1
  26. package/dist/document-inbound.js +94 -110
  27. package/dist/document-inbound.js.map +1 -1
  28. package/dist/document-layer.d.ts +28 -67
  29. package/dist/document-layer.d.ts.map +1 -1
  30. package/dist/document-layer.js +670 -565
  31. package/dist/document-layer.js.map +1 -1
  32. package/dist/document-lifecycle.d.ts +11 -157
  33. package/dist/document-lifecycle.d.ts.map +1 -1
  34. package/dist/document-lifecycle.js +26 -561
  35. package/dist/document-lifecycle.js.map +1 -1
  36. package/dist/document-publish.d.ts +13 -0
  37. package/dist/document-publish.d.ts.map +1 -1
  38. package/dist/document-publish.js +19 -11
  39. package/dist/document-publish.js.map +1 -1
  40. package/dist/document-reconcile-engine.d.ts +66 -0
  41. package/dist/document-reconcile-engine.d.ts.map +1 -0
  42. package/dist/document-reconcile-engine.js +225 -0
  43. package/dist/document-reconcile-engine.js.map +1 -0
  44. package/dist/document-reconcile-scheduler.d.ts +91 -0
  45. package/dist/document-reconcile-scheduler.d.ts.map +1 -0
  46. package/dist/document-reconcile-scheduler.js +153 -0
  47. package/dist/document-reconcile-scheduler.js.map +1 -0
  48. package/dist/document-rejection.d.ts +2 -0
  49. package/dist/document-rejection.d.ts.map +1 -1
  50. package/dist/document-rejection.js +5 -1
  51. package/dist/document-rejection.js.map +1 -1
  52. package/dist/document-store.d.ts +18 -220
  53. package/dist/document-store.d.ts.map +1 -1
  54. package/dist/document-store.js +95 -717
  55. package/dist/document-store.js.map +1 -1
  56. package/dist/initiate-session-handler.d.ts +5 -0
  57. package/dist/initiate-session-handler.d.ts.map +1 -1
  58. package/dist/initiate-session-handler.js +1 -0
  59. package/dist/initiate-session-handler.js.map +1 -1
  60. package/package.json +5 -5
  61. package/dist/delivery-sweep-bound.d.ts +0 -79
  62. package/dist/delivery-sweep-bound.d.ts.map +0 -1
  63. package/dist/delivery-sweep-bound.js +0 -109
  64. package/dist/delivery-sweep-bound.js.map +0 -1
  65. package/dist/document-ack-inbound.d.ts +0 -71
  66. package/dist/document-ack-inbound.d.ts.map +0 -1
  67. package/dist/document-ack-inbound.js +0 -210
  68. package/dist/document-ack-inbound.js.map +0 -1
  69. package/dist/document-control-notifier.d.ts +0 -107
  70. package/dist/document-control-notifier.d.ts.map +0 -1
  71. package/dist/document-control-notifier.js +0 -137
  72. package/dist/document-control-notifier.js.map +0 -1
  73. package/dist/document-delivery.d.ts +0 -229
  74. package/dist/document-delivery.d.ts.map +0 -1
  75. package/dist/document-delivery.js +0 -449
  76. package/dist/document-delivery.js.map +0 -1
  77. package/dist/document-join-store.d.ts +0 -80
  78. package/dist/document-join-store.d.ts.map +0 -1
  79. package/dist/document-join-store.js +0 -197
  80. package/dist/document-join-store.js.map +0 -1
@@ -25,8 +25,7 @@
25
25
  */
26
26
  import { randomBytes, randomUUID } from "node:crypto";
27
27
  import * as Y from "yjs";
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";
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";
30
29
  import { lineHunks, isSupportedDocumentType, SUPPORTED_DOCUMENT_TYPES } from "./document-write-path.js";
31
30
  import { openingNoticeFor, rootForDocumentType } from "./document-types.js";
32
31
  import { projectDocumentText, parseJsonDocument, applyJsonToMap } from "./document-json.js";
@@ -36,6 +35,10 @@ import { profileViolation } from "./document-profile.js";
36
35
  import { screenText, SCREEN_RULE_ID } from "./document-screen.js";
37
36
  /** Document types the notification/diff path understands. Anything else is stored, not diffed. */
38
37
  const DEFAULT_DOCUMENT_TYPE = "markdown";
38
+ /** Ends a fragment with a full stop unless it already ends in one — see the refusal below. */
39
+ function withStop(text) {
40
+ return /[.!?]$/.test(text.trimEnd()) ? text : `${text.trimEnd()}.`;
41
+ }
39
42
  export function registerDocumentHandlers(deps) {
40
43
  const { handlers, logger, layer, publish } = deps;
41
44
  /**
@@ -430,66 +433,40 @@ export function registerDocumentHandlers(deps) {
430
433
  hasStartingContent: p.envelope.starting_content !== null,
431
434
  proposedAtMs: p.envelope.proposed_at_ms,
432
435
  })),
433
- // M14B / DOD-MP-JOIN-1 offers to join an EXISTING document, decided with the same
434
- // accept/refuse verbs (route by document_id). THE RULES ARE SHOWN HERE, derived by replay
435
- // of the carried bytes the operator consents to what their own daemon computed — and
436
- // cello_doc_accept re-derives at the moment of consequence. An entry whose stored bytes no
437
- // longer validate says so, never silently omitted.
438
- joins: layer.joins.pendingFor(who.ownerAgentId).map((j) => {
439
- const derived = validateDocumentJoinOffer(j.offer, documentGovernancePolicy, layer.verifySignature);
440
- return {
441
- documentId: j.documentId,
442
- inviterAgentId: j.inviterAgentId,
443
- amendmentHash: j.amendmentHash,
444
- offeredAtMs: j.offer.offered_at_ms,
445
- carriedEnvelopes: j.offer.envelope_log.length,
446
- ...(derived.ok
447
- ? {
448
- participants: [...derived.arrangement.participants].sort(),
449
- admins: [...derived.arrangement.admins].sort(),
450
- properties: derived.arrangement.properties,
451
- assuranceTier: derived.genesis.properties.assurance_tier,
452
- documentType: derived.genesis.document_type,
453
- }
454
- : { invalid: derived.reason }),
455
- };
436
+ // SYNC-P4 (D5 deleted): open invitations, DERIVED a document this daemon holds whose
437
+ // entry set says this agent is an invited seat. The operator consents to what their own
438
+ // daemon computed from the signed record, not to a carried snapshot; cello_doc_accept
439
+ // re-derives at the moment of consequence. The inviter shown is the author of the admit
440
+ // entry naming this agent (the genesis proposer for a genesis-named seat).
441
+ joins: layer.store
442
+ .listDocuments(who.ownerAgentId)
443
+ .filter((d) => d.status === "active")
444
+ .flatMap((d) => {
445
+ const genesisRecord = layer.handshake.get(who.ownerAgentId, d.documentId);
446
+ if (!genesisRecord)
447
+ return [];
448
+ const derived = deriveDocumentState(arrangementGenesisFromProposal(genesisRecord.envelope), layer.amendments.chain(who.ownerAgentId, d.documentId), documentGovernancePolicy, layer.verifySignature);
449
+ if (!derived.ok || !derived.state.invited.has(who.ownerAgentId))
450
+ return [];
451
+ const admit = layer.amendments
452
+ .chain(who.ownerAgentId, d.documentId)
453
+ .find((e) => e.body.kind === "add_holder" && e.body.subject_agent_id === who.ownerAgentId);
454
+ return [
455
+ {
456
+ documentId: d.documentId,
457
+ inviterAgentId: admit?.body.author_agent_id ?? genesisRecord.envelope.proposer_agent_id,
458
+ participants: [...derived.state.participants].sort(),
459
+ invited: [...derived.state.invited].sort(),
460
+ admins: [...derived.state.admins].sort(),
461
+ properties: derived.state.properties,
462
+ assuranceTier: "authenticated",
463
+ documentType: d.documentType,
464
+ },
465
+ ];
456
466
  }),
457
- // Auto-refused offers (version mismatch, chains that do not replay) — recorded with the
458
- // sentence written for the operator, and listed so it actually reaches one.
459
- refusedJoins: layer.joins.refusedFor(who.ownerAgentId).map((j) => ({
460
- documentId: j.documentId,
461
- inviterAgentId: j.inviterAgentId,
462
- reason: j.reason,
463
- offeredAtMs: j.offer.offered_at_ms,
464
- })),
465
467
  };
466
468
  });
467
- /**
468
- * Tell the INVITER what was decided — the join twin of `tellProposer`, and the same doctrine:
469
- * best-effort, because consent is local and final the moment the operator makes it, and an
470
- * unreachable inviter must not get a veto over the invitee's choice.
471
- */
472
- async function tellInviter(who, documentId, inviterAgentId, answerBytes) {
473
- try {
474
- const sent = await deps.transportFor(who.agentName).sendBytes({
475
- peerAgentId: inviterAgentId,
476
- documentId,
477
- bytes: answerBytes,
478
- correlationId: randomUUID(),
479
- });
480
- if (!sent.ok) {
481
- logger.warn("document.join.answer_unsent", { documentId, reason: sent.reason });
482
- }
483
- return sent.ok;
484
- }
485
- catch (err) {
486
- logger.warn("document.join.answer_send_threw", {
487
- documentId,
488
- error: err instanceof Error ? err.message : String(err),
489
- });
490
- return false;
491
- }
492
- }
469
+ // The join-answer frame died with D5 — the consent/refusal ENTRY is the answer, and it fans out.
493
470
  handlers.set("cello_doc_accept", async (params, connectionId) => {
494
471
  const who = resolve(params, connectionId);
495
472
  if (isRefusal(who))
@@ -498,30 +475,46 @@ export function registerDocumentHandlers(deps) {
498
475
  if (documentId.length === 0) {
499
476
  return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_inbox." };
500
477
  }
501
- // A join offer decided with the SAME verb the inbox advertises. Routed first by exact
502
- // pending match, so a proposal and a join for different documents can never shadow each
503
- // other; an id that matches neither falls through to the proposal path's own refusal.
504
- const pendingJoin = layer.joins
505
- .pendingFor(who.ownerAgentId)
506
- .find((j) => j.documentId === documentId);
507
- if (pendingJoin) {
508
- const joined = await layer.acceptJoin(who.ownerAgentId, pendingJoin.amendmentHash, deps.now());
509
- if (!joined.ok)
510
- return { ok: false, reason: joined.reason, guidance: joined.detail };
511
- const joinFile = await materialize(who.ownerAgentId, documentId, joined.documentType);
512
- const told = await tellInviter(who, documentId, joined.inviterAgentId, joined.answerBytes);
513
- return {
514
- ok: true,
515
- documentId,
516
- joined: true,
517
- peerAgentId: joined.inviterAgentId,
518
- inviterNotified: told,
519
- appliedEnvelopes: joined.applied,
520
- filePath: joinFile.path,
521
- ...(joinFile.path !== null && openingNoticeFor(joined.documentType) !== undefined
522
- ? { fileNotice: openingNoticeFor(joined.documentType) }
523
- : {}),
524
- };
478
+ // THE CURE FOR A HALF-CONSENTED DOCUMENT (P2 review F2): if this agent already holds the
479
+ // document but its own derivation says it is still an INVITED seat, the earlier accept
480
+ // recorded the decision and then failed to record the consent entry and both decision rows
481
+ // are settled, so neither branch below can run again. Re-running accept authors the missing
482
+ // consent, which is exactly what the failure guidance promises.
483
+ if (layer.store.getDocument(who.ownerAgentId, documentId)) {
484
+ const genesisRecord = layer.handshake.get(who.ownerAgentId, documentId);
485
+ if (genesisRecord) {
486
+ const standing = deriveDocumentState(arrangementGenesisFromProposal(genesisRecord.envelope), layer.amendments.chain(who.ownerAgentId, documentId), documentGovernancePolicy, layer.verifySignature);
487
+ if (standing.ok && standing.state.invited.has(who.ownerAgentId)) {
488
+ // THE JOIN PATH (SYNC-P4, D5 deleted): the document arrived through the exchange, this
489
+ // agent derives as an invited seat, and accepting IS authoring the consent entry (R21)
490
+ // — which travels to every holder over the same carrier as everything else. Until it
491
+ // reaches them, their fold shows this agent invited, not participating. Idempotent by
492
+ // re-run: a consent that failed to record is authored on the next accept.
493
+ const consent = await authorConsent(who, documentId);
494
+ if (!consent.ok) {
495
+ return {
496
+ ok: false,
497
+ reason: "document_consent_unrecorded",
498
+ guidance: `You hold this document but your consent entry is not recorded ` +
499
+ `(${consent.reason}) — run cello_doc_accept again once the named condition ` +
500
+ `clears.`,
501
+ };
502
+ }
503
+ const doc = layer.store.getDocument(who.ownerAgentId, documentId);
504
+ const joinFile = await materialize(who.ownerAgentId, documentId, doc.documentType);
505
+ return {
506
+ ok: true,
507
+ documentId,
508
+ joined: true,
509
+ consentEntry: consent.entryHash,
510
+ consentDelivered: consent.holdersNotified,
511
+ filePath: joinFile.path,
512
+ ...(joinFile.path !== null && openingNoticeFor(doc.documentType) !== undefined
513
+ ? { fileNotice: openingNoticeFor(doc.documentType) }
514
+ : {}),
515
+ };
516
+ }
517
+ }
525
518
  }
526
519
  const outcome = layer.handshake.accept(who.ownerAgentId, documentId, deps.now());
527
520
  if (!outcome.ok)
@@ -555,6 +548,15 @@ export function registerDocumentHandlers(deps) {
555
548
  Y.applyUpdate(layer.live.get(who.ownerAgentId, documentId), outcome.envelope.starting_content);
556
549
  }
557
550
  const acceptFile = await materialize(who.ownerAgentId, documentId, outcome.envelope.document_type);
551
+ // R21: the accept IS this agent's consent entry — the proposer's fold shows this agent
552
+ // invited until the entry reaches them over the amendment carrier.
553
+ const consent = await authorConsent(who, documentId);
554
+ if (!consent.ok) {
555
+ logger.warn("document.consent.unrecorded", {
556
+ documentId,
557
+ reason: consent.reason,
558
+ });
559
+ }
558
560
  // NOT re-logged here. `DocumentHandshake` already emits `document.proposal.accepted` for this
559
561
  // exact fact, and two events for one act make every count of "how many were accepted" wrong
560
562
  // depending on which name the query used.
@@ -564,6 +566,9 @@ export function registerDocumentHandlers(deps) {
564
566
  documentId,
565
567
  peerAgentId: outcome.envelope.proposer_agent_id,
566
568
  proposerNotified: told,
569
+ ...(consent.ok
570
+ ? { consentEntry: consent.entryHash, consentDelivered: consent.holdersNotified }
571
+ : { consentUnrecorded: consent.reason }),
567
572
  filePath: acceptFile.path,
568
573
  // Matters MORE here than on propose: the type came from the PROPOSER's envelope, so the
569
574
  // accepter is being handed an executable file they did not choose the format of.
@@ -597,7 +602,6 @@ export function registerDocumentHandlers(deps) {
597
602
  * any envelope at this epoch or later, which proves they applied it.
598
603
  */
599
604
  const fanOutAmendment = async (args) => {
600
- layer.store.seedAmendmentDeliveries(args.ownerAgentId, args.documentId, args.amendmentHashHex, args.holders, deps.now());
601
605
  const told = {};
602
606
  for (const holder of args.holders) {
603
607
  try {
@@ -610,10 +614,7 @@ export function registerDocumentHandlers(deps) {
610
614
  // PARKED IS NOT NOTIFIED. The relay took it because the holder had no live counterparty.
611
615
  const landed = sent.ok && sent.parked !== true;
612
616
  told[holder] = landed;
613
- if (landed) {
614
- layer.store.markAmendmentSent(args.ownerAgentId, args.documentId, args.amendmentHashHex, holder, deps.now(), DELIVERY_ACK_TIMEOUT_MS);
615
- }
616
- else {
617
+ if (!landed) {
617
618
  // NAMED, NOT JUST COUNTED. This used to record `false` with no log line anywhere, so the
618
619
  // only trace of a lost membership change was a boolean inside an `ok: true` response.
619
620
  logger.warn("document.amendment.holder_unnotified", {
@@ -641,17 +642,109 @@ export function registerDocumentHandlers(deps) {
641
642
  }
642
643
  return told;
643
644
  };
645
+ /**
646
+ * SYNC-P2 (R21/R22) — author THIS agent's consent entry for a document they were named into,
647
+ * append it locally, and fan it to the derived participants over the amendment carrier. Both
648
+ * accept branches (a bilateral proposal, a join offer) call this: consenting IS authoring your
649
+ * first entry, and until it reaches the others their fold shows you invited, not participating.
650
+ */
651
+ const authorConsent = async (who, documentId, kind = "consent") => {
652
+ const genesisRecord = layer.handshake.get(who.ownerAgentId, documentId);
653
+ if (!genesisRecord)
654
+ return { ok: false, reason: "document_genesis_missing" };
655
+ const genesisArr = arrangementGenesisFromProposal(genesisRecord.envelope);
656
+ let chain;
657
+ try {
658
+ chain = layer.amendments.chain(who.ownerAgentId, documentId);
659
+ }
660
+ catch (err) {
661
+ return {
662
+ ok: false,
663
+ reason: `document_chain_undecodable: ${err instanceof Error ? err.message : String(err)}`,
664
+ };
665
+ }
666
+ const derived = deriveDocumentState(genesisArr, chain, documentGovernancePolicy, layer.verifySignature);
667
+ if (!derived.ok)
668
+ return { ok: false, reason: derived.reason };
669
+ // R29's author-side mirror of the R30 inbound gate: an ended world takes no further entries.
670
+ // Every other holder would refuse this entry (their gate finds the ending in its closure), so
671
+ // authoring it would only fork this daemon away from the agreement everyone else has settled —
672
+ // the concrete case being a late kill rewriting "closed by agreement" as a unilateral end.
673
+ if (derived.state.ended !== null) {
674
+ return { ok: false, reason: derived.state.ended === "killed" ? "document_killed" : "document_closed" };
675
+ }
676
+ const body = {
677
+ document_id: documentId,
678
+ kind,
679
+ subject_agent_id: who.ownerAgentId,
680
+ // A refusal names nothing it agrees to — it is the subject's own signed no (R24).
681
+ property_change: kind === "consent"
682
+ ? {
683
+ key: "consents_to",
684
+ value: `${String(derived.state.properties["assurance_tier"])}/${DOCUMENT_FEATURE_VERSION}`,
685
+ }
686
+ : null,
687
+ state_hash: null,
688
+ authored_at_ms: deps.now(),
689
+ author_agent_id: who.ownerAgentId,
690
+ author_seq: (layer.amendments.watermarks(who.ownerAgentId, documentId).get(who.ownerAgentId)?.seq ??
691
+ 0) + 1,
692
+ parents: [...derived.state.frontier],
693
+ };
694
+ const entryHash = documentAmendmentHash(body);
695
+ const multisigTbs = buildDocumentMultisigTbs({
696
+ document_id: documentId,
697
+ subject_kind: "document_amendment",
698
+ subject_hash: entryHash,
699
+ required_signers: [who.ownerAgentId],
700
+ });
701
+ const consent = {
702
+ body,
703
+ collection: {
704
+ document_id: documentId,
705
+ subject_kind: "document_amendment",
706
+ subject_hash: entryHash,
707
+ required_signers: [who.ownerAgentId],
708
+ signatures: [
709
+ { signer_agent_id: who.ownerAgentId, signature: await deps.sign(who.agentName, multisigTbs) },
710
+ ],
711
+ },
712
+ };
713
+ // The same author-your-own-void guard every local authoring site carries.
714
+ const withNew = deriveDocumentState(genesisArr, [...chain, consent], documentGovernancePolicy, layer.verifySignature);
715
+ if (!withNew.ok)
716
+ return { ok: false, reason: withNew.reason };
717
+ const entryHex = Buffer.from(entryHash).toString("hex");
718
+ const inert = withNew.state.voids.find((v) => v.hash === entryHex) ??
719
+ withNew.state.excluded.find((e) => e.hash === entryHex);
720
+ if (inert)
721
+ return { ok: false, reason: inert.reason };
722
+ const bytes = new Uint8Array(encodeDocumentAmendment(consent));
723
+ layer.amendments.append(who.ownerAgentId, documentId, bytes, deps.now());
724
+ const holdersNotified = await fanOutAmendment({
725
+ agentName: who.agentName,
726
+ ownerAgentId: who.ownerAgentId,
727
+ documentId,
728
+ amendmentHashHex: entryHex,
729
+ amendmentBytes: bytes,
730
+ holders: [...withNew.state.participants, ...withNew.state.invited].filter((p) => p !== who.ownerAgentId),
731
+ verb: kind,
732
+ });
733
+ // SYNC-P4: the AUTHOR'S own status projection — the inbound path projects on receipt, and
734
+ // the authoring daemon must not read "active" for a document its own entry just ended.
735
+ if ((kind === "close" || kind === "kill") && withNew.state.ended !== null) {
736
+ layer.store.setDocumentStatus(who.ownerAgentId, documentId, withNew.state.ended === "killed" ? "killed" : "closed");
737
+ }
738
+ return { ok: true, entryHash: entryHex, holdersNotified };
739
+ };
644
740
  /**
645
741
  * M14B / DOD-MP-JOIN-1 — invite a third party into an existing document.
646
742
  *
647
- * One admin's signature authors the admitting amendment (D2); the invitee's own consent makes
648
- * the join real (their accept, on their daemon). VALIDATE-THEN-APPEND: the chain including the
649
- * new amendment replays through the real policy before one byte lands the AMEND-1 standing
650
- * condition at its second production append site. The offer carries the genesis, the whole
651
- * chain, and the update-log snapshot re-encoded from rows (lossless: the client id is a
652
- * column, the encoding a pinned constant — the same re-encode the delivery path ships).
653
- * Existing holders get the amendment frame BEST-EFFORT at P1; the epoch gate makes a missed
654
- * one loud, and durable per-holder delivery is FANOUT-1.
743
+ * One admin's signature authors the admitting entry; the invitee's own consent makes the
744
+ * join real (their accept, on their daemon). VALIDATE-THEN-APPEND: the chain including the
745
+ * new entry derives through the real policy before one byte lands. The invitee gets a NOTICE
746
+ * a step-1 reconcile frame (SYNC-R25) and bootstraps the world through the exchange;
747
+ * existing holders get the entry best-effort, with any miss repaired by their next exchange.
655
748
  */
656
749
  handlers.set("cello_doc_invite", async (params, connectionId) => {
657
750
  const who = resolve(params, connectionId);
@@ -690,88 +783,70 @@ export function registerDocumentHandlers(deps) {
690
783
  }
691
784
  const genesisArr = arrangementGenesisFromProposal(genesisRecord.envelope);
692
785
  const chain = layer.amendments.chain(who.ownerAgentId, documentId);
693
- const derived = deriveArrangement(genesisArr, chain, documentGovernancePolicy, layer.verifySignature);
786
+ const derived = deriveDocumentState(genesisArr, chain, documentGovernancePolicy, layer.verifySignature);
694
787
  if (!derived.ok) {
695
788
  return { ok: false, reason: "document_chain_invalid", guidance: derived.reason };
696
789
  }
697
- if (!derived.arrangement.admins.has(who.ownerAgentId)) {
790
+ if (!derived.state.admins.has(who.ownerAgentId)) {
698
791
  return {
699
792
  ok: false,
700
793
  reason: "document_not_admin",
701
794
  guidance: `Inviting takes an admin's signature and this agent holds no admin power here. ` +
702
- `Current admins: ${[...derived.arrangement.admins].join(", ")}.`,
795
+ `Current admins: ${[...derived.state.admins].join(", ")}.`,
703
796
  };
704
797
  }
705
- if (derived.arrangement.participants.has(invitee)) {
706
- // A RE-RUN after the amendment landed but the offer did not reach them: re-send the STORED
707
- // offer bytes (the proposal --retry precedent) rather than refusing — authoring afresh
708
- // would try to admit a holder the chain already admitted, which the replay refuses.
798
+ if (derived.state.participants.has(invitee)) {
799
+ return {
800
+ ok: false,
801
+ reason: "document_already_holder",
802
+ guidance: "That agent already holds this document — there is nothing to invite them to.",
803
+ };
804
+ }
805
+ if (derived.state.invited.has(invitee)) {
806
+ // A RE-RUN while the invitation is open: the admitting entry is already in the chain, so
807
+ // authoring afresh would only mint a void. Re-send the NOTICE (SYNC-R25) — initiating a
808
+ // reconcile exchange is idempotent by construction, and the invitee's empty-handed answer
809
+ // pulls the genesis and the whole entry set across. The other holders are re-fanned the
810
+ // admitting entry too: one who missed it is wedged, refusing the invitee by name.
709
811
  const admitting = chain.find((e) => e.body.kind === "add_holder" && e.body.subject_agent_id === invitee);
710
812
  const priorHash = admitting
711
813
  ? Buffer.from(documentAmendmentHash(admitting.body)).toString("hex")
712
814
  : null;
713
- const outgoing = priorHash ? layer.joins.get(who.ownerAgentId, priorHash) : null;
714
- if (outgoing && outgoing.role === "inviter" && outgoing.state === "pending") {
715
- let resent = false;
716
- try {
717
- const sent = await deps.transportFor(who.agentName).sendBytes({
718
- peerAgentId: invitee,
719
- documentId,
720
- bytes: new Uint8Array(encodeDocumentJoinOffer(outgoing.offer)),
721
- correlationId: randomUUID(),
722
- });
723
- resent = sent.ok;
724
- }
725
- catch (err) {
726
- logger.warn("document.join.offer_resend_threw", {
727
- documentId, error: err instanceof Error ? err.message : String(err),
728
- });
729
- }
730
- // AND THE STALE HOLDERS. A holder who missed the amendment fan-out is wedged — their
731
- // publishes refuse as epoch-stale with guidance they cannot follow, because no verb
732
- // re-sent the amendment. The re-invite is the healing verb: it re-fans the admitting
733
- // amendment to every other current holder, best-effort, reported per holder.
734
- const admittingBytes = outgoing.offer.amendments[outgoing.offer.amendments.length - 1];
735
- let holdersNotified = {};
736
- // `priorHash` is non-null whenever `outgoing` is — the lookup is keyed by it — but the
737
- // narrowing does not survive the branch, and an amendment seeded under a null key would be
738
- // owed to a row nothing can ever join.
739
- if (admittingBytes !== undefined && priorHash !== null) {
740
- holdersNotified = await fanOutAmendment({
741
- agentName: who.agentName,
742
- ownerAgentId: who.ownerAgentId,
743
- documentId,
744
- amendmentHashHex: priorHash,
745
- amendmentBytes: new Uint8Array(admittingBytes),
746
- holders: [...derived.arrangement.participants].filter((holder) => holder !== who.ownerAgentId && holder !== invitee),
747
- verb: "re-invite",
748
- });
749
- }
750
- return {
751
- ok: true,
815
+ const renotice = await layer.initiateReconcile(who.ownerAgentId, invitee, [documentId]);
816
+ let holdersNotified = {};
817
+ if (admitting && priorHash !== null) {
818
+ holdersNotified = await fanOutAmendment({
819
+ agentName: who.agentName,
820
+ ownerAgentId: who.ownerAgentId,
752
821
  documentId,
753
- inviteeAgentId: invitee,
754
- amendmentHash: priorHash,
755
- resent: true,
756
- offerSent: resent,
757
- holdersNotified,
758
- };
822
+ amendmentHashHex: priorHash,
823
+ amendmentBytes: new Uint8Array(encodeDocumentAmendment(admitting)),
824
+ holders: [...derived.state.participants, ...derived.state.invited].filter((holder) => holder !== who.ownerAgentId && holder !== invitee),
825
+ verb: "re-invite",
826
+ });
759
827
  }
760
828
  return {
761
- ok: false,
762
- reason: "document_already_holder",
763
- guidance: "That agent already holds this document — there is nothing to invite them to.",
829
+ ok: true,
830
+ documentId,
831
+ inviteeAgentId: invitee,
832
+ amendmentHash: priorHash,
833
+ resent: true,
834
+ noticeSent: renotice.ok,
835
+ holdersNotified,
764
836
  };
765
837
  }
766
838
  const body = {
767
839
  document_id: documentId,
768
- epoch_id: derived.arrangement.epoch + 1,
769
- prev_amendment_hash: derived.arrangement.lastAmendmentHash,
770
840
  kind: "add_holder",
771
841
  subject_agent_id: invitee,
772
842
  property_change: null,
773
843
  state_hash: null,
774
844
  authored_at_ms: deps.now(),
845
+ // SYNC-P1 — the causal fields: authored on the fold's frontier.
846
+ author_agent_id: who.ownerAgentId,
847
+ author_seq: (layer.amendments.watermarks(who.ownerAgentId, documentId).get(who.ownerAgentId)?.seq ??
848
+ 0) + 1,
849
+ parents: [...derived.state.frontier],
775
850
  };
776
851
  const amendHash = documentAmendmentHash(body);
777
852
  const multisigTbs = buildDocumentMultisigTbs({
@@ -793,63 +868,33 @@ export function registerDocumentHandlers(deps) {
793
868
  },
794
869
  };
795
870
  // VALIDATE-BEFORE-APPEND, on the exact bytes about to land.
796
- const withNew = deriveArrangement(genesisArr, [...chain, amendment], documentGovernancePolicy, layer.verifySignature);
871
+ const withNew = deriveDocumentState(genesisArr, [...chain, amendment], documentGovernancePolicy, layer.verifySignature);
797
872
  if (!withNew.ok) {
798
873
  return { ok: false, reason: "document_amendment_invalid", guidance: withNew.reason };
799
874
  }
875
+ // A locally-authored entry must TAKE EFFECT — a fold-void entry is admissible history when a
876
+ // peer sends it, but authoring one ourselves would be publishing an act we already know is
877
+ // inert, and the void's reason is the refusal the operator needs.
878
+ {
879
+ const candidateHex = Buffer.from(amendHash).toString("hex");
880
+ const inert = withNew.state.voids.find((v) => v.hash === candidateHex) ??
881
+ withNew.state.excluded.find((e) => e.hash === candidateHex);
882
+ if (inert) {
883
+ return { ok: false, reason: "document_amendment_invalid", guidance: inert.reason };
884
+ }
885
+ }
800
886
  const amendmentBytes = new Uint8Array(encodeDocumentAmendment(amendment));
801
887
  layer.amendments.append(who.ownerAgentId, documentId, amendmentBytes, deps.now());
802
- // The snapshot: update rows only, re-encoded losslessly. Rejection records stay local —
803
- // quarantine bridging is receiver-side state, not shared history.
804
- const log = layer.store.getEnvelopeLog(who.ownerAgentId, documentId);
805
- const snapshot = [];
806
- for (const row of log) {
807
- if (row.kind !== "update" || row.payload === null)
808
- continue;
809
- snapshot.push(new Uint8Array(encodeDocumentUpdateEnvelope({
810
- type: "document_update",
811
- document_id: row.documentId,
812
- epoch_id: row.epochId,
813
- doc_prev_hash: row.docPrevHash,
814
- sender_agent_id: row.senderAgentId,
815
- sender_client_id: row.senderClientId ?? 0,
816
- update_encoding: DOCUMENT_UPDATE_ENCODING_V1,
817
- state_vector: row.stateVector,
818
- update: row.payload,
819
- signature: row.signature,
820
- })));
821
- }
822
- const offer = {
823
- type: "document_join_offer",
824
- feature_version: DOCUMENT_FEATURE_VERSION,
825
- inviter_agent_id: who.ownerAgentId,
826
- invitee_agent_id: invitee,
827
- document_id: documentId,
828
- genesis: new Uint8Array(encodeDocumentProposal(genesisRecord.envelope)),
829
- amendments: [...chain.map((e) => new Uint8Array(encodeDocumentAmendment(e))), amendmentBytes],
830
- envelope_log: snapshot,
831
- offered_at_ms: deps.now(),
832
- signature: new Uint8Array(0),
833
- };
834
- offer.signature = await deps.sign(who.agentName, buildDocumentJoinOfferTbs(offer));
835
- const wire = new Uint8Array(encodeDocumentJoinOffer(offer));
836
888
  const amendHashHex = Buffer.from(amendHash).toString("hex");
837
- layer.joins.recordOutgoing(who.ownerAgentId, wire, amendHashHex, deps.now());
838
- // The offer to the invitee, then the amendment to every OTHER current holder — both
839
- // best-effort at P1, each reported as a fact rather than assumed.
840
- let offerSent = false;
841
- try {
842
- const sent = await deps.transportFor(who.agentName).sendBytes({
843
- peerAgentId: invitee, documentId, bytes: wire, correlationId: randomUUID(),
844
- });
845
- offerSent = sent.ok;
846
- if (!sent.ok)
847
- logger.warn("document.join.offer_unsent", { documentId, reason: sent.reason });
848
- }
849
- catch (err) {
850
- logger.warn("document.join.offer_send_threw", {
851
- documentId, error: err instanceof Error ? err.message : String(err),
852
- });
889
+ // THE NOTICE (SYNC-R25, replacing the D5 offer): no bespoke frame carrying history — the
890
+ // inviter initiates a reconcile exchange naming the document. The invitee's daemon answers
891
+ // the unheld position with an empty hand, and the step-2 reply carries the genesis and the
892
+ // whole entry set (the P3 bootstrap). Losing the notice strands nothing: the admission is in
893
+ // the chain, and any later exchange with any holder delivers it.
894
+ const notice = await layer.initiateReconcile(who.ownerAgentId, invitee, [documentId]);
895
+ const offerSent = notice.ok;
896
+ if (!notice.ok) {
897
+ logger.warn("document.join.notice_unsent", { documentId, reason: notice.reason });
853
898
  }
854
899
  // DOD-MP-INVITE-FANOUT-1 — RECORD WHAT IS OWED BEFORE TRYING TO SEND IT.
855
900
  //
@@ -860,7 +905,7 @@ export function registerDocumentHandlers(deps) {
860
905
  //
861
906
  // Seeding first also makes the crash window safe: a daemon that dies between here and the send
862
907
  // still owes the amendment on restart.
863
- const owedHolders = [...derived.arrangement.participants].filter((holder) => holder !== who.ownerAgentId && holder !== invitee);
908
+ const owedHolders = [...derived.state.participants, ...derived.state.invited].filter((holder) => holder !== who.ownerAgentId && holder !== invitee);
864
909
  const holdersTold = await fanOutAmendment({
865
910
  agentName: who.agentName,
866
911
  ownerAgentId: who.ownerAgentId,
@@ -870,22 +915,21 @@ export function registerDocumentHandlers(deps) {
870
915
  holders: owedHolders,
871
916
  verb: "invite",
872
917
  });
873
- logger.info("document.join.invited", { documentId, invitee, epochId: body.epoch_id, offerSent });
918
+ logger.info("document.join.invited", { documentId, invitee, noticeSent: offerSent });
874
919
  return {
875
920
  ok: true,
876
921
  documentId,
877
922
  inviteeAgentId: invitee,
878
923
  amendmentHash: amendHashHex,
879
- epochId: body.epoch_id,
880
- offerSent,
924
+ noticeSent: offerSent,
881
925
  holdersNotified: holdersTold,
882
926
  ...(offerSent
883
927
  ? {}
884
928
  : {
885
- guidance: "The amendment is recorded and the invitation exists, but the offer did not reach " +
929
+ guidance: "The admission is recorded and the invitation exists, but the notice did not reach " +
886
930
  "the invitee — they may be offline. Re-run cello_doc_invite with the same invitee " +
887
- "once they are reachable: it re-sends this exact offer rather than authoring a " +
888
- "second amendment.",
931
+ "once they are reachable: it re-sends the notice rather than authoring a second " +
932
+ "entry.",
889
933
  }),
890
934
  };
891
935
  });
@@ -928,24 +972,21 @@ export function registerDocumentHandlers(deps) {
928
972
  }
929
973
  const genesisArr = arrangementGenesisFromProposal(genesisRecord.envelope);
930
974
  const chain = layer.amendments.chain(who.ownerAgentId, documentId);
931
- const derived = deriveArrangement(genesisArr, chain, documentGovernancePolicy, layer.verifySignature);
975
+ const derived = deriveDocumentState(genesisArr, chain, documentGovernancePolicy, layer.verifySignature);
932
976
  if (!derived.ok) {
933
977
  return { ok: false, reason: "document_chain_invalid", guidance: derived.reason };
934
978
  }
935
- if (!derived.arrangement.participants.has(holder)) {
979
+ if (!derived.state.participants.has(holder) && !derived.state.invited.has(holder)) {
936
980
  // ALREADY REMOVED is the HEALING path, not a refusal (REMOVE-1 review F3): a holder who
937
981
  // was offline at removal time never learned, and no other verb can ever re-send the
938
982
  // removal amendment — a second cello_doc_remove is the invite-retry precedent. A subject
939
983
  // the chain never touched still refuses.
940
- const membership = layer.amendments.membershipOf(who.ownerAgentId, documentId, holder);
941
- if (membership.state === "removed") {
942
- const removal = chain.find((e) => e.body.kind === "remove_holder" &&
943
- e.body.subject_agent_id === holder &&
944
- e.body.epoch_id === membership.epochId);
984
+ if (layer.standingOf(who.ownerAgentId, documentId, holder) === "removed") {
985
+ const removal = [...chain].reverse().find((e) => e.body.kind === "remove_holder" && e.body.subject_agent_id === holder);
945
986
  let resendTold = {};
946
987
  if (removal) {
947
988
  const bytes = new Uint8Array(encodeDocumentAmendment(removal));
948
- const remaining = [...derived.arrangement.participants].filter((m) => m !== who.ownerAgentId && m !== holder);
989
+ const remaining = [...derived.state.participants, ...derived.state.invited].filter((m) => m !== who.ownerAgentId && m !== holder);
949
990
  // The healing re-send is durable for the holders who REMAIN, for the same reason the
950
991
  // re-invite is: it is the verb an operator runs precisely because someone is out of step,
951
992
  // so it must not be the one that gives up quietest.
@@ -975,7 +1016,6 @@ export function registerDocumentHandlers(deps) {
975
1016
  documentId,
976
1017
  removedAgentId: holder,
977
1018
  resent: true,
978
- epochId: membership.epochId,
979
1019
  holdersNotified: resendTold,
980
1020
  };
981
1021
  }
@@ -987,13 +1027,16 @@ export function registerDocumentHandlers(deps) {
987
1027
  }
988
1028
  const body = {
989
1029
  document_id: documentId,
990
- epoch_id: derived.arrangement.epoch + 1,
991
- prev_amendment_hash: derived.arrangement.lastAmendmentHash,
992
1030
  kind: "remove_holder",
993
1031
  subject_agent_id: holder,
994
1032
  property_change: null,
995
1033
  state_hash: null,
996
1034
  authored_at_ms: deps.now(),
1035
+ // SYNC-P1 — the causal fields: authored on the fold's frontier.
1036
+ author_agent_id: who.ownerAgentId,
1037
+ author_seq: (layer.amendments.watermarks(who.ownerAgentId, documentId).get(who.ownerAgentId)?.seq ??
1038
+ 0) + 1,
1039
+ parents: [...derived.state.frontier],
997
1040
  };
998
1041
  const amendHash = documentAmendmentHash(body);
999
1042
  const multisigTbs = buildDocumentMultisigTbs({
@@ -1017,17 +1060,32 @@ export function registerDocumentHandlers(deps) {
1017
1060
  // VALIDATE-BEFORE-APPEND — the policy rules here: a non-admin removing someone else, or any
1018
1061
  // single admin trying to expel a fellow admin through the holder door, refuses with the
1019
1062
  // policy's own sentence. Voluntary self-leave passes for anyone.
1020
- const withNew = deriveArrangement(genesisArr, [...chain, amendment], documentGovernancePolicy, layer.verifySignature);
1063
+ const withNew = deriveDocumentState(genesisArr, [...chain, amendment], documentGovernancePolicy, layer.verifySignature);
1021
1064
  if (!withNew.ok) {
1022
1065
  return { ok: false, reason: "document_amendment_invalid", guidance: withNew.reason };
1023
1066
  }
1067
+ // A locally-authored entry must TAKE EFFECT — a fold-void entry is admissible history when a
1068
+ // peer sends it, but authoring one ourselves would be publishing an act we already know is
1069
+ // inert, and the void's reason is the refusal the operator needs.
1070
+ {
1071
+ const candidateHex = Buffer.from(amendHash).toString("hex");
1072
+ const inert = withNew.state.voids.find((v) => v.hash === candidateHex) ??
1073
+ withNew.state.excluded.find((e) => e.hash === candidateHex);
1074
+ if (inert) {
1075
+ return { ok: false, reason: "document_amendment_invalid", guidance: inert.reason };
1076
+ }
1077
+ }
1024
1078
  const amendmentBytes = new Uint8Array(encodeDocumentAmendment(amendment));
1025
1079
  layer.amendments.append(who.ownerAgentId, documentId, amendmentBytes, deps.now());
1026
- // DOD-MP-CLOSE-N-1 removing the one holder who had not closed leaves everyone who remains in
1027
- // agreement, so the removal itself can complete it. Without this the document sat `active`
1028
- // forever, reporting that it waited on nobody. Also drops the removed holder's close row, so a
1029
- // later re-admission does not carry their old agreement into a new tenure.
1030
- layer.lifecycle.onMembershipChanged(who.ownerAgentId, documentId, holder);
1080
+ // SYNC-P4 (R27): a removal can COMPLETE a standing agreement everyone who remains has
1081
+ // agreed and that now falls out of the DERIVATION; the author's daemon projects it here,
1082
+ // exactly as receiving daemons project on arrival.
1083
+ {
1084
+ const afterRemove = layer.deriveEnded(who.ownerAgentId, documentId);
1085
+ if (afterRemove?.ended) {
1086
+ layer.store.setDocumentStatus(who.ownerAgentId, documentId, afterRemove.ended === "killed" ? "killed" : "closed");
1087
+ }
1088
+ }
1031
1089
  // The amendment travels to EVERY current holder INCLUDING the removed one — being told is
1032
1090
  // how their daemon surfaces the removal to their operator. Best-effort at P1, per holder,
1033
1091
  // reported never assumed.
@@ -1041,7 +1099,7 @@ export function registerDocumentHandlers(deps) {
1041
1099
  documentId,
1042
1100
  amendmentHashHex: Buffer.from(amendHash).toString("hex"),
1043
1101
  amendmentBytes,
1044
- holders: [...withNew.arrangement.participants].filter((m) => m !== who.ownerAgentId),
1102
+ holders: [...withNew.state.participants, ...withNew.state.invited].filter((m) => m !== who.ownerAgentId),
1045
1103
  verb: "remove",
1046
1104
  });
1047
1105
  // THE REMOVED HOLDER IS TOLD ONCE, and is deliberately NOT owed a durable retry: delivery to
@@ -1071,14 +1129,13 @@ export function registerDocumentHandlers(deps) {
1071
1129
  }
1072
1130
  }
1073
1131
  logger.info("document.holder_removed", {
1074
- documentId, holder, epochId: body.epoch_id, voluntary: holder === who.ownerAgentId,
1132
+ documentId, holder, voluntary: holder === who.ownerAgentId,
1075
1133
  });
1076
1134
  return {
1077
1135
  ok: true,
1078
1136
  documentId,
1079
1137
  removedAgentId: holder,
1080
1138
  voluntary: holder === who.ownerAgentId,
1081
- epochId: body.epoch_id,
1082
1139
  holdersNotified: holdersTold,
1083
1140
  guidance: "Removal is forward-only: their existing copy and its history remain theirs — new edits " +
1084
1141
  "simply no longer flow either way.",
@@ -1097,15 +1154,33 @@ export function registerDocumentHandlers(deps) {
1097
1154
  if (documentId.length === 0) {
1098
1155
  return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_inbox." };
1099
1156
  }
1100
- const pendingJoinRefusal = layer.joins
1101
- .pendingFor(who.ownerAgentId)
1102
- .find((j) => j.documentId === documentId);
1103
- if (pendingJoinRefusal) {
1104
- const refused = await layer.refuseJoin(who.ownerAgentId, pendingJoinRefusal.amendmentHash, reason, deps.now());
1105
- if (!refused.ok)
1106
- return { ok: false, reason: refused.reason, guidance: refused.detail };
1107
- const told = await tellInviter(who, documentId, refused.inviterAgentId, refused.answerBytes);
1108
- return { ok: true, documentId, joined: false, inviterNotified: told };
1157
+ // THE NEW-MODEL INVITATION (SYNC-P3): the document arrived through the exchange and this
1158
+ // agent derives as an INVITED seat — declining is authoring your own signed refuse_join
1159
+ // entry (R24), which reaches every holder over the same carrier as everything else and
1160
+ // settles the inviter's surface from the record itself.
1161
+ if (layer.store.getDocument(who.ownerAgentId, documentId)) {
1162
+ const genesisRecord = layer.handshake.get(who.ownerAgentId, documentId);
1163
+ if (genesisRecord) {
1164
+ const standing = deriveDocumentState(arrangementGenesisFromProposal(genesisRecord.envelope), layer.amendments.chain(who.ownerAgentId, documentId), documentGovernancePolicy, layer.verifySignature);
1165
+ if (standing.ok && standing.state.invited.has(who.ownerAgentId)) {
1166
+ const refusal = await authorConsent(who, documentId, "refuse_join");
1167
+ if (!refusal.ok) {
1168
+ return {
1169
+ ok: false,
1170
+ reason: "document_refusal_unrecorded",
1171
+ guidance: `Your refusal entry could not be recorded (${refusal.reason}) — run ` +
1172
+ `cello_doc_refuse again once the named condition clears.`,
1173
+ };
1174
+ }
1175
+ return {
1176
+ ok: true,
1177
+ documentId,
1178
+ joined: false,
1179
+ refusalEntry: refusal.entryHash,
1180
+ refusalDelivered: refusal.holdersNotified,
1181
+ };
1182
+ }
1183
+ }
1109
1184
  }
1110
1185
  const proposal = layer.handshake.get(who.ownerAgentId, documentId);
1111
1186
  const outcome = layer.handshake.refuse(who.ownerAgentId, documentId, reason, deps.now());
@@ -1123,114 +1198,128 @@ export function registerDocumentHandlers(deps) {
1123
1198
  const who = resolve(params, connectionId);
1124
1199
  if (isRefusal(who))
1125
1200
  return who;
1126
- // M14B / DOD-MP-JOIN-1 the inviter's view of every offer they authored: who has answered,
1127
- // who is still thinking, and the refusal prose when there is one. Without this the answer
1128
- // arrived, flipped a row, and reached nobody; a refused-but-admitted holder additionally
1129
- // needs removing (REMOVE-1) and nothing else prompts it.
1130
- // THE DERIVED ARRANGEMENT, per document (enforcer review G0). Until now nothing surfaced who
1131
- // holds a document or who governs it: an operator could not answer "who is in this?", and
1132
- // "all holders derive the same arrangement" the governance line's headline claim — was
1133
- // unassertable from outside the process. DERIVED here, never stored: each daemon computes it
1134
- // from its OWN chain, which is exactly the property worth comparing across machines.
1135
- const unavailable = (reason) => ({
1136
- // THE KEYS ARE ALWAYS PRESENT, null on failure. Dropping them made `row.participants`
1137
- // undefined, which a consumer coerces to [] and reads as "nobody holds this" — a
1138
- // materially wrong answer to the question this surface exists to answer. `null` cannot
1139
- // be mistaken for an empty membership; an absent key already was, in this unit's own
1140
- // enforcer helper.
1141
- participants: null,
1142
- admins: null,
1143
- properties: null,
1144
- arrangementUnavailable: reason,
1145
- });
1146
- const arrangementFor = (documentId, genesisRecord) => {
1147
- // The SAME name the invite path uses for the same fault, carrying the same sentence —
1148
- // one condition should not have two names, and the one an operator reads should be the
1149
- // one that says whose fault it is.
1150
- if (!genesisRecord) {
1151
- return unavailable("document_genesis_missing: the document has a row but no stored genesis proposal to " +
1152
- "replay from — this is a local-state fault, not the peer's");
1201
+ // SYNC-R45/R46everything here is DERIVED at the moment of asking (participants, admins,
1202
+ // properties, ended, own standing) or read from the per-party DISPLAY CACHE (sync,
1203
+ // lastSyncedAtMs spec §9; never a correctness input, R44). R48: a row whose chain does not
1204
+ // derive says so BY NAME (`underivable`) with every derived key present and null an absent
1205
+ // key was already misread once as "nobody holds this".
1206
+ // SYNC-P4 (D5 replacement): the invitation ledger IS the entry set an open invitation is
1207
+ // an invited seat in the derivation; a refusal is the subject's refuse_join entry.
1208
+ const outgoingJoins = layer.store
1209
+ .listDocuments(who.ownerAgentId)
1210
+ .flatMap((d) => {
1211
+ const genesisRecord = layer.handshake.get(who.ownerAgentId, d.documentId);
1212
+ if (!genesisRecord)
1213
+ return [];
1214
+ let chain;
1215
+ try {
1216
+ chain = layer.amendments.chain(who.ownerAgentId, d.documentId);
1153
1217
  }
1154
- // CONTAINED. `chain()` decodes every stored amendment and `documentIdFromProposal` parses
1155
- // the genesis — both THROW on bytes this build cannot read (a client downgrade past an
1156
- // amendment kind is the reachable case). Uncontained, that throw escapes the row, escapes
1157
- // the map, and the operator asking "what documents do I have?" gets NOTHING because one
1158
- // chain would not decode.
1218
+ catch {
1219
+ return [];
1220
+ }
1221
+ const derived = deriveDocumentState(arrangementGenesisFromProposal(genesisRecord.envelope), chain, documentGovernancePolicy, layer.verifySignature);
1222
+ if (!derived.ok)
1223
+ return [];
1224
+ // Review F1 (P5): the genesis peer can refuse BEFORE ever holding the document, so no
1225
+ // refuse_join entry exists anywhere — their signed answer lives on the handshake record.
1226
+ // Without this read, a refused proposal rendered "pending" forever while the same
1227
+ // daemon's publish gate knew "they said no" — two surfaces of one daemon contradicting.
1228
+ const open = [...derived.state.invited]
1229
+ .filter((seat) => seat !== who.ownerAgentId)
1230
+ .map((seat) => {
1231
+ if (seat === genesisRecord.peerAgentId &&
1232
+ genesisRecord.proposerAgentId === who.ownerAgentId &&
1233
+ genesisRecord.peerAccepted === false) {
1234
+ return {
1235
+ documentId: d.documentId,
1236
+ inviteeAgentId: seat,
1237
+ state: "refused",
1238
+ ...(genesisRecord.peerReason ? { reason: genesisRecord.peerReason } : {}),
1239
+ };
1240
+ }
1241
+ return { documentId: d.documentId, inviteeAgentId: seat, state: "pending" };
1242
+ });
1243
+ const refusedSeats = chain
1244
+ .filter((e) => e.body.kind === "refuse_join")
1245
+ .map((e) => e.body.subject_agent_id)
1246
+ .filter((seat) => seat !== null && seat !== who.ownerAgentId)
1247
+ .filter((seat) => !derived.state.participants.has(seat) && !derived.state.invited.has(seat));
1248
+ const refused = [...new Set(refusedSeats)].map((seat) => ({
1249
+ documentId: d.documentId,
1250
+ inviteeAgentId: seat,
1251
+ state: "refused",
1252
+ }));
1253
+ return [...open, ...refused];
1254
+ });
1255
+ const documents = layer.store.listDocuments(who.ownerAgentId).map((doc) => {
1256
+ const base = {
1257
+ documentId: doc.documentId,
1258
+ documentType: doc.documentType,
1259
+ };
1260
+ const underivable = (reason) => ({
1261
+ ...base,
1262
+ underivable: reason,
1263
+ participants: null,
1264
+ admins: null,
1265
+ properties: null,
1266
+ ended: null,
1267
+ yourStanding: "unknown",
1268
+ parties: null,
1269
+ standingGuidance: "This daemon cannot derive this document's arrangement, so nothing here should be " +
1270
+ "taken as confirmation of anything — see the named reason.",
1271
+ });
1272
+ const genesisRecord = layer.handshake.get(who.ownerAgentId, doc.documentId);
1273
+ if (!genesisRecord)
1274
+ return underivable("document_genesis_missing");
1275
+ let chain;
1159
1276
  try {
1160
- const derived = deriveArrangement(arrangementGenesisFromProposal(genesisRecord.envelope), layer.amendments.chain(who.ownerAgentId, documentId), documentGovernancePolicy, layer.verifySignature);
1161
- if (!derived.ok)
1162
- return unavailable(derived.reason);
1163
- return {
1164
- participants: [...derived.arrangement.participants].sort(),
1165
- admins: [...derived.arrangement.admins].sort(),
1166
- properties: derived.arrangement.properties,
1167
- // WAS THE ADMIN SET DECLARED, OR DEFAULTED? A genesis from before the admin slot
1168
- // existed carries none, and the replay hands both parties admin power. Rendering that
1169
- // identically to a declared set would have this surface state as agreed fact something
1170
- // the code decided — so it says which it is.
1171
- adminSetDefaulted: genesisRecord.envelope.properties.admin_set === undefined,
1172
- };
1277
+ chain = layer.amendments.chain(who.ownerAgentId, doc.documentId);
1173
1278
  }
1174
1279
  catch (err) {
1175
- return unavailable(`document_chain_undecodable: ${err instanceof Error ? err.message : String(err)}`);
1280
+ return underivable(`document_chain_undecodable: ${err instanceof Error ? err.message : String(err)}`);
1176
1281
  }
1177
- };
1178
- const outgoingJoins = layer.joins.outgoingFor(who.ownerAgentId).map((j) => ({
1179
- documentId: j.documentId,
1180
- inviteeAgentId: j.inviteeAgentId,
1181
- state: j.state,
1182
- ...(j.reason ? { reason: j.reason } : {}),
1183
- ...(j.state === "refused"
1184
- ? {
1185
- guidance: "The invitee declined but the admitting amendment is already in the chain — they " +
1186
- "count as a holder until removed. The removal verb ships in the next unit of this " +
1187
- "milestone; nothing needs doing meanwhile.",
1188
- }
1189
- : {}),
1190
- }));
1282
+ const derived = deriveDocumentState(arrangementGenesisFromProposal(genesisRecord.envelope), chain, documentGovernancePolicy, layer.verifySignature);
1283
+ if (!derived.ok)
1284
+ return underivable(derived.reason);
1285
+ const state = derived.state;
1286
+ const rawStanding = layer.standingOf(who.ownerAgentId, doc.documentId, who.ownerAgentId);
1287
+ const yourStanding = rawStanding === "stranger" || rawStanding === "unknown" ? "unknown" : rawStanding;
1288
+ // Sync + blockedBy come from the layer's ONE display-cache read (shared with the sweep's
1289
+ // believed-current suppression): "behind" means we hold something the party's last claimed
1290
+ // position did not cover; "unseen" means no exchange was ever recorded here.
1291
+ const parties = [...state.participants, ...state.invited]
1292
+ .filter((agentId) => agentId !== who.ownerAgentId)
1293
+ .sort()
1294
+ .map((agentId) => ({
1295
+ agentId,
1296
+ class: state.participants.has(agentId)
1297
+ ? "participant"
1298
+ : "invited",
1299
+ ...layer.partySync(who.ownerAgentId, doc.documentId, agentId),
1300
+ }));
1301
+ return {
1302
+ ...base,
1303
+ participants: [...state.participants].sort(),
1304
+ admins: [...state.admins].sort(),
1305
+ properties: state.properties,
1306
+ ended: state.ended,
1307
+ yourStanding,
1308
+ ...(yourStanding === "removed"
1309
+ ? {
1310
+ standingGuidance: "You are no longer a holder of this document. Your copy and its full history " +
1311
+ "remain yours, and you can still read it here or open the file. What changed is " +
1312
+ "only the flow of edits: yours no longer publish to the other holders, and " +
1313
+ "theirs no longer reach you.",
1314
+ }
1315
+ : {}),
1316
+ parties,
1317
+ };
1318
+ });
1191
1319
  return {
1192
1320
  ok: true,
1193
1321
  ...(outgoingJoins.length > 0 ? { joinOffers: outgoingJoins } : {}),
1194
- documents: layer.lifecycle.list(who.ownerAgentId, deps.now()).map((d) => {
1195
- // WHOSE OFFER WAS IT, and has the other side actually shown up?
1196
- //
1197
- // Without these three fields, `cello_doc_list` renders identically for a document the peer
1198
- // refused, one whose offer never reached them, and one being actively co-edited — the only
1199
- // moving part is `pendingUnsent`, which also moves for a peer who is merely offline. An
1200
- // operator cannot tell "they said no" from "they are asleep", and those want opposite
1201
- // actions.
1202
- const proposal = layer.handshake.get(who.ownerAgentId, d.documentId);
1203
- const peerAnswer = layer.handshake.peerAnswer(who.ownerAgentId, d.documentId);
1204
- return {
1205
- ...d,
1206
- proposedByUs: proposal?.proposerAgentId === who.ownerAgentId,
1207
- // THE PEER'S OWN SIGNED ANSWER — true accepted, false refused, null not yet heard. This
1208
- // replaced an inference ("they have published into it") that could not tell refused from
1209
- // unreceived from accepted-but-untouched, two of which want the operator to act.
1210
- //
1211
- // The REASON comes with it. It was stored and read by nothing, which defeats why it is
1212
- // mandatory on the wire: a refusal whose reason the proposer cannot see leaves them
1213
- // unable to propose anything better.
1214
- peerAccepted: peerAnswer.accepted,
1215
- peerRefusalReason: peerAnswer.reason,
1216
- // Kept alongside it, because they answer different questions: whether they agreed, and
1217
- // whether anything has actually come back. A document accepted an hour ago with nothing
1218
- // in it is a fine state; it is just not the same state.
1219
- peerHasPublished: layer.store.knownEnvelopeHashesBySender(who.ownerAgentId, d.documentId, d.peerAgentId).size > 0,
1220
- consentState: proposal?.consentState ?? null,
1221
- // WHO HOLDS IT AND WHO GOVERNS IT — derived from THIS daemon's own chain (G0).
1222
- // `proposal` is passed rather than re-fetched: it is the same SQL read and the same
1223
- // CBOR decode of the same bytes, already in hand.
1224
- ...arrangementFor(d.documentId, proposal),
1225
- // DID OUR OFFER LEAVE? Only meaningful for a document WE proposed — for one we accepted
1226
- // there is no offer of ours to have sent. Without this, `peerAccepted: null` meant both
1227
- // "they are thinking" and "they were never asked", and the shipped guidance said WAIT,
1228
- // which is wrong for the second and leaves the operator waiting on nothing.
1229
- proposalSent: proposal?.proposerAgentId === who.ownerAgentId
1230
- ? layer.handshake.proposalSent(who.ownerAgentId, d.documentId)
1231
- : null,
1232
- };
1233
- }),
1322
+ documents,
1234
1323
  };
1235
1324
  });
1236
1325
  handlers.set("cello_doc_read", async (params, connectionId) => {
@@ -1670,7 +1759,11 @@ export function registerDocumentHandlers(deps) {
1670
1759
  return {
1671
1760
  ok: false,
1672
1761
  reason: publishable.reason,
1673
- guidance: `${publishable.detail ?? "This document can no longer accept writes."} Nothing was ` +
1762
+ guidance:
1763
+ // PUNCTUATED. `detail` comes from several producers and not all of them end in a stop,
1764
+ // so the two sentences ran together — "…no longer publish to the other holders Nothing
1765
+ // was changed locally…" — on the one line this DoD calls actionable.
1766
+ `${withStop(publishable.detail ?? "This document can no longer accept writes.")} Nothing was ` +
1674
1767
  `changed locally — an edit applied here could never be published or recovered, and would ` +
1675
1768
  `disappear the next time the daemon restarted.`,
1676
1769
  };
@@ -1773,119 +1866,32 @@ export function registerDocumentHandlers(deps) {
1773
1866
  if (documentId.length === 0) {
1774
1867
  return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_list." };
1775
1868
  }
1776
- const outcome = await layer.lifecycle.close(who.ownerAgentId, documentId, deps.now());
1777
- if (!outcome.ok)
1778
- return { ok: false, reason: outcome.reason, guidance: outcome.detail };
1779
- const status = layer.store.getDocument(who.ownerAgentId, documentId)?.status ?? "unknown";
1780
- // BILATERAL. The document settles when both sides have said it, so "closed" is not this call's
1781
- // answer to give — reporting it would tell an operator the collaboration is over while the peer
1782
- // is still editing.
1783
- //
1784
- // `peerNotified` is reported for the same reason `kill` reports it, and its absence here was an
1785
- // asymmetry with teeth: `status: "active"` after a close is INDISTINGUISHABLE from "sent fine,
1786
- // they have not answered yet". Control frames are fire-once — unlike update envelopes they are
1787
- // not in the log and the delivery sweep never retries them — so a close sent while the peer was
1788
- // offline means the document can never settle, forever, with nothing on either screen.
1869
+ // SYNC-P4 (R26/R27): closing is authoring YOUR OWN close entry — the same self-signed act
1870
+ // consent is, traveling the same carrier, settling by DERIVATION when every current
1871
+ // participant has one. No control frame, no fan-out bookkeeping, no fire-once anything: a
1872
+ // close that has not reached someone yet is just a difference the next exchange closes.
1873
+ const closed = await authorConsent(who, documentId, "close");
1874
+ if (!closed.ok) {
1875
+ return {
1876
+ ok: false,
1877
+ reason: "document_close_unrecorded",
1878
+ guidance: `Your close entry could not be recorded (${closed.reason}).`,
1879
+ };
1880
+ }
1881
+ const derived = layer.governanceFrontierFor(who.ownerAgentId, documentId) !== null
1882
+ ? layer.deriveEnded(who.ownerAgentId, documentId)
1883
+ : null;
1789
1884
  return {
1790
1885
  ok: true,
1791
1886
  documentId,
1792
- status,
1793
- peerNotified: outcome.peerNotified,
1794
- holdersNotified: outcome.holdersNotified,
1795
- holderFailures: outcome.holderFailures,
1796
- ...(outcome.peerNotified
1797
- ? {}
1798
- : {
1799
- reason: outcome.notifyReason,
1800
- guidance: notifyGuidance("close", outcome.notifyReason, outcome.notifyDetail, outcome.holdersNotified, outcome.holderFailures),
1801
- }),
1887
+ closeEntry: closed.entryHash,
1888
+ closeDelivered: closed.holdersNotified,
1889
+ // DERIVED, at this instant: "closed" only when everyone seated has agreed — one party
1890
+ // alone is never the whole agreement, and the surface says who is still being waited on.
1891
+ ended: derived?.ended ?? null,
1892
+ waitingOn: derived?.waitingOn ?? [],
1802
1893
  };
1803
1894
  });
1804
- /**
1805
- * What to tell an operator when a close or kill did not reach the peer — BRANCHED ON THE CAUSE.
1806
- *
1807
- * One string used to cover all three: a transport failure, a signing key that could not be
1808
- * loaded, and this daemon not holding the document's agent at all. Only the first is fixed by
1809
- * waiting, and that is what the single message told them to do. Traced end to end in the
1810
- * DOD-DOC-TOOLS-1 review: an exit-point label standing in for a local fault, pointing at the
1811
- * network.
1812
- */
1813
- function notifyGuidance(verb, reason, detail, holdersNotified, holderFailures) {
1814
- const tail = verb === "close"
1815
- ? `A close is not retried — run cello_doc_close again once this is cleared, or cello_doc_kill if you need it over now.`
1816
- : `The kill stands locally either way; run cello_doc_kill again once this is cleared so they stop editing.`;
1817
- if (reason === "document_control_unsigned") {
1818
- return (`Your ${verb} was recorded, but this agent's signing key could not be loaded, so nothing ` +
1819
- `could be sent to the peer${detail ? ` (${detail})` : ""}. Waiting will not help — the ` +
1820
- `fault is on this machine. ${tail}`);
1821
- }
1822
- if (reason === "document_unknown") {
1823
- return (`Your ${verb} was recorded, but this daemon does not hold the agent that owns the document, ` +
1824
- `so it could not tell the peer. That is a local wiring fault, not the peer being away. ${tail}`);
1825
- }
1826
- // THE FOUR DERIVATION FAULTS (DOD-MP-CONTROL-N-1). Every one of these is a fact about THIS
1827
- // machine's record of the document, and not one of them is fixed by the counterparty coming
1828
- // online — which is what the generic sentence below tells the operator to wait for. That is the
1829
- // exact defect this function's header says it was written to remove, reintroduced by four new
1830
- // reasons that were never added to it.
1831
- if (reason === "document_holders_underivable" || reason === "document_genesis_missing") {
1832
- return (`Your ${verb} was recorded, but this daemon could not work out who currently holds the ` +
1833
- `document, so it sent nothing to anyone. Waiting will not help — the signed history on this ` +
1834
- `machine is the problem, not the network. Look for a "document.holders.underivable" line in ` +
1835
- `the daemon log, which names the specific cause. ${tail}`);
1836
- }
1837
- if (reason?.startsWith("document_chain_undecodable")) {
1838
- return (`Your ${verb} was recorded, but this build cannot read part of the document's signed ` +
1839
- `history${detail ? ` (${detail})` : ""}, so it could not work out who to tell. That is ` +
1840
- `usually a client older than the document — upgrading is the fix, not waiting. ${tail}`);
1841
- }
1842
- if (reason === "document_no_holders") {
1843
- return (`Your ${verb} was recorded, and there is nobody left to tell — the signed history shows no ` +
1844
- `other current holder of this document. Nothing is pending and nothing needs retrying.`);
1845
- }
1846
- // PARTIAL FAN-OUT is its own case and reads nothing like "the peer is offline". With three
1847
- // holders, two hearing you and one not is the ordinary outcome, and the operator needs to know
1848
- // WHICH one is still editing a document they think is live — a message saying "the peer" names
1849
- // nobody when there are several.
1850
- const missed = Object.entries(holdersNotified ?? {}).filter(([, told]) => !told).map(([h]) => h);
1851
- const reached = Object.values(holdersNotified ?? {}).filter(Boolean).length;
1852
- if (missed.length > 0 && reached > 0) {
1853
- return (`Your ${verb} was recorded and reached ${reached} of ${reached + missed.length} other ` +
1854
- `holders, but not ${missed.join(", ")} — they will keep editing a document the rest of you ` +
1855
- `have ended. ${tail}`);
1856
- }
1857
- if (missed.length > 0) {
1858
- // EVERY holder failed. This used to fall through to the generic sentence, which says "the
1859
- // peer" and names nobody — unusable when there are several, and the per-holder transport
1860
- // reasons live only in the log. It also arrives with no `reason` field at all, because the
1861
- // notifier returned ok: signing and addressing both worked, the sends did not.
1862
- //
1863
- // AND IT SAID "most likely offline", WHICH WAS A GUESS. Caught on the live fleet: every
1864
- // holder failed with `session_sealed` — the relay refusing a leaf for a session it has
1865
- // closed — and the operator was told to wait for people who were sitting right there.
1866
- // Waiting cannot reopen a sealed session. The cause is known per holder; it travels.
1867
- const causes = [...new Set(Object.values(holderFailures ?? {}))].filter(Boolean);
1868
- const because = causes.length > 0 ? ` (${causes.join(", ")})` : "";
1869
- // THE WHOLE FAMILY, not one substring. This tested `includes("sealed")`, so `session_sealed`
1870
- // got the useful sentence and every other way a session's record can be over —
1871
- // `relay_session_gone` (the relay restarted or swept it) and `session_not_found` — fell to
1872
- // generic wait-for-them advice, which is the one thing that cannot work when there is nothing
1873
- // left to wait for.
1874
- const sealed = causes.some((c) => c.includes("sealed") ||
1875
- c.includes("relay_session_gone") ||
1876
- c.includes("session_not_found"));
1877
- return (`Your ${verb} was recorded but reached none of the ${missed.length} other ` +
1878
- `holder${missed.length > 1 ? "s" : ""} (${missed.join(", ")})${because}, so the document ` +
1879
- `cannot settle until they hear it. ` +
1880
- (sealed
1881
- ? `The session carrying this document has been sealed, so waiting will not help — ` +
1882
- `the document needs a fresh session to the other holders. `
1883
- : ``) +
1884
- `${tail}`);
1885
- }
1886
- return (`Your ${verb} was recorded but did not reach the peer${detail ? ` (${detail})` : ""}, so the ` +
1887
- `document cannot settle until they hear it. ${tail}`);
1888
- }
1889
1895
  handlers.set("cello_doc_kill", async (params, connectionId) => {
1890
1896
  const who = resolve(params, connectionId);
1891
1897
  if (isRefusal(who))
@@ -1894,26 +1900,25 @@ export function registerDocumentHandlers(deps) {
1894
1900
  if (documentId.length === 0) {
1895
1901
  return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_list." };
1896
1902
  }
1897
- const outcome = await layer.lifecycle.kill(who.ownerAgentId, documentId, deps.now());
1898
- if (!outcome.ok)
1899
- return { ok: false, reason: outcome.reason, guidance: outcome.detail };
1900
- // `peerNotified` is REPORTED, never hidden behind ok. A kill is deliberately independent of the
1901
- // peer being reachable — a decision to stop that depends on the other party being online is not
1902
- // a decision to stop — but an operator who believes the peer was told, when they were not, will
1903
- // not understand why updates keep arriving.
1903
+ // SYNC-P4 (R28): a kill is one admin's own signed entry — immediate and one-sided the
1904
+ // moment it applies anywhere, independent of anyone being reachable (a decision to stop
1905
+ // that depends on the other party being online is not a decision to stop). It travels the
1906
+ // same carrier as everything else; a holder who has not received it yet is a difference
1907
+ // the next exchange closes.
1908
+ const killed = await authorConsent(who, documentId, "kill");
1909
+ if (!killed.ok) {
1910
+ return {
1911
+ ok: false,
1912
+ reason: "document_kill_unrecorded",
1913
+ guidance: `Your kill entry could not be recorded (${killed.reason}).`,
1914
+ };
1915
+ }
1904
1916
  return {
1905
1917
  ok: true,
1906
1918
  documentId,
1907
- peerNotified: outcome.peerNotified,
1908
- holdersNotified: outcome.holdersNotified,
1909
- holderFailures: outcome.holderFailures,
1910
- note: outcome.note,
1911
- ...(outcome.peerNotified
1912
- ? {}
1913
- : {
1914
- reason: outcome.notifyReason,
1915
- guidance: notifyGuidance("kill", outcome.notifyReason, outcome.notifyDetail, outcome.holdersNotified, outcome.holderFailures),
1916
- }),
1919
+ killEntry: killed.entryHash,
1920
+ killDelivered: killed.holdersNotified,
1921
+ ended: "killed",
1917
1922
  };
1918
1923
  });
1919
1924
  handlers.set("cello_doc_publish", async (params, connectionId) => {