Ignore:
Timestamp:
07/24/2026 07:44:15 PM (7 weeks ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
b5924eb
Parents:
c26cc18
Message:

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@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/guardianship/relations.js

    rc26cc18 r780a7c6  
    11/**
    2  * Guardianship (FEP-633c) — the ward ↔ guardian relations (ap_guardianships).
    3  *
    4  * Every row is one relation seen from a LOCAL site: role 'guardian' means the
    5  * site guards `other_uri` (a ward, possibly remote); role 'ward' means
    6  * `other_uri` guards the site. A local ward with a local guardian yields two
    7  * rows, one per perspective — intentional, each side reads its own.
    8  *
    9  * The handshake (spec §3): the guardian-candidate — and only the candidate —
    10  * Offers a Relationship {subject: ward, relationship: shaer:Guardian,
    11  * object: candidate}; the ward Accepts (or Rejects). Status walks
    12  * 'offered' → 'accepted'; a Reject deletes the row.
     2 * Guardianship (FEP-633c) — the COMMITTED ward ↔ guardian relations
     3 * (ap_guardianships). Pending offers live in offers.js; a row here means the
     4 * handshake committed (§3.1.4). Every row is one relation seen from a LOCAL
     5 * site: role 'guardian' = the site guards other_uri; role 'ward' = other_uri
     6 * guards the site.
    137 */
    148import db from '../../config/database.js';
     
    1812  if (!_s) {
    1913    _s = {
    20       ins: db.prepare(`INSERT OR IGNORE INTO ap_guardianships (slug, role, other_uri, other_handle, status, offer_id, created_at)
    21                        VALUES (?,?,?,?,?,?,CURRENT_TIMESTAMP)`),
    22       accept: db.prepare(`UPDATE ap_guardianships SET status='accepted' WHERE slug=? AND role=? AND other_uri=?`),
     14      commit: db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, other_handle, status, offer_id, created_at)
     15                          VALUES (?,?,?,?, 'accepted', ?, CURRENT_TIMESTAMP)
     16                          ON CONFLICT(slug, role, other_uri) DO UPDATE SET status='accepted', offer_id=excluded.offer_id`),
    2317      del: db.prepare('DELETE FROM ap_guardianships WHERE slug=? AND role=? AND other_uri=?'),
    24       bySlugRole: db.prepare('SELECT * FROM ap_guardianships WHERE slug=? AND role=? ORDER BY created_at DESC'),
     18      bySlugRole: db.prepare("SELECT * FROM ap_guardianships WHERE slug=? AND role=? AND status='accepted' ORDER BY created_at DESC"),
    2519      one: db.prepare('SELECT * FROM ap_guardianships WHERE slug=? AND role=? AND other_uri=?'),
    26       byOffer: db.prepare('SELECT * FROM ap_guardianships WHERE offer_id=?'),
    2720    };
    2821  }
     
    3326
    3427/** Accepted guardian URIs of a local ward (feeds shaer:guardians). */
    35 export function listGuardians(slug) {
    36   return stmts().bySlugRole.all(slug, 'ward').filter((r) => r.status === 'accepted');
     28export function listGuardians(slug) { return stmts().bySlugRole.all(slug, 'ward'); }
     29
     30/** Accepted wards of a local guardian (the wards queue). */
     31export function listWards(slug) { return stmts().bySlugRole.all(slug, 'guardian'); }
     32
     33/** A site is a guardian once it stands in any accepted guardian relation. */
     34export function isGuardian(slug) { return listWards(slug).length > 0; }
     35
     36export function getRelation(slug, role, otherUri) { return stmts().one.get(slug, role, otherUri); }
     37
     38// ── Writes (only the handshake commit lands here) ────────────────────────
     39
     40/** The local ward gains a guardian (commit, §3.1.4). */
     41export function commitGuardianForWard(wardSlug, guardianUri, { handle = null, offerId = null } = {}) {
     42  stmts().commit.run(wardSlug, 'ward', guardianUri, handle, offerId);
     43  return stmts().one.get(wardSlug, 'ward', guardianUri);
    3744}
    3845
    39 /** All ward relations of a local guardian (accepted + pending offers). */
    40 export function listWards(slug) {
    41   return stmts().bySlugRole.all(slug, 'guardian');
     46/** The local guardian gains a ward (commit, §3.1.4). */
     47export function commitWardForGuardian(guardianSlug, wardUri, { handle = null, offerId = null } = {}) {
     48  stmts().commit.run(guardianSlug, 'guardian', wardUri, handle, offerId);
     49  return stmts().one.get(guardianSlug, 'guardian', wardUri);
    4250}
    4351
    44 /** Pending offers where the local site is a party (either side). */
    45 export function listOffers(slug) {
    46   return [...stmts().bySlugRole.all(slug, 'guardian'), ...stmts().bySlugRole.all(slug, 'ward')]
    47     .filter((r) => r.status === 'offered');
    48 }
    49 
    50 /** A site is a guardian once it stands in any guardian-side relation. */
    51 export function isGuardian(slug) {
    52   return stmts().bySlugRole.all(slug, 'guardian').length > 0;
    53 }
    54 
    55 export function getRelation(slug, role, otherUri) { return stmts().one.get(slug, role, otherUri); }
    56 export function findByOfferId(offerId) { return offerId ? stmts().byOffer.all(offerId) : []; }
    57 
    58 // ── Writes (the handshake walks through these) ───────────────────────────
    59 
    60 /** Record an outgoing/incoming Offer on the local side with `role`. */
    61 export function recordOffer(slug, role, otherUri, { handle = null, offerId = null } = {}) {
    62   stmts().ins.run(slug, role, otherUri, handle, 'offered', offerId);
    63   return stmts().one.get(slug, role, otherUri);
    64 }
    65 
    66 /** The ward said yes (or our own offer was accepted): relation becomes real. */
    67 export function acceptRelation(slug, role, otherUri) {
    68   stmts().accept.run(slug, role, otherUri);
    69   return stmts().one.get(slug, role, otherUri);
    70 }
    71 
    72 /** Reject / retract / end a relation: the row disappears. */
     52/** End a relation locally (Undo, §3.2 — federation of the Undo is Fase 4). */
    7353export function removeRelation(slug, role, otherUri) {
    7454  stmts().del.run(slug, role, otherUri);
     
    7959
    8060/**
    81  * The guardianship properties for a local actor doc. `id` is the actor URI.
    82  * - shaer:guardians: accepted guardians of this ward (omitted when none)
     61 * Guardianship props for a local actor doc. `id` is the actor URI.
     62 * - shaer:guardians: accepted guardians of this ward (omitted when none, §2.1)
    8363 * - shaer:isGuardian: true once the site guards anyone
    84  * - shaer:queues: the owner-only dashboard collections (always advertised,
    85  *   like `blocked`: clients discover, the routes enforce auth)
     64 * - shaer:queues: the owner-only dashboard collections
     65 *
     66 * §1 mutual exclusion: a ward (has guardians) is never a guardian, so
     67 * shaer:isGuardian is suppressed if guardians exist; the offer path already
     68 * bars a ward from offering.
    8669 */
    8770export function actorProps(id, slug) {
     
    9477  };
    9578  const guardians = listGuardians(slug).map((r) => r.other_uri);
    96   if (guardians.length) props['shaer:guardians'] = guardians;
    97   if (isGuardian(slug)) props['shaer:isGuardian'] = true;
     79  if (guardians.length) {
     80    props['shaer:guardians'] = guardians;   // a ward
     81  } else if (isGuardian(slug)) {
     82    props['shaer:isGuardian'] = true;        // a guardian (never both, §1)
     83  }
    9884  return props;
    9985}
    10086
    10187export default {
    102   listGuardians, listWards, listOffers, isGuardian, getRelation, findByOfferId,
    103   recordOffer, acceptRelation, removeRelation, actorProps,
     88  listGuardians, listWards, isGuardian, getRelation,
     89  commitGuardianForWard, commitWardForGuardian, removeRelation, actorProps,
    10490};
Note: See TracChangeset for help on using the changeset viewer.