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/guardianship/handshake.js

    r6c152a5 r6eab7e9  
    2020import * as relations from './relations.js';
    2121import * as gated from './gated.js';
     22import * as availability from './availability.js';
    2223
    2324let deps = null;
     
    194195  if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return null;
    195196  const me = deps.selfId(site.slug);
     197  // One answer restores everything (§3.6): any C2S activity from this actor
     198  // is that answer, for every local ward it guards. Runs before anything is
     199  // even looked at, so the target of a running lapse cancels it by doing
     200  // anything at all — including trying to vote on it.
     201  try { availability.oneAnswer(me, Date.now()); } catch { /* never load-bearing */ }
    196202
    197203  // ── Undo: a guardian ends its own guardianship (§3.2). Same path as the
     
    206212  // ── Offer: the local site is the guardian-candidate. ───────────────────
    207213  if (type === 'Offer') {
     214    // §3.6.3 over C2S: a guardian here proposes releasing a dormant
     215    // co-guardian. A ward we host opens locally; a remote ward gets the
     216    // proposal delivered, because the ward's server is the one that tallies
     217    // and enforces (the §5.6 line: a guardian next door must not have more
     218    // say than one far away).
     219    const lp = availability.parseLapse(activity.object);
     220    if (lp) {
     221      const id = `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
     222      const wardSlug = deps.localSlug(lp.ward);
     223      if (wardSlug) {
     224        const r = availability.openLapse({ id, wardSlug, wardUri: lp.ward, target: lp.target, openedBy: me, now: Date.now() });
     225        if (r.error) return { status: r.error === 'not_in_available_set' ? 403 : 409, error: r.error };
     226        deps.deliverTo(site, lp.target, { id, type: 'Offer', actor: me, to: [lp.target], object: { type: 'shaer:Lapse', 'shaer:ward': lp.ward, object: lp.target } }).catch(() => { /* best-effort */ });
     227        notify(wardSlug, { kind: 'lapse_opened', lapse: id, target: lp.target, set: r.set });
     228        return { status: 202, id, url: id, 'shaer:set': r.set, 'shaer:threshold': r.threshold };
     229      }
     230      const offer = { id, type: 'Offer', actor: me, to: [lp.ward], object: { type: 'shaer:Lapse', 'shaer:ward': lp.ward, object: lp.target } };
     231      const delivered = await fanout(site, [lp.ward], offer);
     232      return { status: 202, id, url: id, delivered };
     233    }
    208234    const rel = parseRelationship(activity.object);
    209235    if (!rel) return null;
     
    231257  const offerId = idOf(activity.object);
    232258  if (!offerId) return { status: 400, error: 'missing_offer' };
     259  // A lapse vote over C2S (§3.6.3): the same Accept/Reject wire the offers
     260  // and gated follows use, which is exactly why the Shaer clients need no
     261  // new verbs for it.
     262  if (availability.getLapse(offerId)) {
     263    const r = availability.lapseVote(offerId, me, type === 'Accept', Date.now());
     264    if (r && r.error) return { status: r.error === 'not_in_set' ? 403 : 409, error: r.error };
     265    return { status: 202, id: offerId, url: offerId, 'shaer:outcome': 'open', 'shaer:accepts': r.accepts, 'shaer:threshold': r.threshold };
     266  }
    233267  let offer = offers.getOffer(site.slug, offerId);
    234268  if (!offer) return { status: 404, error: 'no_such_offer' };
     
    273307      const r = gated.recordGatedVote(site.slug, gs.feature, actor, gs.value);
    274308      notify(site.slug, { kind: 'gated_setting', feature: gs.feature, value: gs.value, state: r.state });
     309      return true;
     310    }
     311    // §3.6.3: a co-guardian proposes releasing a dormant guardian of THIS
     312    // ward. The ward's server opens, tallies and (after the full window)
     313    // executes, exactly as it does for the gated settings above.
     314    const lp = availability.parseLapse(activity.object);
     315    if (lp) {
     316      if (lp.ward !== me) return false;   // not our ward
     317      const id = idOf(activity) || `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
     318      const r = availability.openLapse({ id, wardSlug: site.slug, wardUri: me, target: lp.target, openedBy: actor, now: Date.now() });
     319      if (r.error) {
     320        notify(site.slug, { kind: 'lapse_refused', reason: r.error, target: lp.target });
     321        return true;   // consumed: the refusal is the answer
     322      }
     323      // The target is notified like any dormancy marking (§3.6.2): in
     324      // protocol (a copy of the Offer, so one answer can cancel it) AND the
     325      // §6 handle, which for a committed guardian is its inbox — the same
     326      // door this delivery knocks on.
     327      deps.deliverTo(site, lp.target, activity).catch(() => { /* best-effort */ });
     328      notify(site.slug, { kind: 'lapse_opened', lapse: id, target: lp.target, set: r.set });
    275329      return true;
    276330    }
     
    304358    return true;
    305359  }
     360  // §3.6.3: a set member answering a running lapse. Irreversible, so even a
     361  // full tally leaves it open until the window closes (§3.5); the completion
     362  // happens lazily on reads (queues) once the window has run.
     363  if (availability.getLapse(offerId)) {
     364    const r = availability.lapseVote(offerId, actor, type === 'Accept', Date.now());
     365    notify(site.slug, { kind: 'lapse_vote', lapse: offerId, by: actor, state: r && !r.error ? 'recorded' : (r && r.error) || 'refused' });
     366    return true;
     367  }
    306368  let offer = offers.getOffer(site.slug, offerId);
    307369  if (!offer) return false;
Note: See TracChangeset for help on using the changeset viewer.