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

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

Gated settings federeren: guardians beslissen samen, ook van een andere server

Ik had de knop alleen voor het co-located geval gebouwd, en dat is precies het
uitzonderingsgeval. In de echte opstelling staat de ward op de ene server en zijn
drie guardians op twee andere: er was dus nergens een knop. Dat botst met onze
eigen regel dat co-locatie een optimalisatie is en nooit de aanname.

Nu volgens FEP-633c 5.6 (deze week aan de spec toegevoegd): een guardian stelt
een wijziging voor met een Offer van een shaer:GatedSetting aan de server van de
WARD; de andere guardians antwoorden met Accept/Reject; de server van de ward
telt en handhaaft, want die serveert de feed. Co-locatie neemt dezelfde weg: ook
daar wordt voorgesteld en geteld, anders zou een guardian naast de deur meer te
zeggen hebben dan een op afstand.

De tally is een 3.5-beslissing en staat als pure functie apart: gesnapshotte set,
strikte meerderheid, venster van een dag. Omkeerbaar, dus race naar de drempel in
BEIDE richtingen (settelt ook zodra een meerderheid onhaalbaar is) en faalt dicht
op de deadline. Een Reject is een stem voor de andere waarde, geen schouderophalen.

New file:
src/services/guardianship/gated.js

  • tallyGatedSetting (puur), thresholdFor, featureColumn (onbekende features geweigerd i.p.v. geraden), recordGatedVote, en de Offer-vorm

test/gated-settings.test.js

  • 9 tests: drempel, vroeg settelen in beide richtingen, dicht op de deadline, vreemden tellen niet mee, geen guardians = niets toegekend, van gedachten veranderen vervangt je stem, en een onbekende feature raakt geen kolom

Changed files:
src/config/database.js

  • ap_gated_offers + ap_gated_votes

src/services/guardianship/handshake.js

  • inbox: Offer(shaer:GatedSetting) en Accept/Reject erop, met de stem van de voorsteller meegeteld (one-step-clausule)

src/services/guardianship/index.js

  • gated geexporteerd

src/routes/guardian.js

  • de knop stuurt een voorstel, lokaal en remote langs dezelfde weg

src/assets/js/guardian.js

  • knop bij ELKE ward, ook remote; toont 'wacht op de andere guardians'

src/services/i18n.js

  • embeds_propose / embeds_waiting in nl, en, de

remarks: 228 tests groen (was 219).

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

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