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