update concord backend

This commit is contained in:
2026-09-15 08:26:07 +07:00
parent 871efa6b6f
commit 9230628441
4 changed files with 171 additions and 15 deletions
+51 -9
View File
@@ -205,7 +205,7 @@ if (rumor != null && concord.onInboxRumor(rumor)) continue
### 5.4 Tests — **none are kept**
Decision: no test files are maintained for this feature. M1 and M2 were verified during development and the checks were then discarded, so the tree carries no `commonTest` or `iosTest` sources for Concord.
Decision: no test files are maintained for this feature. M1M4 were verified during development and the checks were then discarded, so the tree carries no Concord sources under `commonTest` or `iosTest`.
The consequence to plan around is in §14 and risk 13.
@@ -544,6 +544,28 @@ then `UnsignedEvent.fromJson(it.content())`. The Control plane uses the same mec
**Not implemented in v1:** authority resolution against the Roster (`vsk 1` / `vsk 3`), `vac` citation validation, and `vsk 4` banlist filtering. See [Risks](#13-risks-and-open-decisions).
### 9.4 Unread badges → memory only
Deliberately the same shape as the chat layer: `Room.unreadCount` is counted in memory, incremented on
an incoming rumor and zeroed by `markAsRead`, and `ChatRepository.refreshChatRooms` explicitly carries
the in-memory value across a reload rather than recomputing it. Concord mirrors that exactly.
- `ConcordChannel.unreadCount` is the model the UI reads, and `ConcordManager.refreshPlanes` merges it
from an in-memory `Map<channelIdHex, Int>` so a Control edition cannot silently clear every badge.
- A message is counted only when it is a `kind 9` on a plane we hold, authored by someone other than us
— our own wraps come back through the same subscription, and a re-fetch arrives several times.
- It does **not** survive a restart. Neither does a DM's. Persisting it would mean a read-marker store
(a new key, a new write path) for a badge, which is not worth it in v1.
### 9.5 Message freshness → `revision`
`channelMessages` is an on-demand LMDB query, and nothing in the routing branch told a screen that a
message had arrived. `ConcordManager.revision` is that signal: a `StateFlow<Long>` counter bumped on
every cached rumor and every local send, which a screen combines with its Channel id to re-query. A
counter rather than a set of changed scope ids, because two messages in one Channel must both be
observable. It is one value for the whole manager rather than a flow per Channel, so nothing has to be
created, subscribed and disposed per room.
---
## 10. Invites
@@ -735,15 +757,30 @@ Against a real Community, with a real invite link:
1. paste the link → the preview shows the right name and channel count, and no `problems`
2. join → the membership persists across a restart, the community's relays connect, and the Control plane folds metadata + the channel list
3. **a message from another Concord client appears in Coop** — this is the one nothing else catches, since a label typo breaks interop silently
4. a message sent from Coop appears there too (once M4 lands)
4. a message sent from Coop appears there too — the M4 half of the same gate
If step 3 fails, check in this order: the `concord/channel` label, the `channel`/`epoch` binding tags, then `community_id` (risk 1).
If step 4 fails while step 3 passes, the read path is right and the fault is in the publish: the relay set, or the relay dropping these wraps (risk 4).
### M4 — Write
### M4 — Write — **done**
`sendChannelMessage` and `unreadCount`. (The Guestbook `join` originally listed here shipped early, with M3's `join`.)
`ConcordManager.sendChannelMessage`, `unreadCount` / `markChannelRead`, and the `revision` signal a Channel screen re-queries on.
**Done when:** a message sent from Coop appears in another Concord client, and a message from that client appears in Coop.
What shipped:
- `sendChannelMessage(channelIdHex, content)` — the send *is* `PlaneKey.rumor` + `PlaneKey.wrap`, so there is no second encrypter to keep in sync with the read path. The rumor is cached locally **before** publishing, so the message is visible even with every relay down, and the subscription's echo of the wrap lands on the same `d` slot instead of duplicating the message. Throws when the Channel is one we hold no key for — a Private Channel is listable without being writable.
- `unreadCount` / `markChannelRead` and `ConcordChannel.unreadCount` (§9.4), counted from the routing branch rather than from a re-read, so a badge costs no LMDB query per message.
- `revision: StateFlow<Long>` (§9.5), so a Channel screen can tell that something arrived.
**Verified** with a throwaway check on the iOS target (2 cases, all passing), then deleted:
- a message delivered on a Channel plane is routed and raises that Channel's badge — in the map and in `ConcordChannel.unreadCount` — while our own message and a `kind 7` reaction raise nothing ✅
- `markChannelRead` clears the badge and republishes it, and a re-index carries the badge across rather than resetting it ✅
- a sent rumor carries exactly `channel` / `epoch` / `ms` at `kind 9`, and the Guestbook and a different Channel both refuse to read it ✅
**Not covered, and not coverable offline:** `sendChannelMessage` needs a live `client`, so the publish itself — relay selection, the ack policy, the relay's echo landing on the same `d` — is exercised only by the interop smoke test. The check above used a `MemoryStorage` fake, so the real Android Keystore path is untouched by it too.
**Done when:** a message sent from Coop appears in another Concord client, and a message from that client appears in Coop. **This is a network criterion and has not been run** — see §14.
### M4.5 — Community creation (optional)
@@ -791,6 +828,8 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI
| 12 | **`scalar_normalize`'s retry branch is ~2⁻¹²⁸ rare.** It will never fire in practice, so a bug there would never surface either. | Split the pure `groupSeed` out of `groupKey` so the counter path is reachable through its `isValid` seam rather than buried behind a crypto call. |
| 13 | **The nostr SDK cannot run in a host JVM unit test.** Its uniffi bindings are JNA-backed and the Android artifact carries Android-ABI `.so` files, so `testDebugUnitTest` on macOS fails with `UnsatisfiedLinkError: libjnidispatch.jnilib`. Discovered in M1. | Not a problem while tests are not kept, but it does mean **there is no automated regression net** for anything that touches `SecretKey`, `Keys`, `nip44*` or `EventBuilder`. Verification is manual: `:shared:iosSimulatorArm64Test` is the only executable target here that can load the SDK, so a throwaway check in `shared/src/iosTest` is the cheapest way to exercise wire-format code, and the M3 interop smoke test is the real acceptance gate. Keep the SDK-free half in pure functions so it at least *could* be covered without a device. |
| 14 | **`Nostr` is a Context-free singleton, but Concord's keys need `AppStorage`.** Neither the construction site (`NostrManager.instance`) nor the class has a `Context`. | `Nostr.init(dbPath, storage)` takes it and hands it to `ConcordManager.attach`. The foreground service is the only caller and already runs before any notification is handled, so the ordering is guaranteed. A second `AppStorage` instance in the M5 repository is fine — both wrap the same DataStore. |
| 15 | **Unread badges live in memory only**, so a restart clears every one of them (§9.4). | This is the DM path's behaviour too (`Room.unreadCount` is likewise in-memory). Persisting it means a read-marker store, a new key and a new write path, all for a badge — worth doing only when a user asks for it. |
| 16 | **The write path has no offline check.** `sendChannelMessage` needs a live `client`, so nothing kept in the repo exercises a publish, and the `MemoryStorage` fake used by the M4 check never touches Android Keystore. | Acceptance is interop smoke test step 4. Read and write share `PlaneKey.wrap`, and reading is verified independently (M2/M3), so a send-only failure localises to the publish: the relay set, or a relay dropping the wrap (risk 4). |
### 13.3 Open decision
@@ -800,8 +839,8 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI
## 14. Verification plan
**No test files are kept** (decision, M2). The checks below were written and run during M1M3,
then discarded; the tree carries no `commonTest` or `iosTest` sources for Concord. They are
**No test files are kept** (decision, M2). The checks below were written and run during M1M4,
then discarded; the tree carries no Concord sources under `commonTest` or `iosTest`. They are
recorded here (and per-milestone in §12) because they are what establishes the wire format is
right, and because whoever next touches this code should re-run the same checks.
@@ -840,8 +879,11 @@ there is no regression net, so:
| `channel` / `epoch` mismatch and missing binding tags | negative | iOS ✅ M2 |
| `20013` vs `20014` discipline, both directions | negative | iOS ✅ M2 |
| Control readable from the read key + writers' pubkey alone; refuses to wrap; drops a non-staff wrap | integration | iOS ✅ M2 |
| 32 bytes → base64url = 43 chars, unpadded | unit | M3 |
| `expires_at` ms/s conversion | unit | M3 |
| 32 bytes → base64url = 43 chars, unpadded | unit | iOS ✅ M3 |
| `expires_at` ms/s conversion | unit | iOS ✅ M3 |
| a delivered Channel message raises the badge; our own message and a non-message raise nothing | property | iOS ✅ M4 |
| `markChannelRead` clears and republishes; a re-index carries the badge across | property | iOS ✅ M4 |
| a sent rumor is exactly `channel`/`epoch`/`ms` at `kind 9`, refused by the Guestbook and another Channel | unit | iOS ✅ M4 |
**RFC 5869 Test Case 1** (for reference):