Ignore:
Timestamp:
07/28/2026 11:22:07 PM (6 weeks ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
0202104
Parents:
6c152a5
Message:

Beschikbaarheid van guardians (FEP-633c 3.6): away, dormant, lapse

De Klonkt-kant van het beschikbaarheidsvoorstel, nagemaakt zoals eerst in de
daemon gevalideerd (shaer-8z7): dezelfde toestanden, dezelfde regels, dezelfde
weigeringen. De spiegel-tests dragen dezelfde namen als de daemon-tests, zodat
drift tussen de twee backends opvalt als een falende test met dezelfde woorden.

De kern is guardianship/availability.js: drie toestanden per (ward, guardian),
met als regel boven alles dat een antwoord alles herstelt, tot en met een
lopende lapse. Elke geverifieerde inbox-activiteit en elke C2S-handeling van
een guardian herstelt hem en annuleert een lapse tegen hem, nog voor er naar de
activiteit gekeken wordt. Bewust achter de handtekening-poort: een ongeverifieerde
bewering oma te zijn mag oma niet wakker maken.

Afwezig komt binnen over beide wegen: S2S als directe note met shaer:away en
endTime van een guardian elders (het gewone geval), en C2S als een guardian
hier zich afmeldt; die note draagt de marker mee naar wards elders en wordt
voor wards op deze instance direct toegepast, want een lokale inbox ontvangt
zijn eigen bezorging niet. Zonder (toekomstig) einde faalt het luid met 400,
precies zoals de daemon weigert.

Slapend volgt alleen uit onbeantwoorde direct geadresseerde verzoeken; de
follow-gating registreert die nu als bewijs. De markering notificeert verplicht
via protocol en de 6-handle, eenmalig op de overgang, centraal bedraad zodat
elke plek waar een promotie kan gebeuren hetzelfde notificeert.

De drempel van 3.5 rekent voortaan over de beschikbare set: de follow-quorums
en de gated settings allebei. De test die het waarom draagt: vijf guardians van
wie twee weg zijn gaven een drempel van drie die de twee levenden nooit haalden;
over de beschikbare set beslissen zij weer.

De lapse loopt over dezelfde draden als de gated settings: een Offer van
shaer:Lapse opent op de server van het kind, Accept/Reject stemt, het venster
loopt altijd vol, en de voltooiing verwijdert de relatie met de
nooit-leeg-grens uit 3.4 als tweede slot eronder. De offers-queue draagt de
lopende lapses en de nieuwe owner-only guardians-queue de beschikbaarheid, in
precies de vorm die de daemon serveert, dus de Shaer-apps van gisteren werken
zonder wijziging.

Changed files:
src/config/database.js

  • tabellen ap_guardian_attention, ap_attention_requests, ap_lapses
  • kolom ap_outbox.away_until

src/services/guardianship/handshake.js

  • Offer van shaer:Lapse (S2S en C2S), lapse-stemmen op Accept/Reject, one-answer op elke C2S-handeling

src/services/guardianship/gated.js

  • tally en voortgang over de beschikbare set; een stem is een antwoord

src/services/guardianship/notes.js

  • awayProps: shaer:away plus endTime op de uitgaande directe note

src/services/guardianship/delivery.js

  • away_until door het directe pad heen

src/services/guardianship/queues.js

  • guardiansCollection; offersCollection draagt de lapses

src/services/guardianship/index.js

  • exports

src/services/ActivityPubService.js

  • one-answer achter de handtekening-poort
  • away-ingest op het mention-pad en het C2S-directe pad
  • dormancy-bewijs op de follow-gating; quorum over de beschikbare set
  • de notificatieplicht van 3.6.2, een keer bedraad
  • buildReplyNote draagt awayProps

src/routes/activitypub.js

  • owner-only route /queues/guardians

src/routes/guardian.js

  • dashboard-besluit is een antwoord; quorum over de beschikbare set

src/services/guardianship/relations.js

  • guardians-queue aangekondigd in shaer:queues

test/activitypub-as2.test.js

  • guardians toegevoegd aan de queue-sleutels

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

  • de toestandsmachine, de lapse en de endTime-parser

test/availability.test.js

  • veertien spiegel-tests van de daemon, tot en met de volle lapse-flow over de S2S-draad en het vijf-guardians-rekenvoorbeeld

remarks: de PWA toont de beschikbaarheid nog niet (chips in het paneel per
kind en een lapse-kaart komen apart); de echte kruis-implementatie-testbank
blijft open op shaer-6d9. Klonkt heeft geen pinbare klok zoals de daemon; de
tests dateren bewijs terug in plaats van de tijd vooruit te zetten, en dat
staat er als kanttekening bij. Niet uitgerold.

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r6c152a5 r6eab7e9  
    288288      ...Guardianship.helpRequestProps(post),
    289289      ...Guardianship.waveProps(post),
     290      ...Guardianship.awayProps(post),
    290291      // FEP-633c §2.2: object hint that the author is a ward.
    291292      ...Guardianship.hasGuardiansProps(site.slug),
     
    13651366      return 401;
    13661367    }
     1368    // One answer restores everything (FEP-633c 3.6): any VERIFIED activity
     1369    // from an actor that guards someone here restores it to active for those
     1370    // wards and cancels any lapse running against it, before the activity is
     1371    // even looked at. Signature-gated on purpose: an unverified claim of
     1372    // being gran must not wake gran up.
     1373    try {
     1374      const ev = Guardianship.availability.oneAnswer(claimedActor, Date.now());
     1375      if (ev.restored.length) console.log('[AP] guardian restored (one answer, 3.6):', claimedActor, '→', ev.restored.join(', '));
     1376      for (const c of ev.cancelledLapses) console.log('[AP] lapse cancelled by an answer from its target:', c.id);
     1377    } catch { /* availability is never load-bearing for delivery */ }
    13671378  }
    13681379
     
    14541465      const wardKeys = getOrCreateKeys(slug);
    14551466      const followObj = { id: followId, type: 'Follow', actor: who, object: wardActor };
     1467      // Dormancy evidence (FEP-633c 3.6.2): this decision directly addresses
     1468      // every guardian. The ONLY admissible evidence is a request like this
     1469      // one going unanswered; recordRequest itself skips a declared absence.
     1470      for (const g of wardGuardians) {
     1471        try { Guardianship.availability.recordRequest(slug, g, followId, Date.now()); } catch { /* never load-bearing */ }
     1472      }
    14561473      for (const g of wardGuardians) {
    14571474        // Local ONLY when the guardian lives on THIS instance: slugFromActorUrl
     
    16371654        const wave = Guardianship.isWave(o);
    16381655        const hasG = Guardianship.objectHasGuardians(o);   // §2.2 hint, register-only
     1656        // FEP-633c 3.6.1: a guardian declares itself away to its ward, on the
     1657        // same direct note the mention below stores (so the kid also reads it
     1658        // as an ordinary message). Recorded only from an actual guardian of
     1659        // the addressed ward, and only with an end: an absence without an end
     1660        // is logged and dropped, never guessed.
     1661        if (Guardianship.availability.isAway(o)) {
     1662          const until = Guardianship.availability.parseEndTime(o.endTime);
     1663          for (const slug of slugs) {
     1664            const isG = (() => { try { return Guardianship.listGuardians(slug).some((g) => g.other_uri === actorUri); } catch { return false; } })();
     1665            if (!isG) continue;
     1666            if (!until || until <= Date.now()) { console.warn('[AP] away without a (future) end ignored (3.6.1):', actorUri, '→', slug); continue; }
     1667            Guardianship.availability.declareAway(slug, actorUri, until);
     1668            console.log('[AP] guardian declared away (3.6.1):', actorUri, '→', slug, 'until', new Date(until).toISOString());
     1669          }
     1670        }
    16391671        for (const slug of slugs) {
    16401672          try {
     
    22132245            .filter(Boolean);
    22142246          const help = object['shaer:helpRequest'] === true || object.helpRequest === true;
    2215           const r = await deliverDirectNote(site, { recipients, text: plain, language: object.language || null, inReplyTo: typeof object.inReplyTo === 'string' ? object.inReplyTo : null, attachments: atts, helpRequest: help });
     2247          // FEP-633c 3.6.1: a guardian here declaring itself away to its
     2248          // wards. An away without a (future) end fails loudly, exactly as
     2249          // the daemon refuses it: stored quietly it would be a nominal
     2250          // guardian holding a seat.
     2251          let awayUntil = null;
     2252          if (Guardianship.availability.isAway(object)) {
     2253            awayUntil = Guardianship.availability.parseEndTime(object.endTime);
     2254            if (!awayUntil || awayUntil <= Date.now()) return { status: 400, error: 'away_needs_an_end' };
     2255            // A ward we host ourselves never receives its own delivery
     2256            // (private ranges, loopback): apply locally, the way the
     2257            // handshake commit does.
     2258            const meUri = selfActorId(site.slug);
     2259            for (const uri of recipients) {
     2260              const wslug = uri.startsWith(`${base}/`) ? slugFromActorUrl(uri) : null;
     2261              if (wslug && Guardianship.listGuardians(wslug).some((g) => g.other_uri === meUri)) {
     2262                Guardianship.availability.declareAway(wslug, meUri, awayUntil);
     2263              }
     2264            }
     2265          }
     2266          const r = await deliverDirectNote(site, { recipients, text: plain, language: object.language || null, inReplyTo: typeof object.inReplyTo === 'string' ? object.inReplyTo : null, attachments: atts, helpRequest: help, awayUntil });
    22162267          if (!r || !r.id) return { status: 502, error: 'direct_failed' };
    22172268          return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
     
    34393490  const pending = Guardianship.follows.getPending(followId);
    34403491  if (!pending) return false;
    3441   const guardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
    3442   if (!guardians.includes(actorUri)) return false;   // only a real guardian of this ward decides
     3492  const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
     3493  if (!allGuardians.includes(actorUri)) return false;   // only a real guardian of this ward decides
    34433494  const decision = type === 'Reject' ? 'reject' : 'approve';
     3495  // §3.5: the quorum runs over the AVAILABLE set. The voter itself was
     3496  // restored by the one-answer rule when its activity arrived, so answering
     3497  // is exactly what counts a guardian back in.
     3498  const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
    34443499  const r = Guardianship.follows.decide(followId, actorUri, decision, guardians);
    34453500  try {
     
    37843839});
    37853840
     3841// The notification duty of FEP-633c 3.6.2, wired once for every place a
     3842// dormancy promotion can happen (queue reads, fan-outs, tallies): marking a
     3843// guardian dormant MUST notify it, in protocol AND over the §6 handle. The
     3844// one-answer rule is worthless to someone who does not know an answer is
     3845// wanted. The handle of a committed guardian is its inbox (§6 minimum), which
     3846// is the same door this delivery knocks on; both attempts are logged.
     3847Guardianship.wireAvailability({
     3848  onDormant: (wardSlug, guardianUri) => {
     3849    const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     3850    const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(wardSlug);
     3851    if (!base || !site) return;
     3852    const me = selfActorId(wardSlug);
     3853    const note = {
     3854      id: `${me}/dormant/${Date.now().toString(36)}${rid()}`,
     3855      type: 'Note', attributedTo: me, to: [guardianUri],
     3856      'shaer:dormant': true,
     3857      content: '<p>You have been observed dormant as a guardian. Nothing is wrong and nothing is held against you: one answer restores everything (FEP-633c 3.6.2).</p>',
     3858    };
     3859    deliverToActor(site, guardianUri, { id: `${note.id}#create`, type: 'Create', actor: me, to: [guardianUri], object: note })
     3860      .catch(() => { /* retried by the queue */ });
     3861    console.log('[AP] guardian observed dormant (3.6.2):', guardianUri, 'ward', wardSlug, '(notified in protocol; the §6 handle is the same inbox)');
     3862  },
     3863});
     3864
    37863865export default {
    37873866  AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId,
Note: See TracChangeset for help on using the changeset viewer.