Changeset 30d0e2c in Klonkt


Ignore:
Timestamp:
08/03/2026 06:38:16 AM (5 weeks ago)
Author:
Bart <bart@…>
Branches:
main
Children:
3d882bd
Parents:
69bd747
Message:

Een handshake blijft een week open, en faalt daarna onder zijn eigen naam

§4.2 leunt erop dat het uitstel begrensd is: als het venster sluit met de
controle nog onbeslist, faalt de beslissing dicht. Alleen had een
guardianship-offer in Klonkt helemaal geen venster, dus "uitgesteld" was
"voor altijd".

Nu een week. Lang genoeg dat niemand wordt opgejaagd — er moeten een ward, een
kandidaat en alle bestaande guardians antwoorden, en dat zijn mensen — en kort
genoeg dat een vergeten aanbod niet een maand in de wachtrij van een kind staat
alsof het nog een keuze is.

Twee eindtoestanden, want het zijn twee verschillende feiten:
'expired' (niemand heeft geantwoord; zegt niets over de kandidaat) en
'unverified' (iedereen heeft geantwoord, maar de kandidaat was nooit op te
halen). Geen van beide is 'void': de partijen mag niet verteld worden dat de
kandidaat geweigerd is, want dat is niet gebeurd — er heeft alleen nooit iemand
kunnen kijken.

Vervallen gebeurt bij het lezen, zoals een lapse dat ook doet: geen sweeper die
niemand draait. En het lezen van de wachtrij is meteen het moment waarop een
uitgestelde commit opnieuw wordt geprobeerd (§4.2 SHOULD) — nodig, want de
laatste Accept kan al binnen zijn en dan port niemand er ooit nog aan. Niet
awaited: een poll toont wat nu waar is.

Co-Authored-By: Claude Opus 5 <claude@…>

Files:
4 edited

Legend:

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

    r69bd747 r30d0e2c  
    545545}
    546546
     547/**
     548 * §4.2 SHOULD: retry the dereference for handshakes left deferred because the
     549 * candidate could not be read.
     550 *
     551 * Waiting for a further activity from a party is not enough: the commit is
     552 * triggered by the LAST `Accept`, so if that one has already arrived nothing
     553 * will ever poke it again and the handshake would sit until its window closed.
     554 * The ward's dashboard polling its own offers queue is this instance's
     555 * schedule, exactly as a read settles a lapse (§3.6.3).
     556 *
     557 * Deliberately not awaited by the read: a poll should render what is true now,
     558 * not block on someone else's slow server. A retry that succeeds shows up in
     559 * the next poll, which is the same second or two later.
     560 */
     561export async function retryDeferred(slug) {
     562  for (const o of offers.listDeferred(slug)) {
     563    await maybeCommit(slug, o.offer_id).catch(() => { /* next poll tries again */ });
     564  }
     565}
     566
    547567function notify(slug, ev) {
    548568  try { if (deps && typeof deps.onEvent === 'function') deps.onEvent(slug, ev); } catch { /* best-effort */ }
    549569}
    550570
    551 export default { wireHandshake, handleOutbox, handleInbox, parseRelationship, parseUndoRelationship, endGuardianship };
     571export default { wireHandshake, handleOutbox, handleInbox, parseRelationship, parseUndoRelationship, endGuardianship, retryDeferred };
  • src/services/guardianship/offers.js

    r69bd747 r30d0e2c  
    7979}
    8080
    81 /** Pending offers where `me` is a party — the offers queue (daemon shape). */
    82 export function listForParty(slug, me) {
    83   return stmts().listBySlug.get ? stmts().listBySlug.all(slug).filter((o) => isParty(o, me)) : [];
     81/**
     82 * How long a guardianship handshake stays open (§3.5). Adding a guardian is a
     83 * reversible decision, but not a quick one: the ward, the candidate and every
     84 * existing guardian have to answer, and they are people, sometimes on holiday.
     85 * A week is long enough that nobody is rushed and short enough that a forgotten
     86 * offer does not sit in a child's queue for a month looking like a live choice.
     87 */
     88export const OFFER_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
     89
     90/** SQLite writes CURRENT_TIMESTAMP as UTC 'YYYY-MM-DD HH:MM:SS', which
     91 *  Date.parse reads as LOCAL time — hours out, and enough to expire an offer
     92 *  early or late. Same correction as ActivityPubService.isoStamp. */
     93const stampMs = (v) => {
     94  const s = String(v || '');
     95  return Date.parse(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/.test(s) ? `${s.replace(' ', 'T')}Z` : s);
     96};
     97
     98export const closesAt = (o) => stampMs(o.created_at) + OFFER_WINDOW_MS;
     99
     100/**
     101 * §3.5 fails closed: once the window has run, a handshake that never completed
     102 * is over. WHICH failure it was matters (§4.2), so the two get different
     103 * terminal states and neither of them is `void`:
     104 *
     105 *   'expired'     — the parties never all answered. Nothing to say about anyone.
     106 *   'unverified'  — everyone answered; the candidate could never be read, so
     107 *                   the check never got to run. The parties MUST be told this
     108 *                   and MUST NOT be told the candidate was refused. It was not:
     109 *                   nobody ever managed to look.
     110 */
     111export function expireIfDue(slug, offerId, now = Date.now()) {
     112  const o = stmts().getOffer.get(slug, offerId);
     113  if (!o || o.status !== 'pending') return null;
     114  const due = closesAt(o);
     115  if (!Number.isFinite(due) || due > now) return null;
     116  const status = readyToCommit(o) ? 'unverified' : 'expired';
     117  stmts().setStatus.run(status, null, slug, offerId);
     118  return { ...o, status };
     119}
     120
     121/** Pending offers where `me` is a party — the offers queue (daemon shape).
     122 *  Reads are where lazy completion happens, as with the lapses (§3.6.3): a
     123 *  closed window is settled here rather than by a sweeper nobody runs. */
     124export function listForParty(slug, me, now = Date.now()) {
     125  if (!stmts().listBySlug.get) return [];
     126  for (const o of stmts().listBySlug.all(slug)) expireIfDue(slug, o.offer_id, now);
     127  return stmts().listBySlug.all(slug).filter((o) => isParty(o, me));
     128}
     129
     130/** Handshakes whose tally is complete but which are not committed: the §4.2
     131 *  deferred set, waiting on a candidate nobody could dereference. */
     132export function listDeferred(slug) {
     133  if (!stmts().listBySlug.get) return [];
     134  return stmts().listBySlug.all(slug).filter((o) => readyToCommit(o));
    84135}
    85136
     
    109160  start, getOffer, findOfferAnywhere, recordAccept, recordReject, commit,
    110161  readyToCommit, listForParty, queueItem, parties, isParty, acceptsOf,
     162  OFFER_WINDOW_MS, closesAt, expireIfDue, listDeferred,
    111163};
  • src/services/guardianship/queues.js

    r69bd747 r30d0e2c  
    1212import * as relations from './relations.js';
    1313import * as availability from './availability.js';
     14import * as handshake from './handshake.js';
    1415
    1516const collection = (id, items) => ({
     
    2223 *  clients render both without a second fetch. */
    2324export function offersCollection(id, slug, me) {
     25  // §4.2: a handshake whose candidate could not be dereferenced is deferred,
     26  // not decided, and the last Accept may already have landed — so nothing else
     27  // would ever retry it. This poll is the schedule. Not awaited: the read
     28  // answers with what is true now, and a retry that succeeds surfaces in the
     29  // next one. `listForParty` settles closed windows on the way past.
     30  handshake.retryDeferred(slug).catch(() => { /* the next read tries again */ });
    2431  const items = offers.listForParty(slug, me).map((o) => offers.queueItem(o, me));
    2532  items.push(...availability.lapseQueueItems(slug, me, Date.now()));
  • test/guardianship.test.js

    r69bd747 r30d0e2c  
    187187  );
    188188  assert.deepEqual(G.listGuardians('zed'), []);
     189});
     190
     191test('an unreadable candidate defers the commit, and the window names that failure (§4.2)', async () => {
     192  const offers = await import('../src/services/guardianship/offers.js');
     193  const noor = site('s16', 'noor');
     194  const NOOR = A('noor');
     195  const MARA = 'https://elders.example/users/mara';   // remote; fetchActor returns null here
     196  const offerId = `${MARA}/offers/m1`;
     197
     198  // The Offer arrives and is STORED: unreachable is not malformed, and
     199  // refusing on a failed fetch would let any outage block an adoption.
     200  const taken = await G.handleGuardianshipInbox(noor, {
     201    id: offerId, type: 'Offer', actor: MARA, to: [NOOR],
     202    object: { type: 'Relationship', subject: NOOR, relationship: 'shaer:Guardian', object: MARA },
     203  });
     204  assert.equal(taken, true);
     205
     206  // Noor accepts. Free ward + candidate's own offer = the tally is complete,
     207  // so this WOULD commit — except the candidate cannot be read.
     208  const done = await G.handleGuardianshipOutbox(noor, { type: 'Accept', object: offerId });
     209  assert.equal(done.committed, false, 'not committed: nobody verified the candidate');
     210  assert.ok(!done.refused, 'and not refused either — that would blame a candidate nobody could look at');
     211  assert.deepEqual(G.listGuardians('noor'), []);
     212  assert.equal(offers.getOffer('noor', offerId).status, 'pending', 'deferred, not decided');
     213
     214  // A week later the §3.5 window closes and it fails closed — under its own
     215  // name. Not 'void': the parties must not be told the candidate was refused.
     216  const later = Date.now() + offers.OFFER_WINDOW_MS + 1000;
     217  offers.expireIfDue('noor', offerId, later);
     218  assert.equal(offers.getOffer('noor', offerId).status, 'unverified');
     219
     220  const q = G.offersCollection(`${NOOR}/queues/offers`, 'noor', NOOR).orderedItems;
     221  assert.deepEqual(q.filter((o) => o.id === offerId), [], 'and it stops looking like a live choice');
     222});
     223
     224test('a handshake nobody finished expires under a different name (§3.5)', async () => {
     225  const offers = await import('../src/services/guardianship/offers.js');
     226  const finn = site('s17', 'finn');
     227  const iris = site('s18', 'iris');
     228  const [FINN, IRIS] = [A('finn'), A('iris')];
     229
     230  const off = await G.handleGuardianshipOutbox(finn, {
     231    type: 'Offer', object: { type: 'Relationship', subject: IRIS, relationship: 'shaer:Guardian', object: FINN },
     232  });
     233  // Iris never answers. Reading the queue after the window settles it.
     234  G.offersCollection(`${IRIS}/queues/offers`, 'iris', IRIS);          // still open now
     235  assert.equal(offers.getOffer('iris', off.id).status, 'pending');
     236  offers.listForParty('iris', IRIS, Date.now() + offers.OFFER_WINDOW_MS + 1000);
     237  assert.equal(offers.getOffer('iris', off.id).status, 'expired',
     238    'nobody answered — that says nothing about the candidate, so it is not "unverified"');
    189239});
    190240
Note: See TracChangeset for help on using the changeset viewer.