@cello-protocol/daemon 0.0.160 → 0.0.162

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 (60) hide show
  1. package/dist/daemon.js +10 -4
  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-delivery-transport.d.ts +2 -1
  12. package/dist/document-delivery-transport.d.ts.map +1 -1
  13. package/dist/document-delivery-transport.js +1 -1
  14. package/dist/document-delivery-transport.js.map +1 -1
  15. package/dist/document-delivery.d.ts +6 -1
  16. package/dist/document-delivery.d.ts.map +1 -1
  17. package/dist/document-delivery.js +167 -201
  18. package/dist/document-delivery.js.map +1 -1
  19. package/dist/document-frame-router.d.ts +15 -1
  20. package/dist/document-frame-router.d.ts.map +1 -1
  21. package/dist/document-frame-router.js +22 -1
  22. package/dist/document-frame-router.js.map +1 -1
  23. package/dist/document-gate.js +1 -1
  24. package/dist/document-gate.js.map +1 -1
  25. package/dist/document-handlers.d.ts.map +1 -1
  26. package/dist/document-handlers.js +552 -2
  27. package/dist/document-handlers.js.map +1 -1
  28. package/dist/document-handshake.d.ts +13 -0
  29. package/dist/document-handshake.d.ts.map +1 -1
  30. package/dist/document-handshake.js +24 -0
  31. package/dist/document-handshake.js.map +1 -1
  32. package/dist/document-inbound.d.ts +12 -0
  33. package/dist/document-inbound.d.ts.map +1 -1
  34. package/dist/document-inbound.js +141 -10
  35. package/dist/document-inbound.js.map +1 -1
  36. package/dist/document-join-store.d.ts +80 -0
  37. package/dist/document-join-store.d.ts.map +1 -0
  38. package/dist/document-join-store.js +197 -0
  39. package/dist/document-join-store.js.map +1 -0
  40. package/dist/document-layer.d.ts +35 -1
  41. package/dist/document-layer.d.ts.map +1 -1
  42. package/dist/document-layer.js +417 -11
  43. package/dist/document-layer.js.map +1 -1
  44. package/dist/document-lifecycle.d.ts.map +1 -1
  45. package/dist/document-lifecycle.js +24 -4
  46. package/dist/document-lifecycle.js.map +1 -1
  47. package/dist/document-publish.d.ts +7 -0
  48. package/dist/document-publish.d.ts.map +1 -1
  49. package/dist/document-publish.js +20 -4
  50. package/dist/document-publish.js.map +1 -1
  51. package/dist/document-rejection.js +1 -1
  52. package/dist/document-rejection.js.map +1 -1
  53. package/dist/document-store.d.ts +97 -4
  54. package/dist/document-store.d.ts.map +1 -1
  55. package/dist/document-store.js +319 -21
  56. package/dist/document-store.js.map +1 -1
  57. package/dist/vocabulary.d.ts.map +1 -1
  58. package/dist/vocabulary.js +2 -0
  59. package/dist/vocabulary.js.map +1 -1
  60. 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,26 @@ 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
+ const outgoingJoins = layer.joins.outgoingFor(who.ownerAgentId).map((j) => ({
1038
+ documentId: j.documentId,
1039
+ inviteeAgentId: j.inviteeAgentId,
1040
+ state: j.state,
1041
+ ...(j.reason ? { reason: j.reason } : {}),
1042
+ ...(j.state === "refused"
1043
+ ? {
1044
+ guidance: "The invitee declined but the admitting amendment is already in the chain — they " +
1045
+ "count as a holder until removed. The removal verb ships in the next unit of this " +
1046
+ "milestone; nothing needs doing meanwhile.",
1047
+ }
1048
+ : {}),
1049
+ }));
501
1050
  return {
502
1051
  ok: true,
1052
+ ...(outgoingJoins.length > 0 ? { joinOffers: outgoingJoins } : {}),
503
1053
  documents: layer.lifecycle.list(who.ownerAgentId, deps.now()).map((d) => {
504
1054
  // WHOSE OFFER WAS IT, and has the other side actually shown up?
505
1055
  //