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

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

FEP-633c §4.2: een ward wordt geen guardian, ook niet stiekem

Klonkt weigerde een Offer van een bekende ward (a_ward_cannot_guard), maar
controleerde de kandidaat daarna nooit meer. Wie vrij was bij het aanbod en
daarna zelf geadopteerd werd, kwam er alsnog doorheen: de ward telde een
guardian wiens escalaties bij aflevering worden weggegooid (§4.1). De commit is
de onomkeerbare stap, dus daar moet het houden.

maybeCommit controleert nu de kandidaat vlak voor het schrijven. Drie
uitkomsten, en de derde is geen falen van de controle maar het niet kunnen
uitvoeren ervan: 'ok' commit, 'malformed' weigert luid, 'unverified' doet geen
van beide. Niet voiden bij onbereikbaar, want dan sloopt één hik een
meerpartijen-adoptie; niet committen ook niet, want dan leg je een guardian
vast die niemand gecontroleerd heeft. Het aanbod blijft staan.

De weigering is luid: het aanbod wordt void, niets wordt vastgelegd, en de
handelende partij stuurt een Reject (met shaer:notATeapot als reden). Een
Reject is wat §3 al kent, dus een server die nog nooit van §4 gehoord heeft
ruimt zijn kopie gewoon op.

Een lokale kandidaat wordt in onze eigen tabel opgezocht in plaats van bij
onszelf opgehaald. De co-location-guard in de tests ving dat ik daarnaast nog
een tweede lokale tak in een beslispad had gezet, voor het versturen van de
Reject; die is weg. De Reject vertrekt nu gewoon van wie er aan het handelen
was, zonder te vragen wie waar woont.

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

  • Property mode set to 100644
File size: 27.0 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' };
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 } = await maybeCommit(site.slug, offerId);
355 if (refused) {
356 // §4.2: the refusal travels as a `Reject` of the Offer, from whoever was
357 // about to commit — the same voice that just sent the Accept. A `Reject`
358 // is what §3 already understands, so a server that has never heard of §4
359 // still voids its copy correctly; the marker only adds the reason.
360 await fanout(site, others, {
361 id: `${me}/answers/${Date.now().toString(36)}`,
362 type: 'Reject', actor: me, to: others, object: offerId, 'shaer:notATeapot': true,
363 });
364 return { status: 202, id: offerId, url: offerId, committed: false, refused };
365 }
366 return { status: 202, id: offerId, url: offerId, committed: !!done, readyToCommit: offers.readyToCommit(offers.getOffer(site.slug, offerId)) };
367}
368
369// ── S2S: a REMOTE party's activity arrives in a local inbox ────────────────
370
371/**
372 * Handle an inbound guardianship activity for the local site `site` (the inbox
373 * owner). Returns true when consumed.
374 */
375export async function handleInbox(site, activity) {
376 const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
377 if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return false;
378 if (type === 'Undo') return applyInboundUndo(site, activity);
379 const me = deps.selfId(site.slug);
380 const actor = idOf(activity.actor);
381
382 // §5.6: a guardian proposes a gated setting for THIS ward. The ward's server
383 // tallies and enforces, so the decision lands here, not on the proposer.
384 if (type === 'Offer') {
385 const gs = gated.parseGatedSetting(activity.object);
386 if (gs) {
387 const offerId = idOf(activity);
388 // ── I am the WARD: record, tally, and forward to the other guardians.
389 if (gs.ward === me) {
390 gated.rememberGatedOffer(offerId, site.slug, gs.feature, gs.value, actor);
391 // The proposer's Offer carries its own agreement (§3.1's one-step clause).
392 const r = gated.recordGatedVote(site.slug, gs.feature, actor, gs.value);
393 // The forward is the leg that was missing. A proposal addressed to the
394 // ward's server reaches only the proposer and the ward; the other
395 // guardians never learn it exists, so a threshold of two can never be
396 // met and every proposal expires unanswered. The ward's server is the
397 // one that knows the authoritative guardian list, which is exactly why
398 // §5.3 forwards a gated follow from here too.
399 if (r.state === 'open') {
400 for (const g of relations.listGuardians(site.slug).map((x) => x.other_uri)) {
401 if (g === actor) continue; // the proposer already answered
402 // The forward goes out AS THE WARD, because the ward's key signs
403 // it. Keeping the proposer in `actor` made every receiver answer
404 // 401 signer mismatch, and rightly so: the body claimed one author
405 // and the signature proved another. §5.3 forwards a gated follow
406 // the same way. Who proposed it rides along separately, for the
407 // guardian's screen.
408 deps.deliverTo(site, g, {
409 id: offerId, type: 'Offer', actor: me, to: [g], object: activity.object,
410 'shaer:proposer': actor,
411 }).catch(() => { /* the delivery queue retries */ });
412 }
413 } else {
414 gated.clearGatedReviews(offerId); // settled at once: nothing left to ask
415 answerGatedProposer(site, offerId, r);
416 }
417 notify(site.slug, { kind: 'gated_setting', feature: gs.feature, value: gs.value, state: r.state });
418 return true;
419 }
420 // ── I am one of the GUARDIANS: the forwarded copy. Store it so this
421 // guardian can answer; the answer goes back to the ward, which tallies.
422 if (relations.getRelation(site.slug, 'guardian', gs.ward)) {
423 const wardDoc = await deps.fetchActor(gs.ward).catch(() => null);
424 gated.recordGatedReview(site.slug, {
425 id: offerId, wardUri: gs.ward, wardInbox: wardDoc && wardDoc.inbox,
426 // A forward is signed by the ward, so `actor` is the ward; the
427 // guardian who opened it travels in shaer:proposer.
428 proposer: (typeof activity['shaer:proposer'] === 'string' ? activity['shaer:proposer'] : actor),
429 feature: gs.feature, value: gs.value,
430 });
431 notify(site.slug, { kind: 'gated_review', feature: gs.feature, value: gs.value, ward: gs.ward });
432 return true;
433 }
434 return false; // not our ward, and not a ward we guard
435 }
436 // §3.6.3: a co-guardian proposes releasing a dormant guardian of THIS
437 // ward. The ward's server opens, tallies and (after the full window)
438 // executes, exactly as it does for the gated settings above.
439 const lp = availability.parseLapse(activity.object);
440 if (lp) {
441 if (lp.ward !== me) return false; // not our ward
442 const id = idOf(activity) || `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
443 const r = availability.openLapse({ id, wardSlug: site.slug, wardUri: me, target: lp.target, openedBy: actor, now: Date.now() });
444 if (r.error) {
445 notify(site.slug, { kind: 'lapse_refused', reason: r.error, target: lp.target });
446 return true; // consumed: the refusal is the answer
447 }
448 // The target is notified like any dormancy marking (§3.6.2): in
449 // protocol (a copy of the Offer, so one answer can cancel it) AND the
450 // §6 handle, which for a committed guardian is its inbox — the same
451 // door this delivery knocks on.
452 deps.deliverTo(site, lp.target, activity).catch(() => { /* best-effort */ });
453 notify(site.slug, { kind: 'lapse_opened', lapse: id, target: lp.target, set: r.set });
454 return true;
455 }
456 const rel = parseRelationship(activity.object);
457 if (!rel) return false;
458 // I must be a party: the ward, or one of the existing guardians in `to`.
459 const recipients = arr(activity.to);
460 const existing = recipients.filter((u) => u !== rel.ward);
461 if (rel.ward !== me && !existing.includes(me)) return false;
462 offers.start(site.slug, {
463 offerId: idOf(activity), ward: rel.ward, candidate: rel.candidate, existingGuardians: existing,
464 wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(rel.candidate),
465 });
466 // The Offer carries the candidate's agreement (see the C2S side): record it
467 // so this copy's tally matches — a free ward then commits on its own accept.
468 offers.recordAccept(site.slug, idOf(activity), rel.candidate);
469 notify(site.slug, { kind: rel.ward === me ? 'offer_received' : 'offer_for_ward', ward: rel.ward, candidate: rel.candidate });
470 return true;
471 }
472
473 // Accept / Reject of an offer we (also) track.
474 const offerId = idOf(activity.object);
475 // §5.6, the answer coming HOME: the ward's server settled a decision we
476 // proposed and answers our Offer. Accept = it settled on what we proposed,
477 // Reject = on the opposite. Only the ward may say so: the answer must come
478 // from the ward the proposal was about, or anyone could close our books.
479 const sent = gated.recallSent(offerId);
480 if (sent && sent.guardian_slug === site.slug) {
481 if (actor !== sent.ward_uri) return false; // not the ward's voice: not an outcome
482 const outcome = type === 'Accept' ? 'accepted' : 'rejected';
483 gated.settleSent(offerId, outcome);
484 notify(site.slug, { kind: 'gated_outcome', feature: sent.feature, value: !!sent.value, outcome, ward: sent.ward_uri });
485 return true;
486 }
487 // §5.6: a fellow guardian answering a gated-setting proposal. The Accept only
488 // references the offer, so the value comes from the proposal we stored. A
489 // Reject is a vote for the opposite, not a shrug: it is still an answer.
490 const gsOffer = gated.recallGatedOffer(offerId);
491 if (gsOffer && gsOffer.slug === site.slug) {
492 const value = type === 'Accept' ? !!gsOffer.value : !gsOffer.value;
493 const r = gated.recordGatedVote(site.slug, gsOffer.feature, actor, value);
494 if (r.state === 'settled') answerGatedProposer(site, offerId, r);
495 notify(site.slug, { kind: 'gated_setting', feature: gsOffer.feature, value, state: r.state });
496 return true;
497 }
498 // §3.6.3: a set member answering a running lapse. Irreversible, so even a
499 // full tally leaves it open until the window closes (§3.5); the completion
500 // happens lazily on reads (queues) once the window has run.
501 if (availability.getLapse(offerId)) {
502 const r = availability.lapseVote(offerId, actor, type === 'Accept', Date.now());
503 notify(site.slug, { kind: 'lapse_vote', lapse: offerId, by: actor, state: r && !r.error ? 'recorded' : (r && r.error) || 'refused' });
504 return true;
505 }
506 let offer = offers.getOffer(site.slug, offerId);
507 if (!offer) return false;
508 if (!offers.isParty(offer, actor)) return false;
509
510 if (type === 'Reject') {
511 offers.recordReject(site.slug, offerId, actor);
512 notify(site.slug, { kind: 'offer_rejected', offer: offerId });
513 return true;
514 }
515
516 offers.recordAccept(site.slug, offerId, actor);
517 await maybeCommit(site.slug, offerId); // commits this copy once the tally is complete (§4.2 may refuse)
518 return true;
519}
520
521function notify(slug, ev) {
522 try { if (deps && typeof deps.onEvent === 'function') deps.onEvent(slug, ev); } catch { /* best-effort */ }
523}
524
525export default { wireHandshake, handleOutbox, handleInbox, parseRelationship, parseUndoRelationship, endGuardianship };
Note: See TracBrowser for help on using the repository browser.