source: Klonkt/src/services/guardianship/handshake.js@ 69bd747

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

§4.2 bijgewerkt: wie wat te horen krijgt is niet voor iedereen hetzelfde

De herziene §4.2 maakt twee dingen expliciet die de implementatie van vanmiddag
verkeerd deed.

Eén: de kandidaat krijgt een KALE Reject. Commit is de laatste stap van §3.1,
dus een weigering die zichzelf als technisch aankondigt verklapt meteen dat alle
menselijke partijen al hadden geaccepteerd en alleen het protocol nog bezwaar
maakte. Bij een betwiste guardianship is dat precies wat een partij niet hoort
te weten. De ward en de bestaande guardians krijgen de reden wél: zij zijn
partij, de toestand komt uit publieke data (§2.1), en een stille void laat een
ward geloven dat een adoptie doorging die niet doorging.

Twee: dezelfde controle draait nu ook als de Offer binnenkomt. Daar heeft nog
niemand geaccepteerd, dus daar mag de reden gewoon mee — en een kandidaat die
alleen maar verkeerd geconfigureerd staat komt daar achter op het moment dat
dat nog alles is wat het betekent. De controle bij commit blijft verplicht als
vangnet voor wie tussendoor van toestand verandert.

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

  • Property mode set to 100644
File size: 28.7 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 } 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 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
149/** Commit this local copy of the offer when the tally is complete (ward +
150 * candidate + ≥1 existing guardian, §3.1.2). The handle is the candidate's
151 * inbox (§6 minimum); the commit is order-independent, so whichever accept
152 * lands last triggers it on every copy. */
153async function maybeCommit(slug, offerId) {
154 const offer = offers.getOffer(slug, offerId);
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', offer };
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
179 const done = offers.commit(slug, offerId, `${offer.candidate_uri}/inbox`);
180 if (done) { applyCommitLocally(done); notify(slug, { kind: 'committed', ward: done.ward_uri, guardian: done.candidate_uri }); }
181 return { done, refused: null };
182}
183
184/**
185 * End a guardianship from the local guardian's side and let it travel (§3.2).
186 *
187 * One path for both callers: the button in the Guardian PWA and an `Undo` a
188 * Guardian app POSTs to its own outbox. Addressed like the Offer that started
189 * it (§3.1.1): the ward, and every other guardian, so no copy is left behind
190 * believing the relation still stands.
191 */
192export async function endGuardianship(site, wardUri) {
193 const me = deps.selfId(site.slug);
194 if (!relations.getRelation(site.slug, 'guardian', wardUri)) return { status: 404, error: 'not_my_ward' };
195 const set = await existingGuardiansOf(wardUri);
196 const others = set.filter((g) => g !== me);
197 // Only a set we actually read counts as proof. A remote ward whose server is
198 // down reads as an empty set; refusing on that would trap the guardian, and
199 // the ward's server checks again on arrival anyway.
200 if (set.length && others.length === 0) return { status: 409, error: 'would_emancipate' };
201 const recipients = [wardUri, ...others];
202 const undo = {
203 id: `${me}/undo/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`,
204 type: 'Undo', actor: me, to: recipients,
205 object: { type: 'Relationship', subject: wardUri, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: me },
206 };
207 const delivered = await fanout(site, recipients, undo);
208 relations.removeRelation(site.slug, 'guardian', wardUri);
209 // A ward we host ourselves never receives its own delivery: an inbox on this
210 // machine is not reachable over HTTP from this machine (and should not be).
211 // The commit path has the same shape and solves it the same way — each
212 // instance writes what it hosts (applyCommitLocally).
213 const wardSlug = deps.localSlug(wardUri);
214 if (wardSlug) dropGuardianFromWard(wardSlug, deps.selfId(site.slug));
215 notify(site.slug, { kind: 'guardianship_ended', ward: wardUri, delivered });
216 return { status: 202, delivered, guardiansLeft: others.length };
217}
218
219/**
220 * The ward's side of an ended guardianship: drop that guardian, unless doing so
221 * would empty the set. §3.3 only permits this while more than one remains;
222 * emptying it is emancipation (§3.4) and no single party decides that.
223 */
224function dropGuardianFromWard(wardSlug, guardianUri) {
225 const set = relations.listGuardians(wardSlug).map((r) => r.other_uri);
226 if (!set.includes(guardianUri)) return false; // already gone: an Undo is idempotent
227 if (set.length <= 1) {
228 notify(wardSlug, { kind: 'guardianship_end_refused', guardian: guardianUri, reason: 'would_emancipate' });
229 return false;
230 }
231 relations.removeRelation(wardSlug, 'ward', guardianUri);
232 notify(wardSlug, { kind: 'guardian_left', guardian: guardianUri });
233 return true;
234}
235
236/** The receiving side of that Undo. Returns true when consumed. */
237function applyInboundUndo(site, activity) {
238 const rel = parseUndoRelationship(activity);
239 if (!rel) return false;
240 const me = deps.selfId(site.slug);
241 const actor = idOf(activity.actor);
242 const ward = rel.ward;
243 const guardian = rel.candidate; // in an Undo the Relationship's object is the leaving guardian
244
245 if (ward === me) {
246 // I am the ward. Only the guardian itself may end its own relation here;
247 // the ward-co-signed variant of §3.2 needs a second signature and is not
248 // built, so it is refused rather than half-honoured.
249 if (actor !== guardian) return false;
250 dropGuardianFromWard(site.slug, guardian);
251 return true;
252 }
253
254 // I am one of the other guardians: nothing of mine changes, but being left
255 // as one of fewer is exactly the kind of thing a guardian should hear about.
256 if (relations.getRelation(site.slug, 'guardian', ward)) {
257 notify(site.slug, { kind: 'coguardian_left', ward, guardian });
258 return true;
259 }
260 return false;
261}
262
263// ── C2S: a LOCAL party acts (PWA, Berichten, or the Shaer app outbox) ──────
264
265/**
266 * Handle a guardianship activity POSTed to the local outbox. Returns null when
267 * it is not ours, else {status, ...} for the route.
268 */
269export async function handleOutbox(site, activity) {
270 const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
271 if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return null;
272 const me = deps.selfId(site.slug);
273 // One answer restores everything (§3.6): any C2S activity from this actor
274 // is that answer, for every local ward it guards. Runs before anything is
275 // even looked at, so the target of a running lapse cancels it by doing
276 // anything at all — including trying to vote on it.
277 try { availability.oneAnswer(me, Date.now()); } catch { /* never load-bearing */ }
278
279 // ── Undo: a guardian ends its own guardianship (§3.2). Same path as the
280 // button in the Guardian PWA, so an app and the dashboard cannot drift.
281 if (type === 'Undo') {
282 const rel = parseUndoRelationship(activity);
283 if (!rel) return null;
284 if (rel.candidate !== me) return { status: 403, error: 'not_your_relation' };
285 return endGuardianship(site, rel.ward);
286 }
287
288 // ── Offer: the local site is the guardian-candidate. ───────────────────
289 if (type === 'Offer') {
290 // §3.6.3 over C2S: a guardian here proposes releasing a dormant
291 // co-guardian. A ward we host opens locally; a remote ward gets the
292 // proposal delivered, because the ward's server is the one that tallies
293 // and enforces (the §5.6 line: a guardian next door must not have more
294 // say than one far away).
295 const lp = availability.parseLapse(activity.object);
296 if (lp) {
297 // ONE path (Robins regel, 29-7): the ward's server opens, tallies and
298 // enforces, wherever it lives. A local ward is reached by the same
299 // deliverTo, which loops back into the inbox handler; co-location is a
300 // transport detail and never a shortcut past the decision.
301 const id = `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
302 const offer = { id, type: 'Offer', actor: me, to: [lp.ward], object: { type: 'shaer:Lapse', 'shaer:ward': lp.ward, object: lp.target } };
303 const delivered = await fanout(site, [lp.ward], offer);
304 return { status: 202, id, url: id, delivered };
305 }
306 const rel = parseRelationship(activity.object);
307 if (!rel) return null;
308 if (rel.candidate !== me) return { status: 403, error: 'only_the_candidate_offers' }; // fixed initiator (§3.1)
309 if (relations.listGuardians(site.slug).length) return { status: 403, error: 'a_ward_cannot_guard' }; // §1
310 const existing = await existingGuardiansOf(rel.ward);
311 const offerId = `${me}/offers/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
312 offers.start(site.slug, {
313 offerId, ward: rel.ward, candidate: me, existingGuardians: existing,
314 wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(me),
315 });
316 // The Offer IS the candidate's agreement to serve: record it as the
317 // candidate's accept. So a FREE ward commits on its own single accept (no
318 // second guardian to co-approve yet); once it IS a ward, adding another
319 // guardian still needs an existing guardian to co-accept.
320 offers.recordAccept(site.slug, offerId, me);
321 // Addressed to the ward AND every existing guardian (§3.1.1).
322 const recipients = [rel.ward, ...existing];
323 const delivered = await fanout(site, recipients, offerActivity(offerId, rel.ward, me, recipients));
324 notify(site.slug, { kind: 'offer_sent', ward: rel.ward });
325 return { status: 202, id: offerId, url: offerId, delivered };
326 }
327
328 // ── Accept / Reject: the local site is a party answering an offer. ─────
329 const offerId = idOf(activity.object);
330 if (!offerId) return { status: 400, error: 'missing_offer' };
331 // A lapse vote over C2S (§3.6.3): the same Accept/Reject wire the offers
332 // and gated follows use, which is exactly why the Shaer clients need no
333 // new verbs for it.
334 if (availability.getLapse(offerId)) {
335 const r = availability.lapseVote(offerId, me, type === 'Accept', Date.now());
336 if (r && r.error) return { status: r.error === 'not_in_set' ? 403 : 409, error: r.error };
337 return { status: 202, id: offerId, url: offerId, 'shaer:outcome': 'open', 'shaer:accepts': r.accepts, 'shaer:threshold': r.threshold };
338 }
339 let offer = offers.getOffer(site.slug, offerId);
340 if (!offer) return { status: 404, error: 'no_such_offer' };
341 const others = offers.parties(offer).filter((p) => p !== me);
342
343 if (type === 'Reject') {
344 offers.recordReject(site.slug, offerId, me);
345 await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Reject', actor: me, to: others, object: offerId });
346 notify(site.slug, { kind: 'offer_rejected', offer: offerId });
347 return { status: 202, id: offerId, url: offerId };
348 }
349
350 // Accept: record my accept, broadcast it to the other parties, and commit
351 // this copy if the tally is now complete (order-independent, §3.1.3).
352 offers.recordAccept(site.slug, offerId, me);
353 await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Accept', actor: me, to: others, object: offerId });
354 const { done, refused, offer: voided } = await maybeCommit(site.slug, offerId);
355 if (refused) {
356 // §4.2: the refusal travels as a `Reject` of the Offer (§3.2), which an
357 // implementation unaware of §4 still handles correctly. Who is told WHY is
358 // not uniform, and deliberately so.
359 const answer = (to, withReason) => ({
360 id: `${me}/answers/${Date.now().toString(36)}`,
361 type: 'Reject', actor: me, to, object: offerId,
362 ...(withReason ? { 'shaer:notATeapot': true } : {}),
363 });
364 const candidate = voided && voided.candidate_uri;
365 // The ward and its existing guardians MUST learn the reason: they are
366 // parties, the condition is public data (§2.1), and a bare void would
367 // leave a ward believing an adoption completed that did not.
368 const family = others.filter((u) => u !== candidate);
369 if (family.length) await fanout(site, family, answer(family, true));
370 // The candidate gets a BARE Reject. Commit is the last step of §3.1, so a
371 // refusal that names itself technical also discloses that every human
372 // party already accepted and only the protocol objected — which, where a
373 // guardianship is contested, is not theirs to learn. The kind path for an
374 // merely misconfigured candidate is the check on the Offer, before anyone
375 // has consented to anything.
376 if (candidate && others.includes(candidate)) await fanout(site, [candidate], answer([candidate], false));
377 return { status: 202, id: offerId, url: offerId, committed: false, refused };
378 }
379 return { status: 202, id: offerId, url: offerId, committed: !!done, readyToCommit: offers.readyToCommit(offers.getOffer(site.slug, offerId)) };
380}
381
382// ── S2S: a REMOTE party's activity arrives in a local inbox ────────────────
383
384/**
385 * Handle an inbound guardianship activity for the local site `site` (the inbox
386 * owner). Returns true when consumed.
387 */
388export async function handleInbox(site, activity) {
389 const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
390 if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return false;
391 if (type === 'Undo') return applyInboundUndo(site, activity);
392 const me = deps.selfId(site.slug);
393 const actor = idOf(activity.actor);
394
395 // §5.6: a guardian proposes a gated setting for THIS ward. The ward's server
396 // tallies and enforces, so the decision lands here, not on the proposer.
397 if (type === 'Offer') {
398 const gs = gated.parseGatedSetting(activity.object);
399 if (gs) {
400 const offerId = idOf(activity);
401 // ── I am the WARD: record, tally, and forward to the other guardians.
402 if (gs.ward === me) {
403 gated.rememberGatedOffer(offerId, site.slug, gs.feature, gs.value, actor);
404 // The proposer's Offer carries its own agreement (§3.1's one-step clause).
405 const r = gated.recordGatedVote(site.slug, gs.feature, actor, gs.value);
406 // The forward is the leg that was missing. A proposal addressed to the
407 // ward's server reaches only the proposer and the ward; the other
408 // guardians never learn it exists, so a threshold of two can never be
409 // met and every proposal expires unanswered. The ward's server is the
410 // one that knows the authoritative guardian list, which is exactly why
411 // §5.3 forwards a gated follow from here too.
412 if (r.state === 'open') {
413 for (const g of relations.listGuardians(site.slug).map((x) => x.other_uri)) {
414 if (g === actor) continue; // the proposer already answered
415 // The forward goes out AS THE WARD, because the ward's key signs
416 // it. Keeping the proposer in `actor` made every receiver answer
417 // 401 signer mismatch, and rightly so: the body claimed one author
418 // and the signature proved another. §5.3 forwards a gated follow
419 // the same way. Who proposed it rides along separately, for the
420 // guardian's screen.
421 deps.deliverTo(site, g, {
422 id: offerId, type: 'Offer', actor: me, to: [g], object: activity.object,
423 'shaer:proposer': actor,
424 }).catch(() => { /* the delivery queue retries */ });
425 }
426 } else {
427 gated.clearGatedReviews(offerId); // settled at once: nothing left to ask
428 answerGatedProposer(site, offerId, r);
429 }
430 notify(site.slug, { kind: 'gated_setting', feature: gs.feature, value: gs.value, state: r.state });
431 return true;
432 }
433 // ── I am one of the GUARDIANS: the forwarded copy. Store it so this
434 // guardian can answer; the answer goes back to the ward, which tallies.
435 if (relations.getRelation(site.slug, 'guardian', gs.ward)) {
436 const wardDoc = await deps.fetchActor(gs.ward).catch(() => null);
437 gated.recordGatedReview(site.slug, {
438 id: offerId, wardUri: gs.ward, wardInbox: wardDoc && wardDoc.inbox,
439 // A forward is signed by the ward, so `actor` is the ward; the
440 // guardian who opened it travels in shaer:proposer.
441 proposer: (typeof activity['shaer:proposer'] === 'string' ? activity['shaer:proposer'] : actor),
442 feature: gs.feature, value: gs.value,
443 });
444 notify(site.slug, { kind: 'gated_review', feature: gs.feature, value: gs.value, ward: gs.ward });
445 return true;
446 }
447 return false; // not our ward, and not a ward we guard
448 }
449 // §3.6.3: a co-guardian proposes releasing a dormant guardian of THIS
450 // ward. The ward's server opens, tallies and (after the full window)
451 // executes, exactly as it does for the gated settings above.
452 const lp = availability.parseLapse(activity.object);
453 if (lp) {
454 if (lp.ward !== me) return false; // not our ward
455 const id = idOf(activity) || `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
456 const r = availability.openLapse({ id, wardSlug: site.slug, wardUri: me, target: lp.target, openedBy: actor, now: Date.now() });
457 if (r.error) {
458 notify(site.slug, { kind: 'lapse_refused', reason: r.error, target: lp.target });
459 return true; // consumed: the refusal is the answer
460 }
461 // The target is notified like any dormancy marking (§3.6.2): in
462 // protocol (a copy of the Offer, so one answer can cancel it) AND the
463 // §6 handle, which for a committed guardian is its inbox — the same
464 // door this delivery knocks on.
465 deps.deliverTo(site, lp.target, activity).catch(() => { /* best-effort */ });
466 notify(site.slug, { kind: 'lapse_opened', lapse: id, target: lp.target, set: r.set });
467 return true;
468 }
469 const rel = parseRelationship(activity.object);
470 if (!rel) return false;
471 // I must be a party: the ward, or one of the existing guardians in `to`.
472 const recipients = arr(activity.to);
473 const existing = recipients.filter((u) => u !== rel.ward);
474 if (rel.ward !== me && !existing.includes(me)) return false;
475 // §4.2: check the candidate here too, and refuse before anyone accepts.
476 // At this point no party has consented, so saying why discloses nothing
477 // about anyone's position, and a candidate that is merely misconfigured
478 // can find that out and fix it. The commit-time check stays REQUIRED as
479 // the backstop for a candidate whose state changes in between.
480 if (await candidateFitness(rel.candidate) === 'malformed') {
481 notify(site.slug, { kind: 'offer_refused', offer: idOf(activity), reason: 'not_a_teapot', candidate: rel.candidate });
482 await fanout(site, [rel.candidate], {
483 id: `${me}/answers/${Date.now().toString(36)}`,
484 type: 'Reject', actor: me, to: [rel.candidate], object: idOf(activity), 'shaer:notATeapot': true,
485 });
486 return true;
487 }
488 offers.start(site.slug, {
489 offerId: idOf(activity), ward: rel.ward, candidate: rel.candidate, existingGuardians: existing,
490 wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(rel.candidate),
491 });
492 // The Offer carries the candidate's agreement (see the C2S side): record it
493 // so this copy's tally matches — a free ward then commits on its own accept.
494 offers.recordAccept(site.slug, idOf(activity), rel.candidate);
495 notify(site.slug, { kind: rel.ward === me ? 'offer_received' : 'offer_for_ward', ward: rel.ward, candidate: rel.candidate });
496 return true;
497 }
498
499 // Accept / Reject of an offer we (also) track.
500 const offerId = idOf(activity.object);
501 // §5.6, the answer coming HOME: the ward's server settled a decision we
502 // proposed and answers our Offer. Accept = it settled on what we proposed,
503 // Reject = on the opposite. Only the ward may say so: the answer must come
504 // from the ward the proposal was about, or anyone could close our books.
505 const sent = gated.recallSent(offerId);
506 if (sent && sent.guardian_slug === site.slug) {
507 if (actor !== sent.ward_uri) return false; // not the ward's voice: not an outcome
508 const outcome = type === 'Accept' ? 'accepted' : 'rejected';
509 gated.settleSent(offerId, outcome);
510 notify(site.slug, { kind: 'gated_outcome', feature: sent.feature, value: !!sent.value, outcome, ward: sent.ward_uri });
511 return true;
512 }
513 // §5.6: a fellow guardian answering a gated-setting proposal. The Accept only
514 // references the offer, so the value comes from the proposal we stored. A
515 // Reject is a vote for the opposite, not a shrug: it is still an answer.
516 const gsOffer = gated.recallGatedOffer(offerId);
517 if (gsOffer && gsOffer.slug === site.slug) {
518 const value = type === 'Accept' ? !!gsOffer.value : !gsOffer.value;
519 const r = gated.recordGatedVote(site.slug, gsOffer.feature, actor, value);
520 if (r.state === 'settled') answerGatedProposer(site, offerId, r);
521 notify(site.slug, { kind: 'gated_setting', feature: gsOffer.feature, value, state: r.state });
522 return true;
523 }
524 // §3.6.3: a set member answering a running lapse. Irreversible, so even a
525 // full tally leaves it open until the window closes (§3.5); the completion
526 // happens lazily on reads (queues) once the window has run.
527 if (availability.getLapse(offerId)) {
528 const r = availability.lapseVote(offerId, actor, type === 'Accept', Date.now());
529 notify(site.slug, { kind: 'lapse_vote', lapse: offerId, by: actor, state: r && !r.error ? 'recorded' : (r && r.error) || 'refused' });
530 return true;
531 }
532 let offer = offers.getOffer(site.slug, offerId);
533 if (!offer) return false;
534 if (!offers.isParty(offer, actor)) return false;
535
536 if (type === 'Reject') {
537 offers.recordReject(site.slug, offerId, actor);
538 notify(site.slug, { kind: 'offer_rejected', offer: offerId });
539 return true;
540 }
541
542 offers.recordAccept(site.slug, offerId, actor);
543 await maybeCommit(site.slug, offerId); // commits this copy once the tally is complete (§4.2 may refuse)
544 return true;
545}
546
547function notify(slug, ev) {
548 try { if (deps && typeof deps.onEvent === 'function') deps.onEvent(slug, ev); } catch { /* best-effort */ }
549}
550
551export default { wireHandshake, handleOutbox, handleInbox, parseRelationship, parseUndoRelationship, endGuardianship };
Note: See TracBrowser for help on using the repository browser.