Changeset 30d0e2c in Klonkt
- Timestamp:
- 08/03/2026 06:38:16 AM (5 weeks ago)
- Branches:
- main
- Children:
- 3d882bd
- Parents:
- 69bd747
- Files:
-
- 4 edited
-
src/services/guardianship/handshake.js (modified) (1 diff)
-
src/services/guardianship/offers.js (modified) (2 diffs)
-
src/services/guardianship/queues.js (modified) (2 diffs)
-
test/guardianship.test.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/services/guardianship/handshake.js
r69bd747 r30d0e2c 545 545 } 546 546 547 /** 548 * §4.2 SHOULD: retry the dereference for handshakes left deferred because the 549 * candidate could not be read. 550 * 551 * Waiting for a further activity from a party is not enough: the commit is 552 * triggered by the LAST `Accept`, so if that one has already arrived nothing 553 * will ever poke it again and the handshake would sit until its window closed. 554 * The ward's dashboard polling its own offers queue is this instance's 555 * schedule, exactly as a read settles a lapse (§3.6.3). 556 * 557 * Deliberately not awaited by the read: a poll should render what is true now, 558 * not block on someone else's slow server. A retry that succeeds shows up in 559 * the next poll, which is the same second or two later. 560 */ 561 export async function retryDeferred(slug) { 562 for (const o of offers.listDeferred(slug)) { 563 await maybeCommit(slug, o.offer_id).catch(() => { /* next poll tries again */ }); 564 } 565 } 566 547 567 function notify(slug, ev) { 548 568 try { if (deps && typeof deps.onEvent === 'function') deps.onEvent(slug, ev); } catch { /* best-effort */ } 549 569 } 550 570 551 export default { wireHandshake, handleOutbox, handleInbox, parseRelationship, parseUndoRelationship, endGuardianship };571 export default { wireHandshake, handleOutbox, handleInbox, parseRelationship, parseUndoRelationship, endGuardianship, retryDeferred }; -
src/services/guardianship/offers.js
r69bd747 r30d0e2c 79 79 } 80 80 81 /** Pending offers where `me` is a party — the offers queue (daemon shape). */ 82 export function listForParty(slug, me) { 83 return stmts().listBySlug.get ? stmts().listBySlug.all(slug).filter((o) => isParty(o, me)) : []; 81 /** 82 * How long a guardianship handshake stays open (§3.5). Adding a guardian is a 83 * reversible decision, but not a quick one: the ward, the candidate and every 84 * existing guardian have to answer, and they are people, sometimes on holiday. 85 * A week is long enough that nobody is rushed and short enough that a forgotten 86 * offer does not sit in a child's queue for a month looking like a live choice. 87 */ 88 export const OFFER_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; 89 90 /** SQLite writes CURRENT_TIMESTAMP as UTC 'YYYY-MM-DD HH:MM:SS', which 91 * Date.parse reads as LOCAL time — hours out, and enough to expire an offer 92 * early or late. Same correction as ActivityPubService.isoStamp. */ 93 const stampMs = (v) => { 94 const s = String(v || ''); 95 return Date.parse(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/.test(s) ? `${s.replace(' ', 'T')}Z` : s); 96 }; 97 98 export const closesAt = (o) => stampMs(o.created_at) + OFFER_WINDOW_MS; 99 100 /** 101 * §3.5 fails closed: once the window has run, a handshake that never completed 102 * is over. WHICH failure it was matters (§4.2), so the two get different 103 * terminal states and neither of them is `void`: 104 * 105 * 'expired' — the parties never all answered. Nothing to say about anyone. 106 * 'unverified' — everyone answered; the candidate could never be read, so 107 * the check never got to run. The parties MUST be told this 108 * and MUST NOT be told the candidate was refused. It was not: 109 * nobody ever managed to look. 110 */ 111 export function expireIfDue(slug, offerId, now = Date.now()) { 112 const o = stmts().getOffer.get(slug, offerId); 113 if (!o || o.status !== 'pending') return null; 114 const due = closesAt(o); 115 if (!Number.isFinite(due) || due > now) return null; 116 const status = readyToCommit(o) ? 'unverified' : 'expired'; 117 stmts().setStatus.run(status, null, slug, offerId); 118 return { ...o, status }; 119 } 120 121 /** Pending offers where `me` is a party — the offers queue (daemon shape). 122 * Reads are where lazy completion happens, as with the lapses (§3.6.3): a 123 * closed window is settled here rather than by a sweeper nobody runs. */ 124 export function listForParty(slug, me, now = Date.now()) { 125 if (!stmts().listBySlug.get) return []; 126 for (const o of stmts().listBySlug.all(slug)) expireIfDue(slug, o.offer_id, now); 127 return stmts().listBySlug.all(slug).filter((o) => isParty(o, me)); 128 } 129 130 /** Handshakes whose tally is complete but which are not committed: the §4.2 131 * deferred set, waiting on a candidate nobody could dereference. */ 132 export function listDeferred(slug) { 133 if (!stmts().listBySlug.get) return []; 134 return stmts().listBySlug.all(slug).filter((o) => readyToCommit(o)); 84 135 } 85 136 … … 109 160 start, getOffer, findOfferAnywhere, recordAccept, recordReject, commit, 110 161 readyToCommit, listForParty, queueItem, parties, isParty, acceptsOf, 162 OFFER_WINDOW_MS, closesAt, expireIfDue, listDeferred, 111 163 }; -
src/services/guardianship/queues.js
r69bd747 r30d0e2c 12 12 import * as relations from './relations.js'; 13 13 import * as availability from './availability.js'; 14 import * as handshake from './handshake.js'; 14 15 15 16 const collection = (id, items) => ({ … … 22 23 * clients render both without a second fetch. */ 23 24 export function offersCollection(id, slug, me) { 25 // §4.2: a handshake whose candidate could not be dereferenced is deferred, 26 // not decided, and the last Accept may already have landed — so nothing else 27 // would ever retry it. This poll is the schedule. Not awaited: the read 28 // answers with what is true now, and a retry that succeeds surfaces in the 29 // next one. `listForParty` settles closed windows on the way past. 30 handshake.retryDeferred(slug).catch(() => { /* the next read tries again */ }); 24 31 const items = offers.listForParty(slug, me).map((o) => offers.queueItem(o, me)); 25 32 items.push(...availability.lapseQueueItems(slug, me, Date.now())); -
test/guardianship.test.js
r69bd747 r30d0e2c 187 187 ); 188 188 assert.deepEqual(G.listGuardians('zed'), []); 189 }); 190 191 test('an unreadable candidate defers the commit, and the window names that failure (§4.2)', async () => { 192 const offers = await import('../src/services/guardianship/offers.js'); 193 const noor = site('s16', 'noor'); 194 const NOOR = A('noor'); 195 const MARA = 'https://elders.example/users/mara'; // remote; fetchActor returns null here 196 const offerId = `${MARA}/offers/m1`; 197 198 // The Offer arrives and is STORED: unreachable is not malformed, and 199 // refusing on a failed fetch would let any outage block an adoption. 200 const taken = await G.handleGuardianshipInbox(noor, { 201 id: offerId, type: 'Offer', actor: MARA, to: [NOOR], 202 object: { type: 'Relationship', subject: NOOR, relationship: 'shaer:Guardian', object: MARA }, 203 }); 204 assert.equal(taken, true); 205 206 // Noor accepts. Free ward + candidate's own offer = the tally is complete, 207 // so this WOULD commit — except the candidate cannot be read. 208 const done = await G.handleGuardianshipOutbox(noor, { type: 'Accept', object: offerId }); 209 assert.equal(done.committed, false, 'not committed: nobody verified the candidate'); 210 assert.ok(!done.refused, 'and not refused either — that would blame a candidate nobody could look at'); 211 assert.deepEqual(G.listGuardians('noor'), []); 212 assert.equal(offers.getOffer('noor', offerId).status, 'pending', 'deferred, not decided'); 213 214 // A week later the §3.5 window closes and it fails closed — under its own 215 // name. Not 'void': the parties must not be told the candidate was refused. 216 const later = Date.now() + offers.OFFER_WINDOW_MS + 1000; 217 offers.expireIfDue('noor', offerId, later); 218 assert.equal(offers.getOffer('noor', offerId).status, 'unverified'); 219 220 const q = G.offersCollection(`${NOOR}/queues/offers`, 'noor', NOOR).orderedItems; 221 assert.deepEqual(q.filter((o) => o.id === offerId), [], 'and it stops looking like a live choice'); 222 }); 223 224 test('a handshake nobody finished expires under a different name (§3.5)', async () => { 225 const offers = await import('../src/services/guardianship/offers.js'); 226 const finn = site('s17', 'finn'); 227 const iris = site('s18', 'iris'); 228 const [FINN, IRIS] = [A('finn'), A('iris')]; 229 230 const off = await G.handleGuardianshipOutbox(finn, { 231 type: 'Offer', object: { type: 'Relationship', subject: IRIS, relationship: 'shaer:Guardian', object: FINN }, 232 }); 233 // Iris never answers. Reading the queue after the window settles it. 234 G.offersCollection(`${IRIS}/queues/offers`, 'iris', IRIS); // still open now 235 assert.equal(offers.getOffer('iris', off.id).status, 'pending'); 236 offers.listForParty('iris', IRIS, Date.now() + offers.OFFER_WINDOW_MS + 1000); 237 assert.equal(offers.getOffer('iris', off.id).status, 'expired', 238 'nobody answered — that says nothing about the candidate, so it is not "unverified"'); 189 239 }); 190 240
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)