diff --git a/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md index ac884e97a8..b67a5cd4bc 100644 --- a/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md +++ b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md @@ -21,7 +21,7 @@ Kind 11998 sits in the replaceable range (10000–19999), so relays keep only th per author. There is no NIP for this yet — the shape above comes from the operator-side builder and is treated as the wire contract. -The feature: **a DVM counts as alive only if its latest heartbeat is at most 420 seconds +The feature: **a DVM counts as alive only if its latest heartbeat is at most 900 seconds old** (one missed 300s beat plus slack). Dead DVMs disappear from the Discover list; pinned feeds and the detail surface show an offline state instead. @@ -36,7 +36,7 @@ feeds and the detail surface show an offline state instead. (`FavoriteAlgoFeedsListScreen`) also gets the badge. 3. **Pinned chips stay when offline** — the user pinned them deliberately; they gray out with an offline badge rather than vanishing, and tapping still opens the feed. -4. **Threshold: 420 seconds**, a named constant. Exactly 420s old counts as fresh. +4. **Threshold: 900 seconds** (raised from the original 420 after field testing: beats arrive every 300s, and a 420s window tolerated barely one delivery hiccup, dropping live DVMs in oscillations). Exactly 900s old counts as fresh. 5. **Strict from cold start.** No grace period: the Discover list starts empty and fills within ~1–2s as heartbeat REQs return (same behavior as the existing 31990 load). @@ -50,7 +50,7 @@ New `quartz/.../nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt`: `Address(11998, dvmPubkey, dTag)`, the exact mirror of the announcement's `Address(31990, dvmPubkey, dTag)`. - Accessors: `status()`, and `expiration()` via the existing NIP-40 extension. -- `MAX_AGE_SECONDS = 420` and `isFreshAt(now)` live in quartz too (commons imports them). +- `MAX_AGE_SECONDS = 900` and `isFreshAt(now)` live in quartz too (commons imports them). - Registered in `EventFactory` (kind → constructor) and allowlisted in `EventFactoryKindRangeTest.knownDTagReaders`: the `d` tag keys the client-side address while relay storage stays plain-replaceable per the kind range. @@ -67,14 +67,13 @@ invisible. The cache pruner removes old entries on its own schedule. ## 4. Freshness core (amethyst) -Small helper file in `amethyst/.../dvms/`: +Small helper file in `amethyst/.../model/` (the threshold constant itself lives in quartz): -- `const val DVM_HEARTBEAT_MAX_AGE_SECONDS = 420` - `LocalCache.dvmHeartbeatOf(appDef: AppDefinitionEvent): DvmHeartbeatEvent?` — address lookup `Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag())` -- `DvmHeartbeatEvent.isFreshAt(now: Long): Boolean` — `createdAt >= now - 420` +- `DvmHeartbeatEvent.isFreshAt(now: Long): Boolean` — `createdAt >= now - 900` - `@Composable fun rememberDvmHeartbeatFresh(address: Address, accountViewModel: AccountViewModel): State` — - as built (uniform-strict ruling): returns true while the DVM has a heartbeat at most 420s + as built (uniform-strict ruling): returns true while the DVM has a heartbeat at most 900s old; an unresolved/absent beat counts as offline (`false`) on every surface. The returned `State` identity is stable for the lifetime of the call site (one unconditional `rememberUpdatedState`), so callers may capture it across recompositions. Composable-scoped @@ -86,7 +85,7 @@ Small helper file in `amethyst/.../dvms/`: **Discover screen — all DVM heartbeats.** In `commons/.../relayClient/discover/nip90DVMs/SubAssemblyHelper.kt`, `makeContentDVMsFilter` unconditionally appends one filter for every top-filter variant: -`kinds = [11998], since = TimeUtils.now() - 420` — no authors, no tags, scoped to the same +`kinds = [11998], since = TimeUtils.now() - 900` — no authors, no tags, scoped to the same relay set as the 31990 REQs. It deliberately ignores the 31990 `since`-cursor (heartbeats are a rolling window, not a cursor stream — the cursor would miss re-opened tabs after the beats expired). It rides the existing assembler lifecycle: subscribes on entering Discover, @@ -94,7 +93,7 @@ closes on leaving. **Per-surface — pinned chips, home banner, detail screen.** `rememberDvmHeartbeat` opens a tiny composable-scoped subscription: `kinds = [11998], authors = [dvm pubkey], limit = 1, -since = now - 420`. The home top-bar chips live for the whole session, so they double as +since = now - 900`. The home top-bar chips live for the whole session, so they double as the session-scoped watcher for pinned DVMs. Traffic is negligible (a few pinned DVMs × 1 event / 5 min). @@ -104,7 +103,7 @@ relays don't gossip, so alive DVMs whose beats never overlap the user's relay se invisible (their detail screens proved the beats existed on the outbox). `DiscoveryDvmHeartbeatSubAssembler` joins the discovery assembler group and, while Discover is composed, batches the cached content-discovery announcements' authors per **DVM outbox relay** (`kinds = [11998], -authors = [those pubkeys], since = now - 420`, coverage-ranked and capped at 12 relays; +authors = [those pubkeys], since = now - 900`, coverage-ranked and capped at 12 relays; authors with unknown outboxes/hints rely on the global REQ as fallback). It re-issues when the cached announcement set or the NIP-65 relay lists move. @@ -151,7 +150,7 @@ author (the same mix the event finder's `potentialRelaysToFindAddress` uses). - Heartbeat without a `d` tag → cache address dTag `""` → matches nothing → DVM hidden (strict; the wire contract always sends `d`). -- Device/DVM clock skew > 7 min → wrongly hidden (inherent to timestamp-based liveness). +- Device/DVM clock skew > 15 min → wrongly hidden (inherent to timestamp-based liveness). - DVM beats that never reach the relays we query → shows offline (that is the feature). - One keypair running multiple DVMs → relays keep only the latest beat per (kind, author); per-d-tag cache slots help only across relays. Most DVMs use one key each. @@ -165,5 +164,5 @@ author (the same mix the event finder's `potentialRelaysToFindAddress` uses). `expiration()`, address assembly. - **amethyst**: `DiscoverNIP89FeedFilter.acceptApp` matrix — no beat → reject; fresh beat → accept; 421s-old beat → reject. The `updateFeedsWith` heartbeat branch triggers a full - rebuild. `isFreshAt` boundary (420s fresh, 421s stale). + rebuild. `isFreshAt` boundary (900s fresh, 901s stale). - Verify with `./gradlew :quartz:test :amethyst:test`, then `./gradlew spotlessApply`. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt index fa77c0f5f2..886fe55cda 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt @@ -83,16 +83,16 @@ class DvmHeartbeatTest { fun aFreshHeartbeatPassesTheGateAndAStaleOneDoesNot() { val now = 1_760_000_000L // Separate dTags: consumeBaseReplaceable only accepts NEWER beats per address, so a - // 421s-old beat could never supersede the 420s one within a single address slot. + // 901s-old beat could never supersede the 900s one within a single address slot. val freshApp = appDef("dvm-two-fresh") val staleApp = appDef("dvm-two-stale") assertNull("no beat yet", LocalCache.dvmHeartbeatOf(freshApp)) - LocalCache.justConsume(beat("dvm-two-fresh", createdAt = now - 420, id = "f4".repeat(32)), null, true) - LocalCache.justConsume(beat("dvm-two-stale", createdAt = now - 421, id = "f5".repeat(32)), null, true) + LocalCache.justConsume(beat("dvm-two-fresh", createdAt = now - 900, id = "f4".repeat(32)), null, true) + LocalCache.justConsume(beat("dvm-two-stale", createdAt = now - 901, id = "f5".repeat(32)), null, true) - assertTrue("exactly 420s old counts as fresh", LocalCache.hasFreshDvmHeartbeat(freshApp, now)) - assertFalse("421s old is stale", LocalCache.hasFreshDvmHeartbeat(staleApp, now)) + assertTrue("exactly 900s old counts as fresh", LocalCache.hasFreshDvmHeartbeat(freshApp, now)) + assertFalse("901s old is stale", LocalCache.hasFreshDvmHeartbeat(staleApp, now)) } @Test diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/DvmHeartbeatOutboxFiltersTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/DvmHeartbeatOutboxFiltersTest.kt index 816bdd5576..b31176262e 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/DvmHeartbeatOutboxFiltersTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip90DVMs/DvmHeartbeatOutboxFiltersTest.kt @@ -82,7 +82,7 @@ class DvmHeartbeatOutboxFiltersTest { filters.forEach { assertEquals(listOf(DvmHeartbeatEvent.KIND), it.filter.kinds) - assertEquals(1_760_000_000L, it.filter.since, "rolling window: now - MAX_AGE_SECONDS") + assertEquals(1_759_999_520L, it.filter.since, "rolling window: now - MAX_AGE_SECONDS") } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt index 66336a46f8..cad008ee96 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt @@ -61,8 +61,13 @@ class DvmHeartbeatEvent( const val STATUS_TAG = "status" const val CONTENT = "Alive and kicking" - /** A beat older than this no longer proves liveness (one missed 300s beat + slack). */ - const val MAX_AGE_SECONDS = 420 + /** + * A beat older than this no longer proves liveness. Beats arrive every 300s, so this + * window deliberately tolerates several missed deliveries (relay reconnects, REQ churn) + * before a DVM is dropped — hysteresis against transient delivery gaps, at the cost of a + * dead DVM lingering this long before disappearing. + */ + const val MAX_AGE_SECONDS = 900 fun build( dTag: String, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEventTest.kt index 147ce7691e..23c558c868 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip90Dvms/dvmHeartbeat/DvmHeartbeatEventTest.kt @@ -90,8 +90,8 @@ class DvmHeartbeatEventTest { @Test fun freshnessBoundary() { val event = heartbeat() - assertTrue(event.isFreshAt(beatTime + 420)) - assertFalse(event.isFreshAt(beatTime + 421)) + assertTrue(event.isFreshAt(beatTime + 900)) + assertFalse(event.isFreshAt(beatTime + 901)) } @Test