source: Klonkt/src/services/guardianship/offers.js@ 6d5ce0c

main
Last change on this file since 6d5ce0c was 780a7c6, checked in by Robin Genis <roboburr@…>, 7 weeks ago

Guardianship Fase 0+1: de echte multi-party handshake (FEP-633c §3)

De eerste versie committeerde na één accept. Nu de spec: geen enkele partij
maakt een voogdij alleen, en een nieuwe guardian erbij kan niet zonder
toestemming van de bestaande. Daemon als blauwdruk, zodat Klonkt en de
test-daemon exact hetzelfde gedragen en de Shaer-clients één contract lezen.

Fase 0 (datamodel): ap_guardian_offers (per lokale partij een kopie van de
handshake, PK slug+offer_id) + ap_guardian_offer_accepts (de accept-tally).
ap_guardianships houdt alleen nog de GECOMMITTE relaties.

Fase 1 (state-machine): offers.js is een getrouwe port van de daemon-Handshake
(accepts over ward+candidate+existing; ready = ward && candidate && (geen
existing OF >=1 existing); een Reject voidt). handshake.js orchestreert het
gedistribueerd: de kandidaat adresseert de Offer aan ward + alle bestaande
guardians (§3.1.1); elke Accept wordt aan alle andere partijen gebroadcast, dus
elke instance-kopie convergeert; zodra een kopie compleet is committeert die
lokaal (ward schrijft shaer:guardians, guardian schrijft z'n ward), met de
kandidaat-inbox als handle (§6). Volgorde-onafhankelijk.

Ook: §1 wederzijdse uitsluiting (een ward is nooit ook guardian in het
actor-doc), de queues vullen nu de echte accept-tally (needsMyAccept/
readyToCommit/acceptedBy/existingGuardians), en de PWA + Berichten beantwoorden
via de C2S Accept/Reject-pijplijn per offer-id. De co-guardian ziet een
mede-voogdij-aanvraag met accepteer/weiger in de PWA.

Changed files:
src/config/database.js

  • tabellen ap_guardian_offers + ap_guardian_offer_accepts

src/services/guardianship/offers.js (NEW)

  • de handshake-state-machine (daemon-port), per-instance in SQLite

src/services/guardianship/relations.js

  • alleen commit-writers + actor-props (§1 uitsluiting)

src/services/guardianship/handshake.js

  • gedistribueerde multi-party C2S/S2S orchestratie

src/services/guardianship/queues.js

  • offers-queue uit de state-machine

src/services/guardianship/index.js

  • exports bijgewerkt

src/services/ActivityPubService.js

  • wire localSlug + fetchActor; inbound-routing naar alle lokale partijen

src/routes/guardian.js

  • dashboard toont offers met tally; POST /guardian/offer (accept/reject)

src/routes/posts.js

  • Berichten toont ward-offers uit de state-machine; accept via offer-id

src/views/pages/messages.ejs, src/assets/js/guardian.js, src/assets/css/guardian.css

  • offer-kaarten per state (mijn aanvraag / mede-voogdij / wachten)

src/services/i18n.js

  • accept/reject/complete/coguard + co-guardian push (nl/en/de)

test/guardianship.test.js

  • multi-party: eerste guardian, co-approval bestaande guardian, reject voidt, ward-mag-niet-guarden, vaste initiator

remarks: Fase 2 (follow-gating), 3 (hasGuardians + Not-a-Teapot), 4 (Undo/
emancipatie) volgen. 164 tests groen.

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

  • Property mode set to 100644
File size: 5.2 KB
Line 
1/**
2 * Guardianship (FEP-633c §3) — the multi-party handshake state.
3 *
4 * A faithful port of the Shaer test daemon's `Handshake`, persisted per local
5 * site (so the two implementations behave identically and the clients speak
6 * one contract). One row in ap_guardian_offers per offer this instance is a
7 * party to; the accepts accumulate in ap_guardian_offer_accepts.
8 *
9 * The offer commits only when the guardian-candidate returns the handle
10 * (§3.1.3) after ward + candidate + at least one existing guardian have
11 * accepted (§3.1.2). A single Reject from any party voids it (§3.2). This is
12 * the core safety property: no single party creates a guardianship alone, and
13 * no new guardian is added without an existing guardian's consent.
14 */
15import db from '../../config/database.js';
16
17let _s = null;
18function stmts() {
19 if (!_s) {
20 _s = {
21 insOffer: db.prepare(`INSERT OR IGNORE INTO ap_guardian_offers
22 (offer_id, slug, ward_uri, candidate_uri, existing_guardians, status, ward_handle, candidate_handle, created_at)
23 VALUES (?,?,?,?,?, 'pending', ?, ?, CURRENT_TIMESTAMP)`),
24 getOffer: db.prepare('SELECT * FROM ap_guardian_offers WHERE slug=? AND offer_id=?'),
25 offerAnywhere: db.prepare('SELECT * FROM ap_guardian_offers WHERE offer_id=? LIMIT 1'),
26 setStatus: db.prepare('UPDATE ap_guardian_offers SET status=?, handle=COALESCE(?, handle) WHERE slug=? AND offer_id=?'),
27 listBySlug: db.prepare("SELECT * FROM ap_guardian_offers WHERE slug=? AND status='pending' ORDER BY created_at DESC"),
28 insAccept: db.prepare('INSERT OR IGNORE INTO ap_guardian_offer_accepts (offer_id, slug, party_uri, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)'),
29 accepts: db.prepare('SELECT party_uri FROM ap_guardian_offer_accepts WHERE slug=? AND offer_id=?'),
30 };
31 }
32 return _s;
33}
34
35const parties = (o) => [o.ward_uri, o.candidate_uri, ...JSON.parse(o.existing_guardians || '[]')];
36const isParty = (o, actor) => !!actor && parties(o).includes(actor);
37const acceptsOf = (o) => stmts().accepts.all(o.slug, o.offer_id).map((r) => r.party_uri);
38
39/** ward + candidate + (no existing guardians OR at least one existing) accepted. */
40export function readyToCommit(o) {
41 if (!o || o.status !== 'pending') return false;
42 const acc = new Set(acceptsOf(o));
43 const existing = JSON.parse(o.existing_guardians || '[]');
44 const existingOk = existing.length === 0 || existing.some((g) => acc.has(g));
45 return acc.has(o.ward_uri) && acc.has(o.candidate_uri) && existingOk;
46}
47
48/** Start tracking an offer on `slug` (idempotent). */
49export function start(slug, { offerId, ward, candidate, existingGuardians = [], wardHandle = null, candidateHandle = null }) {
50 stmts().insOffer.run(offerId, slug, ward, candidate, JSON.stringify(existingGuardians || []), wardHandle, candidateHandle);
51 return stmts().getOffer.get(slug, offerId);
52}
53
54export function getOffer(slug, offerId) { return stmts().getOffer.get(slug, offerId); }
55export function findOfferAnywhere(offerId) { return stmts().offerAnywhere.get(offerId); }
56
57/** Record an Accept from one party; ignored if not a party or already resolved. */
58export function recordAccept(slug, offerId, party) {
59 const o = stmts().getOffer.get(slug, offerId);
60 if (!o || o.status !== 'pending' || !isParty(o, party)) return o;
61 stmts().insAccept.run(offerId, slug, party);
62 return stmts().getOffer.get(slug, offerId);
63}
64
65/** A single Reject from any party voids the handshake (§3.2). */
66export function recordReject(slug, offerId, party) {
67 const o = stmts().getOffer.get(slug, offerId);
68 if (!o || o.status !== 'pending' || !isParty(o, party)) return o;
69 stmts().setStatus.run('void', null, slug, offerId);
70 return stmts().getOffer.get(slug, offerId);
71}
72
73/** Commit (only when ready): store the returned handle, mark committed. */
74export function commit(slug, offerId, handle) {
75 const o = stmts().getOffer.get(slug, offerId);
76 if (!o || o.status !== 'pending' || !readyToCommit(o)) return null;
77 stmts().setStatus.run('committed', handle || null, slug, offerId);
78 return stmts().getOffer.get(slug, offerId);
79}
80
81/** Pending offers where `me` is a party — the offers queue (daemon shape). */
82export function listForParty(slug, me) {
83 return stmts().listBySlug.get ? stmts().listBySlug.all(slug).filter((o) => isParty(o, me)) : [];
84}
85
86/** One offer as the offers-queue item the Shaer clients parse. */
87export function queueItem(o, me) {
88 const acc = acceptsOf(o).sort();
89 return {
90 id: o.offer_id,
91 type: 'Offer',
92 actor: o.candidate_uri,
93 object: { type: 'Relationship', subject: o.ward_uri, relationship: 'shaer:Guardian', object: o.candidate_uri },
94 'shaer:ward': o.ward_uri,
95 'shaer:candidate': o.candidate_uri,
96 'shaer:existingGuardians': JSON.parse(o.existing_guardians || '[]'),
97 'shaer:acceptedBy': acc,
98 'shaer:needsMyAccept': !acc.includes(me),
99 'shaer:readyToCommit': readyToCommit(o),
100 'shaer:iAmCandidate': me === o.candidate_uri,
101 'shaer:wardHandle': o.ward_handle || undefined,
102 'shaer:candidateHandle': o.candidate_handle || undefined,
103 published: o.created_at,
104 };
105}
106
107export { parties, isParty, acceptsOf };
108export default {
109 start, getOffer, findOfferAnywhere, recordAccept, recordReject, commit,
110 readyToCommit, listForParty, queueItem, parties, isParty, acceptsOf,
111};
Note: See TracBrowser for help on using the repository browser.