@cello-protocol/daemon 0.0.168 → 0.0.170
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/agent-id-migration.d.ts.map +1 -1
- package/dist/agent-id-migration.js +16 -0
- package/dist/agent-id-migration.js.map +1 -1
- package/dist/away-detection.d.ts +62 -15
- package/dist/away-detection.d.ts.map +1 -1
- package/dist/away-detection.js +77 -20
- package/dist/away-detection.js.map +1 -1
- package/dist/close-session-handler.d.ts.map +1 -1
- package/dist/close-session-handler.js +69 -3
- package/dist/close-session-handler.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +406 -303
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-open-registry.d.ts +92 -0
- package/dist/delivery-open-registry.d.ts.map +1 -0
- package/dist/delivery-open-registry.js +121 -0
- package/dist/delivery-open-registry.js.map +1 -0
- 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 +51 -53
- package/dist/document-delivery-transport.d.ts.map +1 -1
- package/dist/document-delivery-transport.js +16 -157
- 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 +462 -511
- 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 +49 -67
- package/dist/document-layer.d.ts.map +1 -1
- package/dist/document-layer.js +688 -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 -568
- 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 +124 -0
- package/dist/document-reconcile-scheduler.d.ts.map +1 -0
- package/dist/document-reconcile-scheduler.js +226 -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 -247
- package/dist/document-store.d.ts.map +1 -1
- package/dist/document-store.js +95 -827
- package/dist/document-store.js.map +1 -1
- package/dist/inbound-sessions.d.ts +7 -0
- package/dist/inbound-sessions.d.ts.map +1 -1
- package/dist/inbound-sessions.js +27 -6
- package/dist/inbound-sessions.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/dist/notification-handlers.d.ts.map +1 -1
- package/dist/notification-handlers.js +39 -2
- package/dist/notification-handlers.js.map +1 -1
- package/dist/session-content-handlers.d.ts.map +1 -1
- package/dist/session-content-handlers.js +134 -8
- package/dist/session-content-handlers.js.map +1 -1
- package/dist/session-node-manager.d.ts +153 -8
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +1376 -35
- package/dist/session-node-manager.js.map +1 -1
- package/dist/types.d.ts +64 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.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 -124
- package/dist/document-control-notifier.d.ts.map +0 -1
- package/dist/document-control-notifier.js +0 -176
- package/dist/document-control-notifier.js.map +0 -1
- package/dist/document-delivery.d.ts +0 -250
- package/dist/document-delivery.d.ts.map +0 -1
- package/dist/document-delivery.js +0 -608
- 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";
|
|
@@ -434,66 +433,40 @@ export function registerDocumentHandlers(deps) {
|
|
|
434
433
|
hasStartingContent: p.envelope.starting_content !== null,
|
|
435
434
|
proposedAtMs: p.envelope.proposed_at_ms,
|
|
436
435
|
})),
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
joins: layer.
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
:
|
|
459
|
-
|
|
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
|
+
];
|
|
460
466
|
}),
|
|
461
|
-
// Auto-refused offers (version mismatch, chains that do not replay) — recorded with the
|
|
462
|
-
// sentence written for the operator, and listed so it actually reaches one.
|
|
463
|
-
refusedJoins: layer.joins.refusedFor(who.ownerAgentId).map((j) => ({
|
|
464
|
-
documentId: j.documentId,
|
|
465
|
-
inviterAgentId: j.inviterAgentId,
|
|
466
|
-
reason: j.reason,
|
|
467
|
-
offeredAtMs: j.offer.offered_at_ms,
|
|
468
|
-
})),
|
|
469
467
|
};
|
|
470
468
|
});
|
|
471
|
-
|
|
472
|
-
* Tell the INVITER what was decided — the join twin of `tellProposer`, and the same doctrine:
|
|
473
|
-
* best-effort, because consent is local and final the moment the operator makes it, and an
|
|
474
|
-
* unreachable inviter must not get a veto over the invitee's choice.
|
|
475
|
-
*/
|
|
476
|
-
async function tellInviter(who, documentId, inviterAgentId, answerBytes) {
|
|
477
|
-
try {
|
|
478
|
-
const sent = await deps.transportFor(who.agentName).sendBytes({
|
|
479
|
-
peerAgentId: inviterAgentId,
|
|
480
|
-
documentId,
|
|
481
|
-
bytes: answerBytes,
|
|
482
|
-
correlationId: randomUUID(),
|
|
483
|
-
});
|
|
484
|
-
if (!sent.ok) {
|
|
485
|
-
logger.warn("document.join.answer_unsent", { documentId, reason: sent.reason });
|
|
486
|
-
}
|
|
487
|
-
return sent.ok;
|
|
488
|
-
}
|
|
489
|
-
catch (err) {
|
|
490
|
-
logger.warn("document.join.answer_send_threw", {
|
|
491
|
-
documentId,
|
|
492
|
-
error: err instanceof Error ? err.message : String(err),
|
|
493
|
-
});
|
|
494
|
-
return false;
|
|
495
|
-
}
|
|
496
|
-
}
|
|
469
|
+
// The join-answer frame died with D5 — the consent/refusal ENTRY is the answer, and it fans out.
|
|
497
470
|
handlers.set("cello_doc_accept", async (params, connectionId) => {
|
|
498
471
|
const who = resolve(params, connectionId);
|
|
499
472
|
if (isRefusal(who))
|
|
@@ -502,30 +475,46 @@ export function registerDocumentHandlers(deps) {
|
|
|
502
475
|
if (documentId.length === 0) {
|
|
503
476
|
return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_inbox." };
|
|
504
477
|
}
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
+
}
|
|
529
518
|
}
|
|
530
519
|
const outcome = layer.handshake.accept(who.ownerAgentId, documentId, deps.now());
|
|
531
520
|
if (!outcome.ok)
|
|
@@ -559,6 +548,15 @@ export function registerDocumentHandlers(deps) {
|
|
|
559
548
|
Y.applyUpdate(layer.live.get(who.ownerAgentId, documentId), outcome.envelope.starting_content);
|
|
560
549
|
}
|
|
561
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
|
+
}
|
|
562
560
|
// NOT re-logged here. `DocumentHandshake` already emits `document.proposal.accepted` for this
|
|
563
561
|
// exact fact, and two events for one act make every count of "how many were accepted" wrong
|
|
564
562
|
// depending on which name the query used.
|
|
@@ -568,6 +566,9 @@ export function registerDocumentHandlers(deps) {
|
|
|
568
566
|
documentId,
|
|
569
567
|
peerAgentId: outcome.envelope.proposer_agent_id,
|
|
570
568
|
proposerNotified: told,
|
|
569
|
+
...(consent.ok
|
|
570
|
+
? { consentEntry: consent.entryHash, consentDelivered: consent.holdersNotified }
|
|
571
|
+
: { consentUnrecorded: consent.reason }),
|
|
571
572
|
filePath: acceptFile.path,
|
|
572
573
|
// Matters MORE here than on propose: the type came from the PROPOSER's envelope, so the
|
|
573
574
|
// accepter is being handed an executable file they did not choose the format of.
|
|
@@ -601,7 +602,6 @@ export function registerDocumentHandlers(deps) {
|
|
|
601
602
|
* any envelope at this epoch or later, which proves they applied it.
|
|
602
603
|
*/
|
|
603
604
|
const fanOutAmendment = async (args) => {
|
|
604
|
-
layer.store.seedAmendmentDeliveries(args.ownerAgentId, args.documentId, args.amendmentHashHex, args.holders, deps.now());
|
|
605
605
|
const told = {};
|
|
606
606
|
for (const holder of args.holders) {
|
|
607
607
|
try {
|
|
@@ -614,10 +614,7 @@ export function registerDocumentHandlers(deps) {
|
|
|
614
614
|
// PARKED IS NOT NOTIFIED. The relay took it because the holder had no live counterparty.
|
|
615
615
|
const landed = sent.ok && sent.parked !== true;
|
|
616
616
|
told[holder] = landed;
|
|
617
|
-
if (landed) {
|
|
618
|
-
layer.store.markAmendmentSent(args.ownerAgentId, args.documentId, args.amendmentHashHex, holder, deps.now(), DELIVERY_ACK_TIMEOUT_MS);
|
|
619
|
-
}
|
|
620
|
-
else {
|
|
617
|
+
if (!landed) {
|
|
621
618
|
// NAMED, NOT JUST COUNTED. This used to record `false` with no log line anywhere, so the
|
|
622
619
|
// only trace of a lost membership change was a boolean inside an `ok: true` response.
|
|
623
620
|
logger.warn("document.amendment.holder_unnotified", {
|
|
@@ -645,17 +642,109 @@ export function registerDocumentHandlers(deps) {
|
|
|
645
642
|
}
|
|
646
643
|
return told;
|
|
647
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
|
+
};
|
|
648
740
|
/**
|
|
649
741
|
* M14B / DOD-MP-JOIN-1 — invite a third party into an existing document.
|
|
650
742
|
*
|
|
651
|
-
* One admin's signature authors the admitting
|
|
652
|
-
*
|
|
653
|
-
* new
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
* column, the encoding a pinned constant — the same re-encode the delivery path ships).
|
|
657
|
-
* Existing holders get the amendment frame BEST-EFFORT at P1; the epoch gate makes a missed
|
|
658
|
-
* 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.
|
|
659
748
|
*/
|
|
660
749
|
handlers.set("cello_doc_invite", async (params, connectionId) => {
|
|
661
750
|
const who = resolve(params, connectionId);
|
|
@@ -694,88 +783,70 @@ export function registerDocumentHandlers(deps) {
|
|
|
694
783
|
}
|
|
695
784
|
const genesisArr = arrangementGenesisFromProposal(genesisRecord.envelope);
|
|
696
785
|
const chain = layer.amendments.chain(who.ownerAgentId, documentId);
|
|
697
|
-
const derived =
|
|
786
|
+
const derived = deriveDocumentState(genesisArr, chain, documentGovernancePolicy, layer.verifySignature);
|
|
698
787
|
if (!derived.ok) {
|
|
699
788
|
return { ok: false, reason: "document_chain_invalid", guidance: derived.reason };
|
|
700
789
|
}
|
|
701
|
-
if (!derived.
|
|
790
|
+
if (!derived.state.admins.has(who.ownerAgentId)) {
|
|
702
791
|
return {
|
|
703
792
|
ok: false,
|
|
704
793
|
reason: "document_not_admin",
|
|
705
794
|
guidance: `Inviting takes an admin's signature and this agent holds no admin power here. ` +
|
|
706
|
-
`Current admins: ${[...derived.
|
|
795
|
+
`Current admins: ${[...derived.state.admins].join(", ")}.`,
|
|
707
796
|
};
|
|
708
797
|
}
|
|
709
|
-
if (derived.
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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.
|
|
713
811
|
const admitting = chain.find((e) => e.body.kind === "add_holder" && e.body.subject_agent_id === invitee);
|
|
714
812
|
const priorHash = admitting
|
|
715
813
|
? Buffer.from(documentAmendmentHash(admitting.body)).toString("hex")
|
|
716
814
|
: null;
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
documentId,
|
|
724
|
-
bytes: new Uint8Array(encodeDocumentJoinOffer(outgoing.offer)),
|
|
725
|
-
correlationId: randomUUID(),
|
|
726
|
-
});
|
|
727
|
-
resent = sent.ok;
|
|
728
|
-
}
|
|
729
|
-
catch (err) {
|
|
730
|
-
logger.warn("document.join.offer_resend_threw", {
|
|
731
|
-
documentId, error: err instanceof Error ? err.message : String(err),
|
|
732
|
-
});
|
|
733
|
-
}
|
|
734
|
-
// AND THE STALE HOLDERS. A holder who missed the amendment fan-out is wedged — their
|
|
735
|
-
// publishes refuse as epoch-stale with guidance they cannot follow, because no verb
|
|
736
|
-
// re-sent the amendment. The re-invite is the healing verb: it re-fans the admitting
|
|
737
|
-
// amendment to every other current holder, best-effort, reported per holder.
|
|
738
|
-
const admittingBytes = outgoing.offer.amendments[outgoing.offer.amendments.length - 1];
|
|
739
|
-
let holdersNotified = {};
|
|
740
|
-
// `priorHash` is non-null whenever `outgoing` is — the lookup is keyed by it — but the
|
|
741
|
-
// narrowing does not survive the branch, and an amendment seeded under a null key would be
|
|
742
|
-
// owed to a row nothing can ever join.
|
|
743
|
-
if (admittingBytes !== undefined && priorHash !== null) {
|
|
744
|
-
holdersNotified = await fanOutAmendment({
|
|
745
|
-
agentName: who.agentName,
|
|
746
|
-
ownerAgentId: who.ownerAgentId,
|
|
747
|
-
documentId,
|
|
748
|
-
amendmentHashHex: priorHash,
|
|
749
|
-
amendmentBytes: new Uint8Array(admittingBytes),
|
|
750
|
-
holders: [...derived.arrangement.participants].filter((holder) => holder !== who.ownerAgentId && holder !== invitee),
|
|
751
|
-
verb: "re-invite",
|
|
752
|
-
});
|
|
753
|
-
}
|
|
754
|
-
return {
|
|
755
|
-
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,
|
|
756
821
|
documentId,
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
};
|
|
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
|
+
});
|
|
763
827
|
}
|
|
764
828
|
return {
|
|
765
|
-
ok:
|
|
766
|
-
|
|
767
|
-
|
|
829
|
+
ok: true,
|
|
830
|
+
documentId,
|
|
831
|
+
inviteeAgentId: invitee,
|
|
832
|
+
amendmentHash: priorHash,
|
|
833
|
+
resent: true,
|
|
834
|
+
noticeSent: renotice.ok,
|
|
835
|
+
holdersNotified,
|
|
768
836
|
};
|
|
769
837
|
}
|
|
770
838
|
const body = {
|
|
771
839
|
document_id: documentId,
|
|
772
|
-
epoch_id: derived.arrangement.epoch + 1,
|
|
773
|
-
prev_amendment_hash: derived.arrangement.lastAmendmentHash,
|
|
774
840
|
kind: "add_holder",
|
|
775
841
|
subject_agent_id: invitee,
|
|
776
842
|
property_change: null,
|
|
777
843
|
state_hash: null,
|
|
778
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],
|
|
779
850
|
};
|
|
780
851
|
const amendHash = documentAmendmentHash(body);
|
|
781
852
|
const multisigTbs = buildDocumentMultisigTbs({
|
|
@@ -797,63 +868,33 @@ export function registerDocumentHandlers(deps) {
|
|
|
797
868
|
},
|
|
798
869
|
};
|
|
799
870
|
// VALIDATE-BEFORE-APPEND, on the exact bytes about to land.
|
|
800
|
-
const withNew =
|
|
871
|
+
const withNew = deriveDocumentState(genesisArr, [...chain, amendment], documentGovernancePolicy, layer.verifySignature);
|
|
801
872
|
if (!withNew.ok) {
|
|
802
873
|
return { ok: false, reason: "document_amendment_invalid", guidance: withNew.reason };
|
|
803
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
|
+
}
|
|
804
886
|
const amendmentBytes = new Uint8Array(encodeDocumentAmendment(amendment));
|
|
805
887
|
layer.amendments.append(who.ownerAgentId, documentId, amendmentBytes, deps.now());
|
|
806
|
-
// The snapshot: update rows only, re-encoded losslessly. Rejection records stay local —
|
|
807
|
-
// quarantine bridging is receiver-side state, not shared history.
|
|
808
|
-
const log = layer.store.getEnvelopeLog(who.ownerAgentId, documentId);
|
|
809
|
-
const snapshot = [];
|
|
810
|
-
for (const row of log) {
|
|
811
|
-
if (row.kind !== "update" || row.payload === null)
|
|
812
|
-
continue;
|
|
813
|
-
snapshot.push(new Uint8Array(encodeDocumentUpdateEnvelope({
|
|
814
|
-
type: "document_update",
|
|
815
|
-
document_id: row.documentId,
|
|
816
|
-
epoch_id: row.epochId,
|
|
817
|
-
doc_prev_hash: row.docPrevHash,
|
|
818
|
-
sender_agent_id: row.senderAgentId,
|
|
819
|
-
sender_client_id: row.senderClientId ?? 0,
|
|
820
|
-
update_encoding: DOCUMENT_UPDATE_ENCODING_V1,
|
|
821
|
-
state_vector: row.stateVector,
|
|
822
|
-
update: row.payload,
|
|
823
|
-
signature: row.signature,
|
|
824
|
-
})));
|
|
825
|
-
}
|
|
826
|
-
const offer = {
|
|
827
|
-
type: "document_join_offer",
|
|
828
|
-
feature_version: DOCUMENT_FEATURE_VERSION,
|
|
829
|
-
inviter_agent_id: who.ownerAgentId,
|
|
830
|
-
invitee_agent_id: invitee,
|
|
831
|
-
document_id: documentId,
|
|
832
|
-
genesis: new Uint8Array(encodeDocumentProposal(genesisRecord.envelope)),
|
|
833
|
-
amendments: [...chain.map((e) => new Uint8Array(encodeDocumentAmendment(e))), amendmentBytes],
|
|
834
|
-
envelope_log: snapshot,
|
|
835
|
-
offered_at_ms: deps.now(),
|
|
836
|
-
signature: new Uint8Array(0),
|
|
837
|
-
};
|
|
838
|
-
offer.signature = await deps.sign(who.agentName, buildDocumentJoinOfferTbs(offer));
|
|
839
|
-
const wire = new Uint8Array(encodeDocumentJoinOffer(offer));
|
|
840
888
|
const amendHashHex = Buffer.from(amendHash).toString("hex");
|
|
841
|
-
|
|
842
|
-
//
|
|
843
|
-
//
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
if (!sent.ok)
|
|
851
|
-
logger.warn("document.join.offer_unsent", { documentId, reason: sent.reason });
|
|
852
|
-
}
|
|
853
|
-
catch (err) {
|
|
854
|
-
logger.warn("document.join.offer_send_threw", {
|
|
855
|
-
documentId, error: err instanceof Error ? err.message : String(err),
|
|
856
|
-
});
|
|
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 });
|
|
857
898
|
}
|
|
858
899
|
// DOD-MP-INVITE-FANOUT-1 — RECORD WHAT IS OWED BEFORE TRYING TO SEND IT.
|
|
859
900
|
//
|
|
@@ -864,7 +905,7 @@ export function registerDocumentHandlers(deps) {
|
|
|
864
905
|
//
|
|
865
906
|
// Seeding first also makes the crash window safe: a daemon that dies between here and the send
|
|
866
907
|
// still owes the amendment on restart.
|
|
867
|
-
const owedHolders = [...derived.
|
|
908
|
+
const owedHolders = [...derived.state.participants, ...derived.state.invited].filter((holder) => holder !== who.ownerAgentId && holder !== invitee);
|
|
868
909
|
const holdersTold = await fanOutAmendment({
|
|
869
910
|
agentName: who.agentName,
|
|
870
911
|
ownerAgentId: who.ownerAgentId,
|
|
@@ -874,22 +915,21 @@ export function registerDocumentHandlers(deps) {
|
|
|
874
915
|
holders: owedHolders,
|
|
875
916
|
verb: "invite",
|
|
876
917
|
});
|
|
877
|
-
logger.info("document.join.invited", { documentId, invitee,
|
|
918
|
+
logger.info("document.join.invited", { documentId, invitee, noticeSent: offerSent });
|
|
878
919
|
return {
|
|
879
920
|
ok: true,
|
|
880
921
|
documentId,
|
|
881
922
|
inviteeAgentId: invitee,
|
|
882
923
|
amendmentHash: amendHashHex,
|
|
883
|
-
|
|
884
|
-
offerSent,
|
|
924
|
+
noticeSent: offerSent,
|
|
885
925
|
holdersNotified: holdersTold,
|
|
886
926
|
...(offerSent
|
|
887
927
|
? {}
|
|
888
928
|
: {
|
|
889
|
-
guidance: "The
|
|
929
|
+
guidance: "The admission is recorded and the invitation exists, but the notice did not reach " +
|
|
890
930
|
"the invitee — they may be offline. Re-run cello_doc_invite with the same invitee " +
|
|
891
|
-
"once they are reachable: it re-sends
|
|
892
|
-
"
|
|
931
|
+
"once they are reachable: it re-sends the notice rather than authoring a second " +
|
|
932
|
+
"entry.",
|
|
893
933
|
}),
|
|
894
934
|
};
|
|
895
935
|
});
|
|
@@ -932,24 +972,21 @@ export function registerDocumentHandlers(deps) {
|
|
|
932
972
|
}
|
|
933
973
|
const genesisArr = arrangementGenesisFromProposal(genesisRecord.envelope);
|
|
934
974
|
const chain = layer.amendments.chain(who.ownerAgentId, documentId);
|
|
935
|
-
const derived =
|
|
975
|
+
const derived = deriveDocumentState(genesisArr, chain, documentGovernancePolicy, layer.verifySignature);
|
|
936
976
|
if (!derived.ok) {
|
|
937
977
|
return { ok: false, reason: "document_chain_invalid", guidance: derived.reason };
|
|
938
978
|
}
|
|
939
|
-
if (!derived.
|
|
979
|
+
if (!derived.state.participants.has(holder) && !derived.state.invited.has(holder)) {
|
|
940
980
|
// ALREADY REMOVED is the HEALING path, not a refusal (REMOVE-1 review F3): a holder who
|
|
941
981
|
// was offline at removal time never learned, and no other verb can ever re-send the
|
|
942
982
|
// removal amendment — a second cello_doc_remove is the invite-retry precedent. A subject
|
|
943
983
|
// the chain never touched still refuses.
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
const removal = chain.find((e) => e.body.kind === "remove_holder" &&
|
|
947
|
-
e.body.subject_agent_id === holder &&
|
|
948
|
-
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);
|
|
949
986
|
let resendTold = {};
|
|
950
987
|
if (removal) {
|
|
951
988
|
const bytes = new Uint8Array(encodeDocumentAmendment(removal));
|
|
952
|
-
const remaining = [...derived.
|
|
989
|
+
const remaining = [...derived.state.participants, ...derived.state.invited].filter((m) => m !== who.ownerAgentId && m !== holder);
|
|
953
990
|
// The healing re-send is durable for the holders who REMAIN, for the same reason the
|
|
954
991
|
// re-invite is: it is the verb an operator runs precisely because someone is out of step,
|
|
955
992
|
// so it must not be the one that gives up quietest.
|
|
@@ -979,7 +1016,6 @@ export function registerDocumentHandlers(deps) {
|
|
|
979
1016
|
documentId,
|
|
980
1017
|
removedAgentId: holder,
|
|
981
1018
|
resent: true,
|
|
982
|
-
epochId: membership.epochId,
|
|
983
1019
|
holdersNotified: resendTold,
|
|
984
1020
|
};
|
|
985
1021
|
}
|
|
@@ -991,13 +1027,16 @@ export function registerDocumentHandlers(deps) {
|
|
|
991
1027
|
}
|
|
992
1028
|
const body = {
|
|
993
1029
|
document_id: documentId,
|
|
994
|
-
epoch_id: derived.arrangement.epoch + 1,
|
|
995
|
-
prev_amendment_hash: derived.arrangement.lastAmendmentHash,
|
|
996
1030
|
kind: "remove_holder",
|
|
997
1031
|
subject_agent_id: holder,
|
|
998
1032
|
property_change: null,
|
|
999
1033
|
state_hash: null,
|
|
1000
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],
|
|
1001
1040
|
};
|
|
1002
1041
|
const amendHash = documentAmendmentHash(body);
|
|
1003
1042
|
const multisigTbs = buildDocumentMultisigTbs({
|
|
@@ -1021,17 +1060,32 @@ export function registerDocumentHandlers(deps) {
|
|
|
1021
1060
|
// VALIDATE-BEFORE-APPEND — the policy rules here: a non-admin removing someone else, or any
|
|
1022
1061
|
// single admin trying to expel a fellow admin through the holder door, refuses with the
|
|
1023
1062
|
// policy's own sentence. Voluntary self-leave passes for anyone.
|
|
1024
|
-
const withNew =
|
|
1063
|
+
const withNew = deriveDocumentState(genesisArr, [...chain, amendment], documentGovernancePolicy, layer.verifySignature);
|
|
1025
1064
|
if (!withNew.ok) {
|
|
1026
1065
|
return { ok: false, reason: "document_amendment_invalid", guidance: withNew.reason };
|
|
1027
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
|
+
}
|
|
1028
1078
|
const amendmentBytes = new Uint8Array(encodeDocumentAmendment(amendment));
|
|
1029
1079
|
layer.amendments.append(who.ownerAgentId, documentId, amendmentBytes, deps.now());
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1033
|
-
|
|
1034
|
-
|
|
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
|
+
}
|
|
1035
1089
|
// The amendment travels to EVERY current holder INCLUDING the removed one — being told is
|
|
1036
1090
|
// how their daemon surfaces the removal to their operator. Best-effort at P1, per holder,
|
|
1037
1091
|
// reported never assumed.
|
|
@@ -1045,7 +1099,7 @@ export function registerDocumentHandlers(deps) {
|
|
|
1045
1099
|
documentId,
|
|
1046
1100
|
amendmentHashHex: Buffer.from(amendHash).toString("hex"),
|
|
1047
1101
|
amendmentBytes,
|
|
1048
|
-
holders: [...withNew.
|
|
1102
|
+
holders: [...withNew.state.participants, ...withNew.state.invited].filter((m) => m !== who.ownerAgentId),
|
|
1049
1103
|
verb: "remove",
|
|
1050
1104
|
});
|
|
1051
1105
|
// THE REMOVED HOLDER IS TOLD ONCE, and is deliberately NOT owed a durable retry: delivery to
|
|
@@ -1075,14 +1129,13 @@ export function registerDocumentHandlers(deps) {
|
|
|
1075
1129
|
}
|
|
1076
1130
|
}
|
|
1077
1131
|
logger.info("document.holder_removed", {
|
|
1078
|
-
documentId, holder,
|
|
1132
|
+
documentId, holder, voluntary: holder === who.ownerAgentId,
|
|
1079
1133
|
});
|
|
1080
1134
|
return {
|
|
1081
1135
|
ok: true,
|
|
1082
1136
|
documentId,
|
|
1083
1137
|
removedAgentId: holder,
|
|
1084
1138
|
voluntary: holder === who.ownerAgentId,
|
|
1085
|
-
epochId: body.epoch_id,
|
|
1086
1139
|
holdersNotified: holdersTold,
|
|
1087
1140
|
guidance: "Removal is forward-only: their existing copy and its history remain theirs — new edits " +
|
|
1088
1141
|
"simply no longer flow either way.",
|
|
@@ -1101,15 +1154,33 @@ export function registerDocumentHandlers(deps) {
|
|
|
1101
1154
|
if (documentId.length === 0) {
|
|
1102
1155
|
return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_inbox." };
|
|
1103
1156
|
}
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
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
|
+
}
|
|
1113
1184
|
}
|
|
1114
1185
|
const proposal = layer.handshake.get(who.ownerAgentId, documentId);
|
|
1115
1186
|
const outcome = layer.handshake.refuse(who.ownerAgentId, documentId, reason, deps.now());
|
|
@@ -1127,153 +1198,128 @@ export function registerDocumentHandlers(deps) {
|
|
|
1127
1198
|
const who = resolve(params, connectionId);
|
|
1128
1199
|
if (isRefusal(who))
|
|
1129
1200
|
return who;
|
|
1130
|
-
//
|
|
1131
|
-
//
|
|
1132
|
-
//
|
|
1133
|
-
//
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1136
|
-
//
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
});
|
|
1150
|
-
const arrangementFor = (documentId, genesisRecord) => {
|
|
1151
|
-
// The SAME name the invite path uses for the same fault, carrying the same sentence —
|
|
1152
|
-
// one condition should not have two names, and the one an operator reads should be the
|
|
1153
|
-
// one that says whose fault it is.
|
|
1154
|
-
if (!genesisRecord) {
|
|
1155
|
-
return unavailable("document_genesis_missing: the document has a row but no stored genesis proposal to " +
|
|
1156
|
-
"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);
|
|
1217
|
+
}
|
|
1218
|
+
catch {
|
|
1219
|
+
return [];
|
|
1157
1220
|
}
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
//
|
|
1162
|
-
//
|
|
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;
|
|
1163
1276
|
try {
|
|
1164
|
-
|
|
1165
|
-
if (!derived.ok)
|
|
1166
|
-
return unavailable(derived.reason);
|
|
1167
|
-
return {
|
|
1168
|
-
participants: [...derived.arrangement.participants].sort(),
|
|
1169
|
-
admins: [...derived.arrangement.admins].sort(),
|
|
1170
|
-
properties: derived.arrangement.properties,
|
|
1171
|
-
// WAS THE ADMIN SET DECLARED, OR DEFAULTED? A genesis from before the admin slot
|
|
1172
|
-
// existed carries none, and the replay hands both parties admin power. Rendering that
|
|
1173
|
-
// identically to a declared set would have this surface state as agreed fact something
|
|
1174
|
-
// the code decided — so it says which it is.
|
|
1175
|
-
adminSetDefaulted: genesisRecord.envelope.properties.admin_set === undefined,
|
|
1176
|
-
};
|
|
1277
|
+
chain = layer.amendments.chain(who.ownerAgentId, doc.documentId);
|
|
1177
1278
|
}
|
|
1178
1279
|
catch (err) {
|
|
1179
|
-
return
|
|
1280
|
+
return underivable(`document_chain_undecodable: ${err instanceof Error ? err.message : String(err)}`);
|
|
1180
1281
|
}
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
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
|
+
});
|
|
1195
1319
|
return {
|
|
1196
1320
|
ok: true,
|
|
1197
1321
|
...(outgoingJoins.length > 0 ? { joinOffers: outgoingJoins } : {}),
|
|
1198
|
-
documents
|
|
1199
|
-
// WHOSE OFFER WAS IT, and has the other side actually shown up?
|
|
1200
|
-
//
|
|
1201
|
-
// Without these three fields, `cello_doc_list` renders identically for a document the peer
|
|
1202
|
-
// refused, one whose offer never reached them, and one being actively co-edited — the only
|
|
1203
|
-
// moving part is `pendingUnsent`, which also moves for a peer who is merely offline. An
|
|
1204
|
-
// operator cannot tell "they said no" from "they are asleep", and those want opposite
|
|
1205
|
-
// actions.
|
|
1206
|
-
const proposal = layer.handshake.get(who.ownerAgentId, d.documentId);
|
|
1207
|
-
const peerAnswer = layer.handshake.peerAnswer(who.ownerAgentId, d.documentId);
|
|
1208
|
-
const arrangement = arrangementFor(d.documentId, proposal);
|
|
1209
|
-
// DOD-MP-REMOVE-FEEDBACK-1 — the SENTENCE for a fact the row already carried.
|
|
1210
|
-
//
|
|
1211
|
-
// The row has shipped `removed: true` since REMOVE-1. What was missing is that a bare flag
|
|
1212
|
-
// is not feedback: it does not say when, it does not say the copy is still yours, and it
|
|
1213
|
-
// does not say what actually stopped. So this completes the existing signal rather than
|
|
1214
|
-
// adding a second name for it computed by a second walk of the same chain.
|
|
1215
|
-
//
|
|
1216
|
-
// NAMED `yourStanding`, NOT `yourAccess`: your access to the copy did not change — reading
|
|
1217
|
-
// it still works and the file is still on disk, which the sentence itself says. A surface
|
|
1218
|
-
// that renders a badge from the key alone would show "access: removed", which is the
|
|
1219
|
-
// confiscation reading FORWARD-ONLY-REMOVAL exists to forbid.
|
|
1220
|
-
//
|
|
1221
|
-
// ALWAYS PRESENT, exactly as `participants` is: an absent key is read as "fine", so on a
|
|
1222
|
-
// chain this build cannot decode — where `removedFromArrangement` honestly cannot tell —
|
|
1223
|
-
// it says `unknown` rather than going quiet and rendering a removed holder as a holder.
|
|
1224
|
-
const standing = arrangement["arrangementUnavailable"] !== undefined
|
|
1225
|
-
? "unknown"
|
|
1226
|
-
: d.removed === true
|
|
1227
|
-
? "removed"
|
|
1228
|
-
: "holder";
|
|
1229
|
-
const removedAtEpoch = d.removedAtEpoch;
|
|
1230
|
-
return {
|
|
1231
|
-
...d,
|
|
1232
|
-
yourStanding: standing,
|
|
1233
|
-
...(standing === "removed"
|
|
1234
|
-
? {
|
|
1235
|
-
standingGuidance: `You are no longer a holder of this document` +
|
|
1236
|
-
(removedAtEpoch === undefined ? `. ` : `, as of epoch ${removedAtEpoch}. `) +
|
|
1237
|
-
`Your copy and its full history remain yours, and you can still read it here or ` +
|
|
1238
|
-
`open the file. What changed is only the flow of edits: yours no longer publish ` +
|
|
1239
|
-
`to the other holders, and theirs no longer reach you.`,
|
|
1240
|
-
}
|
|
1241
|
-
: {}),
|
|
1242
|
-
...(standing === "unknown"
|
|
1243
|
-
? {
|
|
1244
|
-
standingGuidance: `This daemon cannot read this document's amendment chain, so it cannot tell ` +
|
|
1245
|
-
`whether you are still a holder. Nothing here should be taken as confirmation ` +
|
|
1246
|
-
`that you are.`,
|
|
1247
|
-
}
|
|
1248
|
-
: {}),
|
|
1249
|
-
proposedByUs: proposal?.proposerAgentId === who.ownerAgentId,
|
|
1250
|
-
// THE PEER'S OWN SIGNED ANSWER — true accepted, false refused, null not yet heard. This
|
|
1251
|
-
// replaced an inference ("they have published into it") that could not tell refused from
|
|
1252
|
-
// unreceived from accepted-but-untouched, two of which want the operator to act.
|
|
1253
|
-
//
|
|
1254
|
-
// The REASON comes with it. It was stored and read by nothing, which defeats why it is
|
|
1255
|
-
// mandatory on the wire: a refusal whose reason the proposer cannot see leaves them
|
|
1256
|
-
// unable to propose anything better.
|
|
1257
|
-
peerAccepted: peerAnswer.accepted,
|
|
1258
|
-
peerRefusalReason: peerAnswer.reason,
|
|
1259
|
-
// Kept alongside it, because they answer different questions: whether they agreed, and
|
|
1260
|
-
// whether anything has actually come back. A document accepted an hour ago with nothing
|
|
1261
|
-
// in it is a fine state; it is just not the same state.
|
|
1262
|
-
peerHasPublished: layer.store.knownEnvelopeHashesBySender(who.ownerAgentId, d.documentId, d.peerAgentId).size > 0,
|
|
1263
|
-
consentState: proposal?.consentState ?? null,
|
|
1264
|
-
// WHO HOLDS IT AND WHO GOVERNS IT — derived from THIS daemon's own chain (G0).
|
|
1265
|
-
// `proposal` is passed rather than re-fetched: it is the same SQL read and the same
|
|
1266
|
-
// CBOR decode of the same bytes, already in hand.
|
|
1267
|
-
...arrangement,
|
|
1268
|
-
// DID OUR OFFER LEAVE? Only meaningful for a document WE proposed — for one we accepted
|
|
1269
|
-
// there is no offer of ours to have sent. Without this, `peerAccepted: null` meant both
|
|
1270
|
-
// "they are thinking" and "they were never asked", and the shipped guidance said WAIT,
|
|
1271
|
-
// which is wrong for the second and leaves the operator waiting on nothing.
|
|
1272
|
-
proposalSent: proposal?.proposerAgentId === who.ownerAgentId
|
|
1273
|
-
? layer.handshake.proposalSent(who.ownerAgentId, d.documentId)
|
|
1274
|
-
: null,
|
|
1275
|
-
};
|
|
1276
|
-
}),
|
|
1322
|
+
documents,
|
|
1277
1323
|
};
|
|
1278
1324
|
});
|
|
1279
1325
|
handlers.set("cello_doc_read", async (params, connectionId) => {
|
|
@@ -1820,126 +1866,32 @@ export function registerDocumentHandlers(deps) {
|
|
|
1820
1866
|
if (documentId.length === 0) {
|
|
1821
1867
|
return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_list." };
|
|
1822
1868
|
}
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
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;
|
|
1836
1884
|
return {
|
|
1837
1885
|
ok: true,
|
|
1838
1886
|
documentId,
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
: {
|
|
1846
|
-
reason: outcome.notifyReason,
|
|
1847
|
-
guidance: notifyGuidance("close", outcome.notifyReason, outcome.notifyDetail, outcome.holdersNotified, outcome.holderFailures),
|
|
1848
|
-
}),
|
|
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 ?? [],
|
|
1849
1893
|
};
|
|
1850
1894
|
});
|
|
1851
|
-
/**
|
|
1852
|
-
* What to tell an operator when a close or kill did not reach the peer — BRANCHED ON THE CAUSE.
|
|
1853
|
-
*
|
|
1854
|
-
* One string used to cover all three: a transport failure, a signing key that could not be
|
|
1855
|
-
* loaded, and this daemon not holding the document's agent at all. Only the first is fixed by
|
|
1856
|
-
* waiting, and that is what the single message told them to do. Traced end to end in the
|
|
1857
|
-
* DOD-DOC-TOOLS-1 review: an exit-point label standing in for a local fault, pointing at the
|
|
1858
|
-
* network.
|
|
1859
|
-
*/
|
|
1860
|
-
function notifyGuidance(verb, reason, detail, holdersNotified, holderFailures) {
|
|
1861
|
-
// DOD-MP-CONTROL-DURABLE-1 — this said "A close is not retried", and that stopped being true
|
|
1862
|
-
// when the ending became durable. A sentence telling the operator to do by hand what the daemon
|
|
1863
|
-
// now does for them is not merely stale: it invites a second close for no reason, and it
|
|
1864
|
-
// undersells the one case that still needs them — a holder we never manage to confirm.
|
|
1865
|
-
const tail = verb === "close"
|
|
1866
|
-
? `The ending is owed to anyone who did not take it and is re-sent when they return; you do ` +
|
|
1867
|
-
`not need to run this again. If it still cannot be confirmed the daemon says so, naming ` +
|
|
1868
|
-
`them — that one needs you.`
|
|
1869
|
-
: `The kill stands locally either way, and is re-sent to anyone who did not take it until ` +
|
|
1870
|
-
`they do or the daemon reports that it gave up on them.`;
|
|
1871
|
-
if (reason === "document_control_unsigned") {
|
|
1872
|
-
return (`Your ${verb} was recorded, but this agent's signing key could not be loaded, so nothing ` +
|
|
1873
|
-
`could be sent to the peer${detail ? ` (${detail})` : ""}. Waiting will not help — the ` +
|
|
1874
|
-
`fault is on this machine. ${tail}`);
|
|
1875
|
-
}
|
|
1876
|
-
if (reason === "document_unknown") {
|
|
1877
|
-
return (`Your ${verb} was recorded, but this daemon does not hold the agent that owns the document, ` +
|
|
1878
|
-
`so it could not tell the peer. That is a local wiring fault, not the peer being away. ${tail}`);
|
|
1879
|
-
}
|
|
1880
|
-
// THE FOUR DERIVATION FAULTS (DOD-MP-CONTROL-N-1). Every one of these is a fact about THIS
|
|
1881
|
-
// machine's record of the document, and not one of them is fixed by the counterparty coming
|
|
1882
|
-
// online — which is what the generic sentence below tells the operator to wait for. That is the
|
|
1883
|
-
// exact defect this function's header says it was written to remove, reintroduced by four new
|
|
1884
|
-
// reasons that were never added to it.
|
|
1885
|
-
if (reason === "document_holders_underivable" || reason === "document_genesis_missing") {
|
|
1886
|
-
return (`Your ${verb} was recorded, but this daemon could not work out who currently holds the ` +
|
|
1887
|
-
`document, so it sent nothing to anyone. Waiting will not help — the signed history on this ` +
|
|
1888
|
-
`machine is the problem, not the network. Look for a "document.holders.underivable" line in ` +
|
|
1889
|
-
`the daemon log, which names the specific cause. ${tail}`);
|
|
1890
|
-
}
|
|
1891
|
-
if (reason?.startsWith("document_chain_undecodable")) {
|
|
1892
|
-
return (`Your ${verb} was recorded, but this build cannot read part of the document's signed ` +
|
|
1893
|
-
`history${detail ? ` (${detail})` : ""}, so it could not work out who to tell. That is ` +
|
|
1894
|
-
`usually a client older than the document — upgrading is the fix, not waiting. ${tail}`);
|
|
1895
|
-
}
|
|
1896
|
-
if (reason === "document_no_holders") {
|
|
1897
|
-
return (`Your ${verb} was recorded, and there is nobody left to tell — the signed history shows no ` +
|
|
1898
|
-
`other current holder of this document. Nothing is pending and nothing needs retrying.`);
|
|
1899
|
-
}
|
|
1900
|
-
// PARTIAL FAN-OUT is its own case and reads nothing like "the peer is offline". With three
|
|
1901
|
-
// holders, two hearing you and one not is the ordinary outcome, and the operator needs to know
|
|
1902
|
-
// WHICH one is still editing a document they think is live — a message saying "the peer" names
|
|
1903
|
-
// nobody when there are several.
|
|
1904
|
-
const missed = Object.entries(holdersNotified ?? {}).filter(([, told]) => !told).map(([h]) => h);
|
|
1905
|
-
const reached = Object.values(holdersNotified ?? {}).filter(Boolean).length;
|
|
1906
|
-
if (missed.length > 0 && reached > 0) {
|
|
1907
|
-
return (`Your ${verb} was recorded and reached ${reached} of ${reached + missed.length} other ` +
|
|
1908
|
-
`holders, but not ${missed.join(", ")} — they will keep editing a document the rest of you ` +
|
|
1909
|
-
`have ended. ${tail}`);
|
|
1910
|
-
}
|
|
1911
|
-
if (missed.length > 0) {
|
|
1912
|
-
// EVERY holder failed. This used to fall through to the generic sentence, which says "the
|
|
1913
|
-
// peer" and names nobody — unusable when there are several, and the per-holder transport
|
|
1914
|
-
// reasons live only in the log. It also arrives with no `reason` field at all, because the
|
|
1915
|
-
// notifier returned ok: signing and addressing both worked, the sends did not.
|
|
1916
|
-
//
|
|
1917
|
-
// AND IT SAID "most likely offline", WHICH WAS A GUESS. Caught on the live fleet: every
|
|
1918
|
-
// holder failed with `session_sealed` — the relay refusing a leaf for a session it has
|
|
1919
|
-
// closed — and the operator was told to wait for people who were sitting right there.
|
|
1920
|
-
// Waiting cannot reopen a sealed session. The cause is known per holder; it travels.
|
|
1921
|
-
const causes = [...new Set(Object.values(holderFailures ?? {}))].filter(Boolean);
|
|
1922
|
-
const because = causes.length > 0 ? ` (${causes.join(", ")})` : "";
|
|
1923
|
-
// THE WHOLE FAMILY, not one substring. This tested `includes("sealed")`, so `session_sealed`
|
|
1924
|
-
// got the useful sentence and every other way a session's record can be over —
|
|
1925
|
-
// `relay_session_gone` (the relay restarted or swept it) and `session_not_found` — fell to
|
|
1926
|
-
// generic wait-for-them advice, which is the one thing that cannot work when there is nothing
|
|
1927
|
-
// left to wait for.
|
|
1928
|
-
const sealed = causes.some((c) => c.includes("sealed") ||
|
|
1929
|
-
c.includes("relay_session_gone") ||
|
|
1930
|
-
c.includes("session_not_found"));
|
|
1931
|
-
return (`Your ${verb} was recorded but reached none of the ${missed.length} other ` +
|
|
1932
|
-
`holder${missed.length > 1 ? "s" : ""} (${missed.join(", ")})${because}, so the document ` +
|
|
1933
|
-
`cannot settle until they hear it. ` +
|
|
1934
|
-
(sealed
|
|
1935
|
-
? `The session carrying this document has been sealed, so waiting will not help — ` +
|
|
1936
|
-
`the document needs a fresh session to the other holders. `
|
|
1937
|
-
: ``) +
|
|
1938
|
-
`${tail}`);
|
|
1939
|
-
}
|
|
1940
|
-
return (`Your ${verb} was recorded but did not reach the peer${detail ? ` (${detail})` : ""}, so the ` +
|
|
1941
|
-
`document cannot settle until they hear it. ${tail}`);
|
|
1942
|
-
}
|
|
1943
1895
|
handlers.set("cello_doc_kill", async (params, connectionId) => {
|
|
1944
1896
|
const who = resolve(params, connectionId);
|
|
1945
1897
|
if (isRefusal(who))
|
|
@@ -1948,26 +1900,25 @@ export function registerDocumentHandlers(deps) {
|
|
|
1948
1900
|
if (documentId.length === 0) {
|
|
1949
1901
|
return { ok: false, reason: "invalid_document_id", guidance: "Pass 'document_id' from cello_doc_list." };
|
|
1950
1902
|
}
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
//
|
|
1955
|
-
//
|
|
1956
|
-
|
|
1957
|
-
|
|
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
|
+
}
|
|
1958
1916
|
return {
|
|
1959
1917
|
ok: true,
|
|
1960
1918
|
documentId,
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
note: outcome.note,
|
|
1965
|
-
...(outcome.peerNotified
|
|
1966
|
-
? {}
|
|
1967
|
-
: {
|
|
1968
|
-
reason: outcome.notifyReason,
|
|
1969
|
-
guidance: notifyGuidance("kill", outcome.notifyReason, outcome.notifyDetail, outcome.holdersNotified, outcome.holderFailures),
|
|
1970
|
-
}),
|
|
1919
|
+
killEntry: killed.entryHash,
|
|
1920
|
+
killDelivered: killed.holdersNotified,
|
|
1921
|
+
ended: "killed",
|
|
1971
1922
|
};
|
|
1972
1923
|
});
|
|
1973
1924
|
handlers.set("cello_doc_publish", async (params, connectionId) => {
|