source: Klonkt/src/services/guardianship/handshake.js@ 97bcf7e

main
Last change on this file since 97bcf7e was d56d471, checked in by Robin <roboburr@…>, 6 weeks ago

Een voorstel krijgt een antwoord, een status en een zichtbare kring

Robin meldde drie dingen na de voorstelronde: er lijkt niets te gebeuren, de
status van een voorstel is nergens te volgen, en in /guardian zie je niet wie
de mede-guardians van een kind zijn. Onderzoek op beta wees de kern aan: de
ronde is aangekomen en geteld, maar elk voorstel was shaer:externalEmbeds, en
die stond al aan. Afspelen is nooit voorgesteld, en dat KON ook niet: de knop
"Afspelen voorstellen" verscheen alleen bij embeds === true, en voor een ward
op een andere server is die waarde onbekend, dus null, dus geen knop. Onbekend
is niet uit.

Drie reparaties, een per klacht.

Een: de lus sluit. De server van het kind beantwoordt de Offer die de
beslissing opende, terug naar de voorsteller: Accept als hij settelde op het
voorgestelde, Reject bij het tegendeel. Alleen de stem van het kind-account
telt als uitkomst; een vreemde die onze boeken wil sluiten wordt genegeerd.
Zonder dit kon het scherm van de voorsteller alleen maar eeuwig "wacht"
zeggen, wat er ook gebeurd was: de telling is het prive-grootboek van de
server van het kind.

Twee: de voorsteller houdt een eigen boek bij (ap_gated_sent) en het paneel
toont per ward de stand: wacht, aangenomen, afgewezen, of eerlijk verlopen als
het venster leegliep. Stilte na het venster is geen "loopt nog".

Drie: voor een ward op een andere server toont het paneel nu wel wie de
guardians zijn. Het lidmaatschap is publiek op het actordocument
(shaer:guardians, 2.1); de beschikbaarheid is en blijft het prive-grootboek
van hun server (3.6.1) en wordt alleen benoemd, niet getoond.

Changed files:
src/config/database.js

  • tabel ap_gated_sent (het boek van de voorsteller)
  • kolom proposer op ap_gated_offers, voor het antwoord naar huis

src/services/guardianship/gated.js

  • recordSent/recallSent/settleSent/listSent en sentStatus (puur, getest)
  • rememberGatedOffer onthoudt de voorsteller

src/services/guardianship/handshake.js

  • answerGatedProposer: het settle-antwoord naar de voorsteller
  • de inbox herkent dat antwoord en settelt het eigen boek; alleen het kind-account mag dat

src/routes/guardian.js

  • proposeGated schrijft het boek; dashboardState serveert per ward de voorstellen met status
  • GET /wards/guardians: lokaal met beschikbaarheid, remote de publieke lijst van hun actordocument

src/assets/js/guardian.js

  • de afspeel-knop verschijnt ook bij onbekende embeds-stand (de bug)
  • statusregels onder de instellingen-knoppen
  • remote guardians opgehaald bij het openen van het paneel

src/assets/css/guardian.css

  • g-prop-kleuren: de staat spreekt voor de woorden uit

src/services/i18n.js

  • prop_*- en panel_guards_far-strings, NL/EN/DE

test/gated-settings.test.js

  • de lus sluit: de voorsteller krijgt het antwoord, van alleen het kind
  • het boek: open, aangenomen, afgewezen, verlopen

remarks: 334 tests groen, server start op een schone database. De guardian-kant
(sound-fabrics) en de ward-kant (beta) hebben allebei deze commit nodig: de
knop en de status leven bij de guardian, het antwoord bij het kind.

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

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