Changeset 5327324 in Klonkt
- Timestamp:
- 08/03/2026 06:17:00 AM (5 weeks ago)
- Branches:
- main
- Children:
- 69bd747
- Parents:
- 04d5aeb
- Files:
-
- 3 edited
-
src/services/guardianship/handshake.js (modified) (3 diffs)
-
test/co-location.test.js (modified) (1 diff)
-
test/guardianship.test.js (modified) (1 diff)
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 } -
test/co-location.test.js
r04d5aeb r5327324 214 214 const allowed = { 215 215 existingGuardiansOf: 'reads our own guardian list instead of fetching our own actor doc', 216 candidateFitness: '§4.2: same question, same source — is this candidate a ward? Our table, not a self-fetch', 216 217 applyCommitLocally: '§3.1.4: each instance writes the side of the commit it hosts', 217 218 endGuardianship: '§3.2: same, for the ward side of the Undo, after the fanout', -
test/guardianship.test.js
r04d5aeb r5327324 119 119 assert.equal(r.status, 403); 120 120 assert.equal(r.error, 'a_ward_cannot_guard'); 121 }); 122 123 test('a candidate adopted between Offer and Accept is refused at commit (§4.2)', async () => { 124 // The case the §1 check above structurally cannot catch. Tess is free when 125 // she offers, so the Offer is legitimate and accepted. Only afterwards does 126 // she become a ward herself. An implementation that checks the candidate 127 // only when the Offer arrives would commit her anyway, and Sam would be left 128 // counting a guardian whose escalations get dropped (§4.1). 129 // Fresh actors throughout: the suite shares one database, so adopting Tess 130 // with an existing guardian would hand that guardian an extra ward and 131 // quietly change the arithmetic of the emancipation tests further down. 132 const tess = site('s10', 'tess'); 133 const sam = site('s11', 'sam'); 134 const ada = site('s12', 'ada'); 135 const [TESS, SAM, ADA] = [A('tess'), A('sam'), A('ada')]; 136 137 // 1. Tess offers to guard Sam while she is still free of guardians. 138 const off = await G.handleGuardianshipOutbox(tess, { 139 type: 'Offer', object: { type: 'Relationship', subject: SAM, relationship: 'shaer:Guardian', object: TESS }, 140 }); 141 assert.equal(off.status, 202, 'a free candidate may offer'); 142 const id = off.id; 143 assert.deepEqual(G.listGuardians('sam'), [], 'nothing committed until Sam accepts'); 144 145 // 2. Before Sam answers, Tess is adopted: she is now a ward herself. 146 const adopt = await G.handleGuardianshipOutbox(ada, { 147 type: 'Offer', object: { type: 'Relationship', subject: TESS, relationship: 'shaer:Guardian', object: ADA }, 148 }); 149 await G.handleGuardianshipOutbox(tess, { type: 'Accept', object: adopt.id }); 150 assert.equal(G.listGuardians('tess').length, 1, 'Tess is a ward now'); 151 152 // 3. Sam accepts. The tally is complete, so this WOULD commit. 153 const done = await G.handleGuardianshipOutbox(sam, { type: 'Accept', object: id }); 154 assert.equal(done.committed, false, 'but a ward cannot serve as a guardian (§1)'); 155 assert.equal(done.refused, 'not_a_teapot'); 156 157 // The refusal is loud, not a silent skip: nothing recorded, offer voided. 158 assert.deepEqual(G.listGuardians('sam'), [], 'Sam gains no guardian'); 159 assert.deepEqual(G.listWards('tess').map((w) => w.other_uri), [], 'and Tess gains no ward'); 160 const stillPending = G.offersCollection(`${SAM}/queues/offers`, 'sam', SAM).orderedItems.filter((o) => o.id === id); 161 assert.deepEqual(stillPending, [], 'the handshake is void, not left hanging'); 121 162 }); 122 163
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)