source: Klonkt/src/services/guardianship/handshake.js@ ea211b9

main
Last change on this file since ea211b9 was ea211b9, checked in by roboburr <roboburr@…>, 4 weeks ago

Je hoort te weten dat je de doorslag geeft (shaer-8vt)

De telling is een race naar de drempel: zodra het aantal gehaald is, is het
besluit gevallen. Bij 2 van 3 is de tweede ja meteen de beslissing -- en sinds
Barts meerderheidsbesluit van vandaag is bij een volgverzoek met twee guardians
de EERSTE ja dat al. Wie antwoordde wist dat niet, en het scherm zei het nergens.

Dat kon niet, en waarom niet was de vondst bij shaer-nf9: gatedProgress werkt op
een LOKALE slug en er zijn geen lokale wards. De telling loopt op de server van
het kind, en die stuurde hem niet mee. Nu wel: shaer:decisive reist mee met de
doorgestuurde Offer, voor gate-voorstellen en voor volgverzoeken in beide
richtingen.

EEN JA/NEE, GEEN TELLING, en dat is een besluit. Een getal ("1 van 2") reist mee,
veroudert onderweg en leest daarna als een feit; de beschikbare set schuift met
3.6 bovendien mee. En hoeveel guardians een kind heeft, en wie er al gestemd
heeft, is niet vanzelf iets dat elke mede-guardian hoort te zien. Een
waarschuwing veroudert ook, maar hij CLAIMT niets -- en dat scheelt.

BIJ TWIJFEL WAARSCHUWEN. Ontbreekt het veld (een oudere server), dan zeggen we
dat je beslist. De twee fouten zijn niet gelijk: zeggen dat je beslist terwijl
dat niet zo is maakt iemand voorzichtiger dan nodig; niets zeggen terwijl hij wel
beslist laat hem het onwetend doen. Daar staat een toets op, en de mutatie die
ertoe doet -- onbekend als "je beslist niets" lezen -- maakt hem rood.

Eerlijk over de mutatietest: mijn eerste poging (de Number.isFinite-guards
weghalen) gaf nul fouten, want die geven voor undefined dezelfde uitkomst. De
regel bijt wel tegen de realistische verkeerde versie.

Zes toetsen, drie talen. Suite 747/747.

  • Property mode set to 100644
File size: 30.8 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 // §1 is flat in BOTH directions. The Offer path above bars a ward from
346 // offering to guard; this is the mirror: an actor that already guards wards
347 // must not become a ward itself. Only the ward's own accept can create that
348 // state, so the candidate and the existing guardians pass through untouched.
349 //
350 // Without it the inconsistency would also be invisible. actorProps() picks
351 // one role with an if/else and would publish shaer:guardians while dropping
352 // shaer:isGuardian, so this account keeps routing its wards' escalations
353 // locally while every remote §4 check reads it as malformed and drops it —
354 // a ward believing it is watched over when it is not, silent on both sides.
355 if (me === offer.ward_uri && relations.listWards(site.slug).length) {
356 return { status: 403, error: 'a_guardian_cannot_be_guarded' };
357 }
358
359 // Accept: record my accept, broadcast it to the other parties, and commit
360 // this copy if the tally is now complete (order-independent, §3.1.3).
361 offers.recordAccept(site.slug, offerId, me);
362 await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Accept', actor: me, to: others, object: offerId });
363 const { done, refused, offer: voided } = await maybeCommit(site.slug, offerId);
364 if (refused) {
365 // §4.2: the refusal travels as a `Reject` of the Offer (§3.2), which an
366 // implementation unaware of §4 still handles correctly. Who is told WHY is
367 // not uniform, and deliberately so.
368 const answer = (to, withReason) => ({
369 id: `${me}/answers/${Date.now().toString(36)}`,
370 type: 'Reject', actor: me, to, object: offerId,
371 ...(withReason ? { 'shaer:notATeapot': true } : {}),
372 });
373 const candidate = voided && voided.candidate_uri;
374 // The ward and its existing guardians MUST learn the reason: they are
375 // parties, the condition is public data (§2.1), and a bare void would
376 // leave a ward believing an adoption completed that did not.
377 const family = others.filter((u) => u !== candidate);
378 if (family.length) await fanout(site, family, answer(family, true));
379 // The candidate gets a BARE Reject. Commit is the last step of §3.1, so a
380 // refusal that names itself technical also discloses that every human
381 // party already accepted and only the protocol objected — which, where a
382 // guardianship is contested, is not theirs to learn. The kind path for an
383 // merely misconfigured candidate is the check on the Offer, before anyone
384 // has consented to anything.
385 if (candidate && others.includes(candidate)) await fanout(site, [candidate], answer([candidate], false));
386 return { status: 202, id: offerId, url: offerId, committed: false, refused };
387 }
388 return { status: 202, id: offerId, url: offerId, committed: !!done, readyToCommit: offers.readyToCommit(offers.getOffer(site.slug, offerId)) };
389}
390
391// ── S2S: a REMOTE party's activity arrives in a local inbox ────────────────
392
393/**
394 * Handle an inbound guardianship activity for the local site `site` (the inbox
395 * owner). Returns true when consumed.
396 */
397export async function handleInbox(site, activity) {
398 const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
399 if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return false;
400 if (type === 'Undo') return applyInboundUndo(site, activity);
401 const me = deps.selfId(site.slug);
402 const actor = idOf(activity.actor);
403
404 // §5.6: a guardian proposes a gated setting for THIS ward. The ward's server
405 // tallies and enforces, so the decision lands here, not on the proposer.
406 if (type === 'Offer') {
407 const gs = gated.parseGatedSetting(activity.object);
408 if (gs) {
409 const offerId = idOf(activity);
410 // ── I am the WARD: record, tally, and forward to the other guardians.
411 if (gs.ward === me) {
412 gated.rememberGatedOffer(offerId, site.slug, gs.feature, gs.value, actor);
413 // The proposer's Offer carries its own agreement (§3.1's one-step clause).
414 const r = gated.recordGatedVote(site.slug, gs.feature, actor, gs.value);
415 // The forward is the leg that was missing. A proposal addressed to the
416 // ward's server reaches only the proposer and the ward; the other
417 // guardians never learn it exists, so a threshold of two can never be
418 // met and every proposal expires unanswered. The ward's server is the
419 // one that knows the authoritative guardian list, which is exactly why
420 // §5.3 forwards a gated follow from here too.
421 if (r.state === 'open') {
422 for (const g of relations.listGuardians(site.slug).map((x) => x.other_uri)) {
423 if (g === actor) continue; // the proposer already answered
424 // The forward goes out AS THE WARD, because the ward's key signs
425 // it. Keeping the proposer in `actor` made every receiver answer
426 // 401 signer mismatch, and rightly so: the body claimed one author
427 // and the signature proved another. §5.3 forwards a gated follow
428 // the same way. Who proposed it rides along separately, for the
429 // guardian's screen.
430 // Zou DIT antwoord het besluit afmaken (shaer-8vt)? De telling loopt
431 // hier, op de server van het kind, en nergens anders -- zonder dit
432 // veld kan een guardian elders onmogelijk weten dat hij de doorslag
433 // geeft. Een ja/nee en geen getal: zie isDecisive.
434 const p = gated.gatedProgress(site.slug, gs.feature);
435 deps.deliverTo(site, g, {
436 id: offerId, type: 'Offer', actor: me, to: [g], object: activity.object,
437 'shaer:proposer': actor,
438 'shaer:decisive': gated.isDecisive(p.votes, p.need),
439 }).catch(() => { /* the delivery queue retries */ });
440 }
441 } else {
442 gated.clearGatedReviews(offerId); // settled at once: nothing left to ask
443 answerGatedProposer(site, offerId, r);
444 }
445 notify(site.slug, { kind: 'gated_setting', feature: gs.feature, value: gs.value, state: r.state });
446 return true;
447 }
448 // ── I am one of the GUARDIANS: the forwarded copy. Store it so this
449 // guardian can answer; the answer goes back to the ward, which tallies.
450 if (relations.getRelation(site.slug, 'guardian', gs.ward)) {
451 const wardDoc = await deps.fetchActor(gs.ward).catch(() => null);
452 gated.recordGatedReview(site.slug, {
453 id: offerId, wardUri: gs.ward, wardInbox: wardDoc && wardDoc.inbox,
454 // A forward is signed by the ward, so `actor` is the ward; the
455 // guardian who opened it travels in shaer:proposer.
456 proposer: (typeof activity['shaer:proposer'] === 'string' ? activity['shaer:proposer'] : actor),
457 feature: gs.feature, value: gs.value,
458 // Ontbreekt het veld (een oudere server), dan WAARSCHUWEN we: niets
459 // zeggen terwijl je beslist is de gevaarlijke kant (shaer-8vt).
460 decisive: activity['shaer:decisive'] !== false,
461 });
462 notify(site.slug, { kind: 'gated_review', feature: gs.feature, value: gs.value, ward: gs.ward });
463 return true;
464 }
465 return false; // not our ward, and not a ward we guard
466 }
467 // §3.6.3: a co-guardian proposes releasing a dormant guardian of THIS
468 // ward. The ward's server opens, tallies and (after the full window)
469 // executes, exactly as it does for the gated settings above.
470 const lp = availability.parseLapse(activity.object);
471 if (lp) {
472 if (lp.ward !== me) return false; // not our ward
473 const id = idOf(activity) || `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
474 const r = availability.openLapse({ id, wardSlug: site.slug, wardUri: me, target: lp.target, openedBy: actor, now: Date.now() });
475 if (r.error) {
476 notify(site.slug, { kind: 'lapse_refused', reason: r.error, target: lp.target });
477 return true; // consumed: the refusal is the answer
478 }
479 // The target is notified like any dormancy marking (§3.6.2): in
480 // protocol (a copy of the Offer, so one answer can cancel it) AND the
481 // §6 handle, which for a committed guardian is its inbox — the same
482 // door this delivery knocks on.
483 deps.deliverTo(site, lp.target, activity).catch(() => { /* best-effort */ });
484 notify(site.slug, { kind: 'lapse_opened', lapse: id, target: lp.target, set: r.set });
485 return true;
486 }
487 const rel = parseRelationship(activity.object);
488 if (!rel) return false;
489 // I must be a party: the ward, or one of the existing guardians in `to`.
490 const recipients = arr(activity.to);
491 const existing = recipients.filter((u) => u !== rel.ward);
492 if (rel.ward !== me && !existing.includes(me)) return false;
493 // §4.2: check the candidate here too, and refuse before anyone accepts.
494 // At this point no party has consented, so saying why discloses nothing
495 // about anyone's position, and a candidate that is merely misconfigured
496 // can find that out and fix it. The commit-time check stays REQUIRED as
497 // the backstop for a candidate whose state changes in between.
498 if (await candidateFitness(rel.candidate) === 'malformed') {
499 notify(site.slug, { kind: 'offer_refused', offer: idOf(activity), reason: 'not_a_teapot', candidate: rel.candidate });
500 await fanout(site, [rel.candidate], {
501 id: `${me}/answers/${Date.now().toString(36)}`,
502 type: 'Reject', actor: me, to: [rel.candidate], object: idOf(activity), 'shaer:notATeapot': true,
503 });
504 return true;
505 }
506 offers.start(site.slug, {
507 offerId: idOf(activity), ward: rel.ward, candidate: rel.candidate, existingGuardians: existing,
508 wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(rel.candidate),
509 });
510 // The Offer carries the candidate's agreement (see the C2S side): record it
511 // so this copy's tally matches — a free ward then commits on its own accept.
512 offers.recordAccept(site.slug, idOf(activity), rel.candidate);
513 notify(site.slug, { kind: rel.ward === me ? 'offer_received' : 'offer_for_ward', ward: rel.ward, candidate: rel.candidate });
514 return true;
515 }
516
517 // Accept / Reject of an offer we (also) track.
518 const offerId = idOf(activity.object);
519 // §5.6, the answer coming HOME: the ward's server settled a decision we
520 // proposed and answers our Offer. Accept = it settled on what we proposed,
521 // Reject = on the opposite. Only the ward may say so: the answer must come
522 // from the ward the proposal was about, or anyone could close our books.
523 const sent = gated.recallSent(offerId);
524 if (sent && sent.guardian_slug === site.slug) {
525 if (actor !== sent.ward_uri) return false; // not the ward's voice: not an outcome
526 const outcome = type === 'Accept' ? 'accepted' : 'rejected';
527 gated.settleSent(offerId, outcome);
528 notify(site.slug, { kind: 'gated_outcome', feature: sent.feature, value: !!sent.value, outcome, ward: sent.ward_uri });
529 return true;
530 }
531 // §5.6: a fellow guardian answering a gated-setting proposal. The Accept only
532 // references the offer, so the value comes from the proposal we stored. A
533 // Reject is a vote for the opposite, not a shrug: it is still an answer.
534 const gsOffer = gated.recallGatedOffer(offerId);
535 if (gsOffer && gsOffer.slug === site.slug) {
536 const value = type === 'Accept' ? !!gsOffer.value : !gsOffer.value;
537 const r = gated.recordGatedVote(site.slug, gsOffer.feature, actor, value);
538 if (r.state === 'settled') answerGatedProposer(site, offerId, r);
539 notify(site.slug, { kind: 'gated_setting', feature: gsOffer.feature, value, state: r.state });
540 return true;
541 }
542 // §3.6.3: a set member answering a running lapse. Irreversible, so even a
543 // full tally leaves it open until the window closes (§3.5); the completion
544 // happens lazily on reads (queues) once the window has run.
545 if (availability.getLapse(offerId)) {
546 const r = availability.lapseVote(offerId, actor, type === 'Accept', Date.now());
547 notify(site.slug, { kind: 'lapse_vote', lapse: offerId, by: actor, state: r && !r.error ? 'recorded' : (r && r.error) || 'refused' });
548 return true;
549 }
550 let offer = offers.getOffer(site.slug, offerId);
551 if (!offer) return false;
552 if (!offers.isParty(offer, actor)) return false;
553
554 if (type === 'Reject') {
555 offers.recordReject(site.slug, offerId, actor);
556 notify(site.slug, { kind: 'offer_rejected', offer: offerId });
557 return true;
558 }
559
560 offers.recordAccept(site.slug, offerId, actor);
561 await maybeCommit(site.slug, offerId); // commits this copy once the tally is complete (§4.2 may refuse)
562 return true;
563}
564
565/**
566 * §4.2 SHOULD: retry the dereference for handshakes left deferred because the
567 * candidate could not be read.
568 *
569 * Waiting for a further activity from a party is not enough: the commit is
570 * triggered by the LAST `Accept`, so if that one has already arrived nothing
571 * will ever poke it again and the handshake would sit until its window closed.
572 * The ward's dashboard polling its own offers queue is this instance's
573 * schedule, exactly as a read settles a lapse (§3.6.3).
574 *
575 * Deliberately not awaited by the read: a poll should render what is true now,
576 * not block on someone else's slow server. A retry that succeeds shows up in
577 * the next poll, which is the same second or two later.
578 */
579export async function retryDeferred(slug) {
580 for (const o of offers.listDeferred(slug)) {
581 await maybeCommit(slug, o.offer_id).catch(() => { /* next poll tries again */ });
582 }
583}
584
585function notify(slug, ev) {
586 try { if (deps && typeof deps.onEvent === 'function') deps.onEvent(slug, ev); } catch { /* best-effort */ }
587}
588
589export default { wireHandshake, handleOutbox, handleInbox, parseRelationship, parseUndoRelationship, endGuardianship, retryDeferred };
Note: See TracBrowser for help on using the repository browser.