source: Klonkt/src/services/guardianship/gated.js@ 792af53

main
Last change on this file since 792af53 was 792af53, checked in by roboburr <roboburr@…>, 5 weeks ago

Het gate-paneel: een rij per gate, met het soort en de drempel (shaer-ahy.1)

Een guardian zag twee knoppen en moest zelf uitzoeken wat er verder voor dit kind
gold. Wat niet verstelbaar is stond nergens -- terwijl dat de helft is van het
antwoord op "wat mag dit kind".

EEN BRON VAN WAARHEID. GATE_CATALOGUE in gated.js is nu de lijst van gates die
deze Klonkt kent. Wat gated wordt is een ontwerpkeuze van de implementatie: de
FEP levert het mechanisme (voorstel, tally, settle) en een paar voorbeelden, niet
de lijst. Een gate erbij is daarmee een regel data en geen nieuw stuk scherm.

HET SOORT STAAT ERBIJ, want ze werken niet hetzelfde:

setting een stand, aan of uit, terug te draaien
perRequest geen stand maar een stroom beslissingen (5.3 volgverzoeken)
handover draagt gezag OVER, onomkeerbaar zodra de ward hem gebruikt

Die derde bestaat nog niet (shaer-90v), maar de rij kan hem al dragen -- inclusief
"niet terug te draaien", zodat dat er niet later ingebouwd hoeft te worden.

Volgverzoeken staan er nu ook in, met het aantal dat wacht. Ze zijn niet te
verzetten (altijd aan voor een ward, 5.3) en juist daarom horen ze zichtbaar te
zijn.

TWEE DINGEN DIE HET PANEEL NIET VERZINT. Een onbekende stand is ONBEKEND en niet
uit: bij een ward op een andere server staat die kolom daar. En zonder bekend
aantal guardians komt er geen drempel op het scherm -- nul of een gok leest als
een feit, en dit is precies waar een guardian op afgaat voordat hij iets
voorstelt.

De trap uit shaer-ahy blijft: afspelen is pas te bewegen als linkvoorbeelden
aanstaan. Met dezelfde uitzondering als voorheen -- onbekend telt niet als dicht,
want dat kostte ooit een hele voorstelronde.

Terugval ingebouwd: serveert een oudere Klonkt de catalogus nog niet, dan
verschijnen de twee knoppen zoals ze waren in plaats van een leeg vak.

9 tests op gateRows, dat pure stuk waar de regels in zitten. nl/en/de. Suite
565/565. Niet gedekt: de weergave zelf, dat is client-JS.

  • Property mode set to 100644
File size: 14.8 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 = {
59 'shaer:externalEmbeds': 'external_embeds',
60 'shaer:externalPlayback': 'external_playback',
61};
62/**
63 * De gates die deze Klonkt kent, met hun SOORT.
64 *
65 * Wat gated wordt is een ontwerpkeuze van de implementatie: de FEP levert het
66 * mechanisme (voorstel, tally, settle) en een paar voorbeelden, niet de lijst.
67 * Deze catalogus is die lijst, op een plek. Een gate erbij hoort een regel data
68 * te zijn en geen nieuw stuk scherm.
69 *
70 * `kind` is niet decoratief. De gates verschillen in hoe ze werken en dat mag
71 * een guardian niet hoeven raden:
72 *
73 * setting een stand, aan of uit, terug te draaien
74 * perRequest geen stand maar een stroom beslissingen (5.3 volgverzoeken)
75 * handover draagt gezag OVER; onomkeerbaar zodra de ward hem gebruikt
76 *
77 * `needs` is de trap uit shaer-ahy: zien < afspelen. Je kunt niet afspelen wat
78 * je niet mag zien, dus dat tweede is pas te bewegen als het eerste openstaat.
79 */
80export const GATE_CATALOGUE = [
81 { feature: 'shaer:externalEmbeds', kind: 'setting', reversible: true },
82 { feature: 'shaer:externalPlayback', kind: 'setting', reversible: true, needs: 'shaer:externalEmbeds' },
83 // Altijd aan voor een ward (5.3): niet te verzetten, wel te tonen. Een paneel
84 // dat alleen verstelbare dingen laat zien verzwijgt de helft van wat er geldt.
85 { feature: 'shaer:follows', kind: 'perRequest', reversible: true, fixed: true },
86];
87
88/**
89 * De gates van een ward als rijen voor het paneel. Puur, zodat de regels
90 * getoetst kunnen worden zonder database of scherm.
91 *
92 * @param settings {feature: true|false|null} -- null is ONBEKEND, niet uit
93 * @param guardianCount aantal guardians, of null als we het niet weten
94 * @param proposals [{feature, value, status}] lopende voorstellen
95 * @param waiting {feature: aantal} wat er per gate op een besluit wacht
96 */
97export function gateRows({ settings = {}, guardianCount = null, proposals = [], waiting = {} } = {}) {
98 return GATE_CATALOGUE.map((g) => {
99 const value = Object.prototype.hasOwnProperty.call(settings, g.feature) ? settings[g.feature] : null;
100 // De trap: het bovenliggende moet OPEN staan. Onbekend telt niet als dicht --
101 // bij een ward elders kennen we de stand niet, en verbergen betekende daar
102 // ooit dat een voorstel nooit geopend kon worden.
103 const blockedBy = g.needs && settings[g.needs] === false ? g.needs : null;
104 return {
105 feature: g.feature,
106 kind: g.kind,
107 reversible: !!g.reversible,
108 value,
109 // Vast staat vast: tonen mag, verzetten niet.
110 adjustable: !g.fixed && !blockedBy,
111 blockedBy: blockedBy || undefined,
112 // Zonder bekend aantal guardians GEEN drempel verzinnen. Nul of een gok
113 // leest als een feit, en dit is precies waar een guardian op afgaat.
114 threshold: (guardianCount && guardianCount > 0)
115 ? { need: thresholdFor(guardianCount), of: guardianCount } : null,
116 proposal: proposals.find((p) => p.feature === g.feature) || undefined,
117 waiting: waiting[g.feature] || undefined,
118 };
119 });
120}
121
122export function featureColumn(feature) {
123 return Object.prototype.hasOwnProperty.call(FEATURES, feature) ? FEATURES[feature] : null;
124}
125
126/**
127 * Record one guardian's answer and settle if the threshold is now reached.
128 * Returns the tally state so a caller can report it.
129 */
130export function recordGatedVote(slug, feature, guardianUri, value) {
131 const column = featureColumn(feature);
132 if (!column) return { state: 'expired', error: 'unknown_feature' };
133 const all = listGuardians(slug).map((g) => g.other_uri);
134 if (!all.includes(guardianUri)) return { state: 'expired', error: 'not_a_guardian' };
135 // A vote is an answer, whatever it is a vote on (§3.6): the voter is
136 // restored first, so it always counts itself back into the set below.
137 availability.oneAnswer(guardianUri, Date.now());
138 // §3.5: the threshold runs over the AVAILABLE set. Membership is checked
139 // against the full list above: any guardian may answer, and answering is
140 // exactly what brings it back in.
141 const guardians = availability.availableSet(slug, all, Date.now());
142
143 // The window opens with the first answer, and a stale decision starts over:
144 // a proposal from last month should not silently count toward today's.
145 const existing = db.prepare('SELECT MIN(opened_at) AS opened FROM ap_gated_votes WHERE slug = ? AND feature = ?')
146 .get(slug, feature);
147 let openedAt = existing && existing.opened ? new Date(existing.opened).getTime() : Date.now();
148 if (Number.isNaN(openedAt) || Date.now() - openedAt >= GATED_WINDOW_MS) {
149 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
150 openedAt = Date.now();
151 }
152 db.prepare(`INSERT INTO ap_gated_votes (slug, feature, guardian_uri, value, opened_at)
153 VALUES (?,?,?,?,?)
154 ON CONFLICT(slug, feature, guardian_uri) DO UPDATE SET value = excluded.value`)
155 .run(slug, feature, guardianUri, value ? 1 : 0, new Date(openedAt).toISOString());
156
157 const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
158 .all(slug, feature);
159 const result = tallyGatedSetting(votes, guardians, Date.now() - openedAt);
160 if (result.state === 'settled') {
161 db.prepare(`UPDATE sites SET ${column} = ? WHERE slug = ?`).run(result.value ? 1 : 0, slug);
162 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
163 } else if (result.state === 'expired') {
164 db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
165 }
166 return { ...result, need: thresholdFor(guardians.length), of: guardians.length };
167}
168
169/** The open decision for a feature, for showing progress ("1 of 2"). */
170export function gatedProgress(slug, feature) {
171 const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
172 .all(slug, feature);
173 // Progress over the available set (§3.5), like the tally itself.
174 const guardians = availability.availableSet(slug, listGuardians(slug).map((g) => g.other_uri), Date.now());
175 return { votes: votes.length, need: thresholdFor(guardians.length), of: guardians.length };
176}
177
178// ── The federated shape (§5.6) ────────────────────────────────────
179// An Offer of a shaer:GatedSetting, answered with Accept/Reject. Parsing lives
180// here so both the inbox and the outbox read it the same way.
181
182/** Read a shaer:GatedSetting object, or null when this is a different Offer. */
183export function parseGatedSetting(object) {
184 if (!object || typeof object !== 'object') return null;
185 const type = Array.isArray(object.type) ? object.type[0] : object.type;
186 if (type !== 'shaer:GatedSetting' && type !== 'GatedSetting') return null;
187 const ward = object['shaer:ward'] || object.ward;
188 const feature = object['shaer:feature'] || object.feature;
189 const value = object['shaer:value'] !== undefined ? object['shaer:value'] : object.value;
190 if (typeof ward !== 'string' || typeof feature !== 'string') return null;
191 return { ward, feature, value: value === true || value === 1 || value === 'true' };
192}
193
194/** Build the Offer a guardian sends to the ward's server. */
195export function buildGatedOffer(offerId, actor, ward, feature, value) {
196 return {
197 id: offerId,
198 type: 'Offer',
199 actor,
200 to: [ward],
201 object: {
202 type: 'shaer:GatedSetting',
203 'shaer:ward': ward,
204 'shaer:feature': feature,
205 'shaer:value': !!value,
206 },
207 };
208}
209
210// ── The guardian-side copy (the missing leg of §5.6) ──────────────
211// A proposal addressed to the ward's server reaches only the proposer and the
212// ward. The other guardians never learn it exists, so a threshold of two can
213// never be met and every proposal expires unanswered. The ward's server
214// therefore FORWARDS it, exactly as it forwards a gated follow (§5.3): each
215// guardian stores a copy it can answer, and the answer travels back to the
216// ward, which tallies.
217
218let _rs = null;
219function rstmts() {
220 if (!_rs) {
221 _rs = {
222 ins: db.prepare(`INSERT INTO ap_gated_reviews (id, guardian_slug, ward_uri, ward_inbox, proposer, feature, value)
223 VALUES (?,?,?,?,?,?,?)
224 ON CONFLICT(guardian_slug, id) DO UPDATE SET value = excluded.value, ward_inbox = excluded.ward_inbox`),
225 get: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
226 bySlug: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? ORDER BY created_at DESC'),
227 del: db.prepare('DELETE FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
228 delAll: db.prepare('DELETE FROM ap_gated_reviews WHERE id = ?'),
229 };
230 }
231 return _rs;
232}
233
234export function recordGatedReview(guardianSlug, r) {
235 rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.proposer || null, r.feature, r.value ? 1 : 0);
236 return rstmts().get.get(guardianSlug, r.id);
237}
238export function getGatedReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
239export function listGatedReviews(guardianSlug) { return rstmts().bySlug.all(guardianSlug); }
240export function removeGatedReview(guardianSlug, id) { rstmts().del.run(guardianSlug, id); }
241/** Drop every guardian's copy once the decision has settled or lapsed. */
242export function clearGatedReviews(id) { rstmts().delAll.run(id); }
243
244export function rememberGatedOffer(offerId, slug, feature, value, proposer) {
245 try {
246 db.prepare('INSERT OR REPLACE INTO ap_gated_offers (offer_id, slug, feature, value, proposer) VALUES (?,?,?,?,?)')
247 .run(offerId, slug, feature, value ? 1 : 0, proposer || null);
248 } catch { /* non-fatal */ }
249}
250
251export function recallGatedOffer(offerId) {
252 try { return db.prepare('SELECT * FROM ap_gated_offers WHERE offer_id = ?').get(offerId) || null; }
253 catch { return null; }
254}
255
256// ── The proposer's own record (5.6) ───────────────────────────────
257// "Where did my proposal go?" had no answer: the status was a button caption
258// that did not survive a refresh. The ward's server tallies elsewhere, so the
259// proposer keeps its own row and the ward's server ANSWERS the Offer when the
260// decision settles: Accept when it settled on the proposed value, Reject when
261// it settled on the opposite. An open row past the window renders as expired,
262// because an expired decision settles on nothing and nobody writes home.
263
264export function recordSent(offerId, guardianSlug, wardUri, feature, value) {
265 try {
266 db.prepare(`INSERT OR REPLACE INTO ap_gated_sent (offer_id, guardian_slug, ward_uri, feature, value)
267 VALUES (?,?,?,?,?)`).run(offerId, guardianSlug, wardUri, feature, value ? 1 : 0);
268 } catch { /* non-fatal */ }
269}
270
271export function recallSent(offerId) {
272 try { return db.prepare('SELECT * FROM ap_gated_sent WHERE offer_id = ?').get(offerId) || null; }
273 catch { return null; }
274}
275
276export function settleSent(offerId, outcome) {
277 try { db.prepare('UPDATE ap_gated_sent SET status = ? WHERE offer_id = ?').run(outcome, offerId); } catch { /* non-fatal */ }
278}
279
280/** The latest proposal per feature this guardian sent to this ward. */
281export function listSent(guardianSlug, wardUri) {
282 try {
283 return db.prepare(`SELECT * FROM ap_gated_sent WHERE guardian_slug = ? AND ward_uri = ?
284 GROUP BY feature HAVING MAX(created_at) ORDER BY created_at DESC`).all(guardianSlug, wardUri);
285 } catch { return []; }
286}
287
288/**
289 * What a sent row means on a screen. Pure, so the rule is testable: an answer
290 * wins, and silence past the window is not "still running", it is over.
291 */
292export function sentStatus(row, now) {
293 if (!row) return null;
294 if (row.status === 'accepted' || row.status === 'rejected') return row.status;
295 const opened = new Date(String(row.created_at).includes('T') ? row.created_at : `${row.created_at}Z`.replace(' ', 'T')).getTime();
296 if (Number.isFinite(opened) && now - opened >= GATED_WINDOW_MS) return 'expired';
297 return 'open';
298}
299
300export default {
301 GATE_CATALOGUE, gateRows,
302 tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, GATED_WINDOW_MS,
303 parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer,
304 recordGatedReview, getGatedReview, listGatedReviews, removeGatedReview, clearGatedReviews,
305 recordSent, recallSent, settleSent, listSent, sentStatus,
306};
Note: See TracBrowser for help on using the repository browser.