source: Klonkt/test/availability.test.js@ 59f404a

main
Last change on this file since 59f404a 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: 12.8 KB
Line 
1// Guardian availability (FEP-633c §3.6): away, dormant, and the lapse.
2// These tests mirror the daemon's (routes.rs + availability.rs) one for one,
3// same names where possible, so a drift between the two backends shows up as
4// a failing test with the same words on both sides (shaer-6d9).
5import { test } from 'node:test';
6import assert from 'node:assert/strict';
7
8process.env.DATABASE_PATH = ':memory:';
9process.env.PUBLIC_BASE_URL = 'https://test.example';
10
11const dbMod = await import('../src/config/database.js');
12const db = dbMod.default;
13dbMod.initializeDatabase();
14const AP = (await import('../src/services/ActivityPubService.js')).default;
15const G = await import('../src/services/guardianship/index.js');
16const A = G.availability;
17
18const DAY = 24 * 3600 * 1000;
19// The handshake and the queue functions read the wall clock (unlike the
20// daemon, whose Clock is pinnable), so these tests BACKDATE the evidence
21// instead of advancing time: T0 lies eight days in the past, which puts
22// "now" at the wall clock for everything that reads it itself.
23const T0 = Date.now() - 8 * DAY;
24const uri = (slug) => `https://test.example/ap/users/${slug}`;
25
26db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
27function site(id, slug) {
28 db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run(id, slug, slug, 'u1', id === 's1' ? 1 : 0);
29 return db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
30}
31// kid3 guarded by g1, g2, g3 (all remote, the ordinary case).
32const kid3 = site('s1', 'kid3');
33const [G1, G2, G3] = ['https://a.test/u/g1', 'https://b.test/u/g2', 'https://c.test/u/g3'];
34for (const g of [G1, G2, G3]) {
35 db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, other_handle, status, offer_id)
36 VALUES ('kid3','ward',?,?, 'accepted','o1')`).run(g, '@' + g.split('/').pop());
37}
38
39// The handshake drives the lapse over the same deps the guardianship tests use.
40const delivered = [];
41G.wireHandshake({
42 selfId: uri,
43 localSlug: (u) => (u.startsWith('https://test.example/ap/users/') ? u.split('/').pop() : null),
44 deriveHandle: (u) => '@' + u.split('/').pop(),
45 fetchActor: async () => null,
46 deliverTo: async (fromSite, toUri, activity) => { delivered.push({ toUri, activity }); return { delivered: true }; },
47 onEvent: null,
48});
49
50/** Three follow decisions address every guardian; g1 and g2 answer, g3 stays
51 * silent; the clock passes the request TTL. */
52function makeG3Dormant() {
53 db.prepare('DELETE FROM ap_attention_requests').run();
54 db.prepare('DELETE FROM ap_guardian_attention').run();
55 db.prepare('DELETE FROM ap_lapses').run();
56 for (let i = 0; i < 3; i++) {
57 for (const g of [G1, G2, G3]) A.recordRequest('kid3', g, `follow-${i}`, T0 + i * 60_000);
58 }
59 A.oneAnswer(G1, T0 + 3 * 60_000);
60 A.oneAnswer(G2, T0 + 3 * 60_000);
61 return Date.now(); // the requests are now past the 7-day TTL
62}
63
64test('asked nothing never dormant: calendar time alone is no evidence', () => {
65 assert.equal(A.observe('kid3', G1, T0 + 1_000_000 * DAY), false);
66 assert.equal(A.effective('kid3', G1, T0 + 1_000_000 * DAY), 'active');
67});
68
69test('silence on addressed requests makes dormant, and the set shrinks', () => {
70 const now = makeG3Dormant();
71 const avail = A.availableSet('kid3', [G1, G2, G3], now);
72 assert.deepEqual(avail, [G1, G2], 'the set shrank to the two who answered');
73 assert.equal(A.effective('kid3', G3, now), 'dormant');
74});
75
76test('the dormancy promotion fires the notification duty, once', () => {
77 let notices = 0;
78 G.wireAvailability({ onDormant: () => { notices++; } });
79 const now = makeG3Dormant();
80 A.availableSet('kid3', [G1, G2, G3], now);
81 A.availableSet('kid3', [G1, G2, G3], now);
82 assert.equal(notices, 1, 'marking dormant MUST notify (3.6.2), on the transition itself');
83 G.wireAvailability({});
84});
85
86test('one answer restores everything', () => {
87 const now = makeG3Dormant();
88 A.availableSet('kid3', [G1, G2, G3], now);
89 const ev = A.oneAnswer(G3, now);
90 assert.deepEqual(ev.restored, ['kid3']);
91 assert.equal(A.effective('kid3', G3, now), 'active');
92 assert.equal(A.misses('kid3', G3, now), 0, 'slate clean, no mark');
93});
94
95test('away has an end, expires silently, and is never evidence', () => {
96 db.prepare('DELETE FROM ap_attention_requests').run();
97 db.prepare('DELETE FROM ap_guardian_attention').run();
98 A.declareAway('kid3', G2, T0 + 10 * DAY);
99 assert.equal(A.effective('kid3', G2, T0 + 5 * DAY), 'away');
100 assert.equal(A.awayUntil('kid3', G2, T0 + 5 * DAY), T0 + 10 * DAY);
101 // Requests during the absence are not recorded (3.6.1).
102 for (let i = 0; i < 5; i++) A.recordRequest('kid3', G2, `req-${i}`, T0 + DAY);
103 assert.equal(A.observe('kid3', G2, T0 + 20 * DAY), false);
104 assert.equal(A.effective('kid3', G2, T0 + 20 * DAY), 'active', 'the away expired silently, no dormancy left behind');
105});
106
107test('declaring away while dormant is an answer', () => {
108 const now = makeG3Dormant();
109 A.availableSet('kid3', [G1, G2, G3], now);
110 A.declareAway('kid3', G3, now + 3 * DAY);
111 assert.equal(A.effective('kid3', G3, now + DAY), 'away');
112 assert.equal(A.effective('kid3', G3, now + 3 * DAY), 'active');
113});
114
115test('a lapse opens only against a dormant guardian, and never empties', async () => {
116 db.prepare('DELETE FROM ap_attention_requests').run();
117 db.prepare('DELETE FROM ap_guardian_attention').run();
118 // Both active: refused.
119 const r = A.openLapse({ id: 'l-x', wardSlug: 'kid3', wardUri: uri('kid3'), target: G3, openedBy: G1, now: T0 });
120 assert.equal(r.error, 'not_dormant');
121 // A sole guardian can never lapse: that would be emancipation (3.4).
122 const solo = site('s2', 'kid1');
123 db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, status, offer_id) VALUES ('kid1','ward',?, 'accepted','o1')`).run(G1);
124 const r2 = A.openLapse({ id: 'l-y', wardSlug: 'kid1', wardUri: uri('kid1'), target: G1, openedBy: G1, now: T0 });
125 assert.equal(r2.error, 'would_emancipate');
126 assert.ok(solo, 'fixture site exists');
127});
128
129test('lapse full flow: release in absentia, over the S2S wire', async () => {
130 const now = makeG3Dormant();
131 A.availableSet('kid3', [G1, G2, G3], now); // promote g3
132
133 // The proposing guardian's server delivers an Offer of shaer:Lapse.
134 const consumed = await G.handleGuardianshipInbox(kid3, {
135 id: 'https://a.test/lapses/1', type: 'Offer', actor: G1, to: [uri('kid3')],
136 object: { type: 'shaer:Lapse', 'shaer:ward': uri('kid3'), object: G3 },
137 });
138 assert.equal(consumed, true);
139 const row = A.getLapse('https://a.test/lapses/1');
140 assert.ok(row, 'the ward server opened the lapse');
141 assert.deepEqual(JSON.parse(row.set_json), [G1, G2], 'the available set, target excluded');
142 assert.ok(delivered.some((d) => d.toUri === G3), 'the target is notified in protocol (3.6.2)');
143
144 // The second guardian agrees: 2 of 2, threshold met, but an irreversible
145 // decision never settles early (3.5).
146 await G.handleGuardianshipInbox(kid3, { type: 'Accept', actor: G2, object: 'https://a.test/lapses/1' });
147 assert.equal(A.settleLapse('https://a.test/lapses/1', now + DAY).outcome, 'open', 'the window still runs');
148 assert.equal(G.listGuardians('kid3').length, 3);
149
150 // The window closes: released in absentia. The lapse opened at the wall
151 // clock (inside the handshake), so the margin of a day absorbs the skew.
152 const after = Date.now() + A.LAPSE_WINDOW_MS + DAY;
153 const settled = A.settleLapse('https://a.test/lapses/1', after);
154 assert.equal(settled.outcome, 'completed');
155 assert.equal(settled.applied, true);
156 assert.deepEqual(G.listGuardians('kid3').map((g) => g.other_uri), [G1, G2]);
157 // Settling twice never applies twice.
158 assert.equal(A.settleLapse('https://a.test/lapses/1', after).applied, true);
159 assert.equal(G.listGuardians('kid3').length, 2);
160
161 // Restore the fixture for the remaining tests.
162 db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, status, offer_id) VALUES ('kid3','ward',?, 'accepted','o1')`).run(G3);
163});
164
165test('one answer cancels a running lapse and restores the target', async () => {
166 const now = makeG3Dormant();
167 A.availableSet('kid3', [G1, G2, G3], now);
168 await G.handleGuardianshipInbox(kid3, {
169 id: 'https://a.test/lapses/2', type: 'Offer', actor: G1, to: [uri('kid3')],
170 object: { type: 'shaer:Lapse', 'shaer:ward': uri('kid3'), object: G3 },
171 });
172 // The target shows a sign of life: anything at all.
173 const ev = A.oneAnswer(G3, now + DAY);
174 assert.equal(ev.cancelledLapses.length, 1);
175 assert.equal(A.lapseOutcome(A.getLapse('https://a.test/lapses/2'), now + A.LAPSE_WINDOW_MS + DAY), 'cancelled');
176 assert.equal(A.settleLapse('https://a.test/lapses/2', now + A.LAPSE_WINDOW_MS + DAY).applied, false);
177 assert.equal(G.listGuardians('kid3').length, 3, 'one answer cancelled the lapse');
178 assert.equal(A.effective('kid3', G3, now + DAY), 'active', 'and restored the guardian');
179});
180
181test('a stranger cannot make up the majority', async () => {
182 const now = makeG3Dormant();
183 A.availableSet('kid3', [G1, G2, G3], now);
184 await G.handleGuardianshipInbox(kid3, {
185 id: 'https://a.test/lapses/3', type: 'Offer', actor: G1, to: [uri('kid3')],
186 object: { type: 'shaer:Lapse', 'shaer:ward': uri('kid3'), object: G3 },
187 });
188 const r = A.lapseVote('https://a.test/lapses/3', 'https://evil.test/u/stranger', true, now);
189 assert.equal(r.error, 'not_in_set');
190 const t = A.lapseVote('https://a.test/lapses/3', G3, true, now);
191 assert.equal(t.error, 'not_in_set', 'the target is not in the set either');
192});
193
194test('the offers queue carries the lapse, the same shape the daemon serves', async () => {
195 const now = makeG3Dormant();
196 A.availableSet('kid3', [G1, G2, G3], now);
197 await G.handleGuardianshipInbox(kid3, {
198 id: 'https://a.test/lapses/4', type: 'Offer', actor: G1, to: [uri('kid3')],
199 object: { type: 'shaer:Lapse', 'shaer:ward': uri('kid3'), object: G3 },
200 });
201 const col = G.offersCollection(`${uri('kid3')}/queues/offers`, 'kid3', uri('kid3'));
202 const item = col.orderedItems.find((i) => i.id === 'https://a.test/lapses/4');
203 assert.ok(item, 'the ward sees the running lapse');
204 assert.equal(item.object.type, 'shaer:Lapse');
205 assert.equal(item['shaer:threshold'], 2);
206 assert.equal(item['shaer:outcome'], 'open');
207});
208
209test('the guardians queue serves availability, never the public actor doc', () => {
210 const now = makeG3Dormant();
211 A.availableSet('kid3', [G1, G2, G3], now);
212 A.declareAway('kid3', G2, now + 5 * DAY);
213 const col = G.guardiansCollection(`${uri('kid3')}/queues/guardians`, 'kid3');
214 const by = Object.fromEntries(col.orderedItems.map((i) => [i.id, i]));
215 assert.equal(by[G1]['shaer:availability'], 'active');
216 assert.equal(by[G2]['shaer:availability'], 'away');
217 assert.equal(by[G3]['shaer:availability'], 'dormant');
218 // The PUBLIC actor document says nothing about any of this (3.6.1).
219 const doc = AP.buildActor('https://test.example', kid3);
220 assert.ok(!JSON.stringify(doc).match(/away|dormant|availability/i), 'availability is timing intelligence and stays out of the public doc');
221});
222
223test('the away note emits shaer:away with a plain AS2 endTime', () => {
224 db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, visibility, to_actors, away_until, created_at)
225 VALUES ('aw1','kid3','',NULL,NULL,'https://b.test/u/g2','@g2','<p>weg</p>','direct','["https://b.test/u/g2"]',?,CURRENT_TIMESTAMP)`).run(T0 + 10 * DAY);
226 const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get('aw1');
227 const note = AP.buildReplyNote('https://test.example', kid3, row);
228 assert.equal(note['shaer:away'], true);
229 assert.equal(note.endTime, new Date(T0 + 10 * DAY).toISOString());
230 assert.equal(A.parseEndTime(note.endTime), T0 + 10 * DAY, 'and it round-trips through the parser');
231});
232
233test('the gated tally runs over the available set (§3.5)', () => {
234 // The arithmetic problem this section exists for: five guardians of whom
235 // two are gone. Over the full set the threshold is 3 and the two absentees
236 // block every decision by silence alone; over the available set (3) the
237 // threshold is 2 and the living can still decide.
238 site('s3', 'kid5');
239 const gs = [1, 2, 3, 4, 5].map((n) => `https://g${n}.test/u/g`);
240 for (const g of gs) {
241 db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, status, offer_id) VALUES ('kid5','ward',?, 'accepted','o1')`).run(g);
242 }
243 for (let i = 0; i < 3; i++) for (const g of [gs[3], gs[4]]) A.recordRequest('kid5', g, `req-${i}`, T0);
244 const now = T0 + 8 * DAY;
245 assert.deepEqual(A.availableSet('kid5', gs, now), gs.slice(0, 3));
246
247 const r1 = G.gated.recordGatedVote('kid5', 'shaer:externalEmbeds', gs[0], true);
248 assert.equal(r1.state, 'open');
249 assert.equal(r1.need, 2, 'threshold over the available set of 3, not 3 of 5');
250 const r2 = G.gated.recordGatedVote('kid5', 'shaer:externalEmbeds', gs[1], true);
251 assert.equal(r2.state, 'settled');
252 assert.equal(r2.value, true, 'two living guardians decide; the absent no longer freeze the ward');
253});
Note: See TracBrowser for help on using the repository browser.