- Timestamp:
- 08/03/2026 06:17:00 AM (5 weeks ago)
- Branches:
- main
- Children:
- 69bd747
- Parents:
- 04d5aeb
- File:
-
- 1 edited
-
src/services/guardianship/handshake.js (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/services/guardianship/handshake.js
r04d5aeb r5327324 114 114 } 115 115 116 /** 117 * FEP-633c §4.2 — is this candidate fit to be a guardian at all? 118 * 119 * A guardian MUST be free of guardians (§1). Checked here and not at the Offer, 120 * because guardianship state can change in between: a candidate that was free 121 * when it offered may have been adopted before the ward accepted. So the check 122 * runs against a freshly dereferenced actor document, at the moment the 123 * relationship would become real. 124 * 125 * Three answers, and the third is not a failure of this check but a failure to 126 * perform it: 127 * 'ok' — free of guardians, may serve 128 * 'malformed' — carries shaer:guardians; a teapot (§4) 129 * 'unverified' — the actor could not be read at all 130 */ 131 async function candidateFitness(candidateUri) { 132 // A candidate on this instance needs no dereference: our own tables are the 133 // document, and fresher than anything we could fetch from ourselves. This is 134 // also the co-located case (ward and guardian on one Klonkt), where there is 135 // no network to be unreachable on. 136 const local = deps.localSlug(candidateUri); 137 if (local) return relations.listGuardians(local).length > 0 ? 'malformed' : 'ok'; 138 139 const doc = await deps.fetchActor(candidateUri).catch(() => null); 140 if (!doc) return 'unverified'; 141 const g = doc['shaer:guardians']; 142 const has = Array.isArray(g) ? g.length > 0 143 : typeof g === 'string' ? g.length > 0 144 : (g && typeof g === 'object') ? (Array.isArray(g.items) ? g.items.length > 0 : true) 145 : false; 146 return has ? 'malformed' : 'ok'; 147 } 148 116 149 /** Commit this local copy of the offer when the tally is complete (ward + 117 150 * candidate + ≥1 existing guardian, §3.1.2). The handle is the candidate's 118 151 * inbox (§6 minimum); the commit is order-independent, so whichever accept 119 152 * lands last triggers it on every copy. */ 120 function maybeCommit(slug, offerId) {153 async function maybeCommit(slug, offerId) { 121 154 const offer = offers.getOffer(slug, offerId); 122 if (!offer || !offers.readyToCommit(offer)) return null; 155 if (!offer || !offers.readyToCommit(offer)) return { done: null, refused: null }; 156 157 const fitness = await candidateFitness(offer.candidate_uri); 158 159 // §4.2: unlike the soft skip at delivery (§4.1), this refusal is loud. A 160 // handshake concerns exactly one candidate, so there is no remaining 161 // well-formed target to continue to; committing anyway would leave the ward 162 // counting a guardian whose escalations get dropped. Voiding is all this 163 // function does; saying so on the wire belongs to whoever was acting. 164 if (fitness === 'malformed') { 165 offers.recordReject(slug, offerId, offer.ward_uri); // voids this copy (§3.2) 166 notify(slug, { 167 kind: 'offer_rejected', offer: offerId, 168 reason: 'not_a_teapot', candidate: offer.candidate_uri, 169 }); 170 return { done: null, refused: 'not_a_teapot' }; 171 } 172 173 // Could not read the candidate: neither commit nor void. Refusing outright 174 // would let a momentary outage destroy a multi-party adoption; committing 175 // would record a guardian nobody checked. The offer stays pending and the 176 // next accept retries. 177 if (fitness === 'unverified') return { done: null, refused: null }; 178 123 179 const done = offers.commit(slug, offerId, `${offer.candidate_uri}/inbox`); 124 180 if (done) { applyCommitLocally(done); notify(slug, { kind: 'committed', ward: done.ward_uri, guardian: done.candidate_uri }); } 125 return done;181 return { done, refused: null }; 126 182 } 127 183 … … 296 352 offers.recordAccept(site.slug, offerId, me); 297 353 await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Accept', actor: me, to: others, object: offerId }); 298 const done = maybeCommit(site.slug, offerId); 354 const { done, refused } = await maybeCommit(site.slug, offerId); 355 if (refused) { 356 // §4.2: the refusal travels as a `Reject` of the Offer, from whoever was 357 // about to commit — the same voice that just sent the Accept. A `Reject` 358 // is what §3 already understands, so a server that has never heard of §4 359 // still voids its copy correctly; the marker only adds the reason. 360 await fanout(site, others, { 361 id: `${me}/answers/${Date.now().toString(36)}`, 362 type: 'Reject', actor: me, to: others, object: offerId, 'shaer:notATeapot': true, 363 }); 364 return { status: 202, id: offerId, url: offerId, committed: false, refused }; 365 } 299 366 return { status: 202, id: offerId, url: offerId, committed: !!done, readyToCommit: offers.readyToCommit(offers.getOffer(site.slug, offerId)) }; 300 367 } … … 448 515 449 516 offers.recordAccept(site.slug, offerId, actor); 450 maybeCommit(site.slug, offerId); // commits this copy once the tally is complete517 await maybeCommit(site.slug, offerId); // commits this copy once the tally is complete (§4.2 may refuse) 451 518 return true; 452 519 }
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)