source: Klonkt/src/services/guardianship/gated.js@ 0202104

main
Last change on this file since 0202104 was 6eab7e9, checked in by Robin Genis <roboburr@…>, 6 weeks ago

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

  • Property mode set to 100644
File size: 7.7 KB
Line 
1/**
2 * Guardianship (FEP-633c §5.6): gated settings the guardians decide together.
3 *
4 * The point of this file is that it works when the guardians are NOT on the
5 * ward's server, which is the ordinary case: a child on the family instance, a
6 * grandparent on theirs. A guardian proposes with an `Offer` of a
7 * `shaer:GatedSetting` addressed to the ward's server; the other guardians
8 * answer; the ward's server tallies and enforces, because it is the one that
9 * serves the feed.
10 *
11 * The tally is a §3.5 decision: a snapshotted set, a threshold (strict
12 * majority), a window. A setting is reversible (a permission granted can be
13 * withdrawn), so it settles as a race to the threshold and fails closed.
14 */
15import db from '../../config/database.js';
16import { listGuardians } from './relations.js';
17import * as availability from './availability.js';
18
19/** The window a gated-setting decision stays open. Reversible, so a day. */
20export const GATED_WINDOW_MS = 24 * 60 * 60 * 1000;
21
22/** Strict majority of the set: 1 of 1, 2 of 2, 2 of 3, 3 of 4. */
23export function thresholdFor(setSize) {
24 return Math.floor(setSize / 2) + 1;
25}
26
27/**
28 * Tally one decision. Pure, so the rule can be tested without a database.
29 *
30 * @param {Array<{guardian_uri: string, value: number|boolean}>} votes
31 * @param {string[]} guardianSet the guardians at the moment the decision opened
32 * @param {number} ageMs how long the decision has been open
33 * @returns {{state: 'settled'|'open'|'expired', value?: boolean}}
34 */
35export function tallyGatedSetting(votes, guardianSet, ageMs, windowMs = GATED_WINDOW_MS) {
36 const set = new Set((guardianSet || []).filter(Boolean));
37 if (!set.size) return { state: 'expired' }; // nobody may decide
38 const need = thresholdFor(set.size);
39 // Only answers from the snapshotted set count, one per guardian.
40 const seen = new Map();
41 for (const v of (votes || [])) {
42 if (!set.has(v.guardian_uri)) continue;
43 seen.set(v.guardian_uri, v.value === true || v.value === 1);
44 }
45 const yes = [...seen.values()].filter(Boolean).length;
46 const no = seen.size - yes;
47 // Race to the threshold, in both directions: settle the moment it is reached,
48 // and give up the moment it can no longer be reached.
49 if (yes >= need) return { state: 'settled', value: true };
50 if (no >= need) return { state: 'settled', value: false };
51 const undecided = set.size - seen.size;
52 if (yes + undecided < need && no + undecided < need) return { state: 'expired' };
53 if (ageMs >= windowMs) return { state: 'expired' }; // fails closed
54 return { state: 'open' };
55}
56
57/** The column a feature maps onto. Unknown features are refused, not guessed. */
58const FEATURES = { 'shaer:externalEmbeds': 'external_embeds' };
59export function featureColumn(feature) {
60 return Object.prototype.hasOwnProperty.call(FEATURES, feature) ? FEATURES[feature] : null;
61}
62
63/**
64 * Record one guardian's answer and settle if the threshold is now reached.
65 * Returns the tally state so a caller can report it.
66 */
67export function recordGatedVote(slug, feature, guardianUri, value) {
68 const column = featureColumn(feature);
69 if (!column) return { state: 'expired', error: 'unknown_feature' };
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());
79
80 // The window opens with the first answer, and a stale decision starts over:
81 // a proposal from last month should not silently count toward today's.
82 const existing = db.prepare('SELECT MIN(opened_at) AS opened FROM ap_gated_votes WHERE slug = ? AND feature = ?')
83 .get(slug, feature);
84 let openedAt = existing && existing.opened ? new Date(existing.opened).getTime() : Date.now();
85 if (Number.isNaN(openedAt) || Date.now() - openedAt >= GATED_WINDOW_MS) {
86 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
87 openedAt = Date.now();
88 }
89 db.prepare(`INSERT INTO ap_gated_votes (slug, feature, guardian_uri, value, opened_at)
90 VALUES (?,?,?,?,?)
91 ON CONFLICT(slug, feature, guardian_uri) DO UPDATE SET value = excluded.value`)
92 .run(slug, feature, guardianUri, value ? 1 : 0, new Date(openedAt).toISOString());
93
94 const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
95 .all(slug, feature);
96 const result = tallyGatedSetting(votes, guardians, Date.now() - openedAt);
97 if (result.state === 'settled') {
98 db.prepare(`UPDATE sites SET ${column} = ? WHERE slug = ?`).run(result.value ? 1 : 0, slug);
99 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
100 } else if (result.state === 'expired') {
101 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
102 }
103 return { ...result, need: thresholdFor(guardians.length), of: guardians.length };
104}
105
106/** The open decision for a feature, for showing progress ("1 of 2"). */
107export function gatedProgress(slug, feature) {
108 const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
109 .all(slug, feature);
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());
112 return { votes: votes.length, need: thresholdFor(guardians.length), of: guardians.length };
113}
114
115// ── The federated shape (§5.6) ────────────────────────────────────
116// An Offer of a shaer:GatedSetting, answered with Accept/Reject. Parsing lives
117// here so both the inbox and the outbox read it the same way.
118
119/** Read a shaer:GatedSetting object, or null when this is a different Offer. */
120export function parseGatedSetting(object) {
121 if (!object || typeof object !== 'object') return null;
122 const type = Array.isArray(object.type) ? object.type[0] : object.type;
123 if (type !== 'shaer:GatedSetting' && type !== 'GatedSetting') return null;
124 const ward = object['shaer:ward'] || object.ward;
125 const feature = object['shaer:feature'] || object.feature;
126 const value = object['shaer:value'] !== undefined ? object['shaer:value'] : object.value;
127 if (typeof ward !== 'string' || typeof feature !== 'string') return null;
128 return { ward, feature, value: value === true || value === 1 || value === 'true' };
129}
130
131/** Build the Offer a guardian sends to the ward's server. */
132export function buildGatedOffer(offerId, actor, ward, feature, value) {
133 return {
134 id: offerId,
135 type: 'Offer',
136 actor,
137 to: [ward],
138 object: {
139 type: 'shaer:GatedSetting',
140 'shaer:ward': ward,
141 'shaer:feature': feature,
142 'shaer:value': !!value,
143 },
144 };
145}
146
147export function rememberGatedOffer(offerId, slug, feature, value) {
148 try {
149 db.prepare('INSERT OR REPLACE INTO ap_gated_offers (offer_id, slug, feature, value) VALUES (?,?,?,?)')
150 .run(offerId, slug, feature, value ? 1 : 0);
151 } catch { /* non-fatal */ }
152}
153
154export function recallGatedOffer(offerId) {
155 try { return db.prepare('SELECT * FROM ap_gated_offers WHERE offer_id = ?').get(offerId) || null; }
156 catch { return null; }
157}
158
159export default {
160 tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, GATED_WINDOW_MS,
161 parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer,
162};
Note: See TracBrowser for help on using the repository browser.