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

main
Last change on this file since 2708282 was 65abc85, checked in by Robin Genis <roboburr@…>, 6 weeks ago

Gated settings federeren: guardians beslissen samen, ook van een andere server

Ik had de knop alleen voor het co-located geval gebouwd, en dat is precies het
uitzonderingsgeval. In de echte opstelling staat de ward op de ene server en zijn
drie guardians op twee andere: er was dus nergens een knop. Dat botst met onze
eigen regel dat co-locatie een optimalisatie is en nooit de aanname.

Nu volgens FEP-633c 5.6 (deze week aan de spec toegevoegd): een guardian stelt
een wijziging voor met een Offer van een shaer:GatedSetting aan de server van de
WARD; de andere guardians antwoorden met Accept/Reject; de server van de ward
telt en handhaaft, want die serveert de feed. Co-locatie neemt dezelfde weg: ook
daar wordt voorgesteld en geteld, anders zou een guardian naast de deur meer te
zeggen hebben dan een op afstand.

De tally is een 3.5-beslissing en staat als pure functie apart: gesnapshotte set,
strikte meerderheid, venster van een dag. Omkeerbaar, dus race naar de drempel in
BEIDE richtingen (settelt ook zodra een meerderheid onhaalbaar is) en faalt dicht
op de deadline. Een Reject is een stem voor de andere waarde, geen schouderophalen.

New file:
src/services/guardianship/gated.js

  • tallyGatedSetting (puur), thresholdFor, featureColumn (onbekende features geweigerd i.p.v. geraden), recordGatedVote, en de Offer-vorm

test/gated-settings.test.js

  • 9 tests: drempel, vroeg settelen in beide richtingen, dicht op de deadline, vreemden tellen niet mee, geen guardians = niets toegekend, van gedachten veranderen vervangt je stem, en een onbekende feature raakt geen kolom

Changed files:
src/config/database.js

  • ap_gated_offers + ap_gated_votes

src/services/guardianship/handshake.js

  • inbox: Offer(shaer:GatedSetting) en Accept/Reject erop, met de stem van de voorsteller meegeteld (one-step-clausule)

src/services/guardianship/index.js

  • gated geexporteerd

src/routes/guardian.js

  • de knop stuurt een voorstel, lokaal en remote langs dezelfde weg

src/assets/js/guardian.js

  • knop bij ELKE ward, ook remote; toont 'wacht op de andere guardians'

src/services/i18n.js

  • embeds_propose / embeds_waiting in nl, en, de

remarks: 228 tests groen (was 219).

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

  • Property mode set to 100644
File size: 10.9 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';
22
23let deps = null;
24export function wireHandshake(d) { deps = d; }
25
26const idOf = (v) => (typeof v === 'string' ? v : (v && typeof v === 'object' && typeof v.id === 'string' ? v.id : null));
27const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
28
29/** Parse a Relationship object into {ward, candidate} or null. */
30export function parseRelationship(rel) {
31 if (!rel || typeof rel !== 'object') return null;
32 const type = Array.isArray(rel.type) ? rel.type[0] : rel.type;
33 if (type !== 'Relationship') return null;
34 if (!isGuardianRelationship(String(rel.relationship || ''))) return null;
35 const ward = idOf(rel.subject);
36 const candidate = idOf(rel.object);
37 return ward && candidate ? { ward, candidate } : null;
38}
39
40/** The existing guardians of a ward: local list, or the remote actor's shaer:guardians. */
41async function existingGuardiansOf(wardUri) {
42 const local = deps.localSlug(wardUri);
43 if (local) return relations.listGuardians(local).map((r) => r.other_uri);
44 const doc = await deps.fetchActor(wardUri).catch(() => null);
45 const g = doc && doc['shaer:guardians'];
46 return Array.isArray(g) ? g.filter((x) => typeof x === 'string') : [];
47}
48
49function offerActivity(offerId, ward, candidate, recipients) {
50 return {
51 id: offerId, type: 'Offer', actor: candidate, to: recipients,
52 object: { type: 'Relationship', subject: ward, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: candidate },
53 };
54}
55
56/** Deliver `activity` to every uri in `recipients` (skipping the local self). */
57async function fanout(site, recipients, activity) {
58 let anyDelivered = false;
59 for (const uri of [...new Set(recipients)]) {
60 const r = await deps.deliverTo(site, uri, activity).catch(() => ({ delivered: false }));
61 if (r && r.delivered !== false) anyDelivered = true;
62 }
63 return anyDelivered;
64}
65
66/** Apply the local side of a commit: the ward writes its guardian, the
67 * candidate writes its ward. Each instance writes only what it hosts.
68 * other_handle is the human @handle for display (from the offer); the FEP
69 * escalation handle (candidate inbox) lives on the offer row, not here. */
70function applyCommitLocally(offer) {
71 const wardSlug = deps.localSlug(offer.ward_uri);
72 const candSlug = deps.localSlug(offer.candidate_uri);
73 if (wardSlug) relations.commitGuardianForWard(wardSlug, offer.candidate_uri, { handle: offer.candidate_handle, offerId: offer.offer_id });
74 if (candSlug) relations.commitWardForGuardian(candSlug, offer.ward_uri, { handle: offer.ward_handle, offerId: offer.offer_id });
75}
76
77/** Commit this local copy of the offer when the tally is complete (ward +
78 * candidate + ≥1 existing guardian, §3.1.2). The handle is the candidate's
79 * inbox (§6 minimum); the commit is order-independent, so whichever accept
80 * lands last triggers it on every copy. */
81function maybeCommit(slug, offerId) {
82 const offer = offers.getOffer(slug, offerId);
83 if (!offer || !offers.readyToCommit(offer)) return null;
84 const done = offers.commit(slug, offerId, `${offer.candidate_uri}/inbox`);
85 if (done) { applyCommitLocally(done); notify(slug, { kind: 'committed', ward: done.ward_uri, guardian: done.candidate_uri }); }
86 return done;
87}
88
89// ── C2S: a LOCAL party acts (PWA, Berichten, or the Shaer app outbox) ──────
90
91/**
92 * Handle a guardianship activity POSTed to the local outbox. Returns null when
93 * it is not ours, else {status, ...} for the route.
94 */
95export async function handleOutbox(site, activity) {
96 const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
97 if (!['Offer', 'Accept', 'Reject'].includes(type)) return null;
98 const me = deps.selfId(site.slug);
99
100 // ── Offer: the local site is the guardian-candidate. ───────────────────
101 if (type === 'Offer') {
102 const rel = parseRelationship(activity.object);
103 if (!rel) return null;
104 if (rel.candidate !== me) return { status: 403, error: 'only_the_candidate_offers' }; // fixed initiator (§3.1)
105 if (relations.listGuardians(site.slug).length) return { status: 403, error: 'a_ward_cannot_guard' }; // §1
106 const existing = await existingGuardiansOf(rel.ward);
107 const offerId = `${me}/offers/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
108 offers.start(site.slug, {
109 offerId, ward: rel.ward, candidate: me, existingGuardians: existing,
110 wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(me),
111 });
112 // The Offer IS the candidate's agreement to serve: record it as the
113 // candidate's accept. So a FREE ward commits on its own single accept (no
114 // second guardian to co-approve yet); once it IS a ward, adding another
115 // guardian still needs an existing guardian to co-accept.
116 offers.recordAccept(site.slug, offerId, me);
117 // Addressed to the ward AND every existing guardian (§3.1.1).
118 const recipients = [rel.ward, ...existing];
119 const delivered = await fanout(site, recipients, offerActivity(offerId, rel.ward, me, recipients));
120 notify(site.slug, { kind: 'offer_sent', ward: rel.ward });
121 return { status: 202, id: offerId, url: offerId, delivered };
122 }
123
124 // ── Accept / Reject: the local site is a party answering an offer. ─────
125 const offerId = idOf(activity.object);
126 if (!offerId) return { status: 400, error: 'missing_offer' };
127 let offer = offers.getOffer(site.slug, offerId);
128 if (!offer) return { status: 404, error: 'no_such_offer' };
129 const others = offers.parties(offer).filter((p) => p !== me);
130
131 if (type === 'Reject') {
132 offers.recordReject(site.slug, offerId, me);
133 await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Reject', actor: me, to: others, object: offerId });
134 notify(site.slug, { kind: 'offer_rejected', offer: offerId });
135 return { status: 202, id: offerId, url: offerId };
136 }
137
138 // Accept: record my accept, broadcast it to the other parties, and commit
139 // this copy if the tally is now complete (order-independent, §3.1.3).
140 offers.recordAccept(site.slug, offerId, me);
141 await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Accept', actor: me, to: others, object: offerId });
142 const done = maybeCommit(site.slug, offerId);
143 return { status: 202, id: offerId, url: offerId, committed: !!done, readyToCommit: offers.readyToCommit(offers.getOffer(site.slug, offerId)) };
144}
145
146// ── S2S: a REMOTE party's activity arrives in a local inbox ────────────────
147
148/**
149 * Handle an inbound guardianship activity for the local site `site` (the inbox
150 * owner). Returns true when consumed.
151 */
152export async function handleInbox(site, activity) {
153 const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
154 if (!['Offer', 'Accept', 'Reject'].includes(type)) return false;
155 const me = deps.selfId(site.slug);
156 const actor = idOf(activity.actor);
157
158 // §5.6: a guardian proposes a gated setting for THIS ward. The ward's server
159 // tallies and enforces, so the decision lands here, not on the proposer.
160 if (type === 'Offer') {
161 const gs = gated.parseGatedSetting(activity.object);
162 if (gs) {
163 if (gs.ward !== me) return false; // not our ward
164 gated.rememberGatedOffer(idOf(activity), site.slug, gs.feature, gs.value);
165 // The proposer's Offer carries its own agreement (§3.1's one-step clause).
166 const r = gated.recordGatedVote(site.slug, gs.feature, actor, gs.value);
167 notify(site.slug, { kind: 'gated_setting', feature: gs.feature, value: gs.value, state: r.state });
168 return true;
169 }
170 const rel = parseRelationship(activity.object);
171 if (!rel) return false;
172 // I must be a party: the ward, or one of the existing guardians in `to`.
173 const recipients = arr(activity.to);
174 const existing = recipients.filter((u) => u !== rel.ward);
175 if (rel.ward !== me && !existing.includes(me)) return false;
176 offers.start(site.slug, {
177 offerId: idOf(activity), ward: rel.ward, candidate: rel.candidate, existingGuardians: existing,
178 wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(rel.candidate),
179 });
180 // The Offer carries the candidate's agreement (see the C2S side): record it
181 // so this copy's tally matches — a free ward then commits on its own accept.
182 offers.recordAccept(site.slug, idOf(activity), rel.candidate);
183 notify(site.slug, { kind: rel.ward === me ? 'offer_received' : 'offer_for_ward', ward: rel.ward, candidate: rel.candidate });
184 return true;
185 }
186
187 // Accept / Reject of an offer we (also) track.
188 const offerId = idOf(activity.object);
189 // §5.6: a fellow guardian answering a gated-setting proposal. The Accept only
190 // references the offer, so the value comes from the proposal we stored. A
191 // Reject is a vote for the opposite, not a shrug: it is still an answer.
192 const gsOffer = gated.recallGatedOffer(offerId);
193 if (gsOffer && gsOffer.slug === site.slug) {
194 const value = type === 'Accept' ? !!gsOffer.value : !gsOffer.value;
195 const r = gated.recordGatedVote(site.slug, gsOffer.feature, actor, value);
196 notify(site.slug, { kind: 'gated_setting', feature: gsOffer.feature, value, state: r.state });
197 return true;
198 }
199 let offer = offers.getOffer(site.slug, offerId);
200 if (!offer) return false;
201 if (!offers.isParty(offer, actor)) return false;
202
203 if (type === 'Reject') {
204 offers.recordReject(site.slug, offerId, actor);
205 notify(site.slug, { kind: 'offer_rejected', offer: offerId });
206 return true;
207 }
208
209 offers.recordAccept(site.slug, offerId, actor);
210 maybeCommit(site.slug, offerId); // commits this copy once the tally is complete
211 return true;
212}
213
214function notify(slug, ev) {
215 try { if (deps && typeof deps.onEvent === 'function') deps.onEvent(slug, ev); } catch { /* best-effort */ }
216}
217
218export default { wireHandshake, handleOutbox, handleInbox, parseRelationship };
Note: See TracBrowser for help on using the repository browser.