@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.
- package/dist/daemon.js +10 -4
- package/dist/daemon.js.map +1 -1
- package/dist/document-ack-inbound.d.ts +8 -1
- package/dist/document-ack-inbound.d.ts.map +1 -1
- package/dist/document-ack-inbound.js +31 -8
- package/dist/document-ack-inbound.js.map +1 -1
- package/dist/document-amendment-store.d.ts +72 -0
- package/dist/document-amendment-store.d.ts.map +1 -0
- package/dist/document-amendment-store.js +151 -0
- package/dist/document-amendment-store.js.map +1 -0
- package/dist/document-delivery-transport.d.ts +2 -1
- package/dist/document-delivery-transport.d.ts.map +1 -1
- package/dist/document-delivery-transport.js +1 -1
- package/dist/document-delivery-transport.js.map +1 -1
- package/dist/document-delivery.d.ts +6 -1
- package/dist/document-delivery.d.ts.map +1 -1
- package/dist/document-delivery.js +167 -201
- package/dist/document-delivery.js.map +1 -1
- package/dist/document-frame-router.d.ts +15 -1
- package/dist/document-frame-router.d.ts.map +1 -1
- package/dist/document-frame-router.js +22 -1
- package/dist/document-frame-router.js.map +1 -1
- package/dist/document-gate.js +1 -1
- package/dist/document-gate.js.map +1 -1
- package/dist/document-handlers.d.ts.map +1 -1
- package/dist/document-handlers.js +552 -2
- package/dist/document-handlers.js.map +1 -1
- package/dist/document-handshake.d.ts +13 -0
- package/dist/document-handshake.d.ts.map +1 -1
- package/dist/document-handshake.js +24 -0
- package/dist/document-handshake.js.map +1 -1
- package/dist/document-inbound.d.ts +12 -0
- package/dist/document-inbound.d.ts.map +1 -1
- package/dist/document-inbound.js +141 -10
- package/dist/document-inbound.js.map +1 -1
- package/dist/document-join-store.d.ts +80 -0
- package/dist/document-join-store.d.ts.map +1 -0
- package/dist/document-join-store.js +197 -0
- package/dist/document-join-store.js.map +1 -0
- package/dist/document-layer.d.ts +35 -1
- package/dist/document-layer.d.ts.map +1 -1
- package/dist/document-layer.js +417 -11
- package/dist/document-layer.js.map +1 -1
- package/dist/document-lifecycle.d.ts.map +1 -1
- package/dist/document-lifecycle.js +24 -4
- package/dist/document-lifecycle.js.map +1 -1
- package/dist/document-publish.d.ts +7 -0
- package/dist/document-publish.d.ts.map +1 -1
- package/dist/document-publish.js +20 -4
- package/dist/document-publish.js.map +1 -1
- package/dist/document-rejection.js +1 -1
- package/dist/document-rejection.js.map +1 -1
- package/dist/document-store.d.ts +97 -4
- package/dist/document-store.d.ts.map +1 -1
- package/dist/document-store.js +319 -21
- package/dist/document-store.js.map +1 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +2 -0
- package/dist/vocabulary.js.map +1 -1
- 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,
|
|
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:
|
|
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
|
//
|