Changeset d56d471 in Klonkt for src/services/guardianship


Ignore:
Timestamp:
07/29/2026 12:36:50 PM (6 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
7a93fcf
Parents:
6100ce9
Message:

Een voorstel krijgt een antwoord, een status en een zichtbare kring

Robin meldde drie dingen na de voorstelronde: er lijkt niets te gebeuren, de
status van een voorstel is nergens te volgen, en in /guardian zie je niet wie
de mede-guardians van een kind zijn. Onderzoek op beta wees de kern aan: de
ronde is aangekomen en geteld, maar elk voorstel was shaer:externalEmbeds, en
die stond al aan. Afspelen is nooit voorgesteld, en dat KON ook niet: de knop
"Afspelen voorstellen" verscheen alleen bij embeds === true, en voor een ward
op een andere server is die waarde onbekend, dus null, dus geen knop. Onbekend
is niet uit.

Drie reparaties, een per klacht.

Een: de lus sluit. De server van het kind beantwoordt de Offer die de
beslissing opende, terug naar de voorsteller: Accept als hij settelde op het
voorgestelde, Reject bij het tegendeel. Alleen de stem van het kind-account
telt als uitkomst; een vreemde die onze boeken wil sluiten wordt genegeerd.
Zonder dit kon het scherm van de voorsteller alleen maar eeuwig "wacht"
zeggen, wat er ook gebeurd was: de telling is het prive-grootboek van de
server van het kind.

Twee: de voorsteller houdt een eigen boek bij (ap_gated_sent) en het paneel
toont per ward de stand: wacht, aangenomen, afgewezen, of eerlijk verlopen als
het venster leegliep. Stilte na het venster is geen "loopt nog".

Drie: voor een ward op een andere server toont het paneel nu wel wie de
guardians zijn. Het lidmaatschap is publiek op het actordocument
(shaer:guardians, 2.1); de beschikbaarheid is en blijft het prive-grootboek
van hun server (3.6.1) en wordt alleen benoemd, niet getoond.

Changed files:
src/config/database.js

  • tabel ap_gated_sent (het boek van de voorsteller)
  • kolom proposer op ap_gated_offers, voor het antwoord naar huis

src/services/guardianship/gated.js

  • recordSent/recallSent/settleSent/listSent en sentStatus (puur, getest)
  • rememberGatedOffer onthoudt de voorsteller

src/services/guardianship/handshake.js

  • answerGatedProposer: het settle-antwoord naar de voorsteller
  • de inbox herkent dat antwoord en settelt het eigen boek; alleen het kind-account mag dat

src/routes/guardian.js

  • proposeGated schrijft het boek; dashboardState serveert per ward de voorstellen met status
  • GET /wards/guardians: lokaal met beschikbaarheid, remote de publieke lijst van hun actordocument

src/assets/js/guardian.js

  • de afspeel-knop verschijnt ook bij onbekende embeds-stand (de bug)
  • statusregels onder de instellingen-knoppen
  • remote guardians opgehaald bij het openen van het paneel

src/assets/css/guardian.css

  • g-prop-kleuren: de staat spreekt voor de woorden uit

src/services/i18n.js

  • prop_*- en panel_guards_far-strings, NL/EN/DE

test/gated-settings.test.js

  • de lus sluit: de voorsteller krijgt het antwoord, van alleen het kind
  • het boek: open, aangenomen, afgewezen, verlopen

remarks: 334 tests groen, server start op een schone database. De guardian-kant
(sound-fabrics) en de ward-kant (beta) hebben allebei deze commit nodig: de
knop en de status leven bij de guardian, het antwoord bij het kind.

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

Location:
src/services/guardianship
Files:
2 edited

Legend:

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

    r6100ce9 rd56d471  
    182182export function clearGatedReviews(id) { rstmts().delAll.run(id); }
    183183
    184 export function rememberGatedOffer(offerId, slug, feature, value) {
     184export function rememberGatedOffer(offerId, slug, feature, value, proposer) {
    185185  try {
    186     db.prepare('INSERT OR REPLACE INTO ap_gated_offers (offer_id, slug, feature, value) VALUES (?,?,?,?)')
    187       .run(offerId, slug, feature, value ? 1 : 0);
     186    db.prepare('INSERT OR REPLACE INTO ap_gated_offers (offer_id, slug, feature, value, proposer) VALUES (?,?,?,?,?)')
     187      .run(offerId, slug, feature, value ? 1 : 0, proposer || null);
    188188  } catch { /* non-fatal */ }
    189189}
     
    192192  try { return db.prepare('SELECT * FROM ap_gated_offers WHERE offer_id = ?').get(offerId) || null; }
    193193  catch { return null; }
     194}
     195
     196// ── The proposer's own record (5.6) ───────────────────────────────
     197// "Where did my proposal go?" had no answer: the status was a button caption
     198// that did not survive a refresh. The ward's server tallies elsewhere, so the
     199// proposer keeps its own row and the ward's server ANSWERS the Offer when the
     200// decision settles: Accept when it settled on the proposed value, Reject when
     201// it settled on the opposite. An open row past the window renders as expired,
     202// because an expired decision settles on nothing and nobody writes home.
     203
     204export function recordSent(offerId, guardianSlug, wardUri, feature, value) {
     205  try {
     206    db.prepare(`INSERT OR REPLACE INTO ap_gated_sent (offer_id, guardian_slug, ward_uri, feature, value)
     207                VALUES (?,?,?,?,?)`).run(offerId, guardianSlug, wardUri, feature, value ? 1 : 0);
     208  } catch { /* non-fatal */ }
     209}
     210
     211export function recallSent(offerId) {
     212  try { return db.prepare('SELECT * FROM ap_gated_sent WHERE offer_id = ?').get(offerId) || null; }
     213  catch { return null; }
     214}
     215
     216export function settleSent(offerId, outcome) {
     217  try { db.prepare('UPDATE ap_gated_sent SET status = ? WHERE offer_id = ?').run(outcome, offerId); } catch { /* non-fatal */ }
     218}
     219
     220/** The latest proposal per feature this guardian sent to this ward. */
     221export function listSent(guardianSlug, wardUri) {
     222  try {
     223    return db.prepare(`SELECT * FROM ap_gated_sent WHERE guardian_slug = ? AND ward_uri = ?
     224                       GROUP BY feature HAVING MAX(created_at) ORDER BY created_at DESC`).all(guardianSlug, wardUri);
     225  } catch { return []; }
     226}
     227
     228/**
     229 * What a sent row means on a screen. Pure, so the rule is testable: an answer
     230 * wins, and silence past the window is not "still running", it is over.
     231 */
     232export function sentStatus(row, now) {
     233  if (!row) return null;
     234  if (row.status === 'accepted' || row.status === 'rejected') return row.status;
     235  const opened = new Date(String(row.created_at).includes('T') ? row.created_at : `${row.created_at}Z`.replace(' ', 'T')).getTime();
     236  if (Number.isFinite(opened) && now - opened >= GATED_WINDOW_MS) return 'expired';
     237  return 'open';
    194238}
    195239
     
    198242  parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer,
    199243  recordGatedReview, getGatedReview, listGatedReviews, removeGatedReview, clearGatedReviews,
     244  recordSent, recallSent, settleSent, listSent, sentStatus,
    200245};
  • src/services/guardianship/handshake.js

    r6100ce9 rd56d471  
    8181  }
    8282  return anyDelivered;
     83}
     84
     85/**
     86 * §5.6, the closing of the loop: a settled gated decision answers the Offer
     87 * that opened it. Accept when it settled on the proposed value, Reject when on
     88 * the opposite. Without this the proposer's screen can only ever say
     89 * "waiting", forever, whatever actually happened: the tally lives on the
     90 * ward's server and nobody else may read it, so the ward's server must speak.
     91 */
     92function answerGatedProposer(site, offerId, r) {
     93  const o = gated.recallGatedOffer(offerId);
     94  if (!o || !o.proposer) return;
     95  const me = deps.selfId(site.slug);
     96  if (o.proposer === me) return;   // the ward proposed to itself: nothing to write home
     97  const agreed = r.value === !!o.value;
     98  deps.deliverTo(site, o.proposer, {
     99    id: `${me}#gatedanswer-${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`,
     100    type: agreed ? 'Accept' : 'Reject',
     101    actor: me, to: [o.proposer], object: offerId,
     102  }).catch(() => { /* the delivery queue retries */ });
    83103}
    84104
     
    301321      // ── I am the WARD: record, tally, and forward to the other guardians.
    302322      if (gs.ward === me) {
    303         gated.rememberGatedOffer(offerId, site.slug, gs.feature, gs.value);
     323        gated.rememberGatedOffer(offerId, site.slug, gs.feature, gs.value, actor);
    304324        // The proposer's Offer carries its own agreement (§3.1's one-step clause).
    305325        const r = gated.recordGatedVote(site.slug, gs.feature, actor, gs.value);
     
    326346        } else {
    327347          gated.clearGatedReviews(offerId);   // settled at once: nothing left to ask
     348          answerGatedProposer(site, offerId, r);
    328349        }
    329350        notify(site.slug, { kind: 'gated_setting', feature: gs.feature, value: gs.value, state: r.state });
     
    385406  // Accept / Reject of an offer we (also) track.
    386407  const offerId = idOf(activity.object);
     408  // §5.6, the answer coming HOME: the ward's server settled a decision we
     409  // proposed and answers our Offer. Accept = it settled on what we proposed,
     410  // Reject = on the opposite. Only the ward may say so: the answer must come
     411  // from the ward the proposal was about, or anyone could close our books.
     412  const sent = gated.recallSent(offerId);
     413  if (sent && sent.guardian_slug === site.slug) {
     414    if (actor !== sent.ward_uri) return false;   // not the ward's voice: not an outcome
     415    const outcome = type === 'Accept' ? 'accepted' : 'rejected';
     416    gated.settleSent(offerId, outcome);
     417    notify(site.slug, { kind: 'gated_outcome', feature: sent.feature, value: !!sent.value, outcome, ward: sent.ward_uri });
     418    return true;
     419  }
    387420  // §5.6: a fellow guardian answering a gated-setting proposal. The Accept only
    388421  // references the offer, so the value comes from the proposal we stored. A
     
    392425    const value = type === 'Accept' ? !!gsOffer.value : !gsOffer.value;
    393426    const r = gated.recordGatedVote(site.slug, gsOffer.feature, actor, value);
     427    if (r.state === 'settled') answerGatedProposer(site, offerId, r);
    394428    notify(site.slug, { kind: 'gated_setting', feature: gsOffer.feature, value, state: r.state });
    395429    return true;
Note: See TracChangeset for help on using the changeset viewer.