Changeset 6eab7e9 in Klonkt


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

Files:
2 added
12 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    r6c152a5 r6eab7e9  
    523523      PRIMARY KEY (slug, feature, guardian_uri)
    524524    );
     525    -- Guardian availability (FEP-633c 3.6): one guardian's attention as seen
     526    -- from one ward on this server. Never public; the ward reads it via the
     527    -- owner-only guardians queue. One rule above all: one answer restores
     528    -- everything, so every row here is one answer away from disappearing.
     529    CREATE TABLE IF NOT EXISTS ap_guardian_attention (
     530      ward_slug TEXT NOT NULL,
     531      guardian_uri TEXT NOT NULL,
     532      state TEXT NOT NULL DEFAULT 'active',  -- 'active' | 'away' | 'dormant'
     533      away_until INTEGER,                    -- epoch ms while declared away
     534      PRIMARY KEY (ward_slug, guardian_uri)
     535    );
     536    -- The ONLY admissible dormancy evidence (3.6.2): directly addressed
     537    -- requests that went unanswered. Calendar time alone never counts.
     538    CREATE TABLE IF NOT EXISTS ap_attention_requests (
     539      ward_slug TEXT NOT NULL,
     540      guardian_uri TEXT NOT NULL,
     541      request_id TEXT NOT NULL,
     542      asked_at INTEGER NOT NULL,             -- epoch ms
     543      PRIMARY KEY (ward_slug, guardian_uri, request_id)
     544    );
     545    -- A lapse (3.6.3): the available co-guardians deciding to release a
     546    -- dormant one. Irreversible, so the window always runs in full; any sign
     547    -- of life from the target cancels it outright.
     548    CREATE TABLE IF NOT EXISTS ap_lapses (
     549      id TEXT PRIMARY KEY,
     550      ward_slug TEXT NOT NULL,
     551      ward_uri TEXT NOT NULL,
     552      target_uri TEXT NOT NULL,
     553      opened_by TEXT NOT NULL,
     554      set_json TEXT NOT NULL,                -- the available set at open, target excluded
     555      accepts_json TEXT NOT NULL DEFAULT '[]',
     556      rejects_json TEXT NOT NULL DEFAULT '[]',
     557      opened_at INTEGER NOT NULL,            -- epoch ms
     558      window_ms INTEGER NOT NULL,
     559      cancelled INTEGER NOT NULL DEFAULT 0,
     560      applied INTEGER NOT NULL DEFAULT 0,
     561      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
     562    );
    525563    CREATE TABLE IF NOT EXISTS ap_delivery (
    526564      id INTEGER PRIMARY KEY AUTOINCREMENT,
     
    617655  ensureColumn('ap_mentions', 'help_request', 'INTEGER'); // inbound ward call-for-help (Guardian PWA message centre)
    618656  ensureColumn('ap_outbox', 'wave', 'INTEGER');    // FEP-633c shaer:wave (guardian -> ward nudge)
     657  ensureColumn('ap_outbox', 'away_until', 'INTEGER'); // FEP-633c 3.6.1 shaer:away + endTime (epoch ms)
    619658  ensureColumn('ap_mentions', 'wave', 'INTEGER');  // inbound guardian wave
    620659  // FEP-633c §2.2: object hint that the author is a ward. Register-only for now;
  • src/routes/activitypub.js

    r6c152a5 r6eab7e9  
    135135queueRoute('follows', (id) => Guardianship.followsCollection(id));
    136136queueRoute('wards', (id, slug) => Guardianship.wardsCollection(id, slug));
     137// Availability (FEP-633c 3.6.1) is never public: the ward reads its
     138// guardians' real states here and nowhere else.
     139queueRoute('guardians', (id, slug) => Guardianship.guardiansCollection(id, slug));
    137140
    138141// ── Inbox read (owner only, AP C2S) ───────────────────────────────
  • src/routes/guardian.js

    r6c152a5 r6eab7e9  
    210210  const pending = Guardianship.follows.getPending(req.params.id);
    211211  if (!pending) return res.status(404).json({ error: 'gone' });
    212   const guardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
    213   if (!guardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
     212  const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
     213  if (!allGuardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
     214  // Acting from the dashboard is an answer (3.6), and the quorum runs over
     215  // the available set (3.5): both applied here, the same as over the wire.
     216  Guardianship.availability.oneAnswer(me, Date.now());
     217  const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
    214218  const r = Guardianship.follows.decide(pending.id, me, decision, guardians);
    215219  try {
  • 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,
  • src/services/guardianship/delivery.js

    r6c152a5 r6eab7e9  
    4040// guardian on any instance receives it as a private mention (the ward
    4141// call-for-help path).
    42 export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments, helpRequest, wave }) {
     42export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments, helpRequest, wave, awayUntil }) {
    4343  const { actorId, fetchActor, deriveHandle, escHtml, linkUrls, linkHashtags,
    4444          getOutboxRow, buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery } = deps;
     
    7070    .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
    7171  const id = crypto.randomUUID();
    72   db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, visibility, to_actors, help_request, wave, created_at)
    73               VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
    74     .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, media.length ? JSON.stringify(media) : null, 'direct', JSON.stringify(resolved.map((r) => r.uri)), helpRequest ? 1 : 0, wave ? 1 : 0);
     72  db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, visibility, to_actors, help_request, wave, away_until, created_at)
     73              VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
     74    .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, media.length ? JSON.stringify(media) : null, 'direct', JSON.stringify(resolved.map((r) => r.uri)), helpRequest ? 1 : 0, wave ? 1 : 0, awayUntil || null);
    7575  const row = getOutboxRow(id);
    7676  const note = buildReplyNote(base, site, row);
  • src/services/guardianship/gated.js

    r6c152a5 r6eab7e9  
    1515import db from '../../config/database.js';
    1616import { listGuardians } from './relations.js';
     17import * as availability from './availability.js';
    1718
    1819/** The window a gated-setting decision stays open. Reversible, so a day. */
     
    6768  const column = featureColumn(feature);
    6869  if (!column) return { state: 'expired', error: 'unknown_feature' };
    69   const guardians = listGuardians(slug).map((g) => g.other_uri);
    70   if (!guardians.includes(guardianUri)) return { state: 'expired', error: 'not_a_guardian' };
     70  const all = listGuardians(slug).map((g) => g.other_uri);
     71  if (!all.includes(guardianUri)) return { state: 'expired', error: 'not_a_guardian' };
     72  // A vote is an answer, whatever it is a vote on (§3.6): the voter is
     73  // restored first, so it always counts itself back into the set below.
     74  availability.oneAnswer(guardianUri, Date.now());
     75  // §3.5: the threshold runs over the AVAILABLE set. Membership is checked
     76  // against the full list above: any guardian may answer, and answering is
     77  // exactly what brings it back in.
     78  const guardians = availability.availableSet(slug, all, Date.now());
    7179
    7280  // The window opens with the first answer, and a stale decision starts over:
     
    100108  const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
    101109    .all(slug, feature);
    102   const guardians = listGuardians(slug).map((g) => g.other_uri);
     110  // Progress over the available set (§3.5), like the tally itself.
     111  const guardians = availability.availableSet(slug, listGuardians(slug).map((g) => g.other_uri), Date.now());
    103112  return { votes: votes.length, need: thresholdFor(guardians.length), of: guardians.length };
    104113}
  • 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;
  • src/services/guardianship/index.js

    r6c152a5 r6eab7e9  
    1616 */
    1717export { SHAER_CONTEXT, GUARDIAN_RELATIONSHIP, GUARDIAN_RELATIONSHIP_COMPACT, isGuardianRelationship } from './context.js';
    18 export { helpRequestProps, isHelpRequest, waveProps, isWave, hasGuardiansProps, objectHasGuardians, externalEmbedsAllowed } from './notes.js';
     18export { helpRequestProps, isHelpRequest, waveProps, isWave, awayProps, hasGuardiansProps, objectHasGuardians, externalEmbedsAllowed } from './notes.js';
    1919export { wireDelivery, c2sVisibility, deliverDirectNote } from './delivery.js';
    2020export { wireHandshake, handleOutbox as handleGuardianshipOutbox, handleInbox as handleGuardianshipInbox, parseRelationship, parseUndoRelationship, endGuardianship } from './handshake.js';
    21 export { offersCollection, followsCollection, wardsCollection } from './queues.js';
     21export { offersCollection, followsCollection, wardsCollection, guardiansCollection } from './queues.js';
     22export * as availability from './availability.js';
     23export { wireAvailability } from './availability.js';
    2224export * as follows from './follows.js';
    2325export { listForParty as listOffersForParty, getOffer, findOfferAnywhere } from './offers.js';
  • src/services/guardianship/notes.js

    r6c152a5 r6eab7e9  
    6565}
    6666
    67 export default { helpRequestProps, isHelpRequest, waveProps, isWave, hasGuardiansProps, objectHasGuardians, externalEmbedsAllowed };
     67/** shaer:away (3.6.1): a guardian declaring itself away to its ward, with an
     68 *  end. Rides a direct note like the help request, so a ward on a plain
     69 *  server reads a human message; endTime is plain AS2. */
     70export function awayProps(post) {
     71  return (post && post.visibility === 'direct' && post.away_until)
     72    ? { 'shaer:away': true, endTime: new Date(post.away_until).toISOString() }
     73    : {};
     74}
     75
     76export default { helpRequestProps, isHelpRequest, waveProps, isWave, awayProps, hasGuardiansProps, objectHasGuardians, externalEmbedsAllowed };
  • src/services/guardianship/queues.js

    r6c152a5 r6eab7e9  
    1111import * as offers from './offers.js';
    1212import * as relations from './relations.js';
     13import * as availability from './availability.js';
    1314
    1415const collection = (id, items) => ({
     
    1617});
    1718
    18 /** Pending offers where the local site is a party, each with its accept tally. */
     19/** Pending offers where the local site is a party, each with its accept
     20 *  tally. The same collection carries the running lapses (§3.6.3) this
     21 *  account is a party to, exactly as the daemon serves them, so the Shaer
     22 *  clients render both without a second fetch. */
    1923export function offersCollection(id, slug, me) {
    2024  const items = offers.listForParty(slug, me).map((o) => offers.queueItem(o, me));
     25  items.push(...availability.lapseQueueItems(slug, me, Date.now()));
    2126  return collection(id, items);
    2227}
     
    3439}
    3540
    36 export default { offersCollection, followsCollection, wardsCollection };
     41/** The ward's guardians with their availability (§3.6.1: never public,
     42 *  owner-only): the real size of the safety net. Same shape as the daemon. */
     43export function guardiansCollection(id, slug) {
     44  const uris = relations.listGuardians(slug).map((r) => r.other_uri);
     45  return collection(id, availability.statusesFor(slug, uris, Date.now()));
     46}
     47
     48export default { offersCollection, followsCollection, wardsCollection, guardiansCollection };
  • src/services/guardianship/relations.js

    r6c152a5 r6eab7e9  
    7474      follows: `${id}/queues/follows`,
    7575      wards: `${id}/queues/wards`,
     76      guardians: `${id}/queues/guardians`,
    7677    },
    7778  };
  • test/activitypub-as2.test.js

    r6c152a5 r6eab7e9  
    3434  // FEP-633c (Guardians): the owner-only dashboard queues on the actor; the
    3535  // sub-keys are the daemon-contract collection names the Shaer clients read.
    36   'shaer:queues', 'offers', 'follows', 'wards',
     36  // `guardians` is the availability queue (3.6.1: never public, owner-only).
     37  'shaer:queues', 'offers', 'follows', 'wards', 'guardians',
    3738  // ActivityPub §4.1 `endpoints` vocabulary (same category as sharedInbox), used for C2S.
    3839  'oauthAuthorizationEndpoint', 'oauthTokenEndpoint', 'uploadMedia',
Note: See TracChangeset for help on using the changeset viewer.