source: Klonkt/src/services/guardianship/availability.js@ 4d81de5

main
Last change on this file since 4d81de5 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: 14.7 KB
Line 
1/**
2 * Guardian availability (FEP-633c §3.6): away, dormant, and the lapse.
3 *
4 * The port of the Shaer test daemon's availability.rs, validated there first
5 * (shaer-8z7): same states, same rules, same refusals. Guardianship demands
6 * attention; `shaer:guardians` is a public claim about safety, and a guardian
7 * who no longer answers makes it untrue. It also quietly breaks the §3.5
8 * arithmetic: a majority of a set with absent members can be unreachable.
9 *
10 * Three states per (ward, guardian), and one rule above everything else:
11 * ONE ANSWER RESTORES EVERYTHING, at any moment up to and including a
12 * running lapse. Neither away nor dormant is misconduct; neither leaves a
13 * mark.
14 *
15 * Time is always a parameter here, never read from a clock inside the rules,
16 * so a fourteen-day window is a number in a test and not a wait.
17 */
18import db from '../../config/database.js';
19import { listGuardians, removeRelation } from './relations.js';
20
21/** Deployment numbers (§3.6.2 keeps them out of the spec on purpose: any
22 * number written there would punish exactly the long-term ill). Matched to
23 * the daemon's defaults so the two backends behave the same under test. */
24export const POLICY = {
25 requestTtlMs: 7 * 24 * 3600 * 1000, // how long a request may sit unanswered
26 missesForDormant: 3, // how many missed requests make dormant
27};
28
29/** The lapse window. Irreversible per §3.5, so it always runs in full. */
30export const LAPSE_WINDOW_MS = 14 * 24 * 3600 * 1000;
31
32/** Marker detection: the away declaration rides a direct note (§2.4). */
33export function isAway(object) {
34 return !!object && (object['shaer:away'] === true || object.away === true);
35}
36
37/** AS2 endTime → epoch ms. A number passes through; a string goes through
38 * Date.parse (which reads ISO 8601, offsets included). null when absent or
39 * unreadable: an absence without an end is refused, never guessed. */
40export function parseEndTime(v) {
41 if (typeof v === 'number' && Number.isFinite(v)) return v;
42 if (typeof v !== 'string' || !v.trim()) return null;
43 const t = Date.parse(v);
44 return Number.isNaN(t) ? null : t;
45}
46
47// ── Attention (the per-guardian state) ─────────────────────────────────────
48
49function attentionRow(wardSlug, guardianUri) {
50 return db.prepare('SELECT * FROM ap_guardian_attention WHERE ward_slug = ? AND guardian_uri = ?')
51 .get(wardSlug, guardianUri) || { ward_slug: wardSlug, guardian_uri: guardianUri, state: 'active', away_until: null };
52}
53
54/** What the stored state means at `now`: an away past its end is simply
55 * active again, silently (§3.6.1). */
56export function effective(wardSlug, guardianUri, now) {
57 const row = attentionRow(wardSlug, guardianUri);
58 if (row.state === 'away') return (row.away_until && now < row.away_until) ? 'away' : 'active';
59 return row.state;
60}
61
62/** The away end, when there is a running one (for display). */
63export function awayUntil(wardSlug, guardianUri, now) {
64 const row = attentionRow(wardSlug, guardianUri);
65 return (row.state === 'away' && row.away_until && now < row.away_until) ? row.away_until : null;
66}
67
68/** Declare absence with an end (§3.6.1). The declaration is itself an
69 * answer, so it first restores: declaring away while dormant clears the
70 * dormancy, without a mark. Declaring away is the responsible act. */
71export function declareAway(wardSlug, guardianUri, untilMs) {
72 db.prepare('DELETE FROM ap_attention_requests WHERE ward_slug = ? AND guardian_uri = ?').run(wardSlug, guardianUri);
73 db.prepare(`INSERT INTO ap_guardian_attention (ward_slug, guardian_uri, state, away_until)
74 VALUES (?,?, 'away', ?)
75 ON CONFLICT(ward_slug, guardian_uri) DO UPDATE SET state = 'away', away_until = excluded.away_until`)
76 .run(wardSlug, guardianUri, untilMs);
77}
78
79/** A directly addressed request went out to this guardian (a §3.5 decision
80 * naming them, or an explicit check-in). Requests during a declared absence
81 * are not recorded: away MUST NOT count as evidence (§3.6.1). */
82export function recordRequest(wardSlug, guardianUri, requestId, now) {
83 if (effective(wardSlug, guardianUri, now) === 'away') return;
84 db.prepare(`INSERT OR IGNORE INTO ap_attention_requests (ward_slug, guardian_uri, request_id, asked_at)
85 VALUES (?,?,?,?)`).run(wardSlug, guardianUri, requestId, now);
86}
87
88/** Missed requests: unanswered ones older than the policy TTL. */
89export function misses(wardSlug, guardianUri, now) {
90 const r = db.prepare(`SELECT COUNT(*) AS n FROM ap_attention_requests
91 WHERE ward_slug = ? AND guardian_uri = ? AND asked_at <= ?`)
92 .get(wardSlug, guardianUri, now - POLICY.requestTtlMs);
93 return r ? r.n : 0;
94}
95
96/** Promote to dormant when the evidence says so. Returns true only on the
97 * transition itself: THAT is the moment the notification duty of §3.6.2
98 * fires (protocol AND the §6 handle), and it is the caller's job — wired
99 * through onDormant below so every call site notifies the same way. */
100export function observe(wardSlug, guardianUri, now) {
101 if (effective(wardSlug, guardianUri, now) !== 'active') return false;
102 if (misses(wardSlug, guardianUri, now) < POLICY.missesForDormant) return false;
103 db.prepare(`INSERT INTO ap_guardian_attention (ward_slug, guardian_uri, state, away_until)
104 VALUES (?,?, 'dormant', NULL)
105 ON CONFLICT(ward_slug, guardian_uri) DO UPDATE SET state = 'dormant', away_until = NULL`)
106 .run(wardSlug, guardianUri);
107 notifyDormant(wardSlug, guardianUri);
108 return true;
109}
110
111/** The notification duty of §3.6.2, wired once (ActivityPubService). The
112 * one-answer rule is worthless to someone who does not know an answer is
113 * wanted; the §6 handle exists for precisely this moment. */
114let _onDormant = null;
115export function wireAvailability({ onDormant } = {}) { _onDormant = onDormant || null; }
116function notifyDormant(wardSlug, guardianUri) {
117 try { if (_onDormant) _onDormant(wardSlug, guardianUri); } catch { /* best-effort */ }
118}
119
120/**
121 * One answer restores everything (§3.6). Any activity from an actor that
122 * guards someone on this server restores it to active for those wards and
123 * cancels any lapse running against it, up to the last moment of the window.
124 * Returns what changed, so a caller can log or announce it.
125 */
126export function oneAnswer(guardianUri, now) {
127 if (!guardianUri) return { restored: [], cancelledLapses: [] };
128 const restored = [];
129 for (const row of db.prepare(`SELECT ward_slug, state FROM ap_guardian_attention WHERE guardian_uri = ?`).all(guardianUri)) {
130 if (row.state !== 'active') restored.push(row.ward_slug);
131 }
132 const hadRequests = db.prepare('SELECT DISTINCT ward_slug FROM ap_attention_requests WHERE guardian_uri = ?').all(guardianUri);
133 for (const r of hadRequests) if (!restored.includes(r.ward_slug)) restored.push(r.ward_slug);
134 db.prepare("UPDATE ap_guardian_attention SET state = 'active', away_until = NULL WHERE guardian_uri = ?").run(guardianUri);
135 db.prepare('DELETE FROM ap_attention_requests WHERE guardian_uri = ?').run(guardianUri);
136
137 const cancelledLapses = [];
138 for (const l of db.prepare('SELECT * FROM ap_lapses WHERE target_uri = ? AND cancelled = 0 AND applied = 0').all(guardianUri)) {
139 if (lapseOutcome(l, now) === 'open') {
140 db.prepare('UPDATE ap_lapses SET cancelled = 1 WHERE id = ?').run(l.id);
141 cancelledLapses.push({ id: l.id, wardSlug: l.ward_slug, wardUri: l.ward_uri, set: JSON.parse(l.set_json) });
142 }
143 }
144 return { restored, cancelledLapses };
145}
146
147/** The available set of §3.5: the guardians minus away and dormant members.
148 * Observation (and thus the dormancy promotion) happens here, so reading the
149 * set is what moves the clock's consequences. */
150export function availableSet(wardSlug, guardianUris, now) {
151 return guardianUris.filter((g) => {
152 observe(wardSlug, g, now);
153 return effective(wardSlug, g, now) === 'active';
154 });
155}
156
157/** The guardians queue items (§3.6.1: never public, owner-only): the real
158 * size of the ward's safety net. Same shape the daemon serves. */
159export function statusesFor(wardSlug, guardianUris, now) {
160 return guardianUris.map((g) => {
161 observe(wardSlug, g, now);
162 const running = db.prepare(`SELECT id FROM ap_lapses WHERE ward_slug = ? AND target_uri = ? AND cancelled = 0 AND applied = 0`)
163 .get(wardSlug, g);
164 return {
165 id: g,
166 'shaer:availability': effective(wardSlug, g, now),
167 'shaer:awayUntil': awayUntil(wardSlug, g, now),
168 'shaer:lapse': running && lapseOutcome(db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(running.id), now) === 'open' ? running.id : null,
169 };
170 });
171}
172
173// ── The lapse (§3.6.3): release in absentia ────────────────────────────────
174
175/** Read a shaer:Lapse object, or null when this is a different Offer. */
176export function parseLapse(object) {
177 if (!object || typeof object !== 'object') return null;
178 const type = Array.isArray(object.type) ? object.type[0] : object.type;
179 if (type !== 'shaer:Lapse' && type !== 'Lapse') return null;
180 const ward = object['shaer:ward'] || object.ward;
181 const target = typeof object.object === 'string' ? object.object : (object.object && object.object.id);
182 return (typeof ward === 'string' && typeof target === 'string') ? { ward, target } : null;
183}
184
185/** Strict majority of the set (§3.5 default). */
186export function lapseThreshold(setSize) { return Math.floor(setSize / 2) + 1; }
187
188/** Pure outcome: cancelled beats everything; the window always runs in full
189 * (§3.5, irreversible), then a strict majority completes, else it fails
190 * closed. */
191export function lapseOutcome(row, now) {
192 if (!row) return null;
193 if (row.cancelled) return 'cancelled';
194 if (now - row.opened_at < row.window_ms) return 'open';
195 const accepts = JSON.parse(row.accepts_json).length;
196 return accepts >= lapseThreshold(JSON.parse(row.set_json).length) ? 'completed' : 'failed';
197}
198
199/**
200 * Open a lapse on this server (we host the ward). Refusals mirror the
201 * daemon's, status for status:
202 * - not_a_guardian: the target does not guard this ward
203 * - would_emancipate: removing the last guardian is §3.4, never a lapse
204 * - not_dormant: a lapse opens only against a guardian already dormant
205 * - not_in_available_set: only an available co-guardian proposes
206 */
207export function openLapse({ id, wardSlug, wardUri, target, openedBy, now, windowMs = LAPSE_WINDOW_MS }) {
208 const guardians = listGuardians(wardSlug).map((g) => g.other_uri);
209 if (!guardians.includes(target)) return { error: 'not_a_guardian' };
210 if (guardians.length <= 1) return { error: 'would_emancipate' };
211 observe(wardSlug, target, now);
212 if (effective(wardSlug, target, now) !== 'dormant') return { error: 'not_dormant' };
213 const set = availableSet(wardSlug, guardians, now).filter((g) => g !== target);
214 if (!set.includes(openedBy)) return { error: 'not_in_available_set' };
215 // The proposal carries the proposer's own accept (§3.1's one-step clause,
216 // exactly as §5.6 applies it).
217 db.prepare(`INSERT INTO ap_lapses (id, ward_slug, ward_uri, target_uri, opened_by, set_json, accepts_json, opened_at, window_ms)
218 VALUES (?,?,?,?,?,?,?,?,?)`)
219 .run(id, wardSlug, wardUri, target, openedBy, JSON.stringify(set), JSON.stringify([openedBy]), now, windowMs);
220 return { lapse: db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(id), set, threshold: lapseThreshold(set.length) };
221}
222
223/** Record a vote from a set member. Answers from outside the snapshot are
224 * refused, not counted: a stranger cannot make up the majority. */
225export function lapseVote(id, actor, accept, now) {
226 const row = db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(id);
227 if (!row) return null;
228 const outcome = lapseOutcome(row, now);
229 if (outcome !== 'open') return { error: outcome === 'cancelled' ? 'cancelled' : 'closed' };
230 const set = JSON.parse(row.set_json);
231 if (!set.includes(actor)) return { error: 'not_in_set' };
232 const accepts = new Set(JSON.parse(row.accepts_json));
233 const rejects = new Set(JSON.parse(row.rejects_json));
234 if (accept) { rejects.delete(actor); accepts.add(actor); }
235 else { accepts.delete(actor); rejects.add(actor); }
236 db.prepare('UPDATE ap_lapses SET accepts_json = ?, rejects_json = ? WHERE id = ?')
237 .run(JSON.stringify([...accepts]), JSON.stringify([...rejects]), id);
238 return { outcome: 'open', accepts: accepts.size, threshold: lapseThreshold(set.length) };
239}
240
241/**
242 * Evaluate a lapse at `now`, executing the removal exactly once when the
243 * window has closed with a majority. The refusal to empty shaer:guardians
244 * stands as a second lock under this one: even a completed lapse must not
245 * take the last guardian (that is emancipation, §3.4).
246 */
247export function settleLapse(id, now) {
248 const row = db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(id);
249 if (!row) return null;
250 const outcome = lapseOutcome(row, now);
251 if (outcome !== 'completed' || row.applied) return { outcome, applied: !!row.applied, row };
252 if (listGuardians(row.ward_slug).length <= 1) {
253 return { outcome, applied: false, refused: 'would_emancipate', row };
254 }
255 removeRelation(row.ward_slug, 'ward', row.target_uri);
256 db.prepare('UPDATE ap_lapses SET applied = 1 WHERE id = ?').run(id);
257 return { outcome, applied: true, row };
258}
259
260/** The offers-queue items for running lapses this account is a party to:
261 * the ward itself, or a co-located guardian in the set. Same shape as the
262 * daemon's, so the Shaer clients render them as-is. */
263export function lapseQueueItems(slug, me, now) {
264 const items = [];
265 for (const row of db.prepare('SELECT * FROM ap_lapses WHERE applied = 0 AND cancelled = 0').all()) {
266 settleLapse(row.id, now); // reads are where lazy completion happens
267 if (lapseOutcome(row, now) !== 'open') continue;
268 const set = JSON.parse(row.set_json);
269 if (row.ward_slug !== slug && !set.includes(me)) continue;
270 const accepts = JSON.parse(row.accepts_json);
271 const rejects = JSON.parse(row.rejects_json);
272 items.push({
273 id: row.id,
274 type: 'Offer',
275 actor: row.opened_by,
276 object: { type: 'shaer:Lapse', 'shaer:ward': row.ward_uri, object: row.target_uri },
277 'shaer:set': set,
278 'shaer:accepts': accepts.length,
279 'shaer:threshold': lapseThreshold(set.length),
280 'shaer:myVote': accepts.includes(me) || rejects.includes(me),
281 'shaer:outcome': 'open',
282 'shaer:closesAt': row.opened_at + row.window_ms,
283 });
284 }
285 return items;
286}
287
288export function getLapse(id) { return db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(id); }
289
290export default {
291 POLICY, LAPSE_WINDOW_MS, isAway, parseEndTime, effective, awayUntil, declareAway,
292 recordRequest, misses, observe, oneAnswer, availableSet, statusesFor, wireAvailability,
293 parseLapse, lapseThreshold, lapseOutcome, openLapse, lapseVote, settleLapse, lapseQueueItems, getLapse,
294};
Note: See TracBrowser for help on using the repository browser.