source: Klonkt/src/services/guardianship/handshake.js@ 3d882bd

main
Last change on this file since 3d882bd was 3d882bd, checked in by Bart <bart@…>, 5 weeks ago

FEP-633c §4.1: een kapotte guardian kost een kind niet de goede

Een "guardian" met eigen guardians is er geen (§1), en een escalatie daarheen
komt nergens aan: er is geen grand-guardian om naar door te vertakken. Klonkt
handhaafde dat nergens. De daemon doet het vanaf het begin, en dat verschil is
precies waar shaer-6d9 voor bestaat.

Nu zacht falen zoals §4.1 vraagt: dat ene doelwit valt af, de rest krijgt de
hulpvraag gewoon. Andersom zou één verkeerd geconfigureerd account van een
volwassene de noodroep van een kind helemaal laten mislukken.

Alleen bij een hulpvraag. Een gewoon direct bericht is geen escalatie, en een
ward mag een andere ward best iets sturen — daar stilletjes ontvangers uit
slopen zou een bug zijn met een spec-verwijzing eromheen.

Als ELKE guardian kapot is, is er niets om naar door te leveren. §4 dekt dat
niet, want §4.1 gaat ervan uit dat er anderen zijn. Dan komt de hulpvraag bij
niemand aan, en dat is het enige wat deze FEP juist moet voorkomen: dat faalt
dus luid in de log in plaats van een aflevering te melden die niet gebeurde.

carriesGuardians() staat nu in context.js, waar de rest van het vocabulaire ook
woont: §3 en §5.2 stellen dezelfde vraag en moeten hem hetzelfde lezen.

Co-Authored-By: Claude Opus 5 <claude@…>

  • Property mode set to 100644
File size: 29.4 KB
Line 
1/**
2 * Guardianship (FEP-633c §3) — the adoption handshake, multi-party and
3 * distributed across instances.
4 *
5 * The candidate Offers a Relationship{subject: ward, object: candidate},
6 * addressed to the ward AND every existing guardian of the ward. Each party
7 * (ward, existing guardians, and finally the candidate) Accepts, addressed to
8 * all the others, so every instance's copy of the tally converges. The
9 * candidate's Accept is the LAST one and carries the escalation handle in
10 * `result`: that return is the atomic commit (§3.1.3). Only then does the
11 * ward gain the guardian in shaer:guardians and the guardian gain the ward.
12 * A single Reject from any party voids the offer (§3.2).
13 *
14 * The state machine lives in offers.js (a faithful port of the Shaer test
15 * daemon); this module wires it onto Klonkt's C2S/S2S plumbing. AP helpers
16 * arrive once via wireHandshake(deps); nothing here imports ActivityPubService.
17 */
18import { isGuardianRelationship, GUARDIAN_RELATIONSHIP_COMPACT, carriesGuardians } from './context.js';
19import * as offers from './offers.js';
20import * as relations from './relations.js';
21import * as gated from './gated.js';
22import * as availability from './availability.js';
23
24let deps = null;
25export function wireHandshake(d) { deps = d; }
26
27const idOf = (v) => (typeof v === 'string' ? v : (v && typeof v === 'object' && typeof v.id === 'string' ? v.id : null));
28const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
29
30/**
31 * FEP-633c §3.2/§3.3 — ending a guardianship.
32 *
33 * "After commit, either side MAY end the relationship with `Undo` of the
34 * `Relationship`. An `Undo` from a guardian, or from the ward co-signed by an
35 * existing guardian, removes the guardian from `shaer:guardians`."
36 *
37 * §3.3 bounds it: this is how ONE guardian goes while others remain. Removing
38 * the last one empties `shaer:guardians` and that is emancipation (§3.4), which
39 * has its own flow and is explicitly not a single party's call. So an Undo that
40 * would leave a ward with nobody is refused here rather than quietly performed.
41 */
42export function parseUndoRelationship(activity) {
43 const type = Array.isArray(activity && activity.type) ? activity.type[0] : (activity && activity.type);
44 if (type !== 'Undo') return null;
45 return parseRelationship(activity && activity.object);
46}
47
48/** Parse a Relationship object into {ward, candidate} or null. */
49export function parseRelationship(rel) {
50 if (!rel || typeof rel !== 'object') return null;
51 const type = Array.isArray(rel.type) ? rel.type[0] : rel.type;
52 if (type !== 'Relationship') return null;
53 if (!isGuardianRelationship(String(rel.relationship || ''))) return null;
54 const ward = idOf(rel.subject);
55 const candidate = idOf(rel.object);
56 return ward && candidate ? { ward, candidate } : null;
57}
58
59/** The existing guardians of a ward: local list, or the remote actor's shaer:guardians. */
60async function existingGuardiansOf(wardUri) {
61 const local = deps.localSlug(wardUri);
62 if (local) return relations.listGuardians(local).map((r) => r.other_uri);
63 const doc = await deps.fetchActor(wardUri).catch(() => null);
64 const g = doc && doc['shaer:guardians'];
65 return Array.isArray(g) ? g.filter((x) => typeof x === 'string') : [];
66}
67
68function offerActivity(offerId, ward, candidate, recipients) {
69 return {
70 id: offerId, type: 'Offer', actor: candidate, to: recipients,
71 object: { type: 'Relationship', subject: ward, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: candidate },
72 };
73}
74
75/** Deliver `activity` to every uri in `recipients` (skipping the local self). */
76async function fanout(site, recipients, activity) {
77 let anyDelivered = false;
78 for (const uri of [...new Set(recipients)]) {
79 const r = await deps.deliverTo(site, uri, activity).catch(() => ({ delivered: false }));
80 if (r && r.delivered !== false) anyDelivered = true;
81 }
82 return anyDelivered;
83}
84
85/**
86 * §5.6, the closing of the loop: a settled gated decision answers the Offer
87 * that opened it. Accept when it settled on the proposed value, Reject when on
88 * the opposite. Without this the proposer's screen can only ever say
89 * "waiting", forever, whatever actually happened: the tally lives on the
90 * ward's server and nobody else may read it, so the ward's server must speak.
91 */
92function answerGatedProposer(site, offerId, r) {
93 const o = gated.recallGatedOffer(offerId);
94 if (!o || !o.proposer) return;
95 const me = deps.selfId(site.slug);
96 if (o.proposer === me) return; // the ward proposed to itself: nothing to write home
97 const agreed = r.value === !!o.value;
98 deps.deliverTo(site, o.proposer, {
99 id: `${me}#gatedanswer-${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`,
100 type: agreed ? 'Accept' : 'Reject',
101 actor: me, to: [o.proposer], object: offerId,
102 }).catch(() => { /* the delivery queue retries */ });
103}
104
105/** Apply the local side of a commit: the ward writes its guardian, the
106 * candidate writes its ward. Each instance writes only what it hosts.
107 * other_handle is the human @handle for display (from the offer); the FEP
108 * escalation handle (candidate inbox) lives on the offer row, not here. */
109function applyCommitLocally(offer) {
110 const wardSlug = deps.localSlug(offer.ward_uri);
111 const candSlug = deps.localSlug(offer.candidate_uri);
112 if (wardSlug) relations.commitGuardianForWard(wardSlug, offer.candidate_uri, { handle: offer.candidate_handle, offerId: offer.offer_id });
113 if (candSlug) relations.commitWardForGuardian(candSlug, offer.ward_uri, { handle: offer.ward_handle, offerId: offer.offer_id });
114}
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 */
131async 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 return carriesGuardians(doc) ? 'malformed' : 'ok';
142}
143
144/** Commit this local copy of the offer when the tally is complete (ward +
145 * candidate + ≥1 existing guardian, §3.1.2). The handle is the candidate's
146 * inbox (§6 minimum); the commit is order-independent, so whichever accept
147 * lands last triggers it on every copy. */
148async function maybeCommit(slug, offerId) {
149 const offer = offers.getOffer(slug, offerId);
150 if (!offer || !offers.readyToCommit(offer)) return { done: null, refused: null };
151
152 const fitness = await candidateFitness(offer.candidate_uri);
153
154 // §4.2: unlike the soft skip at delivery (§4.1), this refusal is loud. A
155 // handshake concerns exactly one candidate, so there is no remaining
156 // well-formed target to continue to; committing anyway would leave the ward
157 // counting a guardian whose escalations get dropped. Voiding is all this
158 // function does; saying so on the wire belongs to whoever was acting.
159 if (fitness === 'malformed') {
160 offers.recordReject(slug, offerId, offer.ward_uri); // voids this copy (§3.2)
161 notify(slug, {
162 kind: 'offer_rejected', offer: offerId,
163 reason: 'not_a_teapot', candidate: offer.candidate_uri,
164 });
165 return { done: null, refused: 'not_a_teapot', offer };
166 }
167
168 // Could not read the candidate: neither commit nor void. Refusing outright
169 // would let a momentary outage destroy a multi-party adoption; committing
170 // would record a guardian nobody checked. The offer stays pending and the
171 // next accept retries.
172 if (fitness === 'unverified') return { done: null, refused: null };
173
174 const done = offers.commit(slug, offerId, `${offer.candidate_uri}/inbox`);
175 if (done) { applyCommitLocally(done); notify(slug, { kind: 'committed', ward: done.ward_uri, guardian: done.candidate_uri }); }
176 return { done, refused: null };
177}
178
179/**
180 * End a guardianship from the local guardian's side and let it travel (§3.2).
181 *
182 * One path for both callers: the button in the Guardian PWA and an `Undo` a
183 * Guardian app POSTs to its own outbox. Addressed like the Offer that started
184 * it (§3.1.1): the ward, and every other guardian, so no copy is left behind
185 * believing the relation still stands.
186 */
187export async function endGuardianship(site, wardUri) {
188 const me = deps.selfId(site.slug);
189 if (!relations.getRelation(site.slug, 'guardian', wardUri)) return { status: 404, error: 'not_my_ward' };
190 const set = await existingGuardiansOf(wardUri);
191 const others = set.filter((g) => g !== me);
192 // Only a set we actually read counts as proof. A remote ward whose server is
193 // down reads as an empty set; refusing on that would trap the guardian, and
194 // the ward's server checks again on arrival anyway.
195 if (set.length && others.length === 0) return { status: 409, error: 'would_emancipate' };
196 const recipients = [wardUri, ...others];
197 const undo = {
198 id: `${me}/undo/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`,
199 type: 'Undo', actor: me, to: recipients,
200 object: { type: 'Relationship', subject: wardUri, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: me },
201 };
202 const delivered = await fanout(site, recipients, undo);
203 relations.removeRelation(site.slug, 'guardian', wardUri);
204 // A ward we host ourselves never receives its own delivery: an inbox on this
205 // machine is not reachable over HTTP from this machine (and should not be).
206 // The commit path has the same shape and solves it the same way — each
207 // instance writes what it hosts (applyCommitLocally).
208 const wardSlug = deps.localSlug(wardUri);
209 if (wardSlug) dropGuardianFromWard(wardSlug, deps.selfId(site.slug));
210 notify(site.slug, { kind: 'guardianship_ended', ward: wardUri, delivered });
211 return { status: 202, delivered, guardiansLeft: others.length };
212}
213
214/**
215 * The ward's side of an ended guardianship: drop that guardian, unless doing so
216 * would empty the set. §3.3 only permits this while more than one remains;
217 * emptying it is emancipation (§3.4) and no single party decides that.
218 */
219function dropGuardianFromWard(wardSlug, guardianUri) {
220 const set = relations.listGuardians(wardSlug).map((r) => r.other_uri);
221 if (!set.includes(guardianUri)) return false; // already gone: an Undo is idempotent
222 if (set.length <= 1) {
223 notify(wardSlug, { kind: 'guardianship_end_refused', guardian: guardianUri, reason: 'would_emancipate' });
224 return false;
225 }
226 relations.removeRelation(wardSlug, 'ward', guardianUri);
227 notify(wardSlug, { kind: 'guardian_left', guardian: guardianUri });
228 return true;
229}
230
231/** The receiving side of that Undo. Returns true when consumed. */
232function applyInboundUndo(site, activity) {
233 const rel = parseUndoRelationship(activity);
234 if (!rel) return false;
235 const me = deps.selfId(site.slug);
236 const actor = idOf(activity.actor);
237 const ward = rel.ward;
238 const guardian = rel.candidate; // in an Undo the Relationship's object is the leaving guardian
239
240 if (ward === me) {
241 // I am the ward. Only the guardian itself may end its own relation here;
242 // the ward-co-signed variant of §3.2 needs a second signature and is not
243 // built, so it is refused rather than half-honoured.
244 if (actor !== guardian) return false;
245 dropGuardianFromWard(site.slug, guardian);
246 return true;
247 }
248
249 // I am one of the other guardians: nothing of mine changes, but being left
250 // as one of fewer is exactly the kind of thing a guardian should hear about.
251 if (relations.getRelation(site.slug, 'guardian', ward)) {
252 notify(site.slug, { kind: 'coguardian_left', ward, guardian });
253 return true;
254 }
255 return false;
256}
257
258// ── C2S: a LOCAL party acts (PWA, Berichten, or the Shaer app outbox) ──────
259
260/**
261 * Handle a guardianship activity POSTed to the local outbox. Returns null when
262 * it is not ours, else {status, ...} for the route.
263 */
264export async function handleOutbox(site, activity) {
265 const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
266 if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return null;
267 const me = deps.selfId(site.slug);
268 // One answer restores everything (§3.6): any C2S activity from this actor
269 // is that answer, for every local ward it guards. Runs before anything is
270 // even looked at, so the target of a running lapse cancels it by doing
271 // anything at all — including trying to vote on it.
272 try { availability.oneAnswer(me, Date.now()); } catch { /* never load-bearing */ }
273
274 // ── Undo: a guardian ends its own guardianship (§3.2). Same path as the
275 // button in the Guardian PWA, so an app and the dashboard cannot drift.
276 if (type === 'Undo') {
277 const rel = parseUndoRelationship(activity);
278 if (!rel) return null;
279 if (rel.candidate !== me) return { status: 403, error: 'not_your_relation' };
280 return endGuardianship(site, rel.ward);
281 }
282
283 // ── Offer: the local site is the guardian-candidate. ───────────────────
284 if (type === 'Offer') {
285 // §3.6.3 over C2S: a guardian here proposes releasing a dormant
286 // co-guardian. A ward we host opens locally; a remote ward gets the
287 // proposal delivered, because the ward's server is the one that tallies
288 // and enforces (the §5.6 line: a guardian next door must not have more
289 // say than one far away).
290 const lp = availability.parseLapse(activity.object);
291 if (lp) {
292 // ONE path (Robins regel, 29-7): the ward's server opens, tallies and
293 // enforces, wherever it lives. A local ward is reached by the same
294 // deliverTo, which loops back into the inbox handler; co-location is a
295 // transport detail and never a shortcut past the decision.
296 const id = `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
297 const offer = { id, type: 'Offer', actor: me, to: [lp.ward], object: { type: 'shaer:Lapse', 'shaer:ward': lp.ward, object: lp.target } };
298 const delivered = await fanout(site, [lp.ward], offer);
299 return { status: 202, id, url: id, delivered };
300 }
301 const rel = parseRelationship(activity.object);
302 if (!rel) return null;
303 if (rel.candidate !== me) return { status: 403, error: 'only_the_candidate_offers' }; // fixed initiator (§3.1)
304 if (relations.listGuardians(site.slug).length) return { status: 403, error: 'a_ward_cannot_guard' }; // §1
305 const existing = await existingGuardiansOf(rel.ward);
306 const offerId = `${me}/offers/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
307 offers.start(site.slug, {
308 offerId, ward: rel.ward, candidate: me, existingGuardians: existing,
309 wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(me),
310 });
311 // The Offer IS the candidate's agreement to serve: record it as the
312 // candidate's accept. So a FREE ward commits on its own single accept (no
313 // second guardian to co-approve yet); once it IS a ward, adding another
314 // guardian still needs an existing guardian to co-accept.
315 offers.recordAccept(site.slug, offerId, me);
316 // Addressed to the ward AND every existing guardian (§3.1.1).
317 const recipients = [rel.ward, ...existing];
318 const delivered = await fanout(site, recipients, offerActivity(offerId, rel.ward, me, recipients));
319 notify(site.slug, { kind: 'offer_sent', ward: rel.ward });
320 return { status: 202, id: offerId, url: offerId, delivered };
321 }
322
323 // ── Accept / Reject: the local site is a party answering an offer. ─────
324 const offerId = idOf(activity.object);
325 if (!offerId) return { status: 400, error: 'missing_offer' };
326 // A lapse vote over C2S (§3.6.3): the same Accept/Reject wire the offers
327 // and gated follows use, which is exactly why the Shaer clients need no
328 // new verbs for it.
329 if (availability.getLapse(offerId)) {
330 const r = availability.lapseVote(offerId, me, type === 'Accept', Date.now());
331 if (r && r.error) return { status: r.error === 'not_in_set' ? 403 : 409, error: r.error };
332 return { status: 202, id: offerId, url: offerId, 'shaer:outcome': 'open', 'shaer:accepts': r.accepts, 'shaer:threshold': r.threshold };
333 }
334 let offer = offers.getOffer(site.slug, offerId);
335 if (!offer) return { status: 404, error: 'no_such_offer' };
336 const others = offers.parties(offer).filter((p) => p !== me);
337
338 if (type === 'Reject') {
339 offers.recordReject(site.slug, offerId, me);
340 await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Reject', actor: me, to: others, object: offerId });
341 notify(site.slug, { kind: 'offer_rejected', offer: offerId });
342 return { status: 202, id: offerId, url: offerId };
343 }
344
345 // Accept: record my accept, broadcast it to the other parties, and commit
346 // this copy if the tally is now complete (order-independent, §3.1.3).
347 offers.recordAccept(site.slug, offerId, me);
348 await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Accept', actor: me, to: others, object: offerId });
349 const { done, refused, offer: voided } = await maybeCommit(site.slug, offerId);
350 if (refused) {
351 // §4.2: the refusal travels as a `Reject` of the Offer (§3.2), which an
352 // implementation unaware of §4 still handles correctly. Who is told WHY is
353 // not uniform, and deliberately so.
354 const answer = (to, withReason) => ({
355 id: `${me}/answers/${Date.now().toString(36)}`,
356 type: 'Reject', actor: me, to, object: offerId,
357 ...(withReason ? { 'shaer:notATeapot': true } : {}),
358 });
359 const candidate = voided && voided.candidate_uri;
360 // The ward and its existing guardians MUST learn the reason: they are
361 // parties, the condition is public data (§2.1), and a bare void would
362 // leave a ward believing an adoption completed that did not.
363 const family = others.filter((u) => u !== candidate);
364 if (family.length) await fanout(site, family, answer(family, true));
365 // The candidate gets a BARE Reject. Commit is the last step of §3.1, so a
366 // refusal that names itself technical also discloses that every human
367 // party already accepted and only the protocol objected — which, where a
368 // guardianship is contested, is not theirs to learn. The kind path for an
369 // merely misconfigured candidate is the check on the Offer, before anyone
370 // has consented to anything.
371 if (candidate && others.includes(candidate)) await fanout(site, [candidate], answer([candidate], false));
372 return { status: 202, id: offerId, url: offerId, committed: false, refused };
373 }
374 return { status: 202, id: offerId, url: offerId, committed: !!done, readyToCommit: offers.readyToCommit(offers.getOffer(site.slug, offerId)) };
375}
376
377// ── S2S: a REMOTE party's activity arrives in a local inbox ────────────────
378
379/**
380 * Handle an inbound guardianship activity for the local site `site` (the inbox
381 * owner). Returns true when consumed.
382 */
383export async function handleInbox(site, activity) {
384 const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
385 if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return false;
386 if (type === 'Undo') return applyInboundUndo(site, activity);
387 const me = deps.selfId(site.slug);
388 const actor = idOf(activity.actor);
389
390 // §5.6: a guardian proposes a gated setting for THIS ward. The ward's server
391 // tallies and enforces, so the decision lands here, not on the proposer.
392 if (type === 'Offer') {
393 const gs = gated.parseGatedSetting(activity.object);
394 if (gs) {
395 const offerId = idOf(activity);
396 // ── I am the WARD: record, tally, and forward to the other guardians.
397 if (gs.ward === me) {
398 gated.rememberGatedOffer(offerId, site.slug, gs.feature, gs.value, actor);
399 // The proposer's Offer carries its own agreement (§3.1's one-step clause).
400 const r = gated.recordGatedVote(site.slug, gs.feature, actor, gs.value);
401 // The forward is the leg that was missing. A proposal addressed to the
402 // ward's server reaches only the proposer and the ward; the other
403 // guardians never learn it exists, so a threshold of two can never be
404 // met and every proposal expires unanswered. The ward's server is the
405 // one that knows the authoritative guardian list, which is exactly why
406 // §5.3 forwards a gated follow from here too.
407 if (r.state === 'open') {
408 for (const g of relations.listGuardians(site.slug).map((x) => x.other_uri)) {
409 if (g === actor) continue; // the proposer already answered
410 // The forward goes out AS THE WARD, because the ward's key signs
411 // it. Keeping the proposer in `actor` made every receiver answer
412 // 401 signer mismatch, and rightly so: the body claimed one author
413 // and the signature proved another. §5.3 forwards a gated follow
414 // the same way. Who proposed it rides along separately, for the
415 // guardian's screen.
416 deps.deliverTo(site, g, {
417 id: offerId, type: 'Offer', actor: me, to: [g], object: activity.object,
418 'shaer:proposer': actor,
419 }).catch(() => { /* the delivery queue retries */ });
420 }
421 } else {
422 gated.clearGatedReviews(offerId); // settled at once: nothing left to ask
423 answerGatedProposer(site, offerId, r);
424 }
425 notify(site.slug, { kind: 'gated_setting', feature: gs.feature, value: gs.value, state: r.state });
426 return true;
427 }
428 // ── I am one of the GUARDIANS: the forwarded copy. Store it so this
429 // guardian can answer; the answer goes back to the ward, which tallies.
430 if (relations.getRelation(site.slug, 'guardian', gs.ward)) {
431 const wardDoc = await deps.fetchActor(gs.ward).catch(() => null);
432 gated.recordGatedReview(site.slug, {
433 id: offerId, wardUri: gs.ward, wardInbox: wardDoc && wardDoc.inbox,
434 // A forward is signed by the ward, so `actor` is the ward; the
435 // guardian who opened it travels in shaer:proposer.
436 proposer: (typeof activity['shaer:proposer'] === 'string' ? activity['shaer:proposer'] : actor),
437 feature: gs.feature, value: gs.value,
438 });
439 notify(site.slug, { kind: 'gated_review', feature: gs.feature, value: gs.value, ward: gs.ward });
440 return true;
441 }
442 return false; // not our ward, and not a ward we guard
443 }
444 // §3.6.3: a co-guardian proposes releasing a dormant guardian of THIS
445 // ward. The ward's server opens, tallies and (after the full window)
446 // executes, exactly as it does for the gated settings above.
447 const lp = availability.parseLapse(activity.object);
448 if (lp) {
449 if (lp.ward !== me) return false; // not our ward
450 const id = idOf(activity) || `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
451 const r = availability.openLapse({ id, wardSlug: site.slug, wardUri: me, target: lp.target, openedBy: actor, now: Date.now() });
452 if (r.error) {
453 notify(site.slug, { kind: 'lapse_refused', reason: r.error, target: lp.target });
454 return true; // consumed: the refusal is the answer
455 }
456 // The target is notified like any dormancy marking (§3.6.2): in
457 // protocol (a copy of the Offer, so one answer can cancel it) AND the
458 // §6 handle, which for a committed guardian is its inbox — the same
459 // door this delivery knocks on.
460 deps.deliverTo(site, lp.target, activity).catch(() => { /* best-effort */ });
461 notify(site.slug, { kind: 'lapse_opened', lapse: id, target: lp.target, set: r.set });
462 return true;
463 }
464 const rel = parseRelationship(activity.object);
465 if (!rel) return false;
466 // I must be a party: the ward, or one of the existing guardians in `to`.
467 const recipients = arr(activity.to);
468 const existing = recipients.filter((u) => u !== rel.ward);
469 if (rel.ward !== me && !existing.includes(me)) return false;
470 // §4.2: check the candidate here too, and refuse before anyone accepts.
471 // At this point no party has consented, so saying why discloses nothing
472 // about anyone's position, and a candidate that is merely misconfigured
473 // can find that out and fix it. The commit-time check stays REQUIRED as
474 // the backstop for a candidate whose state changes in between.
475 if (await candidateFitness(rel.candidate) === 'malformed') {
476 notify(site.slug, { kind: 'offer_refused', offer: idOf(activity), reason: 'not_a_teapot', candidate: rel.candidate });
477 await fanout(site, [rel.candidate], {
478 id: `${me}/answers/${Date.now().toString(36)}`,
479 type: 'Reject', actor: me, to: [rel.candidate], object: idOf(activity), 'shaer:notATeapot': true,
480 });
481 return true;
482 }
483 offers.start(site.slug, {
484 offerId: idOf(activity), ward: rel.ward, candidate: rel.candidate, existingGuardians: existing,
485 wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(rel.candidate),
486 });
487 // The Offer carries the candidate's agreement (see the C2S side): record it
488 // so this copy's tally matches — a free ward then commits on its own accept.
489 offers.recordAccept(site.slug, idOf(activity), rel.candidate);
490 notify(site.slug, { kind: rel.ward === me ? 'offer_received' : 'offer_for_ward', ward: rel.ward, candidate: rel.candidate });
491 return true;
492 }
493
494 // Accept / Reject of an offer we (also) track.
495 const offerId = idOf(activity.object);
496 // §5.6, the answer coming HOME: the ward's server settled a decision we
497 // proposed and answers our Offer. Accept = it settled on what we proposed,
498 // Reject = on the opposite. Only the ward may say so: the answer must come
499 // from the ward the proposal was about, or anyone could close our books.
500 const sent = gated.recallSent(offerId);
501 if (sent && sent.guardian_slug === site.slug) {
502 if (actor !== sent.ward_uri) return false; // not the ward's voice: not an outcome
503 const outcome = type === 'Accept' ? 'accepted' : 'rejected';
504 gated.settleSent(offerId, outcome);
505 notify(site.slug, { kind: 'gated_outcome', feature: sent.feature, value: !!sent.value, outcome, ward: sent.ward_uri });
506 return true;
507 }
508 // §5.6: a fellow guardian answering a gated-setting proposal. The Accept only
509 // references the offer, so the value comes from the proposal we stored. A
510 // Reject is a vote for the opposite, not a shrug: it is still an answer.
511 const gsOffer = gated.recallGatedOffer(offerId);
512 if (gsOffer && gsOffer.slug === site.slug) {
513 const value = type === 'Accept' ? !!gsOffer.value : !gsOffer.value;
514 const r = gated.recordGatedVote(site.slug, gsOffer.feature, actor, value);
515 if (r.state === 'settled') answerGatedProposer(site, offerId, r);
516 notify(site.slug, { kind: 'gated_setting', feature: gsOffer.feature, value, state: r.state });
517 return true;
518 }
519 // §3.6.3: a set member answering a running lapse. Irreversible, so even a
520 // full tally leaves it open until the window closes (§3.5); the completion
521 // happens lazily on reads (queues) once the window has run.
522 if (availability.getLapse(offerId)) {
523 const r = availability.lapseVote(offerId, actor, type === 'Accept', Date.now());
524 notify(site.slug, { kind: 'lapse_vote', lapse: offerId, by: actor, state: r && !r.error ? 'recorded' : (r && r.error) || 'refused' });
525 return true;
526 }
527 let offer = offers.getOffer(site.slug, offerId);
528 if (!offer) return false;
529 if (!offers.isParty(offer, actor)) return false;
530
531 if (type === 'Reject') {
532 offers.recordReject(site.slug, offerId, actor);
533 notify(site.slug, { kind: 'offer_rejected', offer: offerId });
534 return true;
535 }
536
537 offers.recordAccept(site.slug, offerId, actor);
538 await maybeCommit(site.slug, offerId); // commits this copy once the tally is complete (§4.2 may refuse)
539 return true;
540}
541
542/**
543 * §4.2 SHOULD: retry the dereference for handshakes left deferred because the
544 * candidate could not be read.
545 *
546 * Waiting for a further activity from a party is not enough: the commit is
547 * triggered by the LAST `Accept`, so if that one has already arrived nothing
548 * will ever poke it again and the handshake would sit until its window closed.
549 * The ward's dashboard polling its own offers queue is this instance's
550 * schedule, exactly as a read settles a lapse (§3.6.3).
551 *
552 * Deliberately not awaited by the read: a poll should render what is true now,
553 * not block on someone else's slow server. A retry that succeeds shows up in
554 * the next poll, which is the same second or two later.
555 */
556export async function retryDeferred(slug) {
557 for (const o of offers.listDeferred(slug)) {
558 await maybeCommit(slug, o.offer_id).catch(() => { /* next poll tries again */ });
559 }
560}
561
562function notify(slug, ev) {
563 try { if (deps && typeof deps.onEvent === 'function') deps.onEvent(slug, ev); } catch { /* best-effort */ }
564}
565
566export default { wireHandshake, handleOutbox, handleInbox, parseRelationship, parseUndoRelationship, endGuardianship, retryDeferred };
Note: See TracBrowser for help on using the repository browser.