Index: test/availability.test.js
===================================================================
--- test/availability.test.js	(revision 6eab7e9673764601cadce6d5f98893e9a32f70a9)
+++ test/availability.test.js	(revision 6eab7e9673764601cadce6d5f98893e9a32f70a9)
@@ -0,0 +1,253 @@
+// Guardian availability (FEP-633c §3.6): away, dormant, and the lapse.
+// These tests mirror the daemon's (routes.rs + availability.rs) one for one,
+// same names where possible, so a drift between the two backends shows up as
+// a failing test with the same words on both sides (shaer-6d9).
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = (await import('../src/services/ActivityPubService.js')).default;
+const G = await import('../src/services/guardianship/index.js');
+const A = G.availability;
+
+const DAY = 24 * 3600 * 1000;
+// The handshake and the queue functions read the wall clock (unlike the
+// daemon, whose Clock is pinnable), so these tests BACKDATE the evidence
+// instead of advancing time: T0 lies eight days in the past, which puts
+// "now" at the wall clock for everything that reads it itself.
+const T0 = Date.now() - 8 * DAY;
+const uri = (slug) => `https://test.example/ap/users/${slug}`;
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
+function site(id, slug) {
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run(id, slug, slug, 'u1', id === 's1' ? 1 : 0);
+  return db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
+}
+// kid3 guarded by g1, g2, g3 (all remote, the ordinary case).
+const kid3 = site('s1', 'kid3');
+const [G1, G2, G3] = ['https://a.test/u/g1', 'https://b.test/u/g2', 'https://c.test/u/g3'];
+for (const g of [G1, G2, G3]) {
+  db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, other_handle, status, offer_id)
+              VALUES ('kid3','ward',?,?, 'accepted','o1')`).run(g, '@' + g.split('/').pop());
+}
+
+// The handshake drives the lapse over the same deps the guardianship tests use.
+const delivered = [];
+G.wireHandshake({
+  selfId: uri,
+  localSlug: (u) => (u.startsWith('https://test.example/ap/users/') ? u.split('/').pop() : null),
+  deriveHandle: (u) => '@' + u.split('/').pop(),
+  fetchActor: async () => null,
+  deliverTo: async (fromSite, toUri, activity) => { delivered.push({ toUri, activity }); return { delivered: true }; },
+  onEvent: null,
+});
+
+/** Three follow decisions address every guardian; g1 and g2 answer, g3 stays
+ *  silent; the clock passes the request TTL. */
+function makeG3Dormant() {
+  db.prepare('DELETE FROM ap_attention_requests').run();
+  db.prepare('DELETE FROM ap_guardian_attention').run();
+  db.prepare('DELETE FROM ap_lapses').run();
+  for (let i = 0; i < 3; i++) {
+    for (const g of [G1, G2, G3]) A.recordRequest('kid3', g, `follow-${i}`, T0 + i * 60_000);
+  }
+  A.oneAnswer(G1, T0 + 3 * 60_000);
+  A.oneAnswer(G2, T0 + 3 * 60_000);
+  return Date.now();   // the requests are now past the 7-day TTL
+}
+
+test('asked nothing never dormant: calendar time alone is no evidence', () => {
+  assert.equal(A.observe('kid3', G1, T0 + 1_000_000 * DAY), false);
+  assert.equal(A.effective('kid3', G1, T0 + 1_000_000 * DAY), 'active');
+});
+
+test('silence on addressed requests makes dormant, and the set shrinks', () => {
+  const now = makeG3Dormant();
+  const avail = A.availableSet('kid3', [G1, G2, G3], now);
+  assert.deepEqual(avail, [G1, G2], 'the set shrank to the two who answered');
+  assert.equal(A.effective('kid3', G3, now), 'dormant');
+});
+
+test('the dormancy promotion fires the notification duty, once', () => {
+  let notices = 0;
+  G.wireAvailability({ onDormant: () => { notices++; } });
+  const now = makeG3Dormant();
+  A.availableSet('kid3', [G1, G2, G3], now);
+  A.availableSet('kid3', [G1, G2, G3], now);
+  assert.equal(notices, 1, 'marking dormant MUST notify (3.6.2), on the transition itself');
+  G.wireAvailability({});
+});
+
+test('one answer restores everything', () => {
+  const now = makeG3Dormant();
+  A.availableSet('kid3', [G1, G2, G3], now);
+  const ev = A.oneAnswer(G3, now);
+  assert.deepEqual(ev.restored, ['kid3']);
+  assert.equal(A.effective('kid3', G3, now), 'active');
+  assert.equal(A.misses('kid3', G3, now), 0, 'slate clean, no mark');
+});
+
+test('away has an end, expires silently, and is never evidence', () => {
+  db.prepare('DELETE FROM ap_attention_requests').run();
+  db.prepare('DELETE FROM ap_guardian_attention').run();
+  A.declareAway('kid3', G2, T0 + 10 * DAY);
+  assert.equal(A.effective('kid3', G2, T0 + 5 * DAY), 'away');
+  assert.equal(A.awayUntil('kid3', G2, T0 + 5 * DAY), T0 + 10 * DAY);
+  // Requests during the absence are not recorded (3.6.1).
+  for (let i = 0; i < 5; i++) A.recordRequest('kid3', G2, `req-${i}`, T0 + DAY);
+  assert.equal(A.observe('kid3', G2, T0 + 20 * DAY), false);
+  assert.equal(A.effective('kid3', G2, T0 + 20 * DAY), 'active', 'the away expired silently, no dormancy left behind');
+});
+
+test('declaring away while dormant is an answer', () => {
+  const now = makeG3Dormant();
+  A.availableSet('kid3', [G1, G2, G3], now);
+  A.declareAway('kid3', G3, now + 3 * DAY);
+  assert.equal(A.effective('kid3', G3, now + DAY), 'away');
+  assert.equal(A.effective('kid3', G3, now + 3 * DAY), 'active');
+});
+
+test('a lapse opens only against a dormant guardian, and never empties', async () => {
+  db.prepare('DELETE FROM ap_attention_requests').run();
+  db.prepare('DELETE FROM ap_guardian_attention').run();
+  // Both active: refused.
+  const r = A.openLapse({ id: 'l-x', wardSlug: 'kid3', wardUri: uri('kid3'), target: G3, openedBy: G1, now: T0 });
+  assert.equal(r.error, 'not_dormant');
+  // A sole guardian can never lapse: that would be emancipation (3.4).
+  const solo = site('s2', 'kid1');
+  db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, status, offer_id) VALUES ('kid1','ward',?, 'accepted','o1')`).run(G1);
+  const r2 = A.openLapse({ id: 'l-y', wardSlug: 'kid1', wardUri: uri('kid1'), target: G1, openedBy: G1, now: T0 });
+  assert.equal(r2.error, 'would_emancipate');
+  assert.ok(solo, 'fixture site exists');
+});
+
+test('lapse full flow: release in absentia, over the S2S wire', async () => {
+  const now = makeG3Dormant();
+  A.availableSet('kid3', [G1, G2, G3], now);   // promote g3
+
+  // The proposing guardian's server delivers an Offer of shaer:Lapse.
+  const consumed = await G.handleGuardianshipInbox(kid3, {
+    id: 'https://a.test/lapses/1', type: 'Offer', actor: G1, to: [uri('kid3')],
+    object: { type: 'shaer:Lapse', 'shaer:ward': uri('kid3'), object: G3 },
+  });
+  assert.equal(consumed, true);
+  const row = A.getLapse('https://a.test/lapses/1');
+  assert.ok(row, 'the ward server opened the lapse');
+  assert.deepEqual(JSON.parse(row.set_json), [G1, G2], 'the available set, target excluded');
+  assert.ok(delivered.some((d) => d.toUri === G3), 'the target is notified in protocol (3.6.2)');
+
+  // The second guardian agrees: 2 of 2, threshold met, but an irreversible
+  // decision never settles early (3.5).
+  await G.handleGuardianshipInbox(kid3, { type: 'Accept', actor: G2, object: 'https://a.test/lapses/1' });
+  assert.equal(A.settleLapse('https://a.test/lapses/1', now + DAY).outcome, 'open', 'the window still runs');
+  assert.equal(G.listGuardians('kid3').length, 3);
+
+  // The window closes: released in absentia. The lapse opened at the wall
+  // clock (inside the handshake), so the margin of a day absorbs the skew.
+  const after = Date.now() + A.LAPSE_WINDOW_MS + DAY;
+  const settled = A.settleLapse('https://a.test/lapses/1', after);
+  assert.equal(settled.outcome, 'completed');
+  assert.equal(settled.applied, true);
+  assert.deepEqual(G.listGuardians('kid3').map((g) => g.other_uri), [G1, G2]);
+  // Settling twice never applies twice.
+  assert.equal(A.settleLapse('https://a.test/lapses/1', after).applied, true);
+  assert.equal(G.listGuardians('kid3').length, 2);
+
+  // Restore the fixture for the remaining tests.
+  db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, status, offer_id) VALUES ('kid3','ward',?, 'accepted','o1')`).run(G3);
+});
+
+test('one answer cancels a running lapse and restores the target', async () => {
+  const now = makeG3Dormant();
+  A.availableSet('kid3', [G1, G2, G3], now);
+  await G.handleGuardianshipInbox(kid3, {
+    id: 'https://a.test/lapses/2', type: 'Offer', actor: G1, to: [uri('kid3')],
+    object: { type: 'shaer:Lapse', 'shaer:ward': uri('kid3'), object: G3 },
+  });
+  // The target shows a sign of life: anything at all.
+  const ev = A.oneAnswer(G3, now + DAY);
+  assert.equal(ev.cancelledLapses.length, 1);
+  assert.equal(A.lapseOutcome(A.getLapse('https://a.test/lapses/2'), now + A.LAPSE_WINDOW_MS + DAY), 'cancelled');
+  assert.equal(A.settleLapse('https://a.test/lapses/2', now + A.LAPSE_WINDOW_MS + DAY).applied, false);
+  assert.equal(G.listGuardians('kid3').length, 3, 'one answer cancelled the lapse');
+  assert.equal(A.effective('kid3', G3, now + DAY), 'active', 'and restored the guardian');
+});
+
+test('a stranger cannot make up the majority', async () => {
+  const now = makeG3Dormant();
+  A.availableSet('kid3', [G1, G2, G3], now);
+  await G.handleGuardianshipInbox(kid3, {
+    id: 'https://a.test/lapses/3', type: 'Offer', actor: G1, to: [uri('kid3')],
+    object: { type: 'shaer:Lapse', 'shaer:ward': uri('kid3'), object: G3 },
+  });
+  const r = A.lapseVote('https://a.test/lapses/3', 'https://evil.test/u/stranger', true, now);
+  assert.equal(r.error, 'not_in_set');
+  const t = A.lapseVote('https://a.test/lapses/3', G3, true, now);
+  assert.equal(t.error, 'not_in_set', 'the target is not in the set either');
+});
+
+test('the offers queue carries the lapse, the same shape the daemon serves', async () => {
+  const now = makeG3Dormant();
+  A.availableSet('kid3', [G1, G2, G3], now);
+  await G.handleGuardianshipInbox(kid3, {
+    id: 'https://a.test/lapses/4', type: 'Offer', actor: G1, to: [uri('kid3')],
+    object: { type: 'shaer:Lapse', 'shaer:ward': uri('kid3'), object: G3 },
+  });
+  const col = G.offersCollection(`${uri('kid3')}/queues/offers`, 'kid3', uri('kid3'));
+  const item = col.orderedItems.find((i) => i.id === 'https://a.test/lapses/4');
+  assert.ok(item, 'the ward sees the running lapse');
+  assert.equal(item.object.type, 'shaer:Lapse');
+  assert.equal(item['shaer:threshold'], 2);
+  assert.equal(item['shaer:outcome'], 'open');
+});
+
+test('the guardians queue serves availability, never the public actor doc', () => {
+  const now = makeG3Dormant();
+  A.availableSet('kid3', [G1, G2, G3], now);
+  A.declareAway('kid3', G2, now + 5 * DAY);
+  const col = G.guardiansCollection(`${uri('kid3')}/queues/guardians`, 'kid3');
+  const by = Object.fromEntries(col.orderedItems.map((i) => [i.id, i]));
+  assert.equal(by[G1]['shaer:availability'], 'active');
+  assert.equal(by[G2]['shaer:availability'], 'away');
+  assert.equal(by[G3]['shaer:availability'], 'dormant');
+  // The PUBLIC actor document says nothing about any of this (3.6.1).
+  const doc = AP.buildActor('https://test.example', kid3);
+  assert.ok(!JSON.stringify(doc).match(/away|dormant|availability/i), 'availability is timing intelligence and stays out of the public doc');
+});
+
+test('the away note emits shaer:away with a plain AS2 endTime', () => {
+  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)
+    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);
+  const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get('aw1');
+  const note = AP.buildReplyNote('https://test.example', kid3, row);
+  assert.equal(note['shaer:away'], true);
+  assert.equal(note.endTime, new Date(T0 + 10 * DAY).toISOString());
+  assert.equal(A.parseEndTime(note.endTime), T0 + 10 * DAY, 'and it round-trips through the parser');
+});
+
+test('the gated tally runs over the available set (§3.5)', () => {
+  // The arithmetic problem this section exists for: five guardians of whom
+  // two are gone. Over the full set the threshold is 3 and the two absentees
+  // block every decision by silence alone; over the available set (3) the
+  // threshold is 2 and the living can still decide.
+  site('s3', 'kid5');
+  const gs = [1, 2, 3, 4, 5].map((n) => `https://g${n}.test/u/g`);
+  for (const g of gs) {
+    db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, status, offer_id) VALUES ('kid5','ward',?, 'accepted','o1')`).run(g);
+  }
+  for (let i = 0; i < 3; i++) for (const g of [gs[3], gs[4]]) A.recordRequest('kid5', g, `req-${i}`, T0);
+  const now = T0 + 8 * DAY;
+  assert.deepEqual(A.availableSet('kid5', gs, now), gs.slice(0, 3));
+
+  const r1 = G.gated.recordGatedVote('kid5', 'shaer:externalEmbeds', gs[0], true);
+  assert.equal(r1.state, 'open');
+  assert.equal(r1.need, 2, 'threshold over the available set of 3, not 3 of 5');
+  const r2 = G.gated.recordGatedVote('kid5', 'shaer:externalEmbeds', gs[1], true);
+  assert.equal(r2.state, 'settled');
+  assert.equal(r2.value, true, 'two living guardians decide; the absent no longer freeze the ward');
+});
