@cello-protocol/daemon 0.0.161 → 0.0.163

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 (65) hide show
  1. package/dist/daemon.js +15 -5
  2. package/dist/daemon.js.map +1 -1
  3. package/dist/document-ack-inbound.d.ts +8 -1
  4. package/dist/document-ack-inbound.d.ts.map +1 -1
  5. package/dist/document-ack-inbound.js +31 -8
  6. package/dist/document-ack-inbound.js.map +1 -1
  7. package/dist/document-amendment-store.d.ts +72 -0
  8. package/dist/document-amendment-store.d.ts.map +1 -0
  9. package/dist/document-amendment-store.js +151 -0
  10. package/dist/document-amendment-store.js.map +1 -0
  11. package/dist/document-control-notifier.d.ts +29 -0
  12. package/dist/document-control-notifier.d.ts.map +1 -1
  13. package/dist/document-control-notifier.js +62 -6
  14. package/dist/document-control-notifier.js.map +1 -1
  15. package/dist/document-delivery-transport.d.ts +2 -1
  16. package/dist/document-delivery-transport.d.ts.map +1 -1
  17. package/dist/document-delivery-transport.js +1 -1
  18. package/dist/document-delivery-transport.js.map +1 -1
  19. package/dist/document-delivery.d.ts +6 -1
  20. package/dist/document-delivery.d.ts.map +1 -1
  21. package/dist/document-delivery.js +167 -201
  22. package/dist/document-delivery.js.map +1 -1
  23. package/dist/document-frame-router.d.ts +15 -1
  24. package/dist/document-frame-router.d.ts.map +1 -1
  25. package/dist/document-frame-router.js +22 -1
  26. package/dist/document-frame-router.js.map +1 -1
  27. package/dist/document-gate.js +1 -1
  28. package/dist/document-gate.js.map +1 -1
  29. package/dist/document-handlers.d.ts.map +1 -1
  30. package/dist/document-handlers.js +649 -5
  31. package/dist/document-handlers.js.map +1 -1
  32. package/dist/document-handshake.d.ts +13 -0
  33. package/dist/document-handshake.d.ts.map +1 -1
  34. package/dist/document-handshake.js +24 -0
  35. package/dist/document-handshake.js.map +1 -1
  36. package/dist/document-inbound.d.ts +12 -0
  37. package/dist/document-inbound.d.ts.map +1 -1
  38. package/dist/document-inbound.js +141 -10
  39. package/dist/document-inbound.js.map +1 -1
  40. package/dist/document-join-store.d.ts +80 -0
  41. package/dist/document-join-store.d.ts.map +1 -0
  42. package/dist/document-join-store.js +197 -0
  43. package/dist/document-join-store.js.map +1 -0
  44. package/dist/document-layer.d.ts +52 -2
  45. package/dist/document-layer.d.ts.map +1 -1
  46. package/dist/document-layer.js +457 -12
  47. package/dist/document-layer.js.map +1 -1
  48. package/dist/document-lifecycle.d.ts +32 -1
  49. package/dist/document-lifecycle.d.ts.map +1 -1
  50. package/dist/document-lifecycle.js +88 -20
  51. package/dist/document-lifecycle.js.map +1 -1
  52. package/dist/document-publish.d.ts +7 -0
  53. package/dist/document-publish.d.ts.map +1 -1
  54. package/dist/document-publish.js +20 -4
  55. package/dist/document-publish.js.map +1 -1
  56. package/dist/document-rejection.js +1 -1
  57. package/dist/document-rejection.js.map +1 -1
  58. package/dist/document-store.d.ts +97 -4
  59. package/dist/document-store.d.ts.map +1 -1
  60. package/dist/document-store.js +335 -21
  61. package/dist/document-store.js.map +1 -1
  62. package/dist/vocabulary.d.ts.map +1 -1
  63. package/dist/vocabulary.js +2 -0
  64. package/dist/vocabulary.js.map +1 -1
  65. package/package.json +5 -5
@@ -25,7 +25,7 @@
25
25
  */
26
26
  import { randomBytes, randomUUID } from "node:crypto";
27
27
  import * as Y from "yjs";
28
- import { encodeDocumentProposal, buildDocumentProposalTbs, documentIdFromProposal, seamViolation, ASSURANCE_TIER_V1, TOPOLOGY_V1, DOCUMENT_FEATURE_VERSION, encodeDocumentProposalAck, buildDocumentProposalAckTbs, DOCUMENT_PROPOSAL_ACK_VERSION, MAX_PROPOSAL_REFUSAL_REASON_LENGTH, } from "@cello-protocol/protocol-types";
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
29
  import { lineHunks, isSupportedDocumentType, SUPPORTED_DOCUMENT_TYPES } from "./document-write-path.js";
30
30
  import { openingNoticeFor, rootForDocumentType } from "./document-types.js";
31
31
  import { projectDocumentText, parseJsonDocument, applyJsonToMap } from "./document-json.js";
@@ -232,11 +232,38 @@ export function registerDocumentHandlers(deps) {
232
232
  }
233
233
  startingContent = Y.encodeStateAsUpdate(seed);
234
234
  }
235
+ // THE ADMIN SET IS ALWAYS WRITTEN, NEVER SILENTLY ABSENT (GOVERN-1: the creation flow makes
236
+ // the choice legible). No `admins` param means EVERYONE — both genesis participants — and
237
+ // that default is recorded explicitly in the signed proposal rather than implied by an
238
+ // absent field, so the invitee consents to a stated rule, not a convention.
239
+ const rawAdmins = params?.admins;
240
+ if (rawAdmins !== undefined && (!Array.isArray(rawAdmins) || rawAdmins.length === 0)) {
241
+ return {
242
+ ok: false,
243
+ reason: "document_admins_invalid",
244
+ guidance: "admins must be a non-empty list of 64-hex pubkeys, or omitted for everyone-is-admin. " +
245
+ "Nothing was created.",
246
+ };
247
+ }
248
+ const adminSet = rawAdmins === undefined
249
+ ? [who.ownerAgentId, peerAgentId]
250
+ : [...new Set(rawAdmins)];
251
+ for (const admin of adminSet) {
252
+ if (admin !== who.ownerAgentId && admin !== peerAgentId) {
253
+ return {
254
+ ok: false,
255
+ reason: "document_admins_invalid",
256
+ guidance: `${String(admin).slice(0, 16)}… is not a party to this document — a creation admin ` +
257
+ `must be you or the counterparty (admins are always holders). Nothing was created.`,
258
+ };
259
+ }
260
+ }
235
261
  const properties = {
236
262
  assurance_tier: ASSURANCE_TIER_V1,
237
263
  schema_enforcement: false,
238
- topology: TOPOLOGY_V1,
264
+ topology: TOPOLOGY_DEFAULT,
239
265
  append_only: params?.append_only === true,
266
+ admin_set: adminSet,
240
267
  };
241
268
  const violation = seamViolation(properties);
242
269
  if (violation) {
@@ -402,8 +429,66 @@ export function registerDocumentHandlers(deps) {
402
429
  hasStartingContent: p.envelope.starting_content !== null,
403
430
  proposedAtMs: p.envelope.proposed_at_ms,
404
431
  })),
432
+ // M14B / DOD-MP-JOIN-1 — offers to join an EXISTING document, decided with the same
433
+ // accept/refuse verbs (route by document_id). THE RULES ARE SHOWN HERE, derived by replay
434
+ // of the carried bytes — the operator consents to what their own daemon computed — and
435
+ // cello_doc_accept re-derives at the moment of consequence. An entry whose stored bytes no
436
+ // longer validate says so, never silently omitted.
437
+ joins: layer.joins.pendingFor(who.ownerAgentId).map((j) => {
438
+ const derived = validateDocumentJoinOffer(j.offer, documentGovernancePolicy, layer.verifySignature);
439
+ return {
440
+ documentId: j.documentId,
441
+ inviterAgentId: j.inviterAgentId,
442
+ amendmentHash: j.amendmentHash,
443
+ offeredAtMs: j.offer.offered_at_ms,
444
+ carriedEnvelopes: j.offer.envelope_log.length,
445
+ ...(derived.ok
446
+ ? {
447
+ participants: [...derived.arrangement.participants].sort(),
448
+ admins: [...derived.arrangement.admins].sort(),
449
+ properties: derived.arrangement.properties,
450
+ assuranceTier: derived.genesis.properties.assurance_tier,
451
+ documentType: derived.genesis.document_type,
452
+ }
453
+ : { invalid: derived.reason }),
454
+ };
455
+ }),
456
+ // Auto-refused offers (version mismatch, chains that do not replay) — recorded with the
457
+ // sentence written for the operator, and listed so it actually reaches one.
458
+ refusedJoins: layer.joins.refusedFor(who.ownerAgentId).map((j) => ({
459
+ documentId: j.documentId,
460
+ inviterAgentId: j.inviterAgentId,
461
+ reason: j.reason,
462
+ offeredAtMs: j.offer.offered_at_ms,
463
+ })),
405
464
  };
406
465
  });
466
+ /**
467
+ * Tell the INVITER what was decided — the join twin of `tellProposer`, and the same doctrine:
468
+ * best-effort, because consent is local and final the moment the operator makes it, and an
469
+ * unreachable inviter must not get a veto over the invitee's choice.
470
+ */
471
+ async function tellInviter(who, documentId, inviterAgentId, answerBytes) {
472
+ try {
473
+ const sent = await deps.transportFor(who.agentName).sendBytes({
474
+ peerAgentId: inviterAgentId,
475
+ documentId,
476
+ bytes: answerBytes,
477
+ correlationId: randomUUID(),
478
+ });
479
+ if (!sent.ok) {
480
+ logger.warn("document.join.answer_unsent", { documentId, reason: sent.reason });
481
+ }
482
+ return sent.ok;
483
+ }
484
+ catch (err) {
485
+ logger.warn("document.join.answer_send_threw", {
486
+ documentId,
487
+ error: err instanceof Error ? err.message : String(err),
488
+ });
489
+ return false;
490
+ }
491
+ }
407
492
  handlers.set("cello_doc_accept", async (params, connectionId) => {
408
493
  const who = resolve(params, connectionId);
409
494
  if (isRefusal(who))
@@ -412,6 +497,31 @@ export function registerDocumentHandlers(deps) {
412
497
  if (documentId.length === 0) {
413
498
  return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_inbox." };
414
499
  }
500
+ // A join offer decided with the SAME verb the inbox advertises. Routed first by exact
501
+ // pending match, so a proposal and a join for different documents can never shadow each
502
+ // other; an id that matches neither falls through to the proposal path's own refusal.
503
+ const pendingJoin = layer.joins
504
+ .pendingFor(who.ownerAgentId)
505
+ .find((j) => j.documentId === documentId);
506
+ if (pendingJoin) {
507
+ const joined = await layer.acceptJoin(who.ownerAgentId, pendingJoin.amendmentHash, deps.now());
508
+ if (!joined.ok)
509
+ return { ok: false, reason: joined.reason, guidance: joined.detail };
510
+ const joinFile = await materialize(who.ownerAgentId, documentId, joined.documentType);
511
+ const told = await tellInviter(who, documentId, joined.inviterAgentId, joined.answerBytes);
512
+ return {
513
+ ok: true,
514
+ documentId,
515
+ joined: true,
516
+ peerAgentId: joined.inviterAgentId,
517
+ inviterNotified: told,
518
+ appliedEnvelopes: joined.applied,
519
+ filePath: joinFile.path,
520
+ ...(joinFile.path !== null && openingNoticeFor(joined.documentType) !== undefined
521
+ ? { fileNotice: openingNoticeFor(joined.documentType) }
522
+ : {}),
523
+ };
524
+ }
415
525
  const outcome = layer.handshake.accept(who.ownerAgentId, documentId, deps.now());
416
526
  if (!outcome.ok)
417
527
  return { ok: false, reason: outcome.reason, guidance: outcome.detail };
@@ -469,6 +579,418 @@ export function registerDocumentHandlers(deps) {
469
579
  : {}),
470
580
  };
471
581
  });
582
+ /**
583
+ * M14B / DOD-MP-JOIN-1 — invite a third party into an existing document.
584
+ *
585
+ * One admin's signature authors the admitting amendment (D2); the invitee's own consent makes
586
+ * the join real (their accept, on their daemon). VALIDATE-THEN-APPEND: the chain including the
587
+ * new amendment replays through the real policy before one byte lands — the AMEND-1 standing
588
+ * condition at its second production append site. The offer carries the genesis, the whole
589
+ * chain, and the update-log snapshot re-encoded from rows (lossless: the client id is a
590
+ * column, the encoding a pinned constant — the same re-encode the delivery path ships).
591
+ * Existing holders get the amendment frame BEST-EFFORT at P1; the epoch gate makes a missed
592
+ * one loud, and durable per-holder delivery is FANOUT-1.
593
+ */
594
+ handlers.set("cello_doc_invite", async (params, connectionId) => {
595
+ const who = resolve(params, connectionId);
596
+ if (isRefusal(who))
597
+ return who;
598
+ const documentId = typeof params?.document_id === "string" ? params.document_id : "";
599
+ const invitee = typeof params?.invitee_pubkey === "string" ? params.invitee_pubkey : "";
600
+ if (documentId.length === 0) {
601
+ return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_list." };
602
+ }
603
+ if (!/^[0-9a-f]{64}$/.test(invitee)) {
604
+ return {
605
+ ok: false,
606
+ reason: "invalid_invitee_pubkey",
607
+ guidance: "invitee_pubkey must be the 64-hex agent id — see cello_contacts.",
608
+ };
609
+ }
610
+ const doc = layer.store.getDocument(who.ownerAgentId, documentId);
611
+ if (!doc) {
612
+ return { ok: false, reason: "document_unknown", guidance: `No document ${documentId.slice(0, 16)}… for this agent.` };
613
+ }
614
+ if (doc.status !== "active") {
615
+ return {
616
+ ok: false,
617
+ reason: "document_not_active",
618
+ guidance: `This document is ${doc.status} — only an active document can admit a holder.`,
619
+ };
620
+ }
621
+ const genesisRecord = layer.handshake.get(who.ownerAgentId, documentId);
622
+ if (!genesisRecord) {
623
+ return {
624
+ ok: false,
625
+ reason: "document_genesis_missing",
626
+ guidance: "The document has a row but no stored genesis proposal to replay from — this is a local-state fault, not the peer's.",
627
+ };
628
+ }
629
+ const genesisArr = arrangementGenesisFromProposal(genesisRecord.envelope);
630
+ const chain = layer.amendments.chain(who.ownerAgentId, documentId);
631
+ const derived = deriveArrangement(genesisArr, chain, documentGovernancePolicy, layer.verifySignature);
632
+ if (!derived.ok) {
633
+ return { ok: false, reason: "document_chain_invalid", guidance: derived.reason };
634
+ }
635
+ if (!derived.arrangement.admins.has(who.ownerAgentId)) {
636
+ return {
637
+ ok: false,
638
+ reason: "document_not_admin",
639
+ guidance: `Inviting takes an admin's signature and this agent holds no admin power here. ` +
640
+ `Current admins: ${[...derived.arrangement.admins].join(", ")}.`,
641
+ };
642
+ }
643
+ if (derived.arrangement.participants.has(invitee)) {
644
+ // A RE-RUN after the amendment landed but the offer did not reach them: re-send the STORED
645
+ // offer bytes (the proposal --retry precedent) rather than refusing — authoring afresh
646
+ // would try to admit a holder the chain already admitted, which the replay refuses.
647
+ const admitting = chain.find((e) => e.body.kind === "add_holder" && e.body.subject_agent_id === invitee);
648
+ const priorHash = admitting
649
+ ? Buffer.from(documentAmendmentHash(admitting.body)).toString("hex")
650
+ : null;
651
+ const outgoing = priorHash ? layer.joins.get(who.ownerAgentId, priorHash) : null;
652
+ if (outgoing && outgoing.role === "inviter" && outgoing.state === "pending") {
653
+ let resent = false;
654
+ try {
655
+ const sent = await deps.transportFor(who.agentName).sendBytes({
656
+ peerAgentId: invitee,
657
+ documentId,
658
+ bytes: new Uint8Array(encodeDocumentJoinOffer(outgoing.offer)),
659
+ correlationId: randomUUID(),
660
+ });
661
+ resent = sent.ok;
662
+ }
663
+ catch (err) {
664
+ logger.warn("document.join.offer_resend_threw", {
665
+ documentId, error: err instanceof Error ? err.message : String(err),
666
+ });
667
+ }
668
+ // AND THE STALE HOLDERS. A holder who missed the amendment fan-out is wedged — their
669
+ // publishes refuse as epoch-stale with guidance they cannot follow, because no verb
670
+ // re-sent the amendment. The re-invite is the healing verb: it re-fans the admitting
671
+ // amendment to every other current holder, best-effort, reported per holder.
672
+ 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
+ }
691
+ }
692
+ return {
693
+ ok: true,
694
+ documentId,
695
+ inviteeAgentId: invitee,
696
+ amendmentHash: priorHash,
697
+ resent: true,
698
+ offerSent: resent,
699
+ holdersNotified,
700
+ };
701
+ }
702
+ return {
703
+ ok: false,
704
+ reason: "document_already_holder",
705
+ guidance: "That agent already holds this document — there is nothing to invite them to.",
706
+ };
707
+ }
708
+ const body = {
709
+ document_id: documentId,
710
+ epoch_id: derived.arrangement.epoch + 1,
711
+ prev_amendment_hash: derived.arrangement.lastAmendmentHash,
712
+ kind: "add_holder",
713
+ subject_agent_id: invitee,
714
+ property_change: null,
715
+ state_hash: null,
716
+ authored_at_ms: deps.now(),
717
+ };
718
+ const amendHash = documentAmendmentHash(body);
719
+ const multisigTbs = buildDocumentMultisigTbs({
720
+ document_id: documentId,
721
+ subject_kind: "document_amendment",
722
+ subject_hash: amendHash,
723
+ required_signers: [who.ownerAgentId],
724
+ });
725
+ const amendment = {
726
+ body,
727
+ collection: {
728
+ document_id: documentId,
729
+ subject_kind: "document_amendment",
730
+ subject_hash: amendHash,
731
+ required_signers: [who.ownerAgentId],
732
+ signatures: [
733
+ { signer_agent_id: who.ownerAgentId, signature: await deps.sign(who.agentName, multisigTbs) },
734
+ ],
735
+ },
736
+ };
737
+ // VALIDATE-BEFORE-APPEND, on the exact bytes about to land.
738
+ const withNew = deriveArrangement(genesisArr, [...chain, amendment], documentGovernancePolicy, layer.verifySignature);
739
+ if (!withNew.ok) {
740
+ return { ok: false, reason: "document_amendment_invalid", guidance: withNew.reason };
741
+ }
742
+ const amendmentBytes = new Uint8Array(encodeDocumentAmendment(amendment));
743
+ layer.amendments.append(who.ownerAgentId, documentId, amendmentBytes, deps.now());
744
+ // The snapshot: update rows only, re-encoded losslessly. Rejection records stay local —
745
+ // quarantine bridging is receiver-side state, not shared history.
746
+ const log = layer.store.getEnvelopeLog(who.ownerAgentId, documentId);
747
+ const snapshot = [];
748
+ for (const row of log) {
749
+ if (row.kind !== "update" || row.payload === null)
750
+ continue;
751
+ snapshot.push(new Uint8Array(encodeDocumentUpdateEnvelope({
752
+ type: "document_update",
753
+ document_id: row.documentId,
754
+ epoch_id: row.epochId,
755
+ doc_prev_hash: row.docPrevHash,
756
+ sender_agent_id: row.senderAgentId,
757
+ sender_client_id: row.senderClientId ?? 0,
758
+ update_encoding: DOCUMENT_UPDATE_ENCODING_V1,
759
+ state_vector: row.stateVector,
760
+ update: row.payload,
761
+ signature: row.signature,
762
+ })));
763
+ }
764
+ const offer = {
765
+ type: "document_join_offer",
766
+ feature_version: DOCUMENT_FEATURE_VERSION,
767
+ inviter_agent_id: who.ownerAgentId,
768
+ invitee_agent_id: invitee,
769
+ document_id: documentId,
770
+ genesis: new Uint8Array(encodeDocumentProposal(genesisRecord.envelope)),
771
+ amendments: [...chain.map((e) => new Uint8Array(encodeDocumentAmendment(e))), amendmentBytes],
772
+ envelope_log: snapshot,
773
+ offered_at_ms: deps.now(),
774
+ signature: new Uint8Array(0),
775
+ };
776
+ offer.signature = await deps.sign(who.agentName, buildDocumentJoinOfferTbs(offer));
777
+ const wire = new Uint8Array(encodeDocumentJoinOffer(offer));
778
+ const amendHashHex = Buffer.from(amendHash).toString("hex");
779
+ layer.joins.recordOutgoing(who.ownerAgentId, wire, amendHashHex, deps.now());
780
+ // The offer to the invitee, then the amendment to every OTHER current holder — both
781
+ // best-effort at P1, each reported as a fact rather than assumed.
782
+ let offerSent = false;
783
+ try {
784
+ const sent = await deps.transportFor(who.agentName).sendBytes({
785
+ peerAgentId: invitee, documentId, bytes: wire, correlationId: randomUUID(),
786
+ });
787
+ offerSent = sent.ok;
788
+ if (!sent.ok)
789
+ logger.warn("document.join.offer_unsent", { documentId, reason: sent.reason });
790
+ }
791
+ catch (err) {
792
+ logger.warn("document.join.offer_send_threw", {
793
+ documentId, error: err instanceof Error ? err.message : String(err),
794
+ });
795
+ }
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
+ }
810
+ logger.info("document.join.invited", { documentId, invitee, epochId: body.epoch_id, offerSent });
811
+ return {
812
+ ok: true,
813
+ documentId,
814
+ inviteeAgentId: invitee,
815
+ amendmentHash: amendHashHex,
816
+ epochId: body.epoch_id,
817
+ offerSent,
818
+ holdersNotified: holdersTold,
819
+ ...(offerSent
820
+ ? {}
821
+ : {
822
+ guidance: "The amendment is recorded and the invitation exists, but the offer did not reach " +
823
+ "the invitee — they may be offline. Re-run cello_doc_invite with the same invitee " +
824
+ "once they are reachable: it re-sends this exact offer rather than authoring a " +
825
+ "second amendment.",
826
+ }),
827
+ };
828
+ });
829
+ /**
830
+ * DOD-MP-REMOVE-1 — remove a holder, forward-only. Two shapes, one verb: an admin removing a
831
+ * non-admin holder, and a holder removing THEMSELVES (voluntary leave — always theirs, per
832
+ * D3). What removal means is exactly and only: delivery to them stops and their new edits
833
+ * refuse naming the removal. Their copy is theirs forever — no surface claims more. Removing
834
+ * a fellow ADMIN refuses here the way the policy refuses it everywhere (demote first, under
835
+ * remove_admin's all-others rule — whose cross-daemon signature gathering is a parked design
836
+ * note, Entry 10).
837
+ */
838
+ handlers.set("cello_doc_remove", async (params, connectionId) => {
839
+ const who = resolve(params, connectionId);
840
+ if (isRefusal(who))
841
+ return who;
842
+ const documentId = typeof params?.document_id === "string" ? params.document_id : "";
843
+ const holder = typeof params?.holder_pubkey === "string" ? params.holder_pubkey : "";
844
+ if (documentId.length === 0) {
845
+ return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_list." };
846
+ }
847
+ if (!/^[0-9a-f]{64}$/.test(holder)) {
848
+ return {
849
+ ok: false,
850
+ reason: "invalid_holder_pubkey",
851
+ guidance: "holder_pubkey must be the 64-hex agent id of a current holder.",
852
+ };
853
+ }
854
+ const doc = layer.store.getDocument(who.ownerAgentId, documentId);
855
+ if (!doc) {
856
+ return { ok: false, reason: "document_unknown", guidance: `No document ${documentId.slice(0, 16)}… for this agent.` };
857
+ }
858
+ const genesisRecord = layer.handshake.get(who.ownerAgentId, documentId);
859
+ if (!genesisRecord) {
860
+ return {
861
+ ok: false,
862
+ reason: "document_genesis_missing",
863
+ guidance: "The document has a row but no stored genesis proposal to replay from.",
864
+ };
865
+ }
866
+ const genesisArr = arrangementGenesisFromProposal(genesisRecord.envelope);
867
+ const chain = layer.amendments.chain(who.ownerAgentId, documentId);
868
+ const derived = deriveArrangement(genesisArr, chain, documentGovernancePolicy, layer.verifySignature);
869
+ if (!derived.ok) {
870
+ return { ok: false, reason: "document_chain_invalid", guidance: derived.reason };
871
+ }
872
+ if (!derived.arrangement.participants.has(holder)) {
873
+ // ALREADY REMOVED is the HEALING path, not a refusal (REMOVE-1 review F3): a holder who
874
+ // was offline at removal time never learned, and no other verb can ever re-send the
875
+ // removal amendment — a second cello_doc_remove is the invite-retry precedent. A subject
876
+ // the chain never touched still refuses.
877
+ const membership = layer.amendments.membershipOf(who.ownerAgentId, documentId, holder);
878
+ if (membership.state === "removed") {
879
+ const removal = chain.find((e) => e.body.kind === "remove_holder" &&
880
+ e.body.subject_agent_id === holder &&
881
+ e.body.epoch_id === membership.epochId);
882
+ const resendTold = {};
883
+ if (removal) {
884
+ 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) {
888
+ try {
889
+ const sent = await deps.transportFor(who.agentName).sendBytes({
890
+ peerAgentId: member, documentId, bytes, correlationId: randomUUID(),
891
+ });
892
+ resendTold[member] = sent.ok;
893
+ }
894
+ catch {
895
+ resendTold[member] = false;
896
+ }
897
+ }
898
+ }
899
+ return {
900
+ ok: true,
901
+ documentId,
902
+ removedAgentId: holder,
903
+ resent: true,
904
+ epochId: membership.epochId,
905
+ holdersNotified: resendTold,
906
+ };
907
+ }
908
+ return {
909
+ ok: false,
910
+ reason: "document_not_holder",
911
+ guidance: "That agent does not hold this document — there is nobody to remove.",
912
+ };
913
+ }
914
+ const body = {
915
+ document_id: documentId,
916
+ epoch_id: derived.arrangement.epoch + 1,
917
+ prev_amendment_hash: derived.arrangement.lastAmendmentHash,
918
+ kind: "remove_holder",
919
+ subject_agent_id: holder,
920
+ property_change: null,
921
+ state_hash: null,
922
+ authored_at_ms: deps.now(),
923
+ };
924
+ const amendHash = documentAmendmentHash(body);
925
+ const multisigTbs = buildDocumentMultisigTbs({
926
+ document_id: documentId,
927
+ subject_kind: "document_amendment",
928
+ subject_hash: amendHash,
929
+ required_signers: [who.ownerAgentId],
930
+ });
931
+ const amendment = {
932
+ body,
933
+ collection: {
934
+ document_id: documentId,
935
+ subject_kind: "document_amendment",
936
+ subject_hash: amendHash,
937
+ required_signers: [who.ownerAgentId],
938
+ signatures: [
939
+ { signer_agent_id: who.ownerAgentId, signature: await deps.sign(who.agentName, multisigTbs) },
940
+ ],
941
+ },
942
+ };
943
+ // VALIDATE-BEFORE-APPEND — the policy rules here: a non-admin removing someone else, or any
944
+ // single admin trying to expel a fellow admin through the holder door, refuses with the
945
+ // policy's own sentence. Voluntary self-leave passes for anyone.
946
+ const withNew = deriveArrangement(genesisArr, [...chain, amendment], documentGovernancePolicy, layer.verifySignature);
947
+ if (!withNew.ok) {
948
+ return { ok: false, reason: "document_amendment_invalid", guidance: withNew.reason };
949
+ }
950
+ const amendmentBytes = new Uint8Array(encodeDocumentAmendment(amendment));
951
+ layer.amendments.append(who.ownerAgentId, documentId, amendmentBytes, deps.now());
952
+ // The amendment travels to EVERY current holder INCLUDING the removed one — being told is
953
+ // how their daemon surfaces the removal to their operator. Best-effort at P1, per holder,
954
+ // reported never assumed.
955
+ const holdersTold = {};
956
+ for (const member of withNew.arrangement.participants) {
957
+ if (member === who.ownerAgentId)
958
+ continue;
959
+ try {
960
+ const sent = await deps.transportFor(who.agentName).sendBytes({
961
+ peerAgentId: member, documentId, bytes: amendmentBytes, correlationId: randomUUID(),
962
+ });
963
+ holdersTold[member] = sent.ok;
964
+ }
965
+ catch {
966
+ holdersTold[member] = false;
967
+ }
968
+ }
969
+ if (holder !== who.ownerAgentId) {
970
+ try {
971
+ const sent = await deps.transportFor(who.agentName).sendBytes({
972
+ peerAgentId: holder, documentId, bytes: amendmentBytes, correlationId: randomUUID(),
973
+ });
974
+ holdersTold[holder] = sent.ok;
975
+ }
976
+ catch {
977
+ holdersTold[holder] = false;
978
+ }
979
+ }
980
+ logger.info("document.holder_removed", {
981
+ documentId, holder, epochId: body.epoch_id, voluntary: holder === who.ownerAgentId,
982
+ });
983
+ return {
984
+ ok: true,
985
+ documentId,
986
+ removedAgentId: holder,
987
+ voluntary: holder === who.ownerAgentId,
988
+ epochId: body.epoch_id,
989
+ holdersNotified: holdersTold,
990
+ guidance: "Removal is forward-only: their existing copy and its history remain theirs — new edits " +
991
+ "simply no longer flow either way.",
992
+ };
993
+ });
472
994
  handlers.set("cello_doc_refuse", async (params, connectionId) => {
473
995
  const who = resolve(params, connectionId);
474
996
  if (isRefusal(who))
@@ -482,6 +1004,16 @@ export function registerDocumentHandlers(deps) {
482
1004
  if (documentId.length === 0) {
483
1005
  return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_inbox." };
484
1006
  }
1007
+ const pendingJoinRefusal = layer.joins
1008
+ .pendingFor(who.ownerAgentId)
1009
+ .find((j) => j.documentId === documentId);
1010
+ if (pendingJoinRefusal) {
1011
+ const refused = await layer.refuseJoin(who.ownerAgentId, pendingJoinRefusal.amendmentHash, reason, deps.now());
1012
+ if (!refused.ok)
1013
+ return { ok: false, reason: refused.reason, guidance: refused.detail };
1014
+ const told = await tellInviter(who, documentId, refused.inviterAgentId, refused.answerBytes);
1015
+ return { ok: true, documentId, joined: false, inviterNotified: told };
1016
+ }
485
1017
  const proposal = layer.handshake.get(who.ownerAgentId, documentId);
486
1018
  const outcome = layer.handshake.refuse(who.ownerAgentId, documentId, reason, deps.now());
487
1019
  if (!outcome.ok)
@@ -498,8 +1030,74 @@ export function registerDocumentHandlers(deps) {
498
1030
  const who = resolve(params, connectionId);
499
1031
  if (isRefusal(who))
500
1032
  return who;
1033
+ // M14B / DOD-MP-JOIN-1 — the inviter's view of every offer they authored: who has answered,
1034
+ // who is still thinking, and the refusal prose when there is one. Without this the answer
1035
+ // arrived, flipped a row, and reached nobody; a refused-but-admitted holder additionally
1036
+ // needs removing (REMOVE-1) and nothing else prompts it.
1037
+ // THE DERIVED ARRANGEMENT, per document (enforcer review G0). Until now nothing surfaced who
1038
+ // holds a document or who governs it: an operator could not answer "who is in this?", and
1039
+ // "all holders derive the same arrangement" — the governance line's headline claim — was
1040
+ // unassertable from outside the process. DERIVED here, never stored: each daemon computes it
1041
+ // from its OWN chain, which is exactly the property worth comparing across machines.
1042
+ const unavailable = (reason) => ({
1043
+ // THE KEYS ARE ALWAYS PRESENT, null on failure. Dropping them made `row.participants`
1044
+ // undefined, which a consumer coerces to [] and reads as "nobody holds this" — a
1045
+ // materially wrong answer to the question this surface exists to answer. `null` cannot
1046
+ // be mistaken for an empty membership; an absent key already was, in this unit's own
1047
+ // enforcer helper.
1048
+ participants: null,
1049
+ admins: null,
1050
+ properties: null,
1051
+ arrangementUnavailable: reason,
1052
+ });
1053
+ const arrangementFor = (documentId, genesisRecord) => {
1054
+ // The SAME name the invite path uses for the same fault, carrying the same sentence —
1055
+ // one condition should not have two names, and the one an operator reads should be the
1056
+ // one that says whose fault it is.
1057
+ if (!genesisRecord) {
1058
+ return unavailable("document_genesis_missing: the document has a row but no stored genesis proposal to " +
1059
+ "replay from — this is a local-state fault, not the peer's");
1060
+ }
1061
+ // CONTAINED. `chain()` decodes every stored amendment and `documentIdFromProposal` parses
1062
+ // the genesis — both THROW on bytes this build cannot read (a client downgrade past an
1063
+ // amendment kind is the reachable case). Uncontained, that throw escapes the row, escapes
1064
+ // the map, and the operator asking "what documents do I have?" gets NOTHING because one
1065
+ // chain would not decode.
1066
+ try {
1067
+ const derived = deriveArrangement(arrangementGenesisFromProposal(genesisRecord.envelope), layer.amendments.chain(who.ownerAgentId, documentId), documentGovernancePolicy, layer.verifySignature);
1068
+ if (!derived.ok)
1069
+ return unavailable(derived.reason);
1070
+ return {
1071
+ participants: [...derived.arrangement.participants].sort(),
1072
+ admins: [...derived.arrangement.admins].sort(),
1073
+ properties: derived.arrangement.properties,
1074
+ // WAS THE ADMIN SET DECLARED, OR DEFAULTED? A genesis from before the admin slot
1075
+ // existed carries none, and the replay hands both parties admin power. Rendering that
1076
+ // identically to a declared set would have this surface state as agreed fact something
1077
+ // the code decided — so it says which it is.
1078
+ adminSetDefaulted: genesisRecord.envelope.properties.admin_set === undefined,
1079
+ };
1080
+ }
1081
+ catch (err) {
1082
+ return unavailable(`document_chain_undecodable: ${err instanceof Error ? err.message : String(err)}`);
1083
+ }
1084
+ };
1085
+ const outgoingJoins = layer.joins.outgoingFor(who.ownerAgentId).map((j) => ({
1086
+ documentId: j.documentId,
1087
+ inviteeAgentId: j.inviteeAgentId,
1088
+ state: j.state,
1089
+ ...(j.reason ? { reason: j.reason } : {}),
1090
+ ...(j.state === "refused"
1091
+ ? {
1092
+ guidance: "The invitee declined but the admitting amendment is already in the chain — they " +
1093
+ "count as a holder until removed. The removal verb ships in the next unit of this " +
1094
+ "milestone; nothing needs doing meanwhile.",
1095
+ }
1096
+ : {}),
1097
+ }));
501
1098
  return {
502
1099
  ok: true,
1100
+ ...(outgoingJoins.length > 0 ? { joinOffers: outgoingJoins } : {}),
503
1101
  documents: layer.lifecycle.list(who.ownerAgentId, deps.now()).map((d) => {
504
1102
  // WHOSE OFFER WAS IT, and has the other side actually shown up?
505
1103
  //
@@ -527,6 +1125,10 @@ export function registerDocumentHandlers(deps) {
527
1125
  // in it is a fine state; it is just not the same state.
528
1126
  peerHasPublished: layer.store.knownEnvelopeHashesBySender(who.ownerAgentId, d.documentId, d.peerAgentId).size > 0,
529
1127
  consentState: proposal?.consentState ?? null,
1128
+ // WHO HOLDS IT AND WHO GOVERNS IT — derived from THIS daemon's own chain (G0).
1129
+ // `proposal` is passed rather than re-fetched: it is the same SQL read and the same
1130
+ // CBOR decode of the same bytes, already in hand.
1131
+ ...arrangementFor(d.documentId, proposal),
530
1132
  // DID OUR OFFER LEAVE? Only meaningful for a document WE proposed — for one we accepted
531
1133
  // there is no offer of ours to have sent. Without this, `peerAccepted: null` meant both
532
1134
  // "they are thinking" and "they were never asked", and the shipped guidance said WAIT,
@@ -1096,11 +1698,12 @@ export function registerDocumentHandlers(deps) {
1096
1698
  documentId,
1097
1699
  status,
1098
1700
  peerNotified: outcome.peerNotified,
1701
+ holdersNotified: outcome.holdersNotified,
1099
1702
  ...(outcome.peerNotified
1100
1703
  ? {}
1101
1704
  : {
1102
1705
  reason: outcome.notifyReason,
1103
- guidance: notifyGuidance("close", outcome.notifyReason, outcome.notifyDetail),
1706
+ guidance: notifyGuidance("close", outcome.notifyReason, outcome.notifyDetail, outcome.holdersNotified),
1104
1707
  }),
1105
1708
  };
1106
1709
  });
@@ -1113,7 +1716,7 @@ export function registerDocumentHandlers(deps) {
1113
1716
  * DOD-DOC-TOOLS-1 review: an exit-point label standing in for a local fault, pointing at the
1114
1717
  * network.
1115
1718
  */
1116
- function notifyGuidance(verb, reason, detail) {
1719
+ function notifyGuidance(verb, reason, detail, holdersNotified) {
1117
1720
  const tail = verb === "close"
1118
1721
  ? `A close is not retried — run cello_doc_close again once this is cleared, or cello_doc_kill if you need it over now.`
1119
1722
  : `The kill stands locally either way; run cello_doc_kill again once this is cleared so they stop editing.`;
@@ -1126,6 +1729,46 @@ export function registerDocumentHandlers(deps) {
1126
1729
  return (`Your ${verb} was recorded, but this daemon does not hold the agent that owns the document, ` +
1127
1730
  `so it could not tell the peer. That is a local wiring fault, not the peer being away. ${tail}`);
1128
1731
  }
1732
+ // THE FOUR DERIVATION FAULTS (DOD-MP-CONTROL-N-1). Every one of these is a fact about THIS
1733
+ // machine's record of the document, and not one of them is fixed by the counterparty coming
1734
+ // online — which is what the generic sentence below tells the operator to wait for. That is the
1735
+ // exact defect this function's header says it was written to remove, reintroduced by four new
1736
+ // reasons that were never added to it.
1737
+ if (reason === "document_holders_underivable" || reason === "document_genesis_missing") {
1738
+ return (`Your ${verb} was recorded, but this daemon could not work out who currently holds the ` +
1739
+ `document, so it sent nothing to anyone. Waiting will not help — the signed history on this ` +
1740
+ `machine is the problem, not the network. Look for a "document.holders.underivable" line in ` +
1741
+ `the daemon log, which names the specific cause. ${tail}`);
1742
+ }
1743
+ if (reason?.startsWith("document_chain_undecodable")) {
1744
+ return (`Your ${verb} was recorded, but this build cannot read part of the document's signed ` +
1745
+ `history${detail ? ` (${detail})` : ""}, so it could not work out who to tell. That is ` +
1746
+ `usually a client older than the document — upgrading is the fix, not waiting. ${tail}`);
1747
+ }
1748
+ if (reason === "document_no_holders") {
1749
+ return (`Your ${verb} was recorded, and there is nobody left to tell — the signed history shows no ` +
1750
+ `other current holder of this document. Nothing is pending and nothing needs retrying.`);
1751
+ }
1752
+ // PARTIAL FAN-OUT is its own case and reads nothing like "the peer is offline". With three
1753
+ // holders, two hearing you and one not is the ordinary outcome, and the operator needs to know
1754
+ // WHICH one is still editing a document they think is live — a message saying "the peer" names
1755
+ // nobody when there are several.
1756
+ const missed = Object.entries(holdersNotified ?? {}).filter(([, told]) => !told).map(([h]) => h);
1757
+ const reached = Object.values(holdersNotified ?? {}).filter(Boolean).length;
1758
+ if (missed.length > 0 && reached > 0) {
1759
+ return (`Your ${verb} was recorded and reached ${reached} of ${reached + missed.length} other ` +
1760
+ `holders, but not ${missed.join(", ")} — they will keep editing a document the rest of you ` +
1761
+ `have ended. ${tail}`);
1762
+ }
1763
+ if (missed.length > 0) {
1764
+ // EVERY holder failed. This used to fall through to the generic sentence, which says "the
1765
+ // peer" and names nobody — unusable when there are several, and the per-holder transport
1766
+ // reasons live only in the log. It also arrives with no `reason` field at all, because the
1767
+ // notifier returned ok: signing and addressing both worked, the sends did not.
1768
+ return (`Your ${verb} was recorded but reached none of the ${missed.length} other ` +
1769
+ `holder${missed.length > 1 ? "s" : ""} (${missed.join(", ")}), so the document cannot ` +
1770
+ `settle until they hear it. They are most likely offline. ${tail}`);
1771
+ }
1129
1772
  return (`Your ${verb} was recorded but did not reach the peer${detail ? ` (${detail})` : ""}, so the ` +
1130
1773
  `document cannot settle until they hear it. ${tail}`);
1131
1774
  }
@@ -1148,12 +1791,13 @@ export function registerDocumentHandlers(deps) {
1148
1791
  ok: true,
1149
1792
  documentId,
1150
1793
  peerNotified: outcome.peerNotified,
1794
+ holdersNotified: outcome.holdersNotified,
1151
1795
  note: outcome.note,
1152
1796
  ...(outcome.peerNotified
1153
1797
  ? {}
1154
1798
  : {
1155
1799
  reason: outcome.notifyReason,
1156
- guidance: notifyGuidance("kill", outcome.notifyReason, outcome.notifyDetail),
1800
+ guidance: notifyGuidance("kill", outcome.notifyReason, outcome.notifyDetail, outcome.holdersNotified),
1157
1801
  }),
1158
1802
  };
1159
1803
  });