diff --git a/quartz/plans/2026-09-29-concord-spec-conformance.md b/quartz/plans/2026-09-29-concord-spec-conformance.md index cae024b0b3..a8324e1e6a 100644 --- a/quartz/plans/2026-09-29-concord-spec-conformance.md +++ b/quartz/plans/2026-09-29-concord-spec-conformance.md @@ -84,7 +84,7 @@ Ranked security > interop > feature inside each group. |---|---|---|---| | F1 | 04 §7 | Pins | **fixed** — commons `ConcordPinning` reads each Channel's Pin List off the session fold (`pinHeads`, gated on PIN_MESSAGES + `vac`, with the fold's floors), opens the sealed form with the held key of its epoch (`sealedUnavailable` kept distinct from empty), verifies entries through a per-entry-identity cache, hides entries killed by the author's held kind 5 and marks entries behind a newer held Edit as edited; pin/unpin reopen the message's original wrap (session rumor→wrap index) and write the next edition over the head read, in the channel's folded form (a private-era sealed list is never re-formed public), withheld when unreadable, refused past 25 entries / 32,768 bytes; deleting your own pinned message publishes the omission at once, and an open channel runs the delayed (3–15 s) re-read-then-publish duty for other holders' omissions and the Edit refresh. App: Pin/Unpin in the message sheet, header pin badge + pinned sheet (author, time, edited, unavailable, jump), budget line; `amy concord pins/pin/unpin`. Also fixed `DeletionIndex.DeletionRequest.compareTo` (compared the pubkey with itself, so any author's kind 5 matched on JVM/Android). Open SHOULDs: a Rotator does not republish under the new key after a private-channel rekey, and a Banlist revert is not re-healed; compaction does not omit a deleted Channel's Pin List. **Fixed since** (features batch): the delayed duties run from the account, not the screen — `ConcordPinDutyScheduler` + `AccountConcordActions.scheduleConcordPinDuties`, fired on every Concord revision tick and whenever a delete or an Edit lands (sampled 1 s), for every community where we may write pins and every Channel with a Pin List head; each duty waits the 3–15 s random delay, one per channel in flight, one attempt per distinct debt, and `settleConcordPins` re-reads before publishing (the screen's `ConcordPinDuties` composable is gone); pinned messages render through the feed's pipeline — the rumor rebuilt from the proof (`ConcordPinnedMessage.toRumor`, keeping the original's `imeta` tags) registers its encrypted attachments' keys and goes through `appendMissingImetaUrls` + the shared `TranslatableRichTextViewer`, so images (and links, mentions) show in the pinned sheet even for a message this account never held | | F2 | 08 | Disappearing Messages (sender tags, reader refusal/hiding/purge, 1740 notice, settings UI) | **fixed** — every durable Chat rumor (9/1111/7/3302, image variants) signs `created_at + timer` from the send-time fold and its wrap repeats it (`ConcordStreamEnvelope.wrap(outerTags)`, random `p` kept; never on 5/1740/typing); expired rumors refused at ingest (`openChannelRumor`, session, rumor sink), hidden in feed/preview/unread (`Account.isAcceptable`), and purged from LocalCache + wrap note + session buffer by a sweep scheduled on the earliest deadline (`ConcordSessionManager.nextExpiry`); typed `ConcordTimerNoticeEvent` posted per held channel after a timer change and rendered as a system row only for MANAGE_METADATA authors; timer picker in the edit screen + composer indicator; `amy concord timer`, `send` tags, `read` filters | -| F3 | 07 | A/V calls: only key derivation, the 27235 grant and 23313 presence builders exist; no broker/SFU client, no media E2EE. Needs a LiveKit client whose license must be checked first | open — out of scope for this pass | +| F3 | 07 | A/V calls: only key derivation, the 27235 grant (now with Armada's nonce, F4) and the 23313 presence fold exist; no broker/SFU client, no media E2EE | open — **deferred** (maintainer decision, 2026-09-29). Two blockers found: (1) `io.livekit:livekit-android` 2.29.0 pulls runtime `javax.sip:android-jain-sip-ri` 1.3.0-91, whose `android.javax.sip.*` API classes carry the Sun/BEA "use is subject to license terms" header under the proprietary JSIP spec license (no linking exception; STOP under our licensing rule); it can't simply be excluded because LiveKit's `PeerConnectionTransport` munges SDP through its `SdpFactory` on every publish. (2) Interop: Armada keys LiveKit E2EE with `keySize: 256` (AES-256-GCM per CORD-07 §3), but every native LiveKit/webrtc-sdk frame cryptor derives 128 bits (`frame_crypto_transformer.h` `DeriveKeys(..., 128)`), so an Android client can't decode Armada frames. Matching settings otherwise: HKDF-SHA256 (salt `LKFrameEncryptionKey`, info 128 zero bytes), per-identity key = quartz `voiceSenderKey`, `sharedKey=false`, ratchet window 0, failure tolerance -1, keyring 16, trailer `IV(12) ‖ [12, keyIndex]`. Paths when resumed: CORD-07 allows the 128-bit native key, or a patched WebRTC honouring 256; own LiveKit signaling (Apache-2.0 protocol) instead of livekit-android avoids jain-sip; desktop via webrtc-java 0.19 encoded-frame transforms (WARN: statically linked LGPL FFmpeg). Armada's default broker `https://armada.buzz` answers the capability probe and mints LiveKit HS256 JWTs (6 h TTL, 128-bit identity, SFU `wss://av.armada.buzz`) | | F4 | 07 | Broker token has no nonce (same-second requests collide in the broker's replay set); presence fold doesn't take latest-per-author | **fixed** — `ConcordBrokerToken.buildAuthEvent` signs a fresh `["nonce", <64 hex>]` after `u`/`method` (Armada `signAvGrant`'s tag, order and 32-byte width), so two members' same-second grants never share an id; `VoicePresenceInfo` carries the CORD-02 §4 `ms` basis + rumor id, `parse` drops a bad verb, an identity-less `joined` or a malformed `ms`, and `VoicePresence.fold`/`latestPerAuthor` take each author's latest presence (ms, lower rumor id on a tie, as Armada `foldVoicePresence`) before staleness and the one-claimant identity check; `joined`/`left` stamp `ms`. No app caller yet (the voice UI is not built) | | F5 | 05 §5 | Invite Registry (vsk 8) not published or folded | **fixed** — `ConcordInviteRegistry` (builder, strict-array decode, `nextLinks` pruning expired/tombstoned links) + `ConcordCommunityState.inviteRegistries`/`liveInviteLinks`/`isPublic`/`hasForeignLiveLinks`/`banRequiresRefounding`/`retiringWouldPrivatize` (gated on CREATE_INVITE, coordinate bound to author); mint/revoke publish the registry (app + amy); a Private ban Refounds, a Public one is the Banlist alone; retiring the last live link runs a privatizing Refounding (`privatizeConcordCommunity`; amy reports it and adds `refound --privatize`); Public/Private shown in the server view and warned in the revoke dialog. Deviation from Armada, following the spec: a ban Refounds iff the community is Private without the targets' registries (Armada rotates whenever no *foreign* link exists, and only warns on privatizing revokes) | | F6 | 05 §6 | Direct invites: wire format only, no send/receive | **fixed** — wrap backdates seal/wrap ≤2 days, carries NIP-40 `expiration` = `expires_at`, `ConcordDirectInvite.open` returns the seal-verified sender and refuses rumor/seal pubkey mismatch, bad seal sig, non-3313 rumors, §1 bounds and bad owner proof; send (`sendConcordDirectInvite` / `amy concord invite --to`) vends only the private channels the recipient's channel-scoped roles grant (`ConcordInviteVend`, Armada `vendableChannels`) to their 10050 → NIP-65 read → stock relays; headless `ConcordDirectInviteInbox` (sweep via `directInvitesFilter` + the NIP-17 seal handler) dedupes by wrap id, skips expired wraps, parks invites, remembers declines; accept shares the link join path, refuses past `expires_at`, and for a held community only adopts new private-channel keys on the same root/epoch/control_pk (`catchUpChannelIds`); UI card + "Invite by npub"; `amy concord invites/accept/decline`. F7 follow-ups done: staff-sent catch-ups auto-adopt (`judgeCatchUp`), `channel_cuts` is modeled, and a catch-up now only ADDS a missing key from a staff sender for a live Private Channel (never replaces a held key), re-applied inside the List write | @@ -133,6 +133,8 @@ Batch B can reuse it for the ban/privatize decisions. ## Spec issues to raise upstream +- CORD-07 §3 mandates AES-256-GCM frames, but LiveKit's native SDKs (Android, Swift, Rust, C++) only derive 128-bit frame keys (`livekit-client` documents keySize 128 as "the only value supported by non-web SDKs"), so a conforming native client can't interoperate with a 256-bit web client. The spec should allow (or require) the 128-bit derived key, or pin the KDF output length explicitly. + - CORD-06 §1 counts rekey capacity in blobs ("up to 120 participants per event"), but 120 base blobs overflow NIP-44's 65,535-byte plaintext once wrapped; the spec should state the byte budget (Armada uses a 40,960-byte rumor ceiling).