Commit Graph

1851 Commits

Author SHA1 Message Date
Claude 8517bae7a4 feat(quartz): replace items + ready with sealed ProjectionState
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
2026-04-30 15:20:45 +00:00
Claude f23537a49c refactor(quartz): rename InternedEventStore → InterningEventStore
Past-participle "Interned" suggested an attribute of the events
flowing through; present-participle "Interning" describes what the
decorator actively does. Read clearer.

File renamed, no consumers needed updating (this commit is the only
external reference, since callers compose the decorator inline).
All 240 store + projection + interner tests still pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 15:02:30 +00:00
Claude ecbb7ea10c refactor(quartz): tighten projection package layout + relay flows from store
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
2026-04-30 14:51:16 +00:00
Claude e126ca679a refactor(quartz): move interning classes into nip01Core/cache/interning
Groups EventInterner + InternedEventStore + the platform actuals + the
test under a focused interning sub-package. Leaves nip01Core/cache/ as
the namespace for cache primitives generally; future cache types
(LargeWeakCache, etc.) get sibling sub-packages.

Files moved:
  cache/EventInterner.kt          → cache/interning/EventInterner.kt
  cache/InternedEventStore.kt     → cache/interning/InternedEventStore.kt
  cache/EventInterner.jvmAndroid  → cache/interning/EventInterner.jvmAndroid
  cache/EventInterner.apple       → cache/interning/EventInterner.apple
  cache/EventInterner.linux       → cache/interning/EventInterner.linux
  cache/EventInternerTest.kt      → cache/interning/EventInternerTest.kt

No consumers to update — the previous decorator refactor already
removed the interner from all the store ctors. 7/7 interner +
240/240 store + projection tests still pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 14:40:15 +00:00
Claude 3792c98ec5 refactor(quartz): rename StoreEvent → StoreChange, events flow → changes
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
2026-04-30 14:38:23 +00:00
Claude 3fc4781d12 refactor(quartz): encapsulate interning as InternedEventStore decorator
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
2026-04-30 14:30:48 +00:00
Claude 03e9c9d2b2 refactor(quartz): move EventInterner into nip01Core/cache package
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
2026-04-30 14:19:22 +00:00
Claude 7f613d6003 refactor(quartz): intern only on store reads, inject per-store interner
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
2026-04-30 13:45:01 +00:00
Claude e909866d26 feat(quartz): add EventInterner so deserialized events share canonical instances
Process-wide interner that canonicalises Event instances by id —
whenever the same event id is decoded twice (relay duplicates,
projection seeds, FS re-reads, etc.), every consumer sees the same
object reference. Backed by weak references so entries vanish once
no live consumer (projection slot, UI state) holds the event.

Shape:
- expect class EventInterner in nip01Core/core/, with a Default
  process-wide instance.
- jvmAndroid actual: ConcurrentHashMap<HexKey, WeakReference<Event>>
  pre-sized to 5_000 (load factor 0.75). On-access cleanup atomically
  removes dead entries via map.remove(key, ref). intern() uses
  putIfAbsent + retry-on-dead-canonical to be race-safe.
- apple / linux actuals: passthrough (no canonicalisation). Kotlin/
  Native has weak refs but no built-in concurrent map; rather than
  ship a half-baked impl on Apple targets we skip canonicalisation
  there. Can be revisited if iOS wants the memory savings.

Wire-up:
- Event.fromJson now routes through EventInterner.Default.intern,
  so every deserialised event becomes canonical for free. In-process
  events (signer.sign(...) etc.) aren't auto-interned — the
  canonicaliser pays off for events that re-occur from multiple
  sources, which signed-locally events don't.

Tests (jvmTest):
- internReturnsFirstInstance / internCollapsesDuplicates:
  identity is preserved across equivalent decodes.
- getReturnsLiveEntry / getReturnsNullForUnknownId.
- getEvictsDeadEntries: weak ref cleared by GC, on-access
  cleanup drops the entry.
- draftChurnDoesNotLeak: 100 distinct drafts churn through the
  interner with no strong refs; map shrinks back to ~0 after GC.
- defaultInstanceIsShared: the global Default interner is process-wide.
- 7/7 pass; 240/240 store + projection tests still green.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 13:27:33 +00:00
Claude ae6343cd73 refactor(quartz): simplify EventStoreProjection (-50 lines, -1 bug)
Review of the projection found one real bug and four redundancies:

Bug fix: handleInsert tracked `retained` inline as it walked filters,
flipping it to false when the slot self-evicted from a filter's cap.
But if filter A retained the slot (cap=null or with room) and filter B
later cap-evicted it, retained ended up false even though the slot was
still in A's set — leaking a slot into a per-filter set with no
byId/byAddress entry. Fix: compute retention after the loop with
`perFilter.values.any { it.contains(slot) }`.

Simplifications:
- Drop the redundant `ordered: SortedSet<Slot>` field. It was only
  read by publish(); it's the deduped sorted union of perFilter
  values. Compute it lazily at publish time instead. Removes the
  field, all the ordered.add/ordered.remove calls, and one helper.
- Collapse `dropSlotIndexes` and `removeSlot` paths into
  `removeIndexes` (id+address) + `removeSlot` (also clears per-filter
  sets). The eviction loop calls removeIndexes; everywhere else
  calls removeSlot.
- Drop the defensive `byAddress[address] === slot` identity check —
  with byId as the primary index, removeSlot is only called once per
  slot, and rekey only ever moves byId, never byAddress.
- Pull the iterate-and-drop pattern into a single `dropWhere(predicate)`
  inline helper. handleVanish, applyDeleteByFilter, and
  applyDeleteExpired all collapse to one-liners.
- Apply now returns Boolean from each branch and publish() runs once
  at the end, instead of three separate `if-publish` blocks.

Net: 233 → 142 lines. Same behaviour, plus the retained bug fix.
17/17 projection tests + all other store tests pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 00:03:03 +00:00
Claude 544d26d9ec refactor(quartz): tidy projection package, flatten StoreEvent, per-filter limits
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
2026-04-29 22:43:46 +00:00
Claude f666fe2002 refactor(quartz): key projection's address index by Address instead of String
Renames `byStableKey: HashMap<String, Slot>` to
`byAddress: HashMap<Address, Slot>` in EventStoreProjection. Address
is a data class on every platform so equals/hashCode are derived
from (kind, pubKeyHex, dTag) — same identity as the synthetic string
key, but typed.

`stableKey(event)` / `stableKey(kind, pubKeyHex, dTag)` become
`addressOf(...)` returning `Address?`. Plain replaceables map to
`Address(kind, pubKey, "")`, addressables to
`Address(kind, pubKey, dTag)`, regular events to null.

`limit` was already used at insertNew() to enforce the cap on
inserts; left unchanged.

15/15 projection tests still pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-29 21:42:46 +00:00
Claude d394a38f2b feat(quartz): emit StoreEvent.Delete from observable for filter / expired sweeps
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
2026-04-29 21:20:31 +00:00
Claude 32850c3d40 refactor(quartz): make ObservableEventStore an external composition layer
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
2026-04-29 21:06:27 +00:00
Claude 86537edef5 refactor(quartz): introduce ObservableEventStore so projections see ephemerals
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
2026-04-29 20:34:56 +00:00
Claude c54f34ef08 refactor(quartz): make EventStoreProjection store-agnostic, NIP-aware
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
2026-04-29 19:56:00 +00:00
Claude d370784f3f feat(quartz): reactive EventStoreProjection over SQLiteEventStore
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
2026-04-29 19:02:40 +00:00
Claude 21796cc4ad fix(quartz): skip schnorr256k1 benchmark when native lib fails to load on Windows
The triple benchmark probed libschnorr256k1 with a try/catch that only
handled UnsatisfiedLinkError. On Windows the loader raises
IllegalStateException when the JNI .dll for the platform isn't packaged,
which propagated out as a test failure instead of skipping the C row.

Mirror the broader catch already used in Secp256k1CrossValidationTest so
the benchmark prints a SKIP-style note and continues with the ACINQ +
Kotlin rows on platforms where the C lib isn't available.
2026-04-29 01:28:27 +00:00
Vitor Pamplona 594ead86fe Merge pull request #2627 from vitorpamplona/claude/review-libsecp-migration-Ql9hH
Remove custom C secp256k1 implementation, migrate to libschnorr256k1
2026-04-28 11:42:45 -04:00
Claude 2ad1a48123 refactor(quartz): migrate in-tree C secp256k1 to libschnorr256k1-kmp
The custom C secp256k1 implementation under quartz/src/main/c/ was never
wired into Gradle (no externalNativeBuild block) and only existed for the
3-way benchmark + cross-validation test against ACINQ on JVM/Android.
The C library has been split out to vitorpamplona/libschnorr256k1{,-kmp},
so the in-repo copy is dead weight.

This change replaces it with the published Maven Central artifact
`com.vitorpamplona:schnorr256k1-kmp:1.0.0`, scoped to test/benchmark
configurations only — production crypto continues to use ACINQ
secp256k1-kmp on JVM/Android and the pure-Kotlin Secp256k1 on native.

The Android AAR ships libschnorr256k1_jni.so for arm64-v8a and x86_64,
so the Android benchmark now exercises the C row automatically (no more
manual build_android.sh). On JVM the .so still has to be installed by the
developer; the cross-validation test and triple-benchmark gracefully skip
the C row when System.loadLibrary fails, matching prior behavior.

Verified on JVM: all 13 secp256k1 jvmTest classes pass (188 tests), and
ACINQ vs pure-Kotlin benchmark numbers match the documented baseline
within sandbox noise (verifySchnorr ~15k ops/s, signSchnorr cached
~33k ops/s, pubkeyCreate+Compress ~36k ops/s).

Removes ~5,100 LOC: quartz/src/main/c/ (4,500), Secp256k1InstanceC.*
expect/actual shim (560), Secp256k1C JNI declaration object.

https://claude.ai/code/session_01KnvpK2amcVZKfFiZJvHjVe
2026-04-28 15:36:25 +00:00
Claude 138ee12a6a fix(nests): align kind-4312 + kind-30312 wire format with nostrnests/EGG-07
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
2026-04-28 12:59:48 +00:00
Claude d41a24f945 feat(nests): empty-stage hint, local hush, moderator/force-mute moderation
Three improvements stacked into one commit since they share files:

#3 Empty-stage hint: StageGrid no longer disappears when nobody is on
   stage. The "Stage" label stays and a quiet "Waiting for speakers…"
   line keeps the strip visible so the room doesn't look broken before
   the first speaker arrives.

#5 Local per-speaker hush: AudioPlayer gains a setVolume(Float)
   default-no-op method; AudioTrackPlayer composes mute and volume
   multiplicatively into AudioTrack.setVolume so a hushed stream stays
   silent regardless of mute state. NestViewModel exposes
   locallyHushed: ImmutableSet<String> in NestUiState plus
   setLocalHushed(pubkey, hushed) — applied at attach time so a
   re-subscribe of an already-hushed speaker stays silent. New "Hush
   this speaker" / "Restore this speaker" row in
   ParticipantHostActionsSheet, available to anyone (it affects only
   our own playback, nothing on the wire).

#4 Host moderation gaps: wires Promote-to-Moderator using the existing
   RoomParticipantActions.setRole(ROLE.MODERATOR) builder — UI gap
   only, no protocol change. Adds a new AdminCommandEvent.Action.MUTE
   variant + AdminCommandEvent.forceMute(room, target) builder; the
   sheet emits it on "Force-mute speaker" and the
   AdminCommandsCollector dispatches incoming MUTE actions to a new
   NestViewModel.onForceMuted() that routes through the existing
   setMicMuted(true) path. Honor-based, same trust model as KICK —
   relays don't enforce signer authority, the client checks the
   signer is host or moderator on the active kind-30312.
2026-04-28 12:41:23 +00:00
Claude 6714e74c76 Merge remote-tracking branch 'origin/main' into claude/audio-rooms-android-ui-PIpFN 2026-04-27 15:27:39 +00:00
Claude dc3ac31ae4 refactor: rename Audio Room → Nest project-wide
Aligns class names, package paths, string resource keys, UI text and
intent actions with the Nests branding used by the EGG specs in
nestsClient/specs/. Mechanical rename — no behavior change.

- Folders: audiorooms/ → nests/ (5 paths across amethyst, quartz)
- 30+ class renames (AudioRoom* → Nest*, AudioRooms* → Nests*)
- String resource keys audio_room_* → nest_*
- UI strings "Audio Room"/"Audio Rooms" → "Nest"/"Nests" (incl. all locales)
- Intent extras AUDIO_ROOM_* → NEST_*
- Compose route Route.AudioRooms → Route.Nests

Spec-aligned identifiers (MeetingSpaceEvent, meetingSpaces/, the nip53*
packages) are intentionally untouched — those are NIP-53 protocol
names, not "audio room" branding.

https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
2026-04-27 14:36:50 +00:00
Claude addb2a4abb fix(quartz): accept legacy nostrnests tag names on kind-30312 read
The first-party nostrnests web client (NestsUI-v2) emits kind-30312
events using NIP-53 *streaming-event* tag names rather than the
kind-30312 names defined by NIP-53 itself. Confirmed against
`nostrnests/nests/NestsUI-v2/src/components/EditRoomDialog.tsx` and
`useRoomList.ts`:

  - room name: `title`     (NIP-53 spec for 30312: `room`)
  - MoQ relay: `streaming` (NIP-53 spec for 30312: `endpoint`)
  - moq-auth:  `auth`      (NIP-53 spec for 30312: `service`)
  - status:    `live`      (NIP-53 spec for 30312: `open` / `private`
                            / `closed` / `planned`)

Our parsers followed the NIP-53 spec, so events from the production
nostrnests web app fell through to status=null, which `checkStatus`
then surfaced as "Ended" in the live-rooms UI. Add legacy-alias
acceptance to all four parsers — read-side only; we still emit the
canonical NIP-53 forms, matching EGG-01.

Per-tag changes:
  StatusTag:      "live" → OPEN, "ended" → CLOSED
  RoomNameTag:    "title" alias → room
  EndpointUrlTag: "streaming" alias → endpoint
  ServiceUrlTag:  "auth" alias → service

No emit-side changes; no spec change.

https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
2026-04-27 14:12:23 +00:00
Claude 3fb9f02b6d feat(audio-rooms): "Listen to recording" CTA for closed rooms
Wire MeetingSpaceEvent's existing RecordingTag into the
note-view renderer. Closed rooms (status=CLOSED) that ship a
`["recording", url]` tag get an OutlinedButton that hands the
URL to the system media player via ACTION_VIEW — most users have
a podcast / audio app registered for HTTPS audio URLs.

Live and scheduled rooms keep the Join button. Closed rooms
without a recording render no CTA — there's nothing to enter.

Adds:
  * MeetingSpaceEvent.recording() accessor
  * TagArrayBuilder<MeetingSpaceEvent>.recording(url) DSL builder
  * ListenToRecordingButton composable in the note renderer

Note: nostrnests' moq-lite refactor dropped the LiveKit-era
recording HTTP surface, but the kind-30312 `recording` tag is
still spec-allowed and individual hosts can populate it via
out-of-band capture. This commit makes Amethyst the receiving
end for any host who does.
2026-04-27 01:29:16 +00:00
Claude 14863415d5 feat(audio-rooms): font-tag parser + system-font typography (T3 #1)
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).
2026-04-26 23:55:49 +00:00
Claude f89ab8f1a2 feat(audio-rooms): scheduled rooms (T1 4b)
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.
2026-04-26 23:35:51 +00:00
Claude 885b77f1f3 feat(quartz): theme parser tags for kind-30312 (T3 #1)
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.
2026-04-26 23:00:30 +00:00
Claude 7f5133a08f feat(quartz): AdminCommandEvent for audio-room kick (kind 4312)
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)
2026-04-26 22:45:01 +00:00
Claude 9fa6f756ca feat(quartz): role-check helpers on ParticipantTag
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.
2026-04-26 22:38:39 +00:00
Claude 5a5eaa3b50 feat(quartz): augment kind-10312 presence with publishing + onstage
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.
2026-04-26 21:32:59 +00:00
Claude 015b0d7dac fix(audio-rooms): switch NestsServersEvent to kind 10112 (nostrnests claim)
nostrnests's reference README under "Nostr Integration" already declares:

  kind:10112 — User-published audio server lists

We initially picked 10062 (mirroring BlossomServersEvent's 10063) without
spotting that prior claim. Switching to 10112 so a single replaceable
event surfaces in both Amethyst and nostrnests's web UI without
collision.

No migration cost — no users have published kind 10062 yet.
2026-04-26 19:11:50 +00:00
Claude 364b2cd926 feat(audio-rooms): start space FAB + Nests servers settings (kind 10062)
Two adjacent additions so users can both create and host their own
audio rooms from inside Amethyst:

Create-space flow
  - CreateAudioRoomSheet — modal bottom sheet on AudioRoomsScreen
    surfaced by a new "Start space" FAB. Fields: room name, summary,
    MoQ service URL, MoQ relay endpoint, optional cover image.
  - CreateAudioRoomViewModel — builds + signs MeetingSpaceEvent
    (kind 30312, status=OPEN, tagging the user as `host`),
    broadcasts via account.signAndComputeBroadcast, then returns
    launch info so the sheet can fire AudioRoomActivity straight
    into the freshly-published room.
  - Defaults pull the first saved Nests server (below) when present;
    fall back to https://moq.nostrnests.com.

Nests servers settings (proposed kind 10062)
  - NestsServersEvent — replaceable kind-10062 event listing the
    user's preferred audio-room MoQ servers. Wire shape mirrors
    BlossomServersEvent (one `server` tag per base URL); registered
    in EventFactory; consumed by LocalCache via consumeBaseReplaceable.
  - NestsServerListState — per-account observation state, mirror of
    BlossomServerListState, exposed on Account.nestsServers.
  - sendNestsServersList on Account; included in
    accountSettingsEvents() so an outbox change republishes it.
  - Filter additions: BasicAccountInfo + AccountInfoAndLists now
    request kind 10062 from relays.
  - NestsServersViewModel + NestsServersScreen — Settings UI to
    add / remove / reset to recommended servers (currently just
    nostrnests.com). Wired into AllSettingsScreen as "Audio-room
    servers"; routed via Route.EditNestsServers.
  - kind_nests_servers label for RelayInformationScreen.

Default suggestion list lives in DEFAULT_NESTS_SERVERS at the top
of NestsServersScreen — add new community-run moq-rs deployments
there as they come online.
2026-04-26 18:53:31 +00:00
Claude 3bf1448d63 docs(quartz/store): explain the connection pool and the suspend API
- Add a Concurrency section to the SQLite store README covering the
  Room-style 1-writer + N-reader pool, the in-memory degradation, and
  the non-reentrant Mutex contract.
- Refresh the SQLite "How to Use" examples to call out the suspend
  context and recommend transaction-batching for hot inserts.
- Switch the ExpirationWorker example from Worker to CoroutineWorker
  now that deleteExpiredEvents is suspend.
- Note in the FS README that the IEventStore API is suspend even
  though the FS layer keeps a synchronous flock manager (the
  withWriteLock helper is inline so suspend bodies pass through).
- Update the FsMaintenanceTest description to match the
  coroutine-based concurrency test.
- Document the Mutex non-reentrancy footgun in
  SQLiteConnectionPool's KDoc so module logic doesn't try to re-enter
  the pool from inside useWriter.

https://claude.ai/code/session_016b5kSSbtDS3Ead6pN3Xqt5
2026-04-26 13:49:47 +00:00
Claude 9fcf85bed0 fix(quartz/sqlite): serialise writes via a Room-style connection pool
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
2026-04-26 13:25:30 +00:00
Claude 692b034566 feat(quic): Phase B — TLS 1.3 client on Quartz primitives
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
2026-04-25 17:33:54 +00:00
Vitor Pamplona 1876941d8f Merge pull request #2567 from vitorpamplona/claude/review-quartz-sqlite-store-xBzaq
Implement NIP-01 lexical id tiebreaker and NIP-09 author-only deletion
2026-04-25 10:41:09 -04:00
Claude 75bcd77914 docs(quartz/store): note the NIP-01 tiebreaker, NIP-09 created_at window, and author-check on deletion in both store READMEs
https://claude.ai/code/session_01X163Nr31vGkvAXoJ3JgMov
2026-04-25 14:30:59 +00:00
Claude 819322bbf9 fix(quartz/fs): port the same NIP correctness fixes from the sqlite store
Three of the bugs that the SQLite review surfaced also live in the
filesystem-backed event store. Bringing both stores back to parity:

- NIP-01 lexical-id tiebreaker (FsSlots, FsEventStore): when two
  replaceables / addressables share `createdAt`, the lexically smaller
  id wins. The previous `existing.createdAt >= incoming.createdAt`
  check rejected equal-timestamp inserts unconditionally — which
  blocks the legitimate winner whenever it arrived second.

- delete(Filter()) safe-by-default (FsEventStore): an empty filter
  used to enumerate every event and delete each one, so a stray
  `delete(Filter())` would wipe the entire on-disk store. Now both
  the single-filter and list-of-filters overloads short-circuit when
  every filter is empty, matching the SQLiteEventStore contract.

- NIP-09 author check on tombstone install (FsEventStore,
  FsTombstones): SQLite's `reject_deleted_events` trigger checks
  `event_tags.pubkey_hash = NEW.pubkey_owner_hash`, so a stranger's
  kind-5 with an `e`/`a` tag pointing at someone else's event must
  not block them from re-publishing. The FS store used to install
  the tombstone unconditionally and then read it back without an
  author check.
  - id tombstones still install (so they can fire when the deletion
    arrives before its target), but `idTombstoneOwnerPubKey` is now
    compared against the candidate event's owner pubkey at insert
    time. GiftWrap parity preserved via FsIndexer.ownerPubKey, which
    returns the recipient like the SQLite `pubkey_owner_hash`.
  - addr tombstones are now only installed when
    `addr.pubKeyHex == deletion.pubKey`, since the address itself
    carries the owner identity.

Tests:
- FsSlotsTest: replaces "equal timestamp replaceable is rejected"
  (which pinned the buggy behaviour) with two tests covering the
  lexical-id tiebreaker in both insertion orders.
- FsParityTest: new same-`createdAt` tiebreaker tests for both
  replaceable and addressable kinds, asserting FS and SQLite agree.
- FsDeletionTest:
  - inverts "deletion by non-author does not cascade but still
    installs id tombstone" — the legitimate owner must be able to
    re-insert after the stranger's deletion.
  - new test that a stranger's `a`-tag deletion does not block
    the legitimate addressable owner from publishing a new version.
- FsEventStoreTest: new `delete with empty filter is safe` test
  matching the SQLite-side contract added in batch A.

https://claude.ai/code/session_01X163Nr31vGkvAXoJ3JgMov
2026-04-25 14:24:28 +00:00
Claude 9f83feff47 test(quartz/sqlite): cover transaction rollback, deletion permissions, vanish scope, FTS rotation, vacuum, SQL DSL
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
2026-04-25 13:56:28 +00:00
Claude e9e994fff4 fix(quartz/sqlite): Batch A correctness/consistency fixes
- 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
2026-04-25 13:31:16 +00:00
davotoula adfdfa45cc test(quartz): also normalize CRLF on Jackson output
Jackson's pretty-printer can emit \r\n on Windows depending on the
configured DefaultIndenter, so the previous one-sided normalization
still mismatched. Normalize both sides of the assert to LF.
2026-04-25 15:16:37 +02:00
davotoula 680b1f0ef6 test(quartz): normalize CRLF in pretty-printer string asserts
Triple-quoted Kotlin strings inherit the source file's line endings,
so on Windows checkouts (CRLF) the expected JSON contains \r\n while
Jackson's pretty-printer always outputs \n. Normalize the expected
side with replace("\r\n", "\n") so the assertion is platform-neutral.

Surfaced by adding :quartz:jvmTest to the desktop CI matrix; previously
the bare 'test' lifecycle didn't resolve jvmTest in KMP modules so the
mismatch was never observed on Windows.
2026-04-25 15:01:33 +02:00
Claude 28b23b5e63 fix(quartz/sqlite): NIP compliance and migration safety in event store
- 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
2026-04-25 12:32:11 +00:00
Claude dd3eb0de03 Merge remote-tracking branch 'origin/main' into claude/cli-event-store-database-ef2U2 2026-04-25 05:06:11 +00:00
Claude 57c3265ca0 feat(marmot): receive standalone PrivateMessage proposals (RFC 9420 §6.3.2)
The PROPOSAL branch of `MlsGroup.decrypt` previously threw
`IllegalStateException("Standalone PrivateMessage proposals not yet
supported")`. That worked in the openmls-as-Whitenoise topology because
MDK emits SelfRemove as a PublicMessage — but any peer using the
AlwaysCiphertext wire-format policy (RFC 9420 §6.3.2) wraps standalone
proposals in PrivateMessage, and we'd hard-fail on first contact.

Implements the PROPOSAL branch symmetrically with the existing COMMIT
branch:

1. Decode the Proposal struct from PrivateMessageContent (no length
   prefix), read signature<V>, drain zero-padding.
2. Restrict to SelfRemove (mirrors `receivePublicMessageProposal`'s
   policy — the only standalone proposal type that needs to ride
   outside a commit; widening is one-line if interop demands it).
3. Verify the FramedContentTBS signature with `wire_format =
   PRIVATE_MESSAGE` against the sender's leaf signature_key.
4. Stage the proposal in `pendingProposals` together with the encoded
   AuthenticatedContent (wire_format ‖ FramedContent ‖ signature) so
   a subsequent inbound commit folding it in by ProposalRef can
   resolve via the §5.2 hash. PrivateMessage AC bytes carry no
   membership_tag — auth = signature only.

Adds a symmetric `encryptProposalAsPrivateMessage` helper (internal,
mirrors `encrypt` for application data) so tests can exercise the
inbound path with a real wire frame produced by the same code path
peers use.

2 new tests: round-trip Bob→Alice SelfRemove via Welcome flow with
independent MlsGroup instances, verifies the proposal lands in
Alice's pending pool with AC bytes captured; and rejection of a
non-SelfRemove standalone PrivateMessage proposal (PSK in the test).

Marmot test suite green; marmot-interop-headless 16/16.

Closes audit gap #1.

https://claude.ai/code/session_013VYkpz8P1mPh9Ejxy9anhJ
2026-04-25 05:00:24 +00:00
Claude 8dd98b6b5b feat(quartz): tag-index uses raw values when fs-safe, _h_<hash> otherwise
idx/tag/<name>/ used to bucket every tag value under a 16-hex Murmur
hash, so `ls idx/tag/p/` showed opaque hex even though pubkey values
are perfectly safe filenames. Now the directory name is:

  - the raw tag value, when it's filesystem-safe across Linux/macOS/
    Windows: 1..180 bytes, printable ASCII (0x21..0x7e), none of
    `/ \ : * ? " < > |`, no leading dot or `_h_`, no trailing dot/space;
  - otherwise `_h_<hashHex(murmur)>`. The `_h_` sentinel can never
    collide with a raw value (raw values are forbidden from starting
    with it), so both forms safely live in the same parent dir.

Common cases keep their raw form and become directly inspectable:

  ls idx/tag/p/<your_pubkey>/      — every event that p-tagged you
  ls idx/tag/e/<event_id>/         — replies / reactions to an event
  ls idx/tag/t/nostr/              — every kind-1 with #nostr
  ls idx/tag/k/30023/              — k-tag pointers to articles
  ls idx/tag/g/drt3n/              — geohash mentions

Routes through the _h_ bucket: emojis, URLs (slashes), `a`-tags
(colons), free-form `alt` text, anything ≥ 180 bytes, anything that
breaks Windows reserved-name rules. The hash is still seed-salted
Murmur64 (parity with the previous bucket), so collisions are caught
by FilterMatcher post-filter just like before.

Writer (FsIndexer.pathsFor) and reader (FsQueryPlanner.firstTagKey)
both route through FsLayout.tagValueDirName, so they always agree.

Tests: 5 new in FsQueryTest covering raw ASCII tags landing under
named dirs, p-tag pubkeys keeping their raw form, emoji and URL tags
falling back to _h_, and round-trip queries hitting both buckets
correctly. 122 fs tests green.

No migration: existing stores must `amy store scrub` once after the
upgrade — old purely-hashed tag dirs become unreachable, and scrub
rebuilds idx/ from canonicals using the new naming.
2026-04-25 04:55:34 +00:00
Claude 8c38394385 fix(marmot): bound forward-ratchet steps and tighten PrivateMessage AAD cap
Two DoS surfaces in the inbound path:

**#8 — unbounded forward-ratchet on PrivateMessage decrypt.** A
malicious sender (or any peer who can put bytes in the wire frame)
controls the `generation` field of an inbound PrivateMessage. The
SecretTree was happy to fast-forward the sender's application or
handshake ratchet by however many steps that field implied — every
step costing one HKDF-Expand. A single packet with `generation =
2^31` would have pinned a CPU at SHA-256 for minutes. The skipped-key
cache (`MAX_SKIPPED_KEYS = 1000`) bounds memory but NOT compute: it
just stops *caching* past the cap, the ratchet keeps walking.

Adds `MAX_RATCHET_STEPS_PER_CALL = 4096` and rejects any decrypt that
asks for a larger jump from the sender's current head, applied at
both `applicationKeyNonceForGeneration` and
`handshakeKeyNonceForGeneration`. 4096 leaves room for legitimate
catch-up (mobile waking from a long sleep) while bounding the
worst-case per-packet cost to ~4096 SHA-256 invocations.

**#11 — oversized PrivateMessage AAD allocation.** The underlying
[TlsReader] already caps every opaque<V> read at 1 MiB, so the
ciphertext field is bounded — but `authenticated_data` and
`encrypted_sender_data` were also allowed up to 1 MiB even though
both fields legitimately carry a few hundred bytes at most. A
pre-verification frame would force ~3 MiB of allocation per inbound
packet. Tightens both fields to 64 KiB at PrivateMessage decode
(below TlsReader's global cap) so the per-frame floor is predictable.

2 new tests: `secretTree_rejectsRatchetJumpsBeyondCap` exercises the
boundary (4096 OK, larger throws with a "jump too large" message)
and `privateMessage_rejectsOversizedAuthenticatedData` hand-builds
a frame with a 65 KiB AAD field and confirms rejection. Marmot
test suite green; interop 16/16.

Closes audit gaps #8 and #11.

https://claude.ai/code/session_013VYkpz8P1mPh9Ejxy9anhJ
2026-04-25 04:48:40 +00:00
Claude 1f42afa61a fix(marmot): hard-fail UpdatePath silent decrypt + verify parent_hash on Welcome
Two RFC 9420 hardening gaps in the inbound path:

**#3 — UpdatePath silent decrypt failures.** When `processCommit` was
handed a commit whose UpdatePath either (a) had no node at our common
ancestor with the sender or (b) had no ciphertext encrypted to a node
in our copath resolution, the path-decrypt block silently fell through
and left `commit_secret = 0`. The downstream confirmation_tag check
caught it and rolled back, but the rollback surfaced as a generic
"ConfirmationTagMismatch" instead of pointing at the real cause —
a tree-shape mismatch between our view and the sender's. Now hard-fail
with a precise error naming the indices involved.

**#6 — parent_hash chain verification on Welcome.** `processWelcome`
was checking the GroupInfo signature and the GroupContext.tree_hash,
but neither gates a forged parent_hash inside a COMMIT-source leaf —
the tree_hash check only proves the ratchet_tree extension matches
the bytes the signer signed, not that the stored parent_hash values
are consistent with the tree shape. A peer that DOES validate
parent_hash (per RFC 9420 §7.9) would reject every commit produced
from such a tree, splitting the group on the next epoch.

Adds `verifyTreeParentHashesForJoin(tree)` as a static-tree
counterpart to `verifyParentHash` (which only handles the
post-UpdatePath dynamic case). For each COMMIT-source leaf, recompute
the parent_hash chain top-down on the leaf's filtered direct path and
compare to the stored value. Returns null on success or a human-
readable mismatch reason. Wired into `processWelcome` right after the
tree_hash check, before any capability or key-schedule work.

Also moves `encodeParentHashInput` into the companion object so the
static and instance variants share one TLS encoder, and adds an
`exportTreeBytes()` test accessor.

2 new tests: trivial single-member tree accepts; a tampered
COMMIT-source parent_hash on a 3-member tree is rejected with a
message naming the offending leaf. Quartz marmot tests green;
marmot-interop-headless 16/16. (Two unrelated NostrClient network
tests fail under the full :quartz:jvmTest run — pre-existing flake,
no MLS code touched.)

Closes audit gaps #3 and #6.

https://claude.ai/code/session_013VYkpz8P1mPh9Ejxy9anhJ
2026-04-25 04:37:57 +00:00