// SignerPresence — leak-free single-active-signer election from live CONNECTION PRESENCE.
//
// Replaces the `__lease__ {holder,epoch,ts}` heartbeat (lease.js), which wrote a fresh wall-clock ts to the
// APPEND-ONLY autobase log every ~8s forever (~20MB/day, unreclaimable — see the S1 research). Keet, and
// every other holepunch app, signals liveness EPHEMERALLY over the connection and never persists it. This is
// that idiom: "which device signs" is a deterministic function of who is CONNECTED right now, computed in
// RAM. It appends NOTHING.
//
// Election = the lowest-keyed device among the currently-effective co-owners (a stable, replication-free
// tiebreak every connected peer computes identically → they agree on one signer → only one signs → no fork,
// exactly the guarantee the ts-lease gave in the connected case). A partition still diverges and reconciles
// on merge — identical to the old lease, which also forked on partition (the epoch never prevented that).
//
// GRACE window = blip tolerance: a departed holder stays "effective" for graceMs before failover, so a brief
// disconnect does not thrash the signer (mirrors the old lease's "never steal a live lease for TTL"). Default
// graceMs matches the old ttlMs (15s) so failover timing is unchanged.
//
// Presence is fed by the transport (a co-owner presence handshake on the StateBase swarm): markPresent on
// connect+identify, markAbsent on close. This module is pure + clock-injectable → unit-testable with no swarm.
export class SignerPresence {
// self: this device's member key (hex). graceMs: how long a departed co-owner stays eligible (blip
// tolerance / failover delay). clock: injectable now() for deterministic tests.
// onChange: fired on an ACTUAL presence change (a co-owner connected or departed) — the EVENT that
// drives zero-timer failover: the daemon re-evaluates holdsSigner + signs on this, plus on every
// StateBase advance (sb.watch), so no polling timer is needed. Grace expiry itself has no event (a
// write/replication/presence event after grace picks it up — acceptable failover latency, no loss).
constructor ({ self, graceMs = 15000, clock = () => Date.now(), onChange = null } = {}) {
if (!self) throw new Error('SignerPresence: self (this device member key) required')
this.self = String(self)
this.graceMs = graceMs
this.clock = clock
this.onChange = onChange
this._present = new Map() // memberKey -> lastSeen: Infinity = connected now; a number = ms it departed
this._present.set(this.self, Infinity) // we are always present to ourselves
// CURRENCY (anti-freeze): each co-owner's last-announced signed head rev (a monotonic TID string) or
// null if unknown. A BEHIND co-owner must not win the election — else it holds the lease but the
// forward-only sign gate blocks it (it's behind) while the caught-up peer defers to it → the chain
// FREEZES (the stortron split-brain aftermath). currentSigner filters on this vs the network head.
this._heads = new Map() // memberKey -> headRev (string) | null
}
_fire () { if (this.onChange) { try { this.onChange() } catch {} } }
// A co-owner (holds the signing key + is attested) is connected. Idempotent. Fires onChange only on
// an actual transition (not-present/departed -> present) so the daemon re-evaluates the signer.
markPresent (memberKey, headRev = undefined) {
const k = String(memberKey)
if (headRev !== undefined) this._heads.set(k, headRev || null) // learn the peer's signed-head currency
if (this._present.get(k) === Infinity) return // already connected — no change
this._present.set(k, Infinity)
this._fire()
}
// Record OUR OWN current signed head rev (from the sign loop) so the election knows we're caught up.
setSelfHead (rev) { this._heads.set(this.self, rev || null) }
// Record a co-owner's announced signed head rev (from the presence channel). Fires onChange when the
// currency changes (a peer catching up may flip who is the elected signer → re-evaluate).
noteHead (memberKey, rev) {
const k = String(memberKey)
const prev = this._heads.get(k)
if (prev === (rev || null)) return
this._heads.set(k, rev || null)
this._fire()
}
headOf (memberKey) { const v = this._heads.get(String(memberKey)); return v === undefined ? null : v }
// A co-owner disconnected — start its grace countdown (do NOT drop it immediately; a blip must not fail
// the signer over). No-op for a key we never saw. Fires onChange so a departure is noticed event-driven.
markAbsent (memberKey) {
const k = String(memberKey)
if (this._present.has(k) && this._present.get(k) === Infinity) { this._present.set(k, this.clock()); this._fire() }
}
// The co-owners eligible to sign right now: connected, or departed within graceMs. Sorted (stable pick).
effective (now = this.clock()) {
const out = []
for (const [k, seen] of this._present) if (seen === Infinity || (now - seen) < this.graceMs) out.push(k)
return out.sort()
}
// The elected signer = lowest-keyed effective co-owner (every connected peer computes the same one).
// CURRENCY-AWARE (anti-freeze): when netHead is given, a co-owner is only eligible if its known signed
// head is caught up to netHead (headRev >= netHead; revs are monotonic TID strings, null = unknown =
// not caught up). This EXCLUDES a behind co-owner so it can't hold the lease while the forward-only
// gate blocks it → the caught-up peer signs instead. If NONE are known-caught-up (no currency info
// yet), fall back to the lowest-key effective co-owner — the forward-only sign gate still prevents any
// behind sign, so the worst case is a transient no-op, never a regression. Deterministic: every peer
// computes the same signer from the same (presence, announced-heads, netHead) → still exactly one signer.
currentSigner (now = this.clock(), netHead = null) {
const e = this.effective(now)
if (!e.length) return null
if (!netHead) return e[0]
const caughtUp = e.filter((k) => { const h = this._heads.get(k); return h != null && h >= netHead })
return caughtUp.length ? caughtUp[0] : e[0]
}
// Do WE hold the signer role right now? (Drop-in for lease.holds() / renewOrClaim() in the sign loop.)
holds (now = this.clock(), netHead = null) { return this.currentSigner(now, netHead) === this.self }
// Diagnostics snapshot (no writes).
state (now = this.clock(), netHead = null) {
return { self: this.self, signer: this.currentSigner(now, netHead), effective: this.effective(now), holds: this.holds(now, netHead), heads: Object.fromEntries(this._heads) }
}
}