livekit-android's runtime jain-sip-ri carries the proprietary JSIP API license, and native LiveKit frame cryptors derive 128-bit keys while CORD-07 and Armada use AES-256-GCM. Notes the matching E2EE settings, the resume paths, and a spec issue to raise upstream. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N
34 KiB
Concord spec conformance review (spec b84554e, 2026-08-15)
Why
Our Concord implementation last tracked the spec at bbc67b6 (2026-08-06, CORD-02 §2
control_root). Since then the spec moved on, and several sections that predate that
point were never implemented. This review compares every CORD against
https://github.com/concord-protocol/concord at b84554e, cross-checked against the
Armada reference client (soapbox-pub/armada at 7588884, 2026-09-28), and records what
we fixed and what is left.
Method: one audit per spec area (CORD-01/03, CORD-02 §2/§5/§6 + CORD-04, CORD-02 §7/§9 +
CORD-05/06, CORD-07/08), each reading our code against the spec text and against
Armada. CORD-02 §8 (the Community List) was reviewed by hand against Armada's
listFrag.ts / communityList.ts. Status legend: fixed (this branch, with tests),
open (not done, with the reason).
What changed upstream since our last pass
| Commit | Date | Change | Our state before this review |
|---|---|---|---|
d584686 |
07-27 | CORD-02 §9: the dissolution tombstone's eid is the community_id; verifiers MUST refuse anything else, including the old all-zero eid |
Not implemented at all: we never derived dissolved_pk, never read or wrote a tombstone, and folded a vsk-10 Control Plane edition as dissolution with no eid check |
e8c4eeb |
08-02 | CORD-04 §7: provable, rotation-proof Pins (vsk 11, PIN_MESSAGES bit 11, concord/pins coordinate, NIP-44 key-disclosure proof bundles) |
Missing (only the permission bit existed) |
93fbecf |
08-03 | CORD-08: Disappearing Messages (message_expiration in metadata, NIP-40 tags on rumor + wrap, kind 1740 timer notice) |
Missing, and worse: a metadata edit dropped the field, silently turning off Armada's default 30-day timer for everyone |
96f0647, bbc67b6 |
08-06 | CORD-02 §2 control_root write gate |
Implemented (v1.14.0) |
759448a, ee6f639 |
08-11/15 | CORD-02 §8: the Community List becomes fragmented kind 33302 (13302 retired), unpadded base64url for every 32-byte value, seed omitted when equal to current, mandatory tombstones, read-modify-write, a byte ceiling instead of a 50-entry cap |
Still on 13302, hex, no tombstones (leaving just dropped the entry) |
01.md |
08-11 | Encrypted application documents may choose their own encoding (only §8 does) | n/a |
Still-open upstream PRs worth tracking: #23 (authenticate community_root at accept;
would make our stranded-recovery root move an explicit MUST violation), #22
(community-owned av_brokers in metadata), #17 (Community Signals, vsk 12),
#16 (CORD-09 blinded mention locators), #7 (drop the ms tag). #12 (split
read/write planes) is superseded by the merged control_root design.
Findings
Ranked security > interop > feature inside each group.
Security
| # | Spec | Finding | Status |
|---|---|---|---|
| S1 | 02 §9 | Dissolution: no dissolved_pk plane, no eid binding, spec tombstone (chainless, no ev) could not even parse; a vsk-10 Control Plane edition dissolved with no binding check |
fixed — ConcordDissolution (derive, build, verify with eid == community_id, 20014 seal, owner author); session + planner subscribe the plane; CLI amy concord dissolve; the Control Plane fold no longer reads vsk 10 |
| S2 | 03 §1-2 | Posts into a private:true channel go to the root-derived plane every member can decrypt, under a Lock icon; real private channels can't be read |
fixed — ConcordActions.currentChannelPlane/historicalChannelPlanes derive a private channel only from the entry's held privateChannels key at its channel epoch (never the root plane); session, planner, plane registry, app verbs and CLI all go through it; a keyless private channel has no plane (ConcordChannel.keyHeld/canPost() false, composer replaced by a notice, amy concord send → no_channel_key); held keys adopt in place, and invite-delivered keys are stored on join. Creating private channels stays open (F7) |
| S3 | 01 Deletions | Deleting your own Concord message sends a signed NIP-17 kind 5 to the p-tagged users (leaks the rumor id outside the community) and never reaches the channel | fixed — ChannelChat.delete (examples §2.4: binding + e per target + k per kind) sealed 20013 on the channel plane (ConcordActions.buildChannelDelete); Account.delete/deletePrivately route Concord notes (messages, replies, reactions) to AccountConcordActions.deleteConcordRumors, each target on the plane of its bound epoch (Armada always uses the current plane); tapping an own Concord reaction retracts it the same way |
| S4 | 06 §2 | Stranded recovery adopts a bundle's newer community_root with no continuity or authority check — a link creator can relocate every member who joined through their link |
fixed — ConcordStrandedRecovery only detects (isStranded); the background sweep never moves the base (exposes strandedConcordCommunities); moving forward from a bundle needs the user to re-open the link (joinConcordViaInvite, amy concord recover --rejoin). PR #23's accept-time root gate not implemented (text unavailable; genuine owner editions re-wrap into any plane, so it needs the PR's exact rule) |
| S5 | 04 §1 | Grant eid never checked against grant_locator(cid, member); a second grant chain at a random coordinate overrides the canonical one, order-dependent |
fixed — AuthorityResolver.resolve / ConcordCommunityState.fold / authorizedHeads take the community_id; a Grant edition counts only when its eid == grant_locator(cid, content.member) |
| S6 | 04 §4 | Banlist unions every fork instead of folding to one head; a ban on a losing fork can never be undone; banlist eid unchecked |
fixed — the Banlist folds to ONE authority-gated head at banlist_locator(cid); the rank-delta rule is kept; re-heal = the writer re-applying atop the winner (ConcordModeration.ban again after refold) |
| S7 | 04 §1 | Equal-version ties break on rumor id only, not authority-first; a low-ranked holder can grind an id to beat the owner | fixed — EditionFold.foldEntityGated/foldGated take an author rank: authority first, then rumor id, both in the head pick (Armada pickHead) and in the walk's anchor/next-link choice. The walk part goes past Armada (whose version.fold walks on rumor id only, so a lower-id fork can strand an edition chained on the owner's sibling) — spec followed |
| S8 | 04 §1 | Metadata eid not required to equal community_id; a fresh coordinate at a high version bypasses the chain |
fixed — metadata is read only at eid == community_id (a fold gate, AuthorityResolver.isWellFormed) |
| S9 | 02 §5 / App. B | Seal kind never enforced on read (Control must be 20014, Chat/rekey 20013); any rumor kind from a channel lands in LocalCache |
fixed — control: ControlEdition.fromOpened refuses a non-20014 seal; used by the session, ConcordActions.controlEditions (CLI) and Refounding compaction; chat: ChannelChat.acceptOpened requires a 20013 seal, a CHAT_KINDS rumor (9, 1111, 7, 5, 3302, 23311, 1740), the strict binding and a well-formed ms, for stored and typing wraps; EventCache.consumeConcordRumor refuses non-chat kinds too |
| S10 | App. B | NIP-44 65,535-byte plaintext cap not enforced; quartz silently switches to the extended format strict readers reject | fixed — ConcordStreamEnvelope seal/wrap refuse a plaintext over 65,535 UTF-8 bytes (Armada encryptChecked), and open refuses an extended-format payload before decrypting |
| S11 | 05 §1 | Bundle bounds (channel count, relay cap) not enforced; the join fetches from every relay a bundle names | fixed — ConcordInviteBundle.bound: >256 channels refused, relays de-duplicated + truncated to 5, applied in parse (link bundles) and ConcordDirectInvite.parse; fragments decode ≤3 relays |
| S12 | 06 §3 | Compaction doesn't abort on an incomplete fold, and republishes the compacted plane before the root roll is confirmed | fixed — compactControlPlane(…, mustCarry) throws IncompleteControlPlaneException on a missing honored head; app sweeps the plane paged (majority of relays DRAINED) and CLI pages it; rekey chunks are publishAndConfirmed first, compaction published only after |
| S13 | 02 §9 | "Death wins every race": rekey adoption / recovery / refounding don't check for dissolution | fixed — dissolved check in drainConcordRekeys, recoverStrandedConcordCommunities, refoundConcordCommunity, the explicit rejoin, and CLI rekey/recover/refound (ConcordCommands.isDissolved) |
| S14 | 05 §2 | classify trusts the relay filter: no signature, pubkey == link_signer, or d == "" check; a relay can forge a revocation |
fixed — classify(wraps, linkSignerPubKey, token) keeps only events with kind 33301, author == link signer, d == "" and a valid signature (isAtCoordinate); every caller passes the signer |
Interop (Armada drops or diverges)
| # | Spec | Finding | Status |
|---|---|---|---|
| I1 | 02 §8 | Community List on retired 13302, hex, no fragments, no tombstones | fixed — fragmented kind 33302 (ConcordListFragments/ConcordListFragmentSet), unpadded base64url, seed/current rules, tombstone on leave, byte-identical to Armada's listFrag.ts (golden tests), 13302 read as a rescue source and migrated on the next write |
| I2 | 02 §6 | Metadata/Channel edits rebuilt from scratch, wiping custom, message_expiration (CORD-08), av_brokers |
fixed — ConcordJson.encodePreserving lays every edit over the authorized head; metadata/channel forms start from the folded entity |
| I3 | 03 §2 | Per-channel voice flag still modeled and rendered (every Channel is callable since 23dcea5) |
fixed — field removed (rides through as an unknown key), Mic icon and blank-preview special case removed |
| I4 | 04 §1/§5 | vac never written or verified — Armada drops every non-owner edition we author |
fixed — ConcordModeration stamps every non-owner edition with AuthorityCitations.forActor (own grant coordinate, folded head version + hash); every fold gate (roles, grants, banlist, metadata, channels, unmodeled kinds, floors/compaction) requires it per Armada citationSatisfied |
| I5 | 04 §2 | Role content lacks role_id; Armada ignores every role we mint |
fixed — RoleEntity.roleId written by defineRole; read accepts a legacy role without it, refuses a mismatching one |
| I6 | 04 §1 | First edition is v0; spec says versions start at 1 | fixed — genesis and every new entity start at v1; v0 chains still read |
| I7 | 04 §7 | Unknown-vsk editions (pins, signals) dropped by our compaction | fixed — a canonical unmodeled vsk parses with entityKind == null + raw vsk; floors and compaction carry its gated head verbatim (11 by PIN_MESSAGES, 12 by MANAGE_CHANNELS, others by any staff bit). vsk 6/7/9/10 are no longer parsed as Control editions |
| I8 | 06 | Rekey chunk index is 0-based; Armada requires 1-based and drops all our Refoundings |
fixed — ConcordRekey.tags takes a 1-based index (require 1..n); chunkOf parses strict decimals and refuses 0 / i > n; receivers drop malformed chunks |
| I9 | 06 §3 | Rotations carry no vac |
fixed — rotations carry vac on every chunk (ConcordRotationAuthority.citationFor, owner none); receivers require ConcordReceive.isHonoredRotation (BAN + citationSatisfied, Armada semantics) in the app drain and CLI rekey; a rotator whose chunks cite different Grants is dropped |
| I10 | 06 | 120 base blobs per chunk can overflow NIP-44; Armada budgets 99 @104 B / 90 @136 B | fixed — ConcordRekey.chunkBlobs budgets the rumor JSON at 40,960 bytes (Armada REKEY_RUMOR_MAX_BYTES) plus the 120 count cap; test pins the seal (wrap plaintext) ≤ 65,535 with 136-byte blobs |
| I11 | 05 §3 | Invite links carry more than 3 bootstrap relays; Armada's decoder throws | fixed — encodeFragment truncates non-stock lists to 3 (stock set stays a flag); decodeFragment refuses count > 3 |
| I12 | 06 §3 | No race convergence (lowest new root), not idempotent on retry | fixed — findNewRoot converges on the lowest authorized root (accept filter before converge); sessions watch the current epoch's own rekey address and drainConcordRekeys heals down-only (ConcordReceive.withHealedRoot), keeping the losing root as a same-epoch held root (only the lowest per epoch is folded: canonicalHeldRoots); retries reuse reserved keys (ConcordRefounding.reserveKeys; in-memory in the app, persisted in amy's store). Not done: the CLI has no heal step; re-issuing a losing branch's channel keys on the winning chain (F7 rotates private channels inside a Refounding under the prior root, which both branches can open, but a losing refounder does not yet re-issue its channel keys after the heal) |
| I13 | 03 §3 | Binding check not strict (duplicates accepted, "04"/"+4" parse) |
fixed — exactly one channel and one epoch tag, epoch compared as its canonical decimal string (Armada uniqueTag/checkChannelBinding); builders drop binding tags smuggled in extraTags |
| I14 | 03 §2 | Channel deletion not terminal across the chain; no 64-byte name cap | fixed — any gated channel edition with deleted:true retires the channel for good (Armada everDeleted); the channel gate refuses an empty or >64-byte name so the fold falls back to the previous candidate, and defineChannel/create/rename refuse to mint one |
| I15 | 02 §4 | No ms tag on chat rumors |
fixed — every ChannelChat rumor carries ["ms", 0..999] after the binding; malformed/duplicated ms drops the rumor; channelMessages and edit recency order by created_at*1000+ms (the shared feed still sorts by created_at; open PR #7 may drop ms) |
| I16 | examples §2.1 | Inline quote q tag is 2-element, Armada writes ["q", id, "", author] |
fixed — four-element q (the p credit stays, as Armada keeps it). Also CORD-03 §3: your own kind-1111 thread replies can now be edited (kind 3302) like kind-9 messages |
| I17 | 04 §2, 02 §6 | Caps (role name, roles per member/community, metadata name/description) not enforced | fixed — ConcordLimits; refused on write (factory, ConcordModeration, app verbs, CLI) and enforced at fold like Armada: over-cap role/metadata editions fall back, a Grant's role_ids trim to 64, the Community keeps its 100 lowest role_ids. Also: ev/vac/vsk must be canonical decimals and a duplicate vsk/eid/ev/ep/vac invalidates the edition; CLI knows VIEW_AUDIT_LOG/MENTION_EVERYONE/PIN_MESSAGES; the app's Admin role matches Armada's ADMIN_ALL |
| I18 | 05 §1, §4 | Join doesn't echo invite attribution; CLI join publishes no Guestbook Join; Invite List merge lets the patch win; malformed tombstones dropped | fixed — join echoes creator_npub/label in the Guestbook Join (mints now set creator_npub); amy concord join publishes a Guestbook Join; Invite List merge is first-wins per token; untyped tombstones carried as opaqueTombstones and still retire their token |
Features
| # | Spec | Finding | Status |
|---|---|---|---|
| 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 (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 |
| F7 | 06 §1-2 | Channel-scope rekeys; private-channel keys in invites | fixed — quartz ConcordChannelRekey: root-keyed concord/rekey-pseudonym address, 72-byte scope-bound blobs, chunked 3303 with prevcommit over the held channel key and vac on every chunk; the receive walk adopts only complete, honored (owner / MANAGE_CHANNELS / BAN + synced vac) rotations off the held key (multi-epoch, racing rotators → lowest key) and treats "no blob" as a cut only from a rotator who outranks us, published after our join. ConcordChannelKeyring rotates keys in place, reads older keys from seed/peer priors (never writes intermediate keys, CORD-02 §8) and models Armada's channel_cuts floor (extension, round-tripped). ConcordInviteVend.entitledMembers/accessChanges/judgeCatchUp. Commons ConcordPrivateChannels + app verbs: create Private channel (key at channel epoch 0 + bit-less access Role, as Armada), privatise (next channel epoch, floored by probing the rekey addresses) / publicise, rekeyConcordChannel, vend on grant (Direct Invite limited to the gained channels) and rotate on revoke/ban, the Refounding rotates every held private channel under the prior root, sessions subscribe/AUTH/buffer the channel-rekey window and the revision tick drains it; staff catch-ups auto-adopt. Links carry no channel keys (F6's vendableChannels, audience link). UI: Private toggle + access-role name on create, Make private/public + Rotate key per channel. amy concord channel create/privatize/publicize/rekey, rekey/grant/refound follow channel keys. Not done: republishing a rotated channel's sealed Pin List under the new key (a SHOULD); history across our own rotations after restart (intermediate keys stay out of the List by spec, and the key walk from seed is not implemented); a role scope edit in the UI does not trigger reconcile (only grants/revokes/bans do) |
| F8 | 06 §2, 02 §8 | Walk forward from seed; we still keep intermediate roots in a held_roots List extension the spec says doesn't belong there |
open |
| F9 | 04 §6 | Kick (kind 3309) | fixed — quartz Guestbook.kick writes the examples' shape (ms, p, vac), Guestbook.parse reads Kicks too (target, author, ms basis, rumor id, citation; malformed ms/target dropped; a 3306 "kick" verb is not a Kick), Guestbook.canKick honors one only from a KICK holder who strictly outranks the target with a synced vac (block-until-synced), and Guestbook.coalesce is the CORD-02 §5 fold (latest per npub by ms, lower rumor id on a tie, >1 h future dropped, banned authors dropped) — the old projection was createdAt-only and ignored Kicks. Commons: the session coalesces against its roster and re-coalesces on every control fold (a parked Kick lands when its Grant folds), exposes guestbook, departedMembers() (Leave/Kick not followed by newer observed activity — observation counts forward only) and kickedMe() (honored Kick postdating the entry's added_at); allMembers() drops the departed; guestbook seals must be encrypted. kickConcordMember strips roles first (MANAGE_ROLES + outrank, best-effort, as Armada) then publishes the directive; the revision tick's drainConcordKicks leaves the community locally (List tombstone, like Leave; network-silent) and emits a ConcordKickNotice the app toasts. Re-join works: follow bumps added_at past the tombstone, which also puts the old Kick behind the new membership. UI: Kick (KICK + outrank, confirm dialog) next to Ban in the members roster; departed members show a Kicked/Left badge. amy concord kick COMMUNITY USER. Not done: Armada's double-read debounce before self-removal, the dissolution ordering rule for Kicks (we refuse to write one on a dissolved community but do not date-check received ones), and Guestbook snapshots (kind 3312) |
| F10 | 03 | WebXDC (kind 3310) | fixed (plumbing only) — 3310 was refused by the Chat gate and silently dropped. quartz ConcordWebxdc builds/parses what Armada puts on the kind (the spec leaves the payload opaque, examples §2.6): app state updates (["i", <session>], ["alt","Webxdc update"], optional info/document/summary, JSON content) and realtime peer signals ({"op":"ad","topic":<52-char base32>,"addr":…} / {"op":"left","topic":…}, addr ≤ 2048, as Vector bounds it), both under the usual channel/epoch/ms binding; ChannelChat.PLANE_KINDS = CHAT_KINDS + 3310 and acceptOpened(…, kinds), so 3310 stays out of CHAT_KINDS (never a row, never pinnable, still refused by EventCache.consumeConcordRumor). The session opens its planes with PLANE_KINDS and routes 3310 into a bounded per-channel buffer (webxdcSignals(channel), webxdcRevision; 2000 per channel, CORD-08-expired ones filtered) instead of the chat store, so feeds, previews and unread counts never see it; its author still counts as observed. Amethyst has no WebXDC host. Full support (Armada useConcordAppSync + XdcAttachment, interoperating with Vector) would take: rendering an .xdc attachment (application/x-webxdc imeta carrying a webxdc session/topic id, or a topic derived from URL + message id) as an app card; a sandboxed WebView runtime exposing the webxdc JS API (sendUpdate/setUpdateListener → durable 3310 state updates for the session, read back by #i, with the CORD-08 expiration; joinRealtimeChannel → realtime); and for realtime an iroh gossip transport (Vector's frame format with the 36-byte seq‖sender trailer, topic = base32(sha256(…))) advertised/withdrawn by 3310 peer signals and folded latest-per-author (ties to left, >1 h future refused) — there is no relay fallback by design. The :napplet WebView sandbox is the natural host; iroh has no Kotlin implementation (our :quic is plain QUIC/HTTP3) |
Audit 2 (2026-09-29) — batch A (non-Refounding)
Root cause shared by P1/P13/R7: nothing told a session its Control Plane had finished its
initial drain. Now ConcordCommunitySession.controlDrained is set by
syncConcordControlPlanes once a relay pages the whole current plane (DRAINED) and the
swept wraps are ingested; foldForWrite() is the fold writers may build on (null before).
Batch B can reuse it for the ban/privatize decisions.
| # | Status |
|---|---|
| P1 | fixed — ConcordChannelPins.complete; ConcordPinning.refusal → NOT_FOLDED until drained; the sheet never says "no pins" before the drain |
| P2 | fixed — buildChannelEdit(expiration = expirationOf(target)): an Edit carries the original's deadline verbatim (none if it has none); a smuggled expiration in extraTags is dropped (Armada useTransport.ts; the spec's "computed from the rumor's own created_at" reads otherwise — noted) |
| P3 | fixed — pin writes publishAndConfirm (NOT_CONFIRMED otherwise) and re-apply the same op atop a concurrent winner, ≤2 retries |
| P4 | fixed — rememberConcordChannelPins, the pinned sheet and ConcordTimerIndicator re-resolve the session on concordSessions.revision |
| P5 | fixed — the pins re-read at the soonest pinned message's NIP-40 deadline (ConcordChannelPins.nextExpiry) |
| P6 | fixed — list reads memoized by head rumor id (ConcordPinVerifier.readList); PinListRead.sealedForm (no second parse); evidence trigger filtered to deletes/Edits naming a pinned rumor; action-sheet pin state via produceState on Dispatchers.Default. The per-channel verifier cache stays the session-wide 512-entry one |
| P7 | fixed — deadlines kept sorted (binary-searched insert; no PriorityQueue in common code); sweeps pop only what is due; the account sweep waits 2 s past a deadline to coalesce |
| P8 | fixed — bounded (4096) set of swept wrap ids; re-deliveries dropped before opening |
| P9 | fixed — ConcordSessionRegistry.sync carries trackedExpiring() into the rebuilt session |
| P10 | fixed — sheet and badge leave out banned authors and muted/blocked users (ConcordPinning.visible) |
| P11 | fixed — confirm dialog before pinning a message carrying expiration; amy concord pin refuses (expiring_message) without --force |
| P12 | fixed — amy concord pins hides expired pinned messages |
| P13 | fixed — editConcordMetadata / setConcordMessageExpiration return false until drained (the owner too) and chain onto the floor-aware head |
| P14 | fixed — ExpiredConcordRumor.attachmentUrls; the sweep evicts them from encryptionKeyCache |
| recomputeNextExpiry | fixed — computed and assigned under a lock |
| D3 | fixed — SealEvent.unsealRumorThrowing + ConcordDirectInvite.openRumor/offerRumor: the NIP-17 seal handler decrypts once; k=3313 wraps that never open are marked seen (GiftWrap and Seal handlers), so the hub's sweep skips them. Chosen over a persisted cursor: the DM pipeline sees every invite wrap first in the same process, so the sweep re-decrypts nothing it already handled; a cold start still re-sweeps from since = null once per process |
| D4 | fixed — ≤256 parked (followed senders outrank strangers, then newer), hidden senders never park and are filtered, followed senders listed first, invites rendered as lazy items (the empty state scrolls) |
| D5 | fixed — an invite whose clamped sentAt is at or before the Community List tombstone's removed_at stays hidden (ConcordChannelListState.removedAt) |
| D6 | fixed — the cursor advances to min(created_at, now + 15 min) |
| D7 | fixed — dedupe per (community, sender), ranked by sentAt clamped to now |
| D8 | fixed — written off only on a definitive outcome; openOrRetry rethrows a transient signer failure (timeout, not approved, backgrounded, not found) and the inbox leaves the wrap for a retry |
| D10 | fixed — declines capped at 4096 (app + amy); restoreDeclined is suspend under the inbox mutex; the sweep runs once per hub visit outside the lazy list and rethrows cancellation; catch-up cards list only newly adopted channels, by folded name. Also (F7 note): visible() hides a catch-up acceptPlan would refuse against the held fold |
| R3 | fixed — a readable Invite List is authoritative in nextLinks (no resurrected links); registry edits are publishAndConfirmed |
| R7 | fixed — publishConcordInviteRegistry skips until drained and chains onto the floor-aware head (ConcordModeration.setInviteRegistry(floors = session.controlFloors())) |
| R8 | fixed — no registry edit on a dissolved community; retiringWouldPrivatize is false once dissolved, so a revoke there reports REVOKED |
| R9 | fixed — after a drain, this account's registry is republished pruned when it lists an elapsed/unbacked link (once per community and epoch per process) |
| R11 | fixed — invite_links_locator memoized per (community, author) in AuthorityResolver |
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-clientdocuments 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).
-
The rekey blob plaintext is base64-encoded before NIP-44 (signer APIs take strings); unpinned by the spec, as Armada's own comment notes.
-
Rekey seal kind (20013) and the
chunkindexing base are implied by examples only; worth a MUST. -
02 §8 and examples §6.2 cite "a dissolution payload (CORD-06 §1)", but CORD-06 defines no such payload, and Armada has none. Dangling reference.
-
CORD-07 §2 should require a nonce in the 27235 grant (Armada already adds one): two members requesting in the same second otherwise produce the same event id and collide in the broker's mandatory replay set.