Ignore:
Timestamp:
08/03/2026 06:26:57 AM (5 weeks ago)
Author:
Bart <bart@…>
Branches:
main
Children:
30d0e2c
Parents:
5327324
Message:

§4.2 bijgewerkt: wie wat te horen krijgt is niet voor iedereen hetzelfde

De herziene §4.2 maakt twee dingen expliciet die de implementatie van vanmiddag
verkeerd deed.

Eén: de kandidaat krijgt een KALE Reject. Commit is de laatste stap van §3.1,
dus een weigering die zichzelf als technisch aankondigt verklapt meteen dat alle
menselijke partijen al hadden geaccepteerd en alleen het protocol nog bezwaar
maakte. Bij een betwiste guardianship is dat precies wat een partij niet hoort
te weten. De ward en de bestaande guardians krijgen de reden wél: zij zijn
partij, de toestand komt uit publieke data (§2.1), en een stille void laat een
ward geloven dat een adoptie doorging die niet doorging.

Twee: dezelfde controle draait nu ook als de Offer binnenkomt. Daar heeft nog
niemand geaccepteerd, dus daar mag de reden gewoon mee — en een kandidaat die
alleen maar verkeerd geconfigureerd staat komt daar achter op het moment dat
dat nog alles is wat het betekent. De controle bij commit blijft verplicht als
vangnet voor wie tussendoor van toestand verandert.

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

File:
1 edited

Legend:

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

    r5327324 r69bd747  
    168168      reason: 'not_a_teapot', candidate: offer.candidate_uri,
    169169    });
    170     return { done: null, refused: 'not_a_teapot' };
     170    return { done: null, refused: 'not_a_teapot', offer };
    171171  }
    172172
     
    352352  offers.recordAccept(site.slug, offerId, me);
    353353  await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Accept', actor: me, to: others, object: offerId });
    354   const { done, refused } = await maybeCommit(site.slug, offerId);
     354  const { done, refused, offer: voided } = await maybeCommit(site.slug, offerId);
    355355  if (refused) {
    356     // §4.2: the refusal travels as a `Reject` of the Offer, from whoever was
    357     // about to commit — the same voice that just sent the Accept. A `Reject`
    358     // is what §3 already understands, so a server that has never heard of §4
    359     // still voids its copy correctly; the marker only adds the reason.
    360     await fanout(site, others, {
     356    // §4.2: the refusal travels as a `Reject` of the Offer (§3.2), which an
     357    // implementation unaware of §4 still handles correctly. Who is told WHY is
     358    // not uniform, and deliberately so.
     359    const answer = (to, withReason) => ({
    361360      id: `${me}/answers/${Date.now().toString(36)}`,
    362       type: 'Reject', actor: me, to: others, object: offerId, 'shaer:notATeapot': true,
     361      type: 'Reject', actor: me, to, object: offerId,
     362      ...(withReason ? { 'shaer:notATeapot': true } : {}),
    363363    });
     364    const candidate = voided && voided.candidate_uri;
     365    // The ward and its existing guardians MUST learn the reason: they are
     366    // parties, the condition is public data (§2.1), and a bare void would
     367    // leave a ward believing an adoption completed that did not.
     368    const family = others.filter((u) => u !== candidate);
     369    if (family.length) await fanout(site, family, answer(family, true));
     370    // The candidate gets a BARE Reject. Commit is the last step of §3.1, so a
     371    // refusal that names itself technical also discloses that every human
     372    // party already accepted and only the protocol objected — which, where a
     373    // guardianship is contested, is not theirs to learn. The kind path for an
     374    // merely misconfigured candidate is the check on the Offer, before anyone
     375    // has consented to anything.
     376    if (candidate && others.includes(candidate)) await fanout(site, [candidate], answer([candidate], false));
    364377    return { status: 202, id: offerId, url: offerId, committed: false, refused };
    365378  }
     
    460473    const existing = recipients.filter((u) => u !== rel.ward);
    461474    if (rel.ward !== me && !existing.includes(me)) return false;
     475    // §4.2: check the candidate here too, and refuse before anyone accepts.
     476    // At this point no party has consented, so saying why discloses nothing
     477    // about anyone's position, and a candidate that is merely misconfigured
     478    // can find that out and fix it. The commit-time check stays REQUIRED as
     479    // the backstop for a candidate whose state changes in between.
     480    if (await candidateFitness(rel.candidate) === 'malformed') {
     481      notify(site.slug, { kind: 'offer_refused', offer: idOf(activity), reason: 'not_a_teapot', candidate: rel.candidate });
     482      await fanout(site, [rel.candidate], {
     483        id: `${me}/answers/${Date.now().toString(36)}`,
     484        type: 'Reject', actor: me, to: [rel.candidate], object: idOf(activity), 'shaer:notATeapot': true,
     485      });
     486      return true;
     487    }
    462488    offers.start(site.slug, {
    463489      offerId: idOf(activity), ward: rel.ward, candidate: rel.candidate, existingGuardians: existing,
Note: See TracChangeset for help on using the changeset viewer.