When a replaceable / addressable update arrives in handleInsert, the
projection now re-runs Filter.match against the new event for every
filter. A v2 that no longer matches a filter (e.g. tag list changed)
is removed from that filter's set; a v2 that newly matches a filter
joins it. If no filter retains the slot afterwards, it's fully
dropped from byId / byAddress.
Closes the previous gap where v2 of an addressable kept a stale
filter membership inherited from v1. The slot's MutableStateFlow is
still updated in place — collectors of that handle still see the
content change before the membership update emits.
The cap-eviction-with-cleanup logic was extracted into a small
`admit` helper since the supersession path and the new-slot path
both need it.
For the common case (filter on `kinds + authors`, no tag/time
constraints), v2 always still matches — the new branches are no-ops
and the list reference stays stable, preserving the in-place update
guarantee. The behaviour change kicks in for tag, time-window, or
id-list filters.
New test addressableUpdateDropsSlotWhenFilterStopsMatching: v1 has
hashtag "nostr" and matches a `t = nostr` filter; v2 changes to
"bitcoin"; the projection drops the slot. 18/18 projection tests
pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
The pre-check in SQLiteEventStore.insertEvent rejects events whose
expiration is <= TimeUtils.now(). With expiration = time + 1, the
wall clock can tick past time + 1 between sampling `time` and the
insert call, intermittently failing testDeletingExpiredEvents.
https://claude.ai/code/session_01GoqXtcFnwgyjBict1EURyA
Removes scope ownership from EventStoreProjection. The class is now a
pure state machine — no Job, no AutoCloseable, no embedded
StateFlow:
class EventStoreProjection(store, filters, nowProvider) {
suspend fun seed() // run the seed query
fun apply(change: StoreChange): Boolean // apply one mutation
fun snapshot(): ProjectionState.Loaded // current view
}
ObservableEventStore.observe(filter, scope) is renamed to
ObservableEventStore.project(filter) and now returns a cold
Flow<ProjectionState<T>>. Each collection allocates its own state
machine, runs its own seed, and unsubscribes when the collector
cancels. The flow is built using channelFlow + the state machine —
the class stays as the load-bearing primitive.
Standard caller pattern:
val state = observable.project<TextNoteEvent>(filter)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000),
ProjectionState.Loading)
Tradeoffs:
- Cold flow → multiple collectors each re-seed. ViewModel pattern
with stateIn shares one collection across observers (the standard
Android approach).
- close() goes away. Cancelling the collecting scope tears down the
projection.
- closeStopsListening test renamed to cancellingScopeStopsListening
and now verifies the StateFlow's value freezes after its scope is
cancelled.
17/17 projection + 240/240 store tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
EventStoreProjection now exposes `state: StateFlow<ProjectionState<T>>`
instead of separate `items` + `ready` signals. ProjectionState is a
sealed interface with two cases:
data object Loading // initial state
data class Loaded(items: List<MutableStateFlow<T>>) // post-seed view
This lets the UI distinguish "still seeding" from "seeded but empty"
— the previous `emptyList()` initial value conflated the two. Future
extension to `Failed(throwable)` is a single case addition.
The `ready: CompletableDeferred<Unit>` signal is gone; callers use
`state.first { it is Loaded }` (or the test `awaitReady()` helper).
Test fixtures gain two private extensions on EventStoreProjection<T>:
- val items: List<MutableStateFlow<T>> — terse access to the loaded
list (returns empty if still seeding).
- suspend awaitReady() — replaces ready.await().
awaitItems(predicate) now matches against the Loaded state. 17/17
projection + 240/240 store + interner tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
Three changes wrapped together:
1. relay moves up to IEventStore. The interface gains an abstract
`val relay: NormalizedRelayUrl?` and SQLiteEventStore, EventStore,
FsEventStore, InternedEventStore, and ObservableEventStore all
override it (decorators forward inner.relay). EventStoreProjection
loses its `relay` ctor param and uses `store.relay` for NIP-62
vanish scoping; ObservableEventStore.observe overloads lose their
relay arg. The relay was always a property of the store anyway —
threading it through projection construction was redundant.
2. StoreChange inlines into ObservableEventStore. The sealed type is
now ObservableEventStore.StoreChange (with Insert / DeleteByFilter /
DeleteExpired as nested cases) since it's strictly a contract of
the change stream, never used independently. StoreChange.kt deleted.
3. Package reshuffle:
store/projection/ObservableEventStore.kt → store/ObservableEventStore.kt
store/projection/EventStoreProjection.kt → cache/projection/EventStoreProjection.kt
store/projection/StoreChange.kt → inlined into ObservableEventStore
ObservableEventStore is a store decorator and lives next to
IEventStore. EventStoreProjection is a cache primitive (alongside
EventInterner / InternedEventStore) and lives under cache/. The
`observe(filters, scope)` convenience moved from a method on
ObservableEventStore to a top-level extension function in
cache/projection/, which avoids the otherwise-circular
store → cache/projection → store dependency.
7/7 interner + 17/17 projection + all other store tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
The "Event" name was overloaded — the store-mutation type lived
right next to Nostr Event, so StoreEvent.Insert(event: Event) read
awkwardly. Renaming to StoreChange disambiguates and matches
reactive vocabulary ("store changes").
- StoreEvent → StoreChange (file, sealed type, all references).
- ObservableEventStore.events → ObservableEventStore.changes.
- Internal field _events → _changes.
- KDoc cross-references updated.
Case names (Insert, DeleteByFilter, DeleteExpired) unchanged. All
240 store + projection + interner tests still pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
Replaces the ctor-injected interner inside SQLiteEventStore /
QueryBuilder / EventStore / FsEventStore with a standalone
InternedEventStore decorator in nip01Core/cache/.
Composition is now explicit at the call site:
val sqlite = EventStore(...)
val cached = InternedEventStore(sqlite)
val observable = ObservableEventStore(cached)
Each layer has one job: EventStore persists, InternedEventStore
canonicalises read results, ObservableEventStore publishes the bus.
Stores no longer carry an interner field; passing one through three
layers of constructor params is gone.
InternedEventStore wraps every IEventStore.query variant (list +
streaming, single filter + multi) and pipes results through
interner.intern. Writes (insert, transaction, delete*,
deleteExpiredEvents) and counts pass through untouched.
assertQuery test helpers generalized from EventStore /
SQLiteEventStore to IEventStore so the decorator works with the
existing fixtures.
BaseDBTest reverts to plain EventStore — the basic store tests
don't read through the projection / interning layer, so they don't
need decoration. Tests that DO want canonical-instance reads can
wrap explicitly: `InternedEventStore(eventStore)`.
7/7 interner + 240/240 store + projection tests still pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
Splits the cache primitives off into their own package. Files moved:
nip01Core/core/EventInterner.kt → nip01Core/cache/EventInterner.kt
nip01Core/core/EventInterner.jvmAndroid → nip01Core/cache/EventInterner.jvmAndroid
nip01Core/core/EventInterner.apple → nip01Core/cache/EventInterner.apple
nip01Core/core/EventInterner.linux → nip01Core/cache/EventInterner.linux
nip01Core/core/EventInternerTest.kt → nip01Core/cache/EventInternerTest.kt
Imports updated in SQLiteEventStore, QueryBuilder, EventStore (sqlite),
FsEventStore, BaseDBTest. Each platform actual now explicitly imports
nip01Core.core.Event / HexKey since we crossed package boundaries.
7/7 interner + 240/240 store + projection tests still pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
The previous commit interned at Event.fromJson, which is too early —
the deserializer can produce events with manipulated id fields that
don't match their content. Move interning to the post-validation
boundary: events are only canonicalised when read back from durable
storage, where they were valid when written.
- Revert Event.fromJson to plain OptimizedJsonMapper.fromJson.
- Revert ObservableEventStore.insert interning. Substituting the
caller's event for a previously-cached one with the same id but
potentially different sig was a write-side surprise; we leave the
caller's instance alone.
- Intern at SQLiteStatement.toEvent (read deserialization).
- Intern at FsEventStore.readEvent (FS reads).
The interner is also now constructor-injected through every store
layer (SQLiteEventStore, EventStore, QueryBuilder, FsEventStore)
defaulting to EventInterner.Default. BaseDBTest gives each parallel
forEachDB store its own EventInterner — without this, parallel test
runs that sign "same id, different sig" events through the shared
Default would see the first run's sig leak across all DBs.
All 240 store + projection + interner tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
Malformed `i` tags whose platform-identity field has no `:` (observed in
production logcat as e.g. `["i", " ", " "]`) made `create()` throw
`IndexOutOfBoundsException` from destructuring `split(':')`. The exception
was caught but each event spammed multiple stack traces. Validate the
separator up front in `parse()` and return null silently. Also fix the
diagnostic `joinToString { "," }` typo so the log shows real tag contents.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five changes wrapped together:
1. Move ObservableEventStore + StoreEvent from store/observable/ into
store/projection/. One package owns the projection bus + projection
itself; consumers import only `store.projection.*`.
2. Flatten DeleteRule into the StoreEvent sealed type:
StoreEvent.Insert(event)
StoreEvent.DeleteByFilter(filters)
StoreEvent.DeleteExpired(asOf?)
ObservableEventStore.delete(filter|filters) emits DeleteByFilter;
deleteExpiredEvents() emits DeleteExpired with the pinned cutoff.
3. Use event.address() for AddressableEvent and event.isExpirationBefore(t)
for NIP-40 checks; drop the local addressOf(kind, pubKey, dTag) and
isExpiredAt helpers. Plain replaceables (kind 0/3, 10000-19999) still
build Address(kind, pubKey, "") manually since they don't implement
AddressableEvent. NIP-09 delete-by-address now looks up the projection's
byAddress index with the deletion's Address directly (it's already an
Address, no conversion needed).
4. Per-filter limit. Each filter retains its own capped TreeSet<Slot>
(sorted created_at DESC, id ASC). A slot is "live" iff at least one
filter retains it. The projection's items is the deduped union, so
when filter A and filter B match disjoint events the union can
exceed any single filter's limit. New tests confirm both behaviours:
per-filter eviction + union-larger-than-cap.
5. Drop the redundant + 1 in expiration checks; use isExpirationBefore
directly with TimeUtils.now() for arrival-time guards. The sweep
path subtracts 1 because the store's SQL uses strict `<` while the
helper is `<=`.
17/17 projection tests + all other store tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
Replaces ObservableEventStore.events: SharedFlow<Event> with
SharedFlow<StoreEvent>, where StoreEvent is:
Insert(event)
Delete(rule: DeleteRule)
DeleteRule.Filtered(filters) ← from delete(filter) / delete(filters)
DeleteRule.Expired(asOf) ← from deleteExpiredEvents()
ObservableEventStore now emits Insert for accepted events, and Delete
for every out-of-band removal that went through it. The Expired rule
carries the cutoff timestamp (TimeUtils.now() at call time) so the
projection's in-memory drop matches the store's on-disk drop without
clock skew between collectors.
EventStoreProjection no longer runs its own NIP-40 ticker. Expired
events linger in [items] until the application calls
deleteExpiredEvents() on the observable — at which point the
projection drops everything whose expiration is past `asOf`. The
ticker, expirationTickMs constructor arg, and sweepExpired helper are
all removed.
For DeleteRule.Filtered the projection iterates current slots and
drops any whose event matches any of the rule's filters via
Filter.match — same predicate the store uses.
Tests:
- nip40ExpirationDroppedByTicker → nip40ExpirationDroppedOnStoreSweep:
drives a deleteExpiredEvents() call instead of waiting on a ticker.
- New deleteByFilterRemovesMatchingSlots: confirms delete(filter) on
the observable removes exactly the matching slots from the projection
without touching others.
- 15/15 projection tests + all other store tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
Reverts the previous round's choice of embedding an ObservableEventStore
inside EventStore. The wrapper now lives strictly outside any specific
store: callers compose `ObservableEventStore(EventStore(...))` (or
`ObservableEventStore(FsEventStore(...))`, or any IEventStore) and use
that as their projection bus.
EventStore is back to a plain IEventStore implementation over
SQLiteEventStore — no `observable`, `events`, or `observe()` on it.
ObservableEventStore gains the `observe(filters, relay, scope)` and
`observe(filter, relay, scope)` convenience methods. NIP-62 vanish
scoping is passed in per-projection (rather than a constructor field
on the wrapper) so the same observable can feed projections with
different relay scopes.
Tests construct `observable = ObservableEventStore(store)` in
`setUp` and route inserts through `observable.insert(...)` so the
projection bus actually publishes them. 14/14 projection + 219/219
other store tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
Two interrelated bugs from the deployed-schema flip:
1. Rooms created in Amethyst didn't appear in nostrnests's "Live Now"
lobby. The lobby filter in NestsUI's skeleton.js requires a `title`
tag on every kind:30312 — events without one are dropped from
`live`/`planned`/`ended`. RoomNameTag still emitted the legacy
`room` name, so our rooms passed our own readers (which tolerate
both names) but were invisible to NestsUI. Flip canonical to
`title`, keep `room` as legacy on read.
2. The mute badge over the avatar disappeared shortly after toggling
mute, even though the broadcaster was still muted. Two causes:
- ParticipantsGrid gated both the muted ring and the MicStateBadge
on `member.publishing`. But per the deployed kind-10312 semantics
`publishing=0` whenever `muted=1` (NestRoomPresencePublisher
mirrors this — `publishingNow = broadcasting && !isMuted`), so
the badge that was supposed to *indicate* mute was hidden
exactly when it became relevant. Show the badge when on stage
and either publishing OR muted; gate the muted ring on `muted`
alone.
- The presence heartbeat captured `micMutedTag` / `publishingTag`
/ `onstageTag` / `handRaised` at LaunchedEffect launch and never
refreshed them. The 500 ms debounced LaunchedEffect did publish
the new mute state, but ~30 s later the heartbeat overwrote it
with the captured pre-toggle values, flipping `publishing`
back to 1 and `muted` back to 0 server-side. Wrap the dynamic
fields in `rememberUpdatedState` so the heartbeat reads the
latest snapshot on every refresh; keep the existing key set
(event/handRaised/onstage) for prompt re-emission on those
transitions.
Tests updated for the new canonical `title` emission; both jvmTest
suites pass.
Splits "events accepted for persistence" from "events accepted for
observation". The new ObservableEventStore wraps any IEventStore and
owns events: SharedFlow<Event>:
- Non-ephemeral events forward to the inner store; success → emit, any
rejection (expired, NIP-09 / NIP-62 tombstone, NIP-01 supersession
loser) → no emit.
- Ephemeral events (kinds 20000-29999) skip persistence entirely but
still emit, so an open EventStoreProjection renders them while alive.
They vanish from any future seed because the DB never had them.
Reverts the previous round of plumbing inside SQLiteEventStore /
FsEventStore: stores no longer carry an inserts SharedFlow. IEventStore
goes back to a clean read/write contract. ObservableEventStore is the
single place that decides what makes it onto the projection bus.
EventStore (the SQLite convenience class) embeds an ObservableEventStore
internally so existing call sites like `store.observe(filter, scope)`
keep working without changes.
EventStoreProjection now collects via Flow.onSubscription so the
collector subscription is established before the seed query runs —
fixes a race where an insert immediately after `ready.await()` could
land in the SharedFlow before the collector was subscribed.
Tests:
- ephemeralEventsAppearInProjection — kind-22000 event reaches items
but isn't queryable from the inner store, and a fresh projection
on the same store gets an empty seed.
- 14/14 projection tests + 219/219 other store tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
The projection now sits on top of any IEventStore, not just SQLite.
Stores publish a simple `inserts: SharedFlow<Event>` (one event per
successful insert), and the projection itself replays the NIP rules
against that stream:
- NIP-01 supersession with the lexical-id tiebreaker, for both
replaceables (kind 0/3/10000-19999) and addressables (30000-39999).
Both now share a single in-place update path.
- NIP-09 deletions, with the original-author check (cross-author
kind-5s are inert, matching the store).
- NIP-62 right-to-vanish, scoped by the projection's relay arg.
- NIP-40 expiration via a per-projection ticker that drops slots whose
expiration tag has lapsed.
Out-of-band store mutations (`delete(id)`, `clearDB()`, the periodic
`deleteExpiredEvents()` sweep) are no longer visible to projections —
they're maintenance ops; projections re-seed when their scope is
restarted.
Removed from SQLiteEventStore:
- ChangeLogModule + temp-table + AFTER DELETE trigger.
- StoreChange sealed type and the per-mutation drain/publish path.
Both SQLiteEventStore and FsEventStore now satisfy
`IEventStore.inserts`. FsEventStore.insertLocked returns Boolean so
no-op idempotent retries (canonical already on disk) don't re-publish.
Tests:
- EventStoreProjectionTest moves to `store/projection/`, gains four
new cases: NIP-01 out-of-order rejection, NIP-09 cross-author
inertness, NIP-62 cross-author no-op, NIP-40 ticker-driven removal.
- 13/13 projection tests + 232/232 total store tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
The deployed nostrnests reference (NestsUI v2 + moq-auth + moq-rs)
emits a different on-the-wire schema than the previous EGG-01 / EGG-09
drafts and Quartz writers. Verified by reading the production
NestsUI bundle. The deployed schema is now canonical:
kind:30312 (room event)
- relay URL → ["streaming", url] (was ["endpoint", url])
- auth URL → ["auth", url] (was ["service", url])
- live status → ["status", "live"] (was "open")
- ended status → ["status", "ended"] (was "closed")
- room name → ["title", name] (was ["room", name])
kind:10112 (user MoQ-server list)
- 3-element entries: ["server", relay, auth]
- first-element name "relay" accepted as a synonym (legacy)
- 2-element entries: derive auth host by replacing leading "moq."
with "moq-auth.", or prepending "moq-auth." otherwise
Implementation
- ServiceUrlTag.TAG_NAME = "auth", LEGACY_TAG_NAME = "service"
- EndpointUrlTag.TAG_NAME = "streaming", LEGACY_TAG_NAME = "endpoint"
- StatusTag enum: PLANNED/LIVE/PRIVATE/ENDED with code "live"/"ended";
legacy "open"/"closed" still parsed on read
- NestsServersEvent: emit/read 3-element [server, relay, auth] tags
with the legacy-shape tolerances above; expose a NestsServer pair
- Account.nestsServers.flow now produces List<NestsServer> pairs
- NestsServersScreen: rewritten edit-field asks for both URLs,
recommended row shows the nostrnests pair, list rows show both URLs
- NestsScreen first-time setup writes the nostrnests pair, not a
single URL; gate the create FAB on both URLs being parseable
- CreateNestViewModel: drop the resolveServerPair hardcoded mapping —
the saved pair is now authoritative; defaults stay correct for an
empty kind-10112 list
Specs
- EGG-01 rewritten to canonical streaming/auth/live/ended with a
legacy-spelling table; example uses the real nostrnests pair
- EGG-02 references "auth" tag throughout; error taxonomy says "ended"
- EGG-09 rewritten to 3-element server tag with derivation fallback
- New nestsClient/specs/nip-53-proposed.md — a single self-contained
proposed update to upstream NIP-53 covering kind 30312 + kind 10112
with the deployed schema and the JWT-mint flow
All existing unit tests adjusted; quartz:jvmTest, amethyst:testPlayDebugUnitTest,
and nestsClient:jvmTest pass.
Adds a stateful observer that turns a Filter into a StateFlow<List<MutableStateFlow<Event>>>:
- A TEMP table + AFTER DELETE trigger on event_headers logs OLD.id for every row
that leaves the store, regardless of cause (replaceable / addressable
supersession, NIP-09 deletion, NIP-62 vanish, NIP-40 expiration sweep, manual
delete, clearDB).
- SQLiteEventStore drains the log around each writer unit of work and emits a
StoreChange(inserted, removedIds) on a SharedFlow.
- EventStoreProjection seeds itself from the store, then maintains stable
MutableStateFlow handles keyed by (kind:pubkey:dtag) for addressables and by
id otherwise. Addressable updates mutate the handle's value in place — list
reference stable; insert / remove rebuilds the list reference.
- 11 new tests cover seed, insert, replaceable update, addressable update,
NIP-09 deletion, NIP-62 vanish, NIP-40 expiration, manual delete, limit
enforcement, non-matching insert, and close-cancels-listener.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
After verifying the nostrnests reference (NestsUI-v2 @ main):
- ProfileCard.tsx writes kicks as ['action','kick'] tags with empty content
- useAdminCommands.ts reads action via tags.find(t => t==='action') and
applies a 60-s relay since plus a processedRef Set to dedup re-deliveries
- p-tag role marker for moderators is 'admin', not 'moderator'
Amethyst was diverging on every one of those, which means our outbound
admin commands were invisible to nostrnests, theirs to us, and any
nostrnests admin (role='admin') failed our isModerator() / canSpeak()
gates entirely — kicks and force-mutes signed by them were silently
dropped.
Changes:
quartz/AdminCommandEvent.kt
- Emit ['action', '<verb>'] tag with empty content
- Reader prefers the tag, falls back to content for any in-flight
Amethyst-built kick from before this commit
- kick() and forceMute() share a common build() helper
quartz/ParticipantTag.kt
- ROLE.MODERATOR.code = 'admin' (matches nostrnests + EGG-07)
- Adds legacyCodes = ['moderator'] so older Amethyst-emitted
kind-30312 events still parse as MODERATOR
- effectiveRole() walks both code + legacyCodes
amethyst/AdminCommandsCollector
- Filter carries since = now - 60 (EGG-07 #7)
- Defensive per-event freshness re-check for cached events / clock skew
- mutableSetOf<String>() processed-id dedup for the lifetime of the
collector, mirroring useAdminCommands.ts's processedRef
EGG-07.md
- Documents the Amethyst-only ['action','mute'] extension under a new
'Implemented extensions' section. nostrnests doesn't emit or honour
it today; cross-client force-mutes only work between Amethyst peers
- 'warn' stays in the future-actions list — nostrnests has no plans
for it either
Tests:
- ParticipantTagTest: new asserts that 'admin' / 'Admin' / 'ADMIN'
parse as ROLE.MODERATOR; pins the wire string to 'admin' and the
legacy alias to 'moderator'
- AdminCommandEventTest: kick/forceMute templates carry ['action', _]
tag with empty content; legacy content-form still parses; tag wins
over content when both are present
Closes the deferred font tag from the Tier-3 plan. Wire-up:
* quartz: FontTag(family, optionalUrl) parser/assembler with
blank-rejection on family + blank-URL-becomes-null normalisation.
* MeetingSpaceEvent.font(): FontTag? accessor.
* TagArrayBuilder.font(family, url) DSL.
* RoomTheme gains fontFamily / fontUrl fields.
* AudioRoomThemedScope maps `family` to a Compose FontFamily for
the four CSS-style generic-family names ("sans-serif", "serif",
"monospace", "cursive") — each maps to the corresponding system
fallback. The whole Material3 typography is rebuilt with that
family so headlines / body / labels all swap together.
URL-based font loading (RoomTheme.fontUrl) is the natural follow-up:
fetch + cache via OkHttp, then build a FontFamily from a local
file. Until then, an unknown family is silently a no-op — the room
renders in the platform default rather than crashing or fetching
on the UI thread.
Tests:
* FontTagTest — 9 cases covering family-only, family+URL,
blank rejection, missing family, wrong tag name, blank-URL
normalisation, assembler shapes (with + without URL), and
the assembler's blank-family rejection.
* RoomThemeTest gains 3 cases for font projection (family-only /
family+URL / empty event).
Shipped the deferred slice of Tier 1 Step 4: hosts can now
schedule a room for a future time instead of going live
immediately.
Quartz:
StatusTag.STATUS.PLANNED — new enum value alongside OPEN /
PRIVATE / CLOSED. Pre-Lite-03
clients that don't know the value
get null from the parser; the room
renderer's existing fallback handles
that. The amethyst feed renders
PLANNED with the OPEN badge in v1
(a "Scheduled — starts at HH:MM"
chip is a visual follow-up).
StartsTag — `["starts", "<unix-seconds>"]` parser + assembler.
Strict numeric — non-numeric values return null so
a malformed event can't crash the room-list
renderer. Negative values are rejected at assemble.
MeetingSpaceEvent.starts() — accessor.
TagArrayBuilderExt.starts(unixSeconds) — DSL helper.
Amethyst:
CreateAudioRoomViewModel —
onScheduledToggle(scheduled) — flips PLANNED vs OPEN.
onScheduledStartChange(unixSeconds) — picker callback.
FormState.scheduled / .scheduledStartUnix — additive fields.
canSubmit gates on a picked time when scheduled = true.
publishAndBuildLaunchInfo() — emits status=PLANNED +
`["starts", <unix>]` when
scheduled; otherwise the
existing OPEN path runs.
CreateAudioRoomSheet —
Schedule toggle (Material3 Switch) above the picker.
ScheduleStartPicker — OutlinedButton that opens a Material3
DatePickerDialog. The selected date is
saved as 00:00 of that day in the
local time zone (TimePicker stitching
is a follow-up; nostrnests' web UI
does the same date-only flow).
MeetingSpace.kt feed item — added the PLANNED branch to the
exhaustive when so the build passes.
Tests:
StartsTagTest — numeric parse, malformed-rejected, missing /
wrong-name rejection, assemble shape, negative rejected,
STATUS.PLANNED parse round-trip.
Adds the Tier-3 minimum-viable theming primitives so a themed
nostrnests room renders without crashing the client:
ColorTag — `["c", "<hex6>", "background"|"text"|"primary"]`.
Strict 6-char hex parser; `#abc` shorthand and
named colors are REJECTED so a typo'd room
event can't crash the renderer. Output hex is
normalised uppercase, no `#` prefix.
BackgroundTag — `["bg", "<url>", "tile"|"cover"]`. Mode defaults
to COVER when missing; unknown modes (a future
"blur") fall back to COVER until the renderer
learns them. Empty URL is rejected.
MeetingSpaceEvent.colors() / .background() — tag accessors. The
font tag (`["f", family, optionalUrl]`) is intentionally NOT in
this commit; loading a custom FontFamily would need a per-pack
loader, and font-less rooms still render fine on the client.
Tests:
ColorTagTest — happy path, no-`#` prefix, rejects shorthand /
named / unknown target / missing target;
assemble normalisation; round-trip.
BackgroundTagTest — happy path, default-COVER, future-mode
fallback, rejects empty URL, round-trip.
The Compose `RoomTheme` projection + `AudioRoomThemedScope`
renderer come next.
Adds the ephemeral host-issued admin command event nostrnests uses
for kick (and future moderation actions like mute/ban):
AdminCommandEvent — kind 4312. Carries one Action (currently just
KICK) in `content`, an `a`-tag pointing at the room (kind-30312
address), and a `p`-tag for the target. The verb-in-content shape
lets us extend with new actions without a wire schema bump.
AdminCommandEvent.kick(roomATag, target) — builder.
AdminCommandEvent.action() / .targetPubkey() / .room() — accessors
for the recipient side. Unknown actions return null (forward-compat
with verbs not yet in the enum).
EventFactory — registered so LocalCache + Filter.match can decode
incoming events properly.
Authority enforcement is the CLIENT'S job — the relay just stores
and forwards. Recipients filter on `kinds=[4312], #a=[room],
#p=[me]` and only honour commands whose signer is a participant
marked HOST or MODERATOR on the active kind-30312. The next commit
wires that gating + the disconnect side effect into AudioRoomViewModel.
Tests:
* Build a kick template and verify the action/address/target tags
* Round-trip parse exposes room, target pubkey and action
* Unknown verb in `content` returns null from action()
* Missing tags return null from accessors (no throw)
Adds typed role accessors to make participant-list filtering
self-documenting:
ParticipantTag.effectiveRole(): ROLE? — case-insensitive parse;
null when the role string doesn't match any enum value (so an
unknown future "director" role doesn't accidentally pass canSpeak).
ParticipantTag.isHost() / isModerator() / isSpeaker() — single-role
predicates for per-row UI gating.
ParticipantTag.canSpeak() — true for HOST / MODERATOR / SPEAKER;
the audio-room VM uses this to gate startBroadcast() so anyone who
was promoted (not just the original host) can publish.
5 unit tests cover happy paths, case-insensitivity, the
unknown-role → null contract, the canSpeak union, and the
effectiveRole enum parse.
Tier 1 #5 + #6 consume these — promote/demote and kick both need
to render different rows depending on whether the local user is a
host or moderator (allowed to manage roles) and the target
participant's current role.
Adds two NIP-53 presence tags used by nostrnests' room UI:
["publishing", "0|1"] — peer is actively pushing audio packets
["onstage", "0|1"] — peer holds a speaker slot vs audience
These complement the existing "hand" and "muted" tags. They're
independent: a speaker can be onstage but paused (onstage=1,
publishing=0), or broadcasting silently (onstage=1, publishing=1,
muted=1). The participant grid (Tier 2) and listener counter (Tier 1
#8) consume them.
- PublishingTag / OnstageTag classes mirror HandRaisedTag's parse +
assemble shape exactly so the DSL (TagArrayBuilderExt) extends
uniformly.
- MeetingRoomPresenceEvent.publishing() / onstage() accessor parsers
return Boolean? (null when the tag isn't present) so callers can
distinguish "explicitly false" from "absent".
- The MeetingSpaceEvent overload of build() now accepts both fields
as nullable params; the MeetingRoomEvent overload deliberately
doesn't (those are tier-2 video meetings, not Clubhouse rooms).
- Round-trip tests pin the wire format and the "absent → null"
behaviour.
androidx.sqlite SQLiteConnection is not thread-safe; SQLiteEventStore
shared a single lazy connection across all callers, so two coroutines
calling insertEvent() at the same time would race on BEGIN IMMEDIATE
and the modules' prepared statements, surfacing as
"cannot start a transaction within a transaction" or SQLITE_MISUSE.
Mirror Room's design: introduce SQLiteConnectionPool with one writer
connection guarded by a coroutine Mutex and N reader connections
handed out via a Channel-as-semaphore (file-backed DBs only; in-memory
DBs share the writer because each ":memory:" connection is a separate
DB). Convert IEventStore + SQLiteEventStore + EventStore + FsEventStore
+ LiveEventStore to suspend, route writes through useWriter and reads
through useReader. RelaySession now launches handleEvent / handleCount
on its scope. CLI Context helpers and StoreCommands.sweepExpired pick
up suspend.
Add ParallelInsertTest to lock the behaviour in: 8 coroutines × 200
inserts, parallel reads alongside writes, transaction batches across
coroutines, and a reopen smoke test all pass against a file-backed DB.
https://claude.ai/code/session_016b5kSSbtDS3Ead6pN3Xqt5
Implement a TLS 1.3 client state machine that drives the QUIC handshake using
only Quartz's existing crypto. No BouncyCastle dependency.
- HKDF-Expand and HKDF-Expand-Label upstreamed to Quartz's Hkdf class with
RFC 5869 + RFC 8448 test vectors covering them.
- :quic crypto stack: AEAD (AES-128-GCM via Quartz's AESGCM, ChaCha20-Poly1305
via Quartz's pure-Kotlin impl), header protection (AES-ECB via JCA single
block + ChaCha20 keystream), QUIC Initial-secret derivation matching
RFC 9001 Appendix A.1 bit-for-bit.
- TLS 1.3 transcript hash, key schedule (early/handshake/master + per-direction
client/server traffic secrets), Finished MAC.
- ClientHello + extension encoders carrying SNI, supported_versions=[TLS 1.3],
supported_groups=[X25519], signature_algorithms covering ECDSA/RSA-PSS/Ed25519,
X25519 key_share, psk_dhe_ke, ALPN=[h3], and the QUIC transport_parameters
extension.
- ServerHello + EncryptedExtensions + Certificate + CertificateVerify + Finished
parsers. The state machine handles the certificate path and the PSK-style
no-cert path; certificate validation is wired through a CertificateValidator
SPI (real impl lands in Phase L).
- Transport parameters codec covering all RFC 9000 §18.2 + RFC 9221 fields.
- QuicWriter/QuicReader buffer helpers shared across the rest of the stack.
Round-trip test: a minimal in-process TLS server built from the same primitives
drives a full ClientHello → ServerHello → EE → Finished → client Finished
exchange. Both sides reach handshake-complete and agree bit-for-bit on the
handshake & application traffic secrets. ALPN + transport parameters round-trip
through EncryptedExtensions cleanly.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
Batch B test-coverage gaps from the review:
- testTransactionRollsBackOnTriggerAbort: a multi-insert transaction
whose middle statement is rejected by `reject_deleted_events` rolls
back ALL inserts in the transaction, not just the failing one.
- testDeletionByThirdPartyDoesNothing: NIP-09 author check —
another user's deletion event must not remove the original.
- testKind5CanBeDeletedByAnotherKind5OfSameAuthor: pins the current
behavior that kind-5 events are not specially protected; a same-author
follow-up deletion can remove a previous one, and re-insertion of the
removed deletion is then blocked.
- testGiftWrapDeletionRequiresRecipient: NIP-59 GiftWraps key on the
recipient (p-tag), not the (encrypted) inner author. Sender and
unrelated third parties cannot delete; the recipient can.
- testVanishForDifferentRelayIsNoOp: a kind-62 vanish naming a
different relay must be stored but must not delete events on this
relay nor block new inserts (RightToVanishModule.shouldVanishFrom
contract).
- testFtsCleanedUpAfterReplaceableRotation: FTS rows are cleaned via
the AFTER DELETE trigger when an addressable is superseded — old
content must no longer match search.
- testVacuumAndAnalyseSmoke: VACUUM and ANALYZE run on a populated DB
without throwing and preserve existing rows.
- SqlSelectionBuilderTest: pins NotEquals(null) → IS NOT NULL,
empty IN → "1 = 0", equalsOrIn singleton/multiple paths.
https://claude.ai/code/session_01X163Nr31vGkvAXoJ3JgMov
- isExpired (#10): NIP-40 says an event is expired *once* `expiration`
is reached, and the SQL trigger uses `<= unixepoch()`. The Kotlin
pre-check used `<` (strict) so an event with `expiration == now`
passed the Kotlin check then failed in the trigger. Both layers now
use `<=`. Also applies to `isExpirationBefore` for consistency.
- transaction extension (#7): if the body throws *and* ROLLBACK also
throws, we now attach the rollback failure as a suppressed exception
instead of letting it mask the original cause. COMMIT is moved outside
the catch so a commit failure doesn't trigger a second ROLLBACK on
already-finalized transaction state.
- SeedModule.hasher (#8): the cache field is now a `kotlin.concurrent.
atomics.AtomicReference` (matches the pattern used in BleChunkAssembler
and BasicRelayClient) so the hasher publication is visible across
threads. The race itself is benign — the seed is stable, so two
concurrent computations produce identical hashers — but the prior
plain `var` had no visibility guarantee.
- delete(Filter()) (#12): documents the intentional asymmetry — `query`
on an empty filter returns everything, but `delete` on an empty
filter is a no-op (safe-by-default). New test pins the contract.
Tests:
- testInsertingEventExpiringExactlyNow: events with `expiration == now`
are rejected by both Kotlin and the trigger.
- testTransactionRollsBackOnException: a user transaction whose body
throws leaves the DB unchanged and still accepts new writes.
- testDeleteWithEmptyFilterIsSafe: empty-filter delete is a no-op.
https://claude.ai/code/session_01X163Nr31vGkvAXoJ3JgMov
- NIP-09 (#2): a-tag and replaceable deletes now respect
`created_at <= deletion.created_at` so a stale deletion request
cannot remove a newer addressable that legitimately replaced it.
`+created_at` hint on the addressable path keeps the d_tag-selective
index in use.
- NIP-01 (#1): replaceable / addressable triggers now apply the
lexical-id tiebreaker — when two events share `created_at`, the
one with the lexicographically smaller id wins, matching the spec.
- Schema (#5): FullTextSearchModule.versionFinder probes FTS support
with a dummy table, but used to leave it behind. The first v1->v2
upgrade then failed because re-running create() would hit
"already exists". Now we drop the probe table immediately and
defensively clean up any stragglers.
- Schema (#6): onCreate / onUpgrade and the matching `setUserVersion`
are now wrapped in a single transaction so a partial migration
cannot leave the DB with mismatched user_version and schema.
- SQL DSL (#4): Condition.NotEquals(null) now produces `IS NOT NULL`
instead of `IS NULL`.
- Doc fix (#13): swapped vacuum/analyse comments now describe the
right command.
Tests: same-`created_at` tiebreaker for replaceables and addressables,
NIP-09 created_at window for a-tag deletes, schema drop+recreate
idempotency (covers the FTS dummy-table regression).
https://claude.ai/code/session_01X163Nr31vGkvAXoJ3JgMov
When wn admin-removes A in interop test 14, A processed the proposal
locally — `tree.removeLeaf(A.leafIndex)` blanked her leaf and shrank
`tree.leafCount` past `myLeafIndex` — and then immediately walked into
BinaryTree.directPath(myLeafIndex, tree.leafCount)
at MlsGroup.processCommitInner. With `myLeafIndex >= leafCount`, the
left-balanced parent walk had no valid stopping point: each recursion
step doubled the candidate parent index, integer-overflowed past 2^31,
and either returned garbage or kept appending to the result list until
the JVM OOM'd. The OOM bubbled up as a `runBlocking` failure that
rolled back the whole commit — so A also stayed locally convinced she
was still a member, and the test timed out waiting for `not_member`.
Three layered fixes so the failure mode can't recur:
* `BinaryTree.parent`/`directPath`/`copath` now `require` an in-range
input. The root and any node ≥ nodeCount used to silently loop or
return garbage; now they throw `IllegalArgumentException` immediately.
This is defense-in-depth — a future caller passing an invalid index
gets a stack trace at the boundary instead of an OOM five frames
deep.
* `MlsGroup.processCommitInner` short-circuits the path-decrypt + epoch
advancement when the proposals in the commit just removed *us*. We
preserve the proposal-side tree mutations so the caller (and the
outer state machine) can observe that we're out, clear pending
proposals + sent keys, and return. Without this short-circuit the
function would derive a bogus all-zero `commit_secret`, fail
`confirmation_tag` verification, throw, and roll the snapshot back —
leaving us still convinced we were a live member.
* `MlsGroupManager.isMember` and a new `MlsGroup.isLocalMember()`
helper now return false once our leaf is null or past `leafCount`.
The post-Remove group entry still lives in `groups` so callers can
inspect the final tree, but every cli command (`group show`,
`message send`, etc.) sees `not_member` and returns the right error.
Also add a comprehensive `BinaryTreeTest` covering non-power-of-2
trees (3, 5, …, 32 leaves) and the boundary cases (root has no
parent, leafIndex ≥ leafCount must throw). The pre-existing tests
only exercised the 4-leaf example from RFC 9420 Appendix C; nothing
hit the `parentInRange` branch, which is exactly where the OOM lived.
Test 14's bash polling needed a small companion fix: it captured
amy's stderr through `2>&1` and fed the result to `jq`, but the
captured stream is interleaved with quartz `Log.d(…)` debug lines,
so the JSON parse always failed. Switch to a `grep` for the
`"error":"not_member"` literal — that signature only appears in
the JSON payload and survives the debug noise. Also tee the
captured output into `$LOG_FILE` so post-mortem logs include each
polling iteration's `[cli] ingest …` traces.
Marmot interop score: 11/16 → 14/16 (tests 9, 15 are unrelated MLS
issues — see follow-up).
https://claude.ai/code/session_013VYkpz8P1mPh9Ejxy9anhJ
Two drifts surfaced as 7 failing tests on the jvmTest run:
1. MarmotGroupData CURRENT_VERSION is held at 2 for mdk-core interop
(see the const-doc block), but three tests still assumed v3:
- marmotGroupData_defaultVersionIsThree asserted the constant directly.
- marmotGroupData_roundTripWithDisappearingSecs and
buildGroupEvent_appendsExpirationTagWhenDisappearingConfigured relied
on the default version emitting the v3-only disappearing_message_secs
field, which the encoder correctly skips for v2.
Rename the first to `currentVersionIsHeldAtTwoForMdkInterop` with a
spec-cross-reference comment and an extra assertion that v3 is still
in SUPPORTED_VERSIONS. Pin version=3 explicitly in the other two so
they exercise the v3 path regardless of the interop hold-back.
2. MlsGroup.processCommit now requires a non-empty FramedContentTBS
signature on non-external commits (per RFC 9420 §6.1, introduced in
0c970945). Four pipeline tests still called the raw processCommit
overload with signature=ByteArray(0), so they all failed with
"FramedContentTBS signature missing on commit from leaf 0".
Add a `processFramedCommit` wrapper on MlsGroupManager that mirrors
MlsGroup.processFramedCommit + the retention/persistence bookkeeping
from processCommit, and switch the four tests to feed framedCommitBytes
through it. MarmotInboundProcessor is unaffected (it already has the
decoded PublicMessage fields).
All 7 originally-failing Marmot tests now pass; the 4 unrelated
NostrClient network tests remain independently flaky.
https://claude.ai/code/session_01XQNAmwn1y87GAoK94QWgyn
Quartz:
- processWelcome now enforces hash(ratchet_tree) == GroupContext.tree_hash
and verifies the joiner-derived confirmation_tag matches GroupInfo's,
closing the gap where a tampered but-signed GroupInfo could diverge
the joiner's epoch secrets.
- externalJoin does the equivalent tree_hash check.
- Promoted constantTimeEquals to a file-level helper so the companion
object (processWelcome) can use it alongside instance methods.
Tests:
- New MlsGroupNegativeTest exercises every authenticity knob on inbound
commits: tampered confirmation_tag, bit-flipped signature, spoofed
senderLeafIndex, wrong wire_format, empty confirmation_tag, and
tampered/missing membership_tag — each must throw and leave the
receiving group's epoch unchanged (atomic-rollback regression cover).
- Fixed stale jvmAndroidTest references to the removed `selfRemove()`
helper — use `buildSelfRemoveProposalMessage()` (now Pair-returning).
Interop harness:
- Adds tests 14–16 as inverted-role scaffolding (wn drives, amy receives).
Currently recorded as SKIP with explicit notes:
14 / 15 block on filtered-direct-path UpdatePath support in
quartz's applyUpdatePath (RFC 9420 §7.7 path compression).
16 blocks on a createdAt-sorted KeyPackage fetch path — the current
fetchFirst-based fetcher is non-deterministic on relay order.
Preserves the test functions so they start running the moment those
gaps are closed.
- MeetingRoomPresenceEvent (kind 10312) now extends BaseReplaceableEvent
instead of BaseAddressableEvent. NIP-53 states this kind is a regular
replaceable event ("presence can only be indicated in one room at a
time"), and LocalCache already routes it through consumeBaseReplaceable.
createAddress/createAddressATag/createAddressTag now take only pubKey.
- Add TagArrayBuilder extensions for streaming (30311), meeting space
(30312) and meeting room (30313) tags, plus build() helpers that assemble
the required tags per NIP-53.
- Add ProofOfAgreement utility to sign and verify the schnorr proof that
NIP-53 places in the 5th element of a participant p-tag
(SHA256 of kind:pubkey:dTag signed by the participant's private key).
Includes LiveActivitiesEvent.hasValidProof() convenience.
marmot-ts (the TypeScript Marmot client — browsers, Node, Bun, Deno)
wraps a completely different MLS implementation: ts-mls. That backend
shares no code with OpenMLS, which is what MDK/whitenoise use, so
Amethyst passing on both is a strong signal that the on-wire MLS layer
is spec-correct rather than coincidentally self-consistent.
Add a Node-based generator under quartz/tools/tsmls-vector-gen/ that
uses ts-mls 2.0.0-rc.10 directly to emit:
- Alice's KeyPackage + Bob's joiner KeyPackage
- Alice's Welcome after add_member + commit
- Bob's init/encryption/signature private keys (PKCS#8 Ed25519 unwrapped
to the raw 32-byte seed for parity with the MDK vector shape)
- Post-join MLS-Exporter("marmot","group-event",32) KAT
- Three application PrivateMessages from Alice → Bob with the
expected plaintexts
TsMlsWelcomeInteropTest mirrors MdkWelcomeInteropTest but consumes the
new vector. It proves:
- KeyPackage self-signature verifies under our Ed25519 + SignContent.
- Amethyst.processWelcome unwraps the group secrets, derives the
welcome_key/nonce, AEAD-decrypts GroupInfo, verifies GroupInfoTBS
with signer_pub, decodes the ratchet tree, and binds the joiner's
leaf — end-to-end.
- Post-join exporter secret matches ts-mls byte-for-byte.
- Amethyst decrypt() parses the RFC 9420 §6.3.1 PrivateMessageContent
framing (application_data<V> + FramedContentAuthData.signature<V> +
zero padding) and verifies the sender's FramedContentTBS signature
against Alice's leaf, for all three ciphertexts.
https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
Add a Rust generator under quartz/tools/mdk-vector-gen that emits a
Welcome, joiner KeyPackage, committer signer_pub, joiner's three
private keys, and a post-join MLS-Exporter KAT, using the same
openmls 0.8 backend MDK / whitenoise use. Commit its output at
quartz/src/commonTest/resources/mls/mdk-welcome.json.
MdkWelcomeInteropTest consumes the vector and proves Amethyst can:
- parse a KeyPackage OpenMLS authored and verify its self-signature;
- run the joiner's processWelcome end-to-end (HPKE unwrap of group
secrets, welcome-key derivation, AEAD GroupInfo decrypt, ratchet
tree decode, signer leaf lookup, GroupInfoTBS Ed25519 verify);
- derive MLS-Exporter("marmot", "group-event", 32) byte-for-byte
identically to openmls — the exact exporter Marmot uses for the
outer ChaCha20 wrap on kind:445 events.
testBobDecryptsMdkApplicationMessagesFromAlice is @Ignored with a
detailed TODO because Amethyst's MlsGroup.encrypt/decrypt skip the
RFC 9420 §6.3.1 PrivateMessageContent framing (application_data<V> +
FramedContentAuthData + padding). Amethyst ↔ Amethyst works (both
sides omit it) but every cross-implementation PrivateMessage fails.
Tracked as a follow-up.
https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
Existing interop tests covered wire-format round-trips but never actually
ran ciphertext from IETF/OpenMLS/mls-rs test vectors through our crypto
code paths. That left two major spec bugs unverified by CI — the wrong
HPKE Welcome-context and the wrong "eae_prk" DHKEM label — because
both sides of every round-trip were wrong the same way.
Tighten coverage with cross-implementation KATs:
CryptoBasicsInteropTest
+ testEncryptWithLabelDecryptsIetfVector: decrypt the IETF
encrypt_with_label vector directly (kem_output + ciphertext), not
just our own round-trip output.
WelcomeInteropTest
+ testX25519Rfc7748Vectors: RFC 7748 §6.1 X25519 KATs.
+ testX25519DhMatchesJavaXdh: compare against JDK 11+ XDH on the IETF
encrypt_with_label priv/kem_output.
+ testWelcomeInitKeyMatchesPrivateKey: init_pub == publicFromPrivate
(init_priv) for every IETF welcome vector.
+ testIetfKeyPackageSelfSignatureVerifies: exercises Ed25519 +
SignContext + LeafNode TLS encoding against OpenMLS output.
+ testIetfWelcomeFullDecryptAndGroupInfoSignature: end-to-end path
through HPKE GroupSecrets decrypt → welcome_key derivation → AEAD
GroupInfo decrypt → GroupInfoTBS Ed25519 verify against signer_pub.
+ testPassiveClientWelcomeDecryptsGroupInfoAndFindsLeaf: same
end-to-end path on passive-client-welcome.json, plus ratchet-tree
leaf lookup and signer-leaf GroupInfo verify. Skips vectors with
external_psks (we don't model PSK secret derivation here) and with
no ratchet tree (delivery-service lookup).
The TreeKEM UpdatePath KAT was considered but needs the full direct
path / copath / resolution / LCA plumbing from MlsGroup.processCommit
to pick which HPKE ciphertext a given leaf should decrypt. Not worth
the duplication in a test — the "UpdatePathNode" HPKE path now has KAT
coverage indirectly via the IETF encrypt_with_label vector above
(same HPKE stack, different label).
https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
RFC 9180 §4.1 specifies DHKEM derives the shared secret as:
eae_prk = LabeledExtract("", "eae_prk", dh)
shared_secret = LabeledExpand(eae_prk, "shared_secret",
kem_context, Nsecret)
Hpke.extractAndExpand was using "shared_secret" for both the Extract and
Expand labels, so every HPKE decapsulation produced a key schedule that
disagreed with every spec-compliant implementation (OpenMLS, MDK/
whitenoise, OpenSSL, BoringSSL, Java XDH). Round-trips within Amethyst
worked because both sides were wrong the same way; cross-implementation
Welcome HPKE decryption always failed with AEAD BAD_DECRYPT.
Add IETF-vector interop tests locking in the fix:
- decrypt the crypto-basics encrypt_with_label KAT end-to-end,
- decrypt GroupSecrets from a passive-client Welcome vector,
- X25519 DH matches RFC 7748 §6.1 and Java XDH on a KAT input,
- init_pub in each Welcome vector matches publicFromPrivate(init_priv).
Drop the stale comment in CryptoBasicsInteropTest that claimed the vector
decryption was impossible due to an "X25519 DH discrepancy" — the DH was
always fine; the eae_prk label was the bug.
https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
Private emojis stored in the encrypted .content of kind 30030 EmojiPackEvents
were honest about being private but useless: they never appeared in the `:`
autocomplete or the reaction menu, even for the pack owner. This wires up
async decryption so pack owners actually see their private emojis everywhere
public ones already show.
- quartz: add EmojiPackEvent.publicEmojis() / privateEmojis(signer) /
allEmojis(signer) helpers, mirroring BookmarkListEvent's public/private
accessor split. privateEmojis() returns null for non-author signers, which
preserves the rule that foreign packs cannot expose private entries.
- commons: EmojiPackState.mergePack becomes suspend (mergePackWithPrivate)
and decrypts inside the existing combineTransform hot path; the StateFlow
consumers (account.emoji.myEmojis → EmojiSuggestionState) get private
entries automatically once decryption resolves. The combiner already runs
on Dispatchers.IO so the suspending decrypt does not block the UI.
- amethyst: RenderEmojiPack uses produceState to seed with the public list
immediately and replace with the merged list once allEmojis(signer)
resolves — keeps the reaction-menu gallery responsive.
- AddEmojiDialog explainer + KDoc no longer claim private emojis are
invisible; OwnedEmojiPacksState.toOwnedEmojiPack uses the new accessors.
- Test EmojiPackEventTest covers public/private/all access including the
foreign-signer case.
https://claude.ai/code/session_01SNG3nj8ZZDChggTsg1qznn
- Extract a shared StreamSystemCard composable for the rounded accent
container used by zap, raid, and clip cards so they share one visual
language.
- Unify clip caption rendering through CrossfadeToDisplayComment so
captions get the same NIP-19 / emoji treatment as zap and raid
messages.
- Iterate every `a` tag when routing zap receipts into live-activity
channels so a receipt referencing multiple simulcasted streams lands
in each of them, and fold the host match into the same pass.
- Bucket anonymous and private zaps under a single sentinel key in the
Top Zappers aggregator so an "Anonymous" chip shows once with the
combined total instead of one chip per one-time pubkey.
- Add commonTest coverage for LiveActivitiesRaidEvent marker parsing,
LiveActivitiesClipEvent tag parsing, and LiveActivitiesEvent.goalEventId().
https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi
Follow-up to the earlier MIP-spec alignment commit, addressing the
remaining audit items:
- MIP-00 (KeyPackageUtils.isValid): now performs every strict tag-level
check that MDK does — d tag is exactly 64 hex chars, mls_protocol_version
is exactly "1.0", mls_ciphersuite is exactly "0x0001", mls_extensions
contains both 0xf2ee + 0x000a, mls_proposals contains 0x000a, encoding
is "base64", content non-empty, i tag present. Adds
`isCryptographicallyValid` for deep checks: i tag matches the computed
KeyPackageRef (RFC 9420 §5.2), Basic credential identity equals the
event's pubkey, KeyPackage signature verifies.
- MIP-01 (MarmotGroupData): encoder now omits the v3-only
`disappearing_message_secs` field when version < 3, so v2 output
matches MDK's `tls_codec` 0.4 byte-for-byte. Adds byte-level fixture
tests pinned to the Rust reference (encode + decode in both directions).
- MIP-02 (WelcomeGiftWrap): replaces the redundant sign-then-rumorize
step with `RumorAssembler.assembleRumor`, producing a true unsigned
rumor as NIP-59 requires (no wasted secp256k1 signature).
- MIP-05 kind 449 (TokenRemovalEvent.build): no longer accepts a tag
initializer. Per the MIP-05 spec the token-removal event MUST have
empty tags; allowing arbitrary caller-supplied tags risks metadata
leakage and rejection by strict validators (e.g. the MDK reference).
- RFC 9420 §6 PublicMessage framing: encoder/decoder now branch on
`contentType`. Application messages keep the `opaque<V>` length prefix;
Proposal/Commit bodies are emitted/parsed as their typed structs (no
outer length prefix), per the MLS RFC. Previously encoder used
`putBytes` raw and decoder used `readOpaqueVarInt`, so neither could
roundtrip and Proposal/Commit PublicMessages from other MLS clients
would mis-parse.
- KeyPackageUtilsTest fixtures: switch tiny "0"/"1"/"lr" slot ids to
realistic 64-char hex slot ids, since `isValid` now enforces the
MIP-00 d-tag format.
The build DSL was typed as TagArrayBuilder<GitRepositoryEvent>, which made
signer.sign(template) return the wrong event subclass. Retype it to
EmojiPackEvent and add local TagArrayBuilder extensions for title,
description, and image tags. Also add title()/description()/image()
accessors on EmojiPackEvent to match the LabeledBookmarkListEvent pattern.
Brings Amethyst's Marmot code into wire-level interoperability with the
reference MDK (Rust) implementation by resolving four spec violations and
tightening the epoch lookback window:
- MIP-01 (NostrGroupData extension): switch to QUIC-style VarInt length
prefixes (RFC 9000 §16) instead of fixed uint16. MIP-01 mandates this
encoding (the one produced by the Rust `tls_codec` crate v0.4+), so
Amethyst's previous uint16 framing could not round-trip with any MDK
peer. admin_pubkeys, relays (outer + each inner), name, description,
image_*, and disappearing_message_secs now all use VarInt prefixes.
- MIP-01: enforce "admin_pubkeys MUST NOT contain duplicates" in the
MarmotGroupData constructor.
- MIP-05 (Push Notifications): token plaintext padded size was 220 (→ 280
encrypted); spec MUSTs are exactly 1024 bytes plaintext (1084 encrypted).
A server expecting MIP-05 tokens would reject Amethyst's frames outright.
- MIP-05: HKDF IKM was sha256(shared_point); spec requires the raw 32-byte
ECDH x-coordinate. The extra sha256 produced a different PRK, so token
ciphertext authenticated only within Amethyst. Removed the hash; ECDH
already returns the x-only compact form via pubKeyTweakMulCompact.
- MIP-03: bump EPOCH_RETENTION_WINDOW from 2 to 5 to match MDK's
DEFAULT_EPOCH_LOOKBACK, so late-arriving GroupEvents whose ChaCha20
outer key was derived from a prior epoch's exporter secret can still
be decrypted after a Commit advances the group.
Hand-crafted MarmotGroupData test blobs were updated to emit VarInt
length prefixes.
Makes the API safer: callers can't accidentally pass an unparsed or
malformed identifier. EmojiUrlTag.emojiSet is now Address?, serialized
via toValue() and parsed via Address.parse() (which rejects anything
that isn't a well-formed kind:pubkey:dTag).