Changeset 5327324 in Klonkt


Ignore:
Timestamp:
08/03/2026 06:17:00 AM (5 weeks ago)
Author:
Bart <bart@…>
Branches:
main
Children:
69bd747
Parents:
04d5aeb
Message:

FEP-633c §4.2: een ward wordt geen guardian, ook niet stiekem

Klonkt weigerde een Offer van een bekende ward (a_ward_cannot_guard), maar
controleerde de kandidaat daarna nooit meer. Wie vrij was bij het aanbod en
daarna zelf geadopteerd werd, kwam er alsnog doorheen: de ward telde een
guardian wiens escalaties bij aflevering worden weggegooid (§4.1). De commit is
de onomkeerbare stap, dus daar moet het houden.

maybeCommit controleert nu de kandidaat vlak voor het schrijven. Drie
uitkomsten, en de derde is geen falen van de controle maar het niet kunnen
uitvoeren ervan: 'ok' commit, 'malformed' weigert luid, 'unverified' doet geen
van beide. Niet voiden bij onbereikbaar, want dan sloopt één hik een
meerpartijen-adoptie; niet committen ook niet, want dan leg je een guardian
vast die niemand gecontroleerd heeft. Het aanbod blijft staan.

De weigering is luid: het aanbod wordt void, niets wordt vastgelegd, en de
handelende partij stuurt een Reject (met shaer:notATeapot als reden). Een
Reject is wat §3 al kent, dus een server die nog nooit van §4 gehoord heeft
ruimt zijn kopie gewoon op.

Een lokale kandidaat wordt in onze eigen tabel opgezocht in plaats van bij
onszelf opgehaald. De co-location-guard in de tests ving dat ik daarnaast nog
een tweede lokale tak in een beslispad had gezet, voor het versturen van de
Reject; die is weg. De Reject vertrekt nu gewoon van wie er aan het handelen
was, zonder te vragen wie waar woont.

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

Files:
3 edited

Legend:

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

    r04d5aeb r5327324  
    114114}
    115115
     116/**
     117 * FEP-633c §4.2 — is this candidate fit to be a guardian at all?
     118 *
     119 * A guardian MUST be free of guardians (§1). Checked here and not at the Offer,
     120 * because guardianship state can change in between: a candidate that was free
     121 * when it offered may have been adopted before the ward accepted. So the check
     122 * runs against a freshly dereferenced actor document, at the moment the
     123 * relationship would become real.
     124 *
     125 * Three answers, and the third is not a failure of this check but a failure to
     126 * perform it:
     127 *   'ok'          — free of guardians, may serve
     128 *   'malformed'   — carries shaer:guardians; a teapot (§4)
     129 *   'unverified'  — the actor could not be read at all
     130 */
     131async function candidateFitness(candidateUri) {
     132  // A candidate on this instance needs no dereference: our own tables are the
     133  // document, and fresher than anything we could fetch from ourselves. This is
     134  // also the co-located case (ward and guardian on one Klonkt), where there is
     135  // no network to be unreachable on.
     136  const local = deps.localSlug(candidateUri);
     137  if (local) return relations.listGuardians(local).length > 0 ? 'malformed' : 'ok';
     138
     139  const doc = await deps.fetchActor(candidateUri).catch(() => null);
     140  if (!doc) return 'unverified';
     141  const g = doc['shaer:guardians'];
     142  const has = Array.isArray(g) ? g.length > 0
     143    : typeof g === 'string' ? g.length > 0
     144      : (g && typeof g === 'object') ? (Array.isArray(g.items) ? g.items.length > 0 : true)
     145        : false;
     146  return has ? 'malformed' : 'ok';
     147}
     148
    116149/** Commit this local copy of the offer when the tally is complete (ward +
    117150 *  candidate + ≥1 existing guardian, §3.1.2). The handle is the candidate's
    118151 *  inbox (§6 minimum); the commit is order-independent, so whichever accept
    119152 *  lands last triggers it on every copy. */
    120 function maybeCommit(slug, offerId) {
     153async function maybeCommit(slug, offerId) {
    121154  const offer = offers.getOffer(slug, offerId);
    122   if (!offer || !offers.readyToCommit(offer)) return null;
     155  if (!offer || !offers.readyToCommit(offer)) return { done: null, refused: null };
     156
     157  const fitness = await candidateFitness(offer.candidate_uri);
     158
     159  // §4.2: unlike the soft skip at delivery (§4.1), this refusal is loud. A
     160  // handshake concerns exactly one candidate, so there is no remaining
     161  // well-formed target to continue to; committing anyway would leave the ward
     162  // counting a guardian whose escalations get dropped. Voiding is all this
     163  // function does; saying so on the wire belongs to whoever was acting.
     164  if (fitness === 'malformed') {
     165    offers.recordReject(slug, offerId, offer.ward_uri);   // voids this copy (§3.2)
     166    notify(slug, {
     167      kind: 'offer_rejected', offer: offerId,
     168      reason: 'not_a_teapot', candidate: offer.candidate_uri,
     169    });
     170    return { done: null, refused: 'not_a_teapot' };
     171  }
     172
     173  // Could not read the candidate: neither commit nor void. Refusing outright
     174  // would let a momentary outage destroy a multi-party adoption; committing
     175  // would record a guardian nobody checked. The offer stays pending and the
     176  // next accept retries.
     177  if (fitness === 'unverified') return { done: null, refused: null };
     178
    123179  const done = offers.commit(slug, offerId, `${offer.candidate_uri}/inbox`);
    124180  if (done) { applyCommitLocally(done); notify(slug, { kind: 'committed', ward: done.ward_uri, guardian: done.candidate_uri }); }
    125   return done;
     181  return { done, refused: null };
    126182}
    127183
     
    296352  offers.recordAccept(site.slug, offerId, me);
    297353  await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Accept', actor: me, to: others, object: offerId });
    298   const done = maybeCommit(site.slug, offerId);
     354  const { done, refused } = await maybeCommit(site.slug, offerId);
     355  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, {
     361      id: `${me}/answers/${Date.now().toString(36)}`,
     362      type: 'Reject', actor: me, to: others, object: offerId, 'shaer:notATeapot': true,
     363    });
     364    return { status: 202, id: offerId, url: offerId, committed: false, refused };
     365  }
    299366  return { status: 202, id: offerId, url: offerId, committed: !!done, readyToCommit: offers.readyToCommit(offers.getOffer(site.slug, offerId)) };
    300367}
     
    448515
    449516  offers.recordAccept(site.slug, offerId, actor);
    450   maybeCommit(site.slug, offerId);   // commits this copy once the tally is complete
     517  await maybeCommit(site.slug, offerId);   // commits this copy once the tally is complete (§4.2 may refuse)
    451518  return true;
    452519}
  • test/co-location.test.js

    r04d5aeb r5327324  
    214214  const allowed = {
    215215    existingGuardiansOf: 'reads our own guardian list instead of fetching our own actor doc',
     216    candidateFitness: '§4.2: same question, same source — is this candidate a ward? Our table, not a self-fetch',
    216217    applyCommitLocally: '§3.1.4: each instance writes the side of the commit it hosts',
    217218    endGuardianship: '§3.2: same, for the ward side of the Undo, after the fanout',
  • test/guardianship.test.js

    r04d5aeb r5327324  
    119119  assert.equal(r.status, 403);
    120120  assert.equal(r.error, 'a_ward_cannot_guard');
     121});
     122
     123test('a candidate adopted between Offer and Accept is refused at commit (§4.2)', async () => {
     124  // The case the §1 check above structurally cannot catch. Tess is free when
     125  // she offers, so the Offer is legitimate and accepted. Only afterwards does
     126  // she become a ward herself. An implementation that checks the candidate
     127  // only when the Offer arrives would commit her anyway, and Sam would be left
     128  // counting a guardian whose escalations get dropped (§4.1).
     129  // Fresh actors throughout: the suite shares one database, so adopting Tess
     130  // with an existing guardian would hand that guardian an extra ward and
     131  // quietly change the arithmetic of the emancipation tests further down.
     132  const tess = site('s10', 'tess');
     133  const sam = site('s11', 'sam');
     134  const ada = site('s12', 'ada');
     135  const [TESS, SAM, ADA] = [A('tess'), A('sam'), A('ada')];
     136
     137  // 1. Tess offers to guard Sam while she is still free of guardians.
     138  const off = await G.handleGuardianshipOutbox(tess, {
     139    type: 'Offer', object: { type: 'Relationship', subject: SAM, relationship: 'shaer:Guardian', object: TESS },
     140  });
     141  assert.equal(off.status, 202, 'a free candidate may offer');
     142  const id = off.id;
     143  assert.deepEqual(G.listGuardians('sam'), [], 'nothing committed until Sam accepts');
     144
     145  // 2. Before Sam answers, Tess is adopted: she is now a ward herself.
     146  const adopt = await G.handleGuardianshipOutbox(ada, {
     147    type: 'Offer', object: { type: 'Relationship', subject: TESS, relationship: 'shaer:Guardian', object: ADA },
     148  });
     149  await G.handleGuardianshipOutbox(tess, { type: 'Accept', object: adopt.id });
     150  assert.equal(G.listGuardians('tess').length, 1, 'Tess is a ward now');
     151
     152  // 3. Sam accepts. The tally is complete, so this WOULD commit.
     153  const done = await G.handleGuardianshipOutbox(sam, { type: 'Accept', object: id });
     154  assert.equal(done.committed, false, 'but a ward cannot serve as a guardian (§1)');
     155  assert.equal(done.refused, 'not_a_teapot');
     156
     157  // The refusal is loud, not a silent skip: nothing recorded, offer voided.
     158  assert.deepEqual(G.listGuardians('sam'), [], 'Sam gains no guardian');
     159  assert.deepEqual(G.listWards('tess').map((w) => w.other_uri), [], 'and Tess gains no ward');
     160  const stillPending = G.offersCollection(`${SAM}/queues/offers`, 'sam', SAM).orderedItems.filter((o) => o.id === id);
     161  assert.deepEqual(stillPending, [], 'the handshake is void, not left hanging');
    121162});
    122163
Note: See TracChangeset for help on using the changeset viewer.