connectAndSyncFiltersIfDisconnected() bailed whenever a socket already
existed, so a still-connecting socket built for the wrong transport (e.g.
a relay whose Tor classification changed since the dial started) could
never be preempted — it blocked until the hung dial timed out. The
connected-relay path in RelayPool.reconnectIfNeedsTo already rebuilds
ready sockets via needsToReconnect(); this covers the connecting state it
cannot see (isConnectionStarted() true but isConnected() false).
Now: if a socket exists but reports needsReconnect() (transport/proxy
mismatch against the current builder decision), drop it and redial on the
correct transport; otherwise leave it. Disconnected relays still honor
their reconnect backoff.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
From the audit of this session's changes:
- Error surfacing: the budget (WalletScreen) and offer/invoice card
(InvoicePaymentDispatcher) paths now use DebitResponse.failureDetail() like the
zap path, so a GFY code-5/code-4 surfaces its range/retry_after instead of just
the bare error string.
- NOffer.priceType is now non-null: decode already defaults an absent TLV 3 to
SPONTANEOUS, so the nullable type was misleading and the '?: SPONTANEOUS'
fallbacks in ClinkOfferPreview were dead. Drops them and the now-redundant
always-emit-TLV3 test (covered by the spontaneous round-trip).
- WalletViewModel.requestDebitBudget catches the budget-validation
IllegalArgumentException so a malformed frequency dismisses the dialog instead
of hanging the spinner.
- Document why ClinkDebitPayer signs with the persistent account key (stable
identity for budgets) while ClinkOfferPayer uses an ephemeral key.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Sweeps every typed kind: addressable kinds (30000..39999) must read
their d tag, plain replaceables (10000..19999, 0, 3) must ignore stray
ones — the invariant the kind-34235/34236 fix restores.
Follow-ups from the line-by-line spec audit, scoped to the consume-only client:
- NDebit.parse rejects a TLV-3 session id that isn't exactly 32 bytes (64 hex),
per clink-debits: a wrong-length k1 is a malformed session pointer.
- DebitClient.requestBudget validates frequency.unit is one of day/week/month
(DebitFrequency.VALID_UNITS) instead of sending a unit a node service will GFY.
- OfferClient caps the invoice description at 100 chars per clink-offers.
- DebitResponse.failureDetail() composes the GFY error with its actionable extra
(allowed range for code 5, retry_after for code 4); the debit zap path now
surfaces that instead of the bare error string.
Adds regression tests for each (malformed-k1 rejection, invalid-unit throw,
description truncation, failureDetail range/retry_after).
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
The vector is the canonical @shocknet/clink-sdk example — both its MIT README
usage snippet and clink-demo's public-domain DEFAULT_NOFFER are the same string.
Confirmed the published npm tarball ships only build output (no test vectors), so
this is the one real codec vector the ecosystem exposes.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Adds ClinkWireShapeTest: the literal decrypted JSON payload bodies documented in
shocknet/CLINK/specs/clink-{offers,debits,manage}.md (public domain) must
deserialize into our DTOs with the right fields. Covers the encrypted-content
half the bech32 pointer vectors don't: offer request + success/error codes 1-5
(incl. code-3 latest, code-5 range) + receipts; debit direct/budget requests,
success, and GFY 1-6 (incl. delta, retry_after, range); manage nested
offer.fields requests and responses — including the single-object 'details'
coercing to a list, which exercises the Manage list/single interop fix.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
The clinkme.dev demo (shocknet/clink-demo, public domain) hard-codes a live
default noffer. Adds it as an 8th cross-impl vector — a real-world spontaneous,
relay-bearing, no-price offer with a 64-char-hex offer-id — decoded and
round-tripped through our parser.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Interop review against the shocknet/CLINK ecosystem (Lightning.Pub, clink-sdk,
ShockWallet, Zeus, Stacker News, bridgelet, clinkme.dev) surfaced five fixes:
1. Manage `details` single-object responses now parse. Lightning.Pub returns a
bare OfferData object for create/update/get and an array only for list; enable
Jackson ACCEPT_SINGLE_VALUE_AS_ARRAY so both shapes coerce into the list field.
2. NOffer.encode() always emits the price-type TLV (3), even for spontaneous
offers — the reference SDK and bridgelet decoders throw on a missing TLV 3, so
an absent field made our pointers undecodable by every JS consumer. Decode now
defaults an absent/unknown price-type to SPONTANEOUS, per the spec.
3. Nip05Parser.parseClinkOffer accepts bridgelet's flat top-level
`"clink_offer":"noffer1…"` string in addition to the spec's per-name map.
4. Offer payment receipts: OfferEvent.createReceipt/decryptReceipt +
OfferClient.parseReceipt + OfferReceipt.isOk() make the post-settlement receipt
(the SDK's onReceipt) a parseable primitive instead of a dead DTO.
5. ClinkOfferPayer signs offer requests with an ephemeral key, like the SDK / Zeus
/ Stacker News, so paying an offer no longer reveals the user's Nostr identity
to the service. Debits keep the persistent account key (budgets need a stable
app identity).
Adds regression tests for each: always-emit TLV3, flat-string NIP-05 discovery,
and a receipt round-trip.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Append a 'Final implementation state' section to the CLINK plan capturing
what shipped across Phases 0-3 + the receive side + CLI, the audit findings
and their fixes, the three-level verification matrix, and the critical
spec-vs-SDK gotchas (offer 'latest' at GFY code 3 and ndebit k1 at TLV-3 are
spec-defined and must not be removed). Flip the doc status to implemented.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Locks in the protocol-layer fixes that were previously only compile-checked:
- offerLargePriceRoundTripIsUnsigned: a price > Int.MAX_VALUE round-trips as a
positive Long (guards the unsigned-decode fix).
- cannotDecryptAuthoredEventMissingRecipient: an authored event with no p tag
can't be decrypted by anyone (guards the no-self-fallback conversationPeer).
- manageCreateRequestSerializesNested + manageFailureResponseParsesField: the
Manage request nests under offer.fields, payer_data is a string list, and the
failure response carries field (guards the 21003 shape fix).
All CLINK tests pass.
Two robustness fixes from the audit:
- OfferEvent/DebitEvent/ManageEvent: replace talkingWith() (which fell back to
self when an authored event lacked its p tag, deriving a NIP-44 key with
myself) with conversationPeer(), which returns null when I'm neither the
author nor the addressed recipient; decryptContent then fails cleanly with
UnauthorizedDecryptionException. canDecrypt() is now exactly 'a valid peer
exists'.
- OfferClient/DebitClient/ManageClient responseFilter now also requires
#p == my pubkey, so a service reply that e-tags my request but is addressed
to a different payer no longer matches my subscription.
Valid request/response round-trips are unchanged (CLINK tests pass).
From a spec/SDK audit (verified against the CLINK spec, not just SDK 1.5.5):
- NOffer.price: decode as UNSIGNED 4-byte big-endian (now Long) — the SDK reads
price via parseInt(hex); reading it signed turned prices >= 2^31 sats negative
and broke encode/decode idempotency for high-bit prices.
- Manage (21003) messages corrected to the nested spec shape: request nests offer
data under offer{id,fields}, payer_data is a string list (not a map), and the
response uses details + field (was offer/offers). Documented the single-object
details limitation (Manage is consume-unused).
- DisplayClinkOffer: cache NIP-05 .well-known clink_offer lookups (incl. negative
results) so profile visits / kind-0 refreshes don't refetch nostr.json.
Deliberately NOT changed: the offer 'latest' (code 3) field and ndebit k1 at
TLV-3 — both are SPEC-defined; the SDK 1.5.5 merely lags, as the code comments
already noted. CLINK tests pass; app compiles.
Exposes the CLINK Debits budget capability (requestBudget) the spec describes:
- ClinkDebitPayer.requestBudget publishes the kind-21002 budget request and
awaits the reply; the publish/await machinery is factored out of payInvoice
into a shared sendAndAwait helper.
- DebitFrequency gains UNIT_DAY/WEEK/MONTH constants.
- WalletViewModel.requestDebitBudget resolves the debit pointer and runs it.
- A 'Budget' action on CLINK debit rows opens ClinkBudgetDialog (amount +
one-time/daily/weekly/monthly cadence); the result is surfaced as a toast.
:amethyst compiles. The 21002 budget round-trip is untested end-to-end.
Models the protocol-version tag the way other tags are modeled, instead of a
loose helper on the Clink object:
- New ClinkVersionTag (TAG_NAME/CURRENT/assemble/parse) under clink/tags, with
a clinkVersion() TagArrayBuilder DSL extension, reused by all three events.
- OfferEvent/DebitEvent/ManageEvent read version() via ClinkVersionTag::parse
and build via clinkVersion() in their templates.
- Retires the now-empty Clink object (its KDoc moved to the tag class).
Behavior-preserving: assemble() emits the identical ["clink_version", "1"]
tag in the same position. All CLINK tests pass.
Brings OfferEvent/DebitEvent/ManageEvent (21001-3) in line with the codebase
tag conventions, replacing raw inline tags:
- Build via eventTemplate(KIND, content) { pTag(...); eTag/add; alt(...) } and
signer.sign(template), instead of hand-rolled arrayOf("p"/"e", ...) + sign().
- Accessors use PTag.parseKey / ETag.parseId instead of matching "p"/"e" literals.
Behavior-preserving: PTag.assemble(x, null) yields the identical ["p", x] bytes
and tag order is unchanged, so signed events are byte-identical. All CLINK tests
pass (ClinkEventTest, ClinkClientServerTest, pointer/interop).
Note: these are NIP-44-encrypted request/response events, so create*() stays a
suspend factory that encrypts then signs the template — matching NIP-47; a pure
pre-signing template isn't possible without the signer.
Brings the kind-0 clink_offer field in line with the sibling fields' structure
instead of a raw string constant written to content only:
- New ClinkOfferTag (TAG_NAME/assemble/parse) under nip01Core/metadata/tags.
- clinkOffer() TagArrayBuilder DSL extension in TagArrayBuilderExt.
- MetadataEvent uses ClinkOfferTag.TAG_NAME and dual-writes it as a kind-0 tag
in updateOrDeleteTagNames (NIP-1770 pattern), like lud16/nip05; drops the
ad-hoc CLINK_OFFER_PROPERTY constant.
UpdateMetadataTest now also asserts the tag is emitted. quartz tests pass.
Completes the receive side: a payable CLINK Offer card now appears on a
profile that advertises one, preferring the kind-0 clink_offer and falling
back to the NIP-05 .well-known clink_offer.
- Nip05Parser.parseClinkOffer + INip05Client.loadClinkOffer fetch/parse the
well-known clink_offer (keyed by local name, mirroring the names map; exact
shape isn't a finalized spec so a mismatch yields null). JVM-tested.
- DrawAdditionalInfo.DisplayClinkOffer resolves kind-0 first, else fetches
NIP-05 on IO, parses the noffer, and renders ClinkOfferPreview zapping the
profile.
quartz tests pass; :amethyst compiles. Network fetch + card render untested
end-to-end.
Adds the CLINK Offers discovery pointer to profile metadata, mirroring the
NIP-05 `clink_offer` key:
- UserMetadata.clinkOffer (@SerialName clink_offer) + clinkOffer() accessor,
with trim/blank cleanup alongside the other fields.
- MetadataEvent.createNew/updateFromPast gain a clinkOffer param written into
kind-0 content via the new CLINK_OFFER_PROPERTY key.
Covered by UpdateMetadataTest (write + parse round-trip) on JVM.
Adds ClinkInteropTest with bech32 pointer strings generated by the
reference TypeScript SDK (clink-sdk 1.5.5) for noffer/ndebit/nmanage.
Asserts our parser decodes the SDK's bytes into the expected fields and
that re-encoding round-trips. TLV is order-independent on decode, so
interop is functional (not byte-identical: we emit fields ascending,
the SDK descending); the reverse direction (SDK decoding our output)
was verified out-of-band against decodeBech32.
Adds the high-level request/response orchestration over the CLINK
pointers and event kinds (experimental/clink):
- OfferClient / DebitClient / ManageClient: build the kind-21001/2/3
request from a decoded pointer, expose the relays to publish on, the
response filter (kind + author + #e=requestId), and the response parser
- ClinkServer: per-kind request filters (#p=service), 30s freshness
check, plus K1Tracker for single-use debit session enforcement
Filter construction, freshness window and k1 single-use covered by
ClinkClientServerTest on JVM; request-building encryption round-trips
will be added under androidDeviceTest (lazysodium constraint).
Adds the three CLINK message kinds to quartz (experimental/clink):
- OfferEvent (21001), DebitEvent (21002), ManageEvent (21003), each
carrying both request and response over one kind, NIP-44 encrypted,
with p + clink_version tags and an e tag on responses
- Request/response DTOs per spec (offers, debits, manage) plus shared
SatRange/GfyDelta and GFY/offer error-code constants
- Registers all three kinds in EventFactory
Pure-logic + JSON (de)serialization covered by ClinkEventTest on JVM;
the NIP-44 encrypt/decrypt round-trip will live in androidDeviceTest
(lazysodium is unavailable in JVM unit tests).
Implements the noffer/ndebit/nmanage pointers (CLINK Offers/Debits/Manage)
as standard-bech32 TLV codes, with a dedicated ClinkPointerParser kept
separate from NIP-19. Wire format (HRPs, TLV indices, single-byte priceType,
4-byte big-endian price) verified against @shocknet/clink-sdk 1.5.5.
Adds round-trip + dispatch + reject tests in commonTest.
Plan to implement CLINK (Offers 21001 / Debits 21002 / Manage 21003) on
Quartz (client + server) and Amethyst (consume-only), reusing NIP-44,
bech32/TLV, the NWC encrypted-event pattern, and ZapPaymentHandler.
Pointers parsed by a dedicated ClinkPointerParser (separate from NIP-19).
Memory pruning drops DM messages out of the cache but left the per-relay
paging cursors untouched, so a relay still claimed to have delivered the
dropped band (reachedUntil deep, or done) and the demand-driven loader
never re-requested it — a silent hole until app restart.
- Prune NIP-17 too: pruneMessagesToTheLatestOnly now reaps both NIP-04
(PrivateDmEvent) and NIP-17 (WrappedEvent rumors) on one merged top-N
cut, so a conversation is cut at a single time point (no NIP-04-without
-NIP-17 holes). NIP-17 is the actual memory-pressure driver.
- HostStub carries the host's createdAt, so a decrypted rumor self-
describes its outer gift-wrap time (the time the cursor pages by; the
rumor's own time is the message time, not the wrap time).
- RelayLoadingCursors.rewindTo() pulls a relay's reached cursor up past
the pruned band, clears done, and un-arms it (demand-driven re-fetch);
advance() now resumes from the rewound reached point instead of the
floor.
- LocalCache.pruneOldMessages accumulates the newest pruned created_at
per relay (outer-wrap time for gift wraps, event time for NIP-04),
filtered below each cursor's floor, then rewinds giftWrapHistory +
rooms-list nip04History (account-wide) and the per-conversation
nip04History.
The gift-wrap window is account-global, so pruning one room rewinds the
shared sweep; the interference is bounded (already-held wraps short-
circuit in consumeRegularEvent, re-fetch is demand-gated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Expose a nullable descriptor
- Log the JSON element kind instead of the raw, network-sourced value.
- drop birthday happy-path tests duplicated by UpdateMetadataTest
- Make birdex_species_preview_more a <plurals> keyed on the remaining count
- Bound the species preview with maxLines=2
- Hoist the joined-names remember out of the conditional (stable slot).
- Drop the unused accountViewModel parameter
- BirdexEvent.speciesCount() derives from speciesNames().size instead of re-scanning tags
- remember() the joined species-name string so it is not rebuilt on every recomposition.
Two correctness bugs in `RemoteSignerManager` (NIP-46) and its NIP-55
sibling `IntentRequestManager`:
1. **Double-resume crash** — `awaitingRequests.get(id)?.resume(value)`
was non-atomic. Multi-relay delivery, bunker echo/retry, and
late-after-timeout responses could call `resume` twice for the same
continuation, throwing `IllegalStateException: Already resumed` on a
`Dispatchers.Default` worker.
2. **Retry id-reuse → wrong data** (NIP-46 only) —
`launchWaitAndParse` built the request and event once, then re-used
the same `request.id` across retry attempts. A late response from
attempt N could resume attempt N+1's continuation with stale data.
Replace the cached-`Continuation` map with the in-house Channel-per-request
correlation pattern already used in `quartz/.../accessories/NostrClientPublishExt.kt`
(`LargeCache<id, Channel<Response>(capacity=1)>` + atomic `remove` +
`trySend` + `withTimeoutOrNull { receive() }`). Each retry attempt now
builds a fresh request with a new id; the builder is still called only
once. `finally`-block cleanup removes the cache entry on every path,
incidentally fixing a slow leak on the success path.
Adds three regression tests:
- duplicate responses → no crash + single resume (fails on \`main\`
with \`IllegalStateException\`)
- late response after timeout → silently discarded
- late attempt-1 response does not corrupt attempt-2 result (fails on
\`main\`: the two attempts share an id)
Design + review notes: \`quartz/plans/2026-06-03-fix-nip46-bunker-double-resume-plan.md\`
Agora (a crowdfunding client on the Ditto stack) publishes fundraising
campaigns as kind 33863 — an app-specific addressable kind with no NIP.
Amethyst had no parser or renderer, so it hit the "Event Not Supported"
path and was dropped; reposts of one rendered as a permanently blank card.
Add first-class support, modelled on NIP-99 Classifieds (title/image/body)
plus NIP-75 zap goals (goal/deadline/progress).
Per commons/ARCHITECTURE.md, quartz is protocol/NIPs/crypto/relay framing while
commons owns the relay-subscription client and StateFlow state holders. The
paging *orchestrators* are exactly that — StateFlow-backed, subscription-loading
state — so they belong in commons, not quartz:
- BackwardRelayPager, PerRelayLoadTracker, WindowLoadTracker (+ trackingListener)
-> commons/relayClient/paging (jvmAndroid source set, same as before).
- BackwardRelayPagerTest -> commons jvmTest.
The pure protocol-paging primitives stay in quartz commonMain:
- RelayLoadingCursors (the until+limit cursor mechanics) and RelayPagingProgress.
They had no upward deps, so the move is downhill (commons -> quartz): the
orchestrators now import RelayLoadingCursors / RelayPagingProgress from quartz.
Consumers (the six DM managers/assemblers + WindowLoadTrackerIdleTest) repoint
their imports to the commons package. The quartz geode wire test keeps testing
the relay contract; its lone BackwardRelayPager KDoc link is demoted to a
backtick (no longer reachable from quartz).
No behaviour change. DM suite green (26/26); quartz + commons compile on iOS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Same principle as RelayLoadingCursors: BackwardRelayPager, PerRelayLoadTracker,
WindowLoadTracker and RelayPagingProgress are reusable quartz classes, so their
docs shouldn't lean on Amethyst's Chatroom / ChatroomList / feeds / loading card
/ on-screen markers / sentinels / "decrypted into rooms" / invalidateFilters().
Reworded to generic library terms ("the caller", "the bound scope", "a
demand-driven loader", "a per-relay progress display", "the owning object").
Comment-only. (The DMPagination log tag stays — it's an established log key,
not doc prose.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
It's a reusable quartz class, so it shouldn't document itself in terms of
Amethyst's Chatroom / ChatroomList / feeds / on-screen markers / sentinels /
invalidateFilters(). Reword generically: "the caller holds one instance per
scope on whatever object owns it", "a demand-driven loader", "the loaded-back-to
point", "the owner re-issues the relay's REQ". BackwardRelayPager demoted to a
backtick mention (it's jvmAndroid, so the KDoc link can't resolve from
commonMain anyway).
Comment-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Same content, ~⅓ shorter: fold the per-relay/on-demand intro into the
asked-vs-delivered framing, compress the time-window rationale and the
thread-safety note, keep the two-cursor explanation and the short-page caveat.
Comment-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After the class moved to commonMain and was renamed, two doc nits remained:
- [BackwardRelayPager] is a jvmAndroid type, so the KDoc link can't resolve
from commonMain — demote it to a plain mention.
- the bare [done] links pointed at the private RelayCursor.done, not a member
of this class — repoint them to the public [isDone].
Also reword "Not internally synchronized" (it leans on the thread-safe
LargeCache + serialized per-relay callbacks) to not read as a contradiction.
Comment-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Once the pager was split into the orchestrator (BackwardRelayPager) and the
pure per-relay cursor state that lives on the model, "UntilLimitPager" no longer
described the latter — it pages nothing, it just records how far each relay has
loaded. Rename it (and its test) to RelayLoadingCursors.
The geode wire-contract test keeps its name (UntilLimitPagingRelayTest): it
pins the relay-side `until`+`limit` paging behaviour, not the class.
Pure rename — no behaviour change. Design doc updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The DM history widgets extracted into commons were never compiled for the
commons iOS target, which hid two Kotlin/Native-only breaks:
- RelayReachMarker: `toSortedMap(compareBy { it.ordinal })` + a destructured
`(state, list)` Map.Entry inside an inline @Composable lambda don't type-infer
on Native. Rewrite as `.entries.sortedBy { it.key.ordinal }` with explicit
`entry.key` / `entry.value`.
- DmHistoryLoadingCard referenced RelayPagingProgress, which sat in quartz's
jvmAndroid source set — visible to commonMain only when building JVM/Android,
not iOS. It's a pure data class, so move it to quartz commonMain.
commons:compileKotlinIosArm64 now succeeds; JVM/Android unaffected and the DM
test suite is still green (26/26).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The history pagers were keyed by account / (account, conversation) inside the
quartz engine, with all per-key state in inner hashmaps + an activeKey +
activate() machinery. But the loaders are per-account-VM and the on-screen
scope is single-active, so the key was redundant indirection.
Move the per-relay cursor *state* onto the domain object whose lifetime it
should share:
- UntilLimitPager is now keyless (per-relay cursors + a pinned floor only) and
lives in commonMain (LargeCache<NormalizedRelayUrl, RelayCursor> — keyed only
by relay url, which is Comparable + equals-consistent, so the sorted cache is
safe; kotlin.concurrent.Volatile for the fields). It is stored on:
* Chatroom.nip04History (per conversation)
* ChatroomList.giftWrapHistory (account NIP-17)
* ChatroomList.nip04History (account rooms-list NIP-04)
The LocalCache object graph is now the partition; cursors are dropped exactly
when the cached messages they describe are pruned, and survive an account
switch (no re-page on switch-back).
- BackwardRelayPager is now a keyless single-active orchestrator: it owns only
the transient bits (in-flight tracker, stalled set, display flows) and binds
to the active scope's cursors via bind(cursors, scope, relaysFor). Removed
activeKey / activate() / the per-key exhausted+floor+stalled maps. Safe as
single-active because history relays only arm while their markers are
on-screen, so a backgrounded scope emits no callbacks.
The three assemblers resolve the scope's cursors from the account's
chatroomList and bind on newSub; the redundant `user` arg dropped from the
account-level advance/advanceAll (callers updated).
Behaviour change: switching between two conversations no longer keeps both
rooms' cursors live in one engine — each room's cursors persist on its own
Chatroom instead, so reopening a room restores its progress (strictly better).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The relay-paging trackers measured elapsed wall-clock with
System.currentTimeMillis() directly. Introduce a multiplatform millisecond
clock (currentTimeMillis expect/actual across jvm/android/ios/macos/linux,
mirroring the existing currentTimeSeconds) exposed as TimeUtils.nowMillis(),
and route PerRelayLoadTracker + WindowLoadTracker through it.
TimeUtils.now() is seconds, so it can't be used for the ms-scale silence /
idle / linger timers — nowMillis() is the correct primitive. No behavior
change (same underlying clock on JVM/Android).
Note: the two trackers stay in jvmAndroid for now — @Synchronized has no
commonMain equivalent. ConcurrentHashMap is kept deliberately: LargeCache is
a sorted ConcurrentSkipListMap (Comparable, compareTo-identity keys) and the
pager keys (ConvoKey not Comparable; ChatroomKey.compareTo is hashCode-based)
don't satisfy that, so ConcurrentHashMap is the correct structure here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- BackwardRelayPager.floorFor was public but only ever called inside the
pager (and its same-module test) — narrow to internal.
- RelayReachMarker composable was public but only rendered by
RelayWindowLimitMarkers in the same file; the public entry points are
RelayWindowLimitMarkers/Sentinels — make it private.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Removes machinery that ships but is unreachable from any live path:
- WindowLoadTracker's REQ-aware backstops (silence + connect-grace). All
three live-tail managers construct it with the default tracksReqSends =
false, so onReqSent/reqSentAt/silencedOut/connectStalled and the
onAbandoned reporting could never fire. Only WindowLoadTrackerSilenceTest
exercised them, so it goes too. accountedFor collapses to "settled".
- trackingListener's onEachEvent param — no caller ever passed it.
- UntilLimitPager.isArmed() / activeRelays() — only the unit test called
them; the pager uses armedRelays() in production.
- Orphaned string chats_load_entire_history, left behind when the
"load entire history" button was removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>