Skip to content

spec: seal + gift-wrap DM format for metadata privacy#218

Merged
intendednull merged 3 commits into
mainfrom
claude/spec-seal-gift-wrap-dms
Apr 26, 2026
Merged

spec: seal + gift-wrap DM format for metadata privacy#218
intendednull merged 3 commits into
mainfrom
claude/spec-seal-gift-wrap-dms

Conversation

@intendednull
Copy link
Copy Markdown
Owner

Part of a set of 8 specs drawing lessons from Nostr's protocol and ecosystem. Use this PR to discuss the design — not proposing implementation, only the spec.

What & why

Willow has excellent symmetric-key channel encryption for groups, but no DM story. A naive DM built on the current event model would leak sender/recipient/timing to any passive observer (same failure as Nostr's NIP-04). This spec adapts Nostr's NIP-59 gift wrap triple-nesting (and NIP-44 v2 payload format) to hide that metadata.

Three layers:

  1. DMRumor — the real content, unsigned, off-DAG (enables deniability). Contains real author, timestamp hint, Content.
  2. EventKind::DMSeal — NIP-44-encrypts the rumor to the recipient's pubkey, signed by the real author, no recipient tag. Inert in ServerState.
  3. EventKind::DMGiftWrap — NIP-44-encrypts the seal to the recipient using a fresh ephemeral Ed25519 keypair (seq=1, empty deps, one-shot DAG). Only the gift wrap is visible on gossip; the real sender is invisible.

Payload mirrors NIP-44 v2 byte-for-byte with HKDF salt "willow-dm-v1" and power-of-two padding, reusing Willow's existing Ed25519→X25519 conversion. Delivery via a per-recipient inbox topic _willow_inbox/<blake3(recipient_pk)>; timestamps independently jittered up to 2 days on each layer.

Spec includes explicit non-goals (FS, PCS, length above padding bucket, IP address, precise timing) — honest threat model, matching Nostr's own NIP-44 "Limitations" section.

Spec file: docs/specs/2026-04-24-seal-gift-wrap-dms.md

Open questions for review

  1. Topic design — per-recipient inbox topic leaks the recipient set; shared global inbox floods everyone. Which tradeoff?
  2. MLS for group DMs (>2 participants) vs N-wise gift wraps
  3. Worker TTL for gift-wrap events — indefinite, or auto-expiring?
  4. Forward secrecy — out of scope here; is a separate DM-ratchet spec warranted?
  5. Should Willow split Ed25519 identity key from a per-session signing key now (NIP-EE's rule) so we can rotate without losing identity?
  6. DAG pollution — ephemeral sender DAGs are one-shot; do they eventually get garbage-collected?

Composition with sibling specs

  • Epoch rotation (channels only): this spec is DMs only; they need parallel stories
  • Outbox relay discovery: inbox topics need discovery
  • Error prefixes: DMDeliveryFailed variants
  • Negentropy sync: gift wraps reconcile like any other event

Commit is unsigned due to harness signing backend failure (same as sibling PRs in this set).


Generated by Claude Code

Co-authored-by: Claude <noreply@anthropic.com>
Copy link
Copy Markdown
Owner Author

@intendednull intendednull left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Substantive review of the seal + gift-wrap DM spec. Overall: the direction is right (NIP-44/59/17 is the right template, the threat-model table is the most honest one in the Nostr-inspired set so far), but several load-bearing claims need rework before this becomes implementable. Most urgently: the deniability claim is wrong as stated, the inbox topic design needs to acknowledge the worker-observable activity graph, and the per-message ephemeral key story has unaddressed storage/RNG operational concerns.

Strengths

  • Threat-model table is concrete. Clear "sees today / sees after" rows with named adversaries beats the usual "metadata privacy" hand-waving. The non-goals table is also unusually honest (FS, PCS, IP, timing, length, PQ all explicitly out).
  • Three-layer mapping to Willow's event model is well thought out. Treating seals as inert events on the real author's DAG and gift wraps as one-shot ephemeral-author DAGs is the right shape — it reuses existing wire/sig machinery (identity::pack/unpack) instead of inventing parallel transport.
  • Pubkey-swap defense (lines 112–116) is correctly identified. This is the NIP-17 foot-gun that several Nostr clients shipped without and got bitten by. Calling it out as a verifier MUST is exactly right.
  • Independent timestamp jitter on each layer (line 253) correctly defeats the obvious wrap↔seal correlation a relay could otherwise exploit.
  • Reuses Willow primitives. identity_to_x25519 / ed25519_public_to_x25519 reuse (line 168) avoids reinventing the Ed25519→X25519 conversion. Test #2 protects the swap defense. The HKDF salt domain-separation pattern matches "willow-channel-key-wrap" at crates/crypto/src/lib.rs:360.

Concerns

  1. Deniability claim is false (inline at line 87). Alice's Ed25519 signature on the seal cryptographically attributes the rumor to her once anyone obtains the plaintext. The "no signature on rumor → deniable" reasoning is the same flawed pattern that NIP-59 inherits and is contradicted by the seal's own signature. Fix: drop the deniability claim or commit to a designated-verifier construction.

  2. Inbox topic leak is downplayed (inline at line 207). Worker nodes observe the graph of subscribed inbox topics — this is the recipient activity graph the threat model claims to hide. The "pubkey is already public" sentence is a non-sequitur. Either acknowledge the leak in the threat-model table or specify a k-anonymity bucket scheme.

  3. NIP-44 fork is gratuitous (inline at line 155). Bumping to a "v1" with a custom HKDF salt forfeits cross-implementation KAT vectors for zero security or migration benefit. Implement NIP-44 v2 byte-for-byte; you can always fork later.

  4. DAG pollution is hand-waved to Open Question 6 (inline at line 144). Spec ships an event class that explicitly creates one ephemeral-author DAG per recipient per DM — at the spec level this is a known cost, not an open question. Either route gift wraps through a separate non-DAG storage path or specify a GC policy.

  5. SealedContent reuse is unclear (line 151). The spec talks about a Vec<u8> ciphertext field on EventKind::DMSeal but doesn't say whether it reuses willow_messaging::SealedContent (crates/messaging/src/lib.rs:159–172) or wraps NIP-44 bytes raw. They are different constructions: existing SealedContent is ChaCha20-Poly1305 with key_epoch/ratchet_counter; NIP-44 is ChaCha20 + HMAC-SHA256. The spec implies a parallel raw-bytes path. State this explicitly: "DM ciphertext is opaque NIP-44 v1 bytes, NOT a SealedContent value." Otherwise an implementer will try to reuse seal_content/open_content and silently get the wrong cipher.

  6. required_permission() table — missing the actual Permission enum check (lines 106–107). Looking at crates/state/src/event.rs:21–33, the existing Permission set is SyncProvider | ManageChannels | ManageRoles | SendMessages | CreateInvite. The spec says "maps to None (unrestricted)" — but SendMessages is a real permission, and DMSeal-by-author is structurally a message. Without a permission gate, a peer who has been kicked from the server can still DM members because gift wraps are addressed by inbox topic, not by server membership. Is that the intent? If yes, document it. If no, add a check.

  7. DM↔channel-rotation (#220) interaction creates a false-comfort risk (lines 282–283 + Open Question 4). Channels will get FS via epoch rotation; DMs explicitly will not. A user reading "Willow has forward secrecy" will assume both. At minimum: cross-link to #220 from the non-goals table with a one-liner ("FS exists for channels, NOT for DMs — see #220 §X").

  8. Group DM threshold is undefined (line 246). Spec says O(N) wrap events and "no shared identifier on the wire," then defers MLS to Open Question 2. State a hard recommendation, e.g. "1:1 and group DMs ≤ 8 use this spec; > 8 must use MLS once available; clients SHOULD warn at 8 and refuse at 32." Implementers will pick a number anyway; pick one in the spec.

  9. Test matrix gaps (lines 266–276):

    • No test for inert apply on DMSeal/DMGiftWrap beyond "ServerState unchanged" — also need: replaying a DM event sequence does not change the StateHash. (Test #5 likely covers this; clarify.)
    • No test for wrong-ephemeral-author wraps (an attacker who reuses the same ephemeral key for two recipients).
    • No test for rumor timestamp_hint_ms jitter bounds (chi-squared on the wrap timestamp is checked, but the rumor timestamp is also separately exposed once decrypted).
    • No test for the inbox topic id derivation matches topic_id(&format!("_willow_inbox/{}", hex(blake3(recipient_pk)))) exactly — this is wire-format and needs a KAT.

Suggestions

  1. Rewrite the deniability paragraph (line 86–87). Either remove or replace with: "The seal layer provides standard EUF-CMA signatures by the real author. Deniability against third parties who later obtain the rumor plaintext is not provided by this spec." Honesty here matters more than aspiration.

  2. Add a "What workers see" subsection to the threat model. Today the table conflates "passive network observer" and "relay/worker" but they have very different visibility: workers see the subscription graph in addition to the wire bytes. Make this explicit.

  3. Pin the NIP-44 reference. Line 145 says "NIP-44 v1, Willow-flavored" — link the exact NIP-44 commit hash you're cloning from. NIP-44 has had silent revisions; pinning matters for audit.

  4. Move DMSeal and DMGiftWrap to a new EventKind category in crates/state/src/event.rs:69 ("-- DM (inert) --") and add a doc comment that they are structurally not state mutations. This is more enforceable than a comment in materialize.rs.

  5. Negentropy interaction (sibling #219): if negentropy syncs the per-author DAG, gift-wrap DAGs flood the diff set. The negentropy spec needs to know to skip ephemeral-author DAGs (or this spec needs a "do not negentropy-sync" marker). Add a one-line cross-reference.

  6. Inbox discovery (sibling #221): how does a recipient discover they have a new wrap if they're offline? Workers serving as inbox aggregators (briefly mentioned line 209) needs to be specified by either this PR or #221, not punted between them.

  7. Consider adding a "v2" hook now for session keys (Open Question 5). If you ship v1 with identity-key DH, you cannot later add per-session keys without a wire-format break. Reserve a version = 0x02 slot now, even if v1 is what you implement.


Generated by Claude Code

Comment on lines +156 to +158

Key derivation:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HKDF salt is fixed-string per layer — fine, but please confirm the conv_key is per-pair-of-keys, not per-message.

HKDF-Extract(salt = "willow-dm-v1", ikm = shared) produces the same conv_key for the same (sender_sk, recipient_pk) pair across every message. NIP-44 v2 is built that way deliberately so the per-conversation derivation can be cached, and the per-message uniqueness comes entirely from the random nonce passed as HKDF-Expand info. The spec doesn't say this out loud — please confirm that's intended (it should be), and add a one-line note that conv_key is intentionally a function only of the static keypair so that implementers don't try to "improve" it by mixing in a counter or timestamp (which would break the cache and add no security).

A second, more substantive concern: the same "willow-dm-v1" HKDF salt is used for both the seal-layer encryption (real_sender_sk → recipient_pk) and the wrap-layer encryption (ephemeral_sk → recipient_pk). Because the input keys differ, the resulting conv_keys differ, so there's no key reuse — but cross-layer domain separation is cheap and a defense-in-depth norm in modern AEAD designs. Worth using "willow-dm-seal-v1" and "willow-dm-wrap-v1" so a future bug that confuses the two layers (e.g. swapping inner/outer ciphertexts) fails closed at HKDF rather than silently producing structured-but-wrong output. NIP-59 doesn't bother because Nostr only has one layer of NIP-44, but Willow has two stacked.


Generated by Claude Code


**No signature.** This preserves deniability: a leaked rumor cannot
be cryptographically attributed to its author.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deniability claim is overstated and needs to be rewritten or removed.

The seal layer (line 100, "Signed by the real author") commits a signature by Alice over Encrypt(rumor, recipient_pk). Anyone who later compromises Bob's key (or Alice's, or extracts the rumor any other way) can show:

  1. Alice signed a DMSeal ciphertext at her seq=N (publicly verifiable).
  2. The plaintext of that ciphertext is DMRumor { author = Alice, content = "..." }.

That is cryptographic attribution. The fact that the rumor itself is unsigned is irrelevant — Alice's seal signature binds her to the ciphertext, and the ciphertext binds (via AEAD) to the rumor. The only thing the unsigned rumor buys you is that the rumor in isolation (without the seal) cannot be attributed — but in practice the rumor never exists in isolation; it's always derived by decrypting a seal Alice signed.

NIP-59's deniability story is the same and is similarly weak. Compare to OTR/Signal "deniable authentication" via MAC-only constructions: there the binding key is symmetric and known to the recipient, so the recipient could have forged the message. Here Alice's Ed25519 signature is non-repudiable.

Concrete fix: either (a) remove the deniability claim, or (b) replace the seal's Ed25519 signature with a designated-verifier construction (e.g. a triple-DH MAC keyed on Alice↔Bob shared secret), and document this trade-off explicitly. (a) is what I'd recommend for v1 — keep the spec honest and revisit deniability with the FS/PCS spec.


Generated by Claude Code

Comment on lines +238 to +246

Group DMs produce **N independent gift wraps**, one per recipient
(including the sender themselves so their other devices see a copy,
matching NIP-17's rule). There is **no shared identifier on the
wire**: each wrap has a distinct ephemeral author, distinct
ciphertext, distinct timestamp. Clients reconstruct a "room" locally
by hashing the sorted participant set from the decrypted rumor's
`Content`-level metadata (to be defined in a follow-up — for now
group DM is just "DM to these N pubkeys").
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-recipient: the rumor must carry the participant set, or replies break.

"Clients reconstruct a 'room' locally by hashing the sorted participant set from the decrypted rumor's Content-level metadata (to be defined in a follow-up — for now group DM is just 'DM to these N pubkeys')" leaves a real correctness hole, not just a UX gap.

If Alice sends a wrap to {Bob, Charlie} and Bob replies to {Alice, Charlie}, both must end up grouped under the same local room ID. That requires the rumor itself to carry the canonical sorted participant list (Alice, Bob, Charlie) so that room_id = blake3(sorted_participants) is computed identically by every recipient. NIP-17 handles this with explicit p-tag participants on the rumor.

Without this in v1 of the spec, two recipients of the same DM cannot agree they are in the same conversation, "reply to room" cannot be implemented, and bolting it on later will require migrating already-encrypted-and-stored rumors. Recommend either (a) defining the participant field on DMRumor now (cheap), or (b) explicitly scoping this spec to 1:1 only and removing the "Multi-recipient" section until the follow-up exists.


Generated by Claude Code

own inbox on startup. Senders subscribe briefly to publish, then
unsubscribe. The recipient-pubkey leak is judged acceptable because
the pubkey is already public-key material; the shared-inbox DoS is
not.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "the pubkey is already public-key material" hand-wave glosses the actual leak.

The leak isn't the pubkey itself — it's the graph of which pubkeys are subscribed to inbox topics. Worker nodes (which already see every gossip subscription on the topics they bridge — see crates/network/src/iroh.rs) get a directly-observable index of "currently active DM users" the moment a recipient subscribes. That is exactly the metadata the threat model on lines 32–50 claims to hide ("recipient set for group DMs").

Three concrete things to address before this lands:

  1. Acknowledge the leak in the threat-model table. Update the "Relay / worker" row: workers learn the recipient set of all online DM users via topic subscription, not just per-message hints.
  2. Pick a position on whether workers need SyncProvider to even see inbox subscriptions, or whether inbox topics bypass the trust check (CLAUDE.md "Trust Model": "the relay is a regular client").
  3. Enumerate the bloom-filter / k-anonymity-bucket option you dismissed in the table. Even a 4-bit bucket (recipients hash their pubkey to 1-of-16 inbox topics) reduces the per-user graph leak by 16× at the cost of 16× wrap traffic per recipient. That's a real knob worth costing out, not a one-liner dismissal.

The "judged acceptable because the pubkey is already public-key material" sentence should be cut — the publicness of a pubkey value is unrelated to the privacy of the online activity graph keyed by it.


Generated by Claude Code

Comment on lines +118 to +128
### Gift wrap (`EventKind::DMGiftWrap`)

A new `EventKind` variant authored by a **fresh ephemeral Ed25519
identity generated per recipient per message**:

```rust
EventKind::DMGiftWrap {
ciphertext: Vec<u8>, // NIP-44 v1 payload over the Seal event bytes
recipient_hint: EndpointId, // recipient pubkey (routing only)
}
```
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recipient_hint should be unauthenticated/forgeable and that should be stated.

Spec correctly says "an attacker can forge a hint; it's a fast filter" — good. But this means a recipient cannot trust the hint at all and must still attempt AEAD decryption against every wrap whose hint matches their pubkey. Worth being explicit that:

  1. The recipient_hint is included in the wrap event's signed body (since it's a field of EventKind), and is therefore signed by the ephemeral key, not the real sender — so its signature carries no meaningful authority. That's fine, but it means the hint could be used by an attacker to flood Bob's inbox topic with garbage wraps that pass signature verification but fail AEAD. The spec should mention rate-limiting / proof-of-work / quota expectations on the inbox subscription, or accept the unbounded-spam risk and say so.

  2. NIP-59's gift wrap doesn't have a separate recipient_hint — the wrap is published to a relay tied to the recipient. Willow's per-recipient inbox topic (line 198-202) already plays that role. Carrying both the hint and publishing on _willow_inbox/<blake3(recipient_pk)> is redundant unless the hint gates filtering before topic resolution; please justify keeping both, or drop one. If kept, document why.


Generated by Claude Code

Because the gift wrap lives on a single-event ephemeral-author DAG,
it does not interfere with any real peer's `seq`/`prev` chain and
does not need DAG-level merge (`crates/state/src/materialize.rs`).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per-message ephemeral Ed25519 keypair — please address WASM RNG and DAG-pollution explicitly.

Two operational concerns the current text glosses:

  1. Entropy on WASM. CLAUDE.md mandates getrandom with the js/wasm_js feature for browser builds. ed25519-dalek::SigningKey::generate ultimately calls OsRng, which in crates/crypto/src/lib.rs:353 already works because the dep tree is wired correctly — but a new module in crates/messaging/src/dm.rs (per checklist line 327) won't automatically inherit those features. Add a checklist item: "verify getrandom features propagate to the new dm crate path; add a wasm32 smoke test that generates 1000 ephemerals."

  2. DAG pollution is not actually addressed; it's deferred to "Open question 6". A spec that introduces a new event class which spawns a 1-event author DAG per DM cannot punt on retention — a chatty user generates millions of one-shot DAGs. crates/state/src/dag.rs (and the EventStore trait) index per-author; a million ephemeral authors is a real cost. Concretely:

    • Either gift wraps need a separate storage path (not the per-author DAG at all — they're orthogonal to state), or
    • The spec must specify a GC policy (e.g., "ephemeral-author DAGs with seq=1 only and kind=DMGiftWrap are evicted from author indexes after recipient ack OR after 30 days").

The "ephemeral author" approach is structurally elegant on the wire but pays for it in storage. Pick one before merge.


Generated by Claude Code

| `version` | 1 B | `0x01` |
| `nonce` | 32 B | CSPRNG, also used as HKDF-expand `info` |
| `ciphertext` | variable | ChaCha20 (counter=0) output, input is padded plaintext |
| `mac` | 32 B | HMAC-SHA256(hmac_key, nonce ‖ ciphertext) |
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inventing a Willow-specific NIP-44 variant ("v1") is a foot-gun. Use NIP-44 v2 verbatim.

The spec writes version = 0x01 to "reserve future migration independently of Nostr" and changes the HKDF salt to "willow-dm-v1". This buys nothing and costs you:

  • No KAT reuse. Test Add just dev to start all services for local development #7 (line 275) says "Padding buckets match NIP-44 vectors for sizes 1, 32, 33, 145, 600" — but if you change the HKDF salt, you can only reuse the padding vectors, not the end-to-end encryption KAT vectors. The most valuable thing you get from cloning NIP-44 is the existence of cross-implementation test vectors.
  • The "future migration" argument is backwards. If Nostr publishes NIP-44 v3, you have to migrate anyway because peers will start sending v3. A separate version byte does not insulate you; it just guarantees you have to maintain two parallel tables.
  • The salt change is purely cosmetic and gives an attacker no extra work. Domain separation between channel-key-wrap and DM is already provided by the entirely different cipher construction (channel uses ChaCha20-Poly1305 AEAD with no HKDF; DM uses ChaCha20+HMAC).

Recommendation: use NIP-44 v2 byte-for-byte, including version 0x02 and salt "nip44-v2". If you ever need to fork, do it then. Document this as "Willow DMs implement NIP-44 v2 unmodified" — and you get the entire Nostr test corpus for free.

Side note: line 153 says "ChaCha20 (counter=0)" — make sure the spec is explicit that this is unauthenticated ChaCha20 with the MAC computed encrypt-then-MAC over nonce ‖ ciphertext, NOT ChaCha20-Poly1305. NIP-44 deliberately doesn't use Poly1305, and this is easy to get wrong on implementation.


Generated by Claude Code

Comment on lines +219 to +230
2. `apply_event()` in `crates/state/src/materialize.rs` recognises
`DMGiftWrap` / `DMSeal` and short-circuits: it does **not**
mutate `ServerState`. Both return `None` from
`required_permission()` and are listed in the catch-all comment
alongside `SetProfile` (see
`docs/specs/2026-04-12-state-authority-and-mutations.md:96–107`).
3. A separate `DMInbox` side-state (local, per-client, not shared)
collects decrypted rumors, keyed by `(participant_set, rumor_id)`
where `rumor_id = blake3(bincode(DMRumor))`.
4. **Dedup** happens at the rumor layer: two different ephemeral
wraps of the same rumor produce identical `rumor_id`s after
decryption.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

required_permission() integration: please follow the checklist comment, not just match the catch-all.

crates/state/src/materialize.rs:283-298 has a documented checklist that says: "If a new EventKind variant is added and is NOT listed here or in an arm above, it will silently get no permission check. That is a bug." — a deliberate guard against accidentally-permissionless events. Adding DMSeal / DMGiftWrap to the catch-all comment alongside SetProfile (line 290) is correct, but worth stating in the spec that they are explicitly listed in that comment block, not just allowed to fall into the wildcard _ => None arm. The spec sort of says this on line 224 ("listed in the catch-all comment") — please make it imperative ("MUST add a comment line in required_permission() listing both new variants").

A related, subtler concern: SetProfile is unrestricted-but-still-mutates-state. DMGiftWrap is unrestricted and non-mutating. There is no other "no-op event" variant in EventKind today, so the apply path (apply_mutation returning without modifying ServerState) is novel. Please add a state-machine test that round-trips a (CreateServer, DMGiftWrap, SomeOtherMutation) sequence and confirms the resulting state.hash() is identical to (CreateServer, SomeOtherMutation) — i.e. the wrap is provably inert with respect to the state-hash divergence detector. Test #5 in the table only says "leaves ServerState unchanged" which is weaker than "leaves state_hash unchanged".


Generated by Claude Code

Comment on lines +252 to +263

Each layer independently jitters `timestamp_hint_ms`:

```
t_wrap = now_ms - rand(0, 2 * 86_400_000)
t_seal = now_ms - rand(0, 2 * 86_400_000)
```

Independent jitter breaks the obvious "wrap.ts == seal.ts" linkage a
relay could exploit. `HLC` (`crates/messaging/src/hlc.rs`) is **not**
used for DMs — HLC is designed for totally-ordered channel history
and would leak real sender clocks.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timestamp jitter: forward-only jitter into the past creates a "freshness band" that's worse than NIP-59's symmetric jitter? Please check.

NIP-59 specifies randomize_time as up to 2 days in the past, so the spec matches Nostr exactly — fine. One thing worth flagging though: a recipient comparing the wrap timestamp to their own clock will reject obviously-future wraps, but the spec doesn't define what (if any) freshness check is acceptable. If a recipient processes wraps with timestamp_hint_ms < now - 2d - tolerance as "too old" they'll drop the very wraps the jitter scheme deliberately backdates. Conversely, no freshness check at all means a stored wrap can be replayed weeks later and the DMInbox will accept it as long as (participant_set, rumor_id) doesn't dedup-collide.

Recommendation: define a freshness window (e.g. accept timestamps in [now - 4d, now + 5min]), and dedup at the rumor_id level within that window. Otherwise the spec implicitly relies on the unbounded DMInbox map for replay protection, which is unbounded-state in practice.

(Independent jitter for seal vs wrap is correct — agree with the χ² test #4.)


Generated by Claude Code

Comment on lines +68 to +76

```rust
// crates/messaging/src/dm.rs (new)
pub struct DMRumor {
pub author_peer_id: EndpointId, // real author
pub timestamp_hint_ms: u64, // jittered up to 2d in the past
pub content: Content, // reuse willow_messaging::Content
}
```
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: DMRumor.author_peer_id field naming and "no signature" claim.

Two small things:

  1. The field is author_peer_id: EndpointId — the rest of the codebase (crates/state/src/event.rs:189) uses just author: EndpointId. Recommend matching the existing convention so the rumor "looks like" a stripped-down event when serialized.

  2. "No signature. This preserves deniability" is correct in principle, but the actual deniability is provided by the seal layer being signed but never broadcast — since the rumor inside the seal is bound to seal.author by the cross-check on line 113-116, a leaked rumor + the leaker's word that "this came from author X" is, in fact, attributable as long as the seal is also leaked. NIP-59 explicitly notes this — recipients can prove a seal came from a sender (because the seal is signed by the sender), they just can't prove the inner rumor's author independently. The spec's deniability claim glosses over this. Please rewrite as: "The rumor is unsigned so that a leaked rumor alone cannot be attributed; attribution still works against anyone who leaks the surrounding signed seal." This matters for users' threat-modeling expectations.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

@intendednull intendednull left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second-pass review focused on the crypto/state-machine integration. The NIP-44 v2 payload construction is faithful in shape (ChaCha20 + encrypt-then-HMAC-SHA256, 32-byte nonce as HKDF-Expand info, 76-byte expanded output split 32/12/32, length-prefixed power-of-two padding). Threat-model framing is mostly honest. Substantive issues inline (this is a second review on top of the prior one — there is meaningful overlap with the deniability + DAG-pollution + NIP-44-fork comments, treated as independent confirmation).

Top issues (would be REQUEST_CHANGES on someone else's PR):

  1. Is the DMSeal actually broadcast on the wire? The spec is ambiguous and both readings are wrong: if yes, the real sender is attributable on every seal event (defeating the metadata-hiding goal — a passive observer of the server-ops topic sees author X authored a DMSeal at seq N, time T); if no, advancing the real author's seq/prev head with a never-broadcast event will fork the chain for everyone else (breaks dag.rs:146-172 invariants). NIP-59's design — seal exists only as the wrap's plaintext, never as a standalone event — should be adopted. Drop "real author's DAG, seq++" entirely; cache decoded seals/rumors in DMInbox side-state.

  2. Ephemeral-author DAG entries pollute shared sync state. Walking through crates/state/src/dag.rs:115-194: dag.insert() will accept a wrap (seq=1, prev=ZERO) and add a permanent entry to chains/heads. heads_summary() then carries that entry in every subsequent sync handshake — linearly growing in total DM count across the network, including for peers who never sent a DM. Wraps should ride the inbox topic as plain transport-layer payloads, not enter the per-author DAG.

  3. Multi-recipient room reconstruction is unsound without participant-set in the rumor. Replies from B → {A,C} cannot be grouped with A's original → {B,C}; deferring to a follow-up means re-encrypting historical messages later. Either ship participants on DMRumor now or scope this spec to 1:1 only.

Should-fix:

  • Section title says "NIP-44 v1" but construction is NIP-44 v2; v1 was withdrawn for security reasons. Disambiguate (or, per prior review, just adopt v2 verbatim — agreed).
  • HKDF: use distinct salts (willow-dm-seal-v1 / willow-dm-wrap-v1) for cross-layer domain separation. Cheap defense-in-depth.
  • Define a freshness window for replay protection so DMInbox isn't unbounded state.
  • recipient_hint is redundant with the per-recipient inbox topic; pick one or document why both.
  • Strengthen test #5 from "ServerState unchanged" to "state_hash unchanged" — this is the divergence-detector invariant.

Generated by Claude Code

Copy link
Copy Markdown
Owner Author

@intendednull intendednull left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline crypto/state-machine notes attached. See the summary review just submitted for the headline verdict; the per-line comments here add the specific code-level analysis for each issue (DAG insert path walk-through, NIP-44 v2 confirmation + over-size question, state-hash invariant test, replay window, multi-recipient correctness).


Generated by Claude Code

Comment on lines +100 to +110
- Signed by the **real author** and inserted at the next `seq` on
their chain, like any other event.
- `deps` MUST be empty (no cross-author dependency leaks a link to
the recipient).
- No field names the recipient; the seal's only public metadata is
"author X produced a DMSeal at seq N."
- `required_permission()` in `crates/state/src/materialize.rs` maps
this variant to `None` (unrestricted — DMs aren't server actions).
- `apply_mutation()` treats it as **inert**: the seal exists on the
author's DAG for local recall but does **not** change
`ServerState`.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most important issue: is the DMSeal actually broadcast on the wire?

The spec is ambiguous and the answer materially changes the threat model.

Two readings, both problematic:

  1. Seal is broadcast as a normal author-DAG event — which is what "Signed by the real author and inserted at the next seq on their chain, like any other event" + "ride the same wire pipeline (identity::pack / unpack)" naturally implies. Then every server peer syncing the real author's DAG sees author X authored a DMSeal at seq N, time T. That directly contradicts the threat-model row "Passive network observer / Sees after this spec / Ephemeral sender only" — the seal is signed by and attributable to the real author. Sender identity, DM count, and DM cadence all leak.

  2. Seal is created locally and only its serialized bytes are encrypted into the wrap, never broadcast. Then the spec needs to say so explicitly, AND dag.insert() of a DMSeal that bumps the real author's seq is actively harmful: it advances latest_seq(author) and the prev chain head locally, so the next legitimate event the user authors will have a seq/prev that other peers cannot reconcile (they never saw the seal). This breaks the "no chain forking" invariant in crates/state/src/dag.rs:146-172 for everyone except the local sender.

The right design is the NIP-59 one: the seal is a transient, ephemeral object that exists only as the wrap's plaintext. It is not an Event on the real author's per-author DAG and does not consume a seq. Locally caching decrypted seals/rumors for "local recall" is fine, but that belongs in the side-state DMInbox (line 226-228), not in the DAG. Drop the "real author's DAG, seq++" rule for DMSeal entirely.


Generated by Claude Code

Comment on lines +130 to +143
Rules:

- The wrapping `Event.author` is the ephemeral key, not the real
sender. `seq = 1`, `prev = EventHash::ZERO`, `deps = []`.
- The ephemeral key is used exactly once and then discarded.
- `timestamp_hint_ms` is independently jittered up to 2 days in the
past (the seal's timestamp is also jittered — independently).
- `recipient_hint` is the only metadata needed for delivery routing.
It does **not** prove the recipient was actually addressed (an
attacker can forge a hint); it's a fast filter.

Because the gift wrap lives on a single-event ephemeral-author DAG,
it does not interfere with any real peer's `seq`/`prev` chain and
does not need DAG-level merge (`crates/state/src/materialize.rs`).
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ephemeral-author DAG entry is structurally accepted but pollutes shared sync state.

Walking through crates/state/src/dag.rs:115-194 for a wrap (seq=1, prev=ZERO, ephemeral_author=fresh_key):

  • Step 3 (genesis): genesis_hash is already set, kind is DMGiftWrap not CreateServer, so it falls through — OK.
  • Step 4 (seq): latest_seq(ephemeral_author) = 0, expected_seq = 1, wrap has seq = 1 — OK.
  • Step 5 (prev): expected_prev = ZERO for an unknown author, wrap has prev = ZERO — OK.
  • Step 7: a new entry is added to chains[ephemeral_author] and heads[ephemeral_author], permanently.

Consequences worth promoting from "open question 6" to a first-class spec issue:

  1. EventDag::heads_summary() (crates/state/src/dag.rs:266-275) iterates every author. After N DMs on the network, HeadsSummary carries N + real-author entries — included in every sync handshake. Linearly blows up the sync-handshake size for everyone, including peers who never sent a DM.
  2. authors() (line 242) leaks the count of DMs that have ever transited this server to anyone who calls it.
  3. events_after() / replay-node 1000-event cap (per CLAUDE.md) means real channel events get evicted by DM wraps. Already noted in open question 3, but it's a sync correctness issue, not just a TTL one.

Recommendation: gift wraps should not enter the shared per-author DAG at all. They should be plain transport-layer payloads on the inbox topic — signed (so the recipient can verify the ephemeral signature and reject malformed input) but not subject to dag.insert.


Generated by Claude Code

Comment on lines +156 to +166

Key derivation:

```
shared = X25519(ed25519_to_x25519(sender_sk), ed25519_to_x25519(recipient_pk))
conv_key = HKDF-Extract(salt = "willow-dm-v1", ikm = shared)
expanded = HKDF-Expand(prk = conv_key, info = nonce, L = 76)
chacha_key = expanded[0..32]
chacha_iv = expanded[32..44] // 12 bytes
hmac_key = expanded[44..76]
```
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIP-44 cross-check confirms the construction; one HKDF defense-in-depth nit and one over-size question.

The construction is correct in shape — ChaCha20 (no Poly1305) + encrypt-then-HMAC-SHA256 over nonce ‖ ciphertext, 32-byte nonce as HKDF-Expand info, 76-byte expanded output split 32/12/32, length-prefixed power-of-two padding. That all matches NIP-44 v2 verbatim modulo the salt change.

Two notes (the prior review covers the bigger "don't fork v2 at all" point — agreeing):

  1. Confirm conv_key is per-pair-of-keys, not per-message. HKDF-Extract(salt = "willow-dm-v1", ikm = shared) produces the same conv_key for the same (sender_sk, recipient_pk) pair across every message. NIP-44 v2 is built that way deliberately so the per-conversation derivation can be cached, and per-message uniqueness comes entirely from the random nonce in HKDF-Expand info. Worth a one-line note so implementers don't try to "improve" it by mixing in a counter or timestamp (which would break the cache and add no security).

  2. Cross-layer HKDF domain separation. The same "willow-dm-v1" salt is used for both the seal-layer encryption (real_sender_sk → recipient_pk) and the wrap-layer encryption (ephemeral_sk → recipient_pk). Because the input keys differ the resulting conv_keys differ, so there's no key reuse — but cross-layer domain separation is cheap. Use "willow-dm-seal-v1" and "willow-dm-wrap-v1" so a future bug that confuses the two layers (e.g. swapping inner/outer ciphertexts in a refactor) fails closed at HKDF rather than silently producing structured-but-wrong output. NIP-59 doesn't bother because Nostr only has one NIP-44 layer; Willow has two stacked.

  3. Over-size plaintext. The padded-plaintext layout uses [u16 BE length], capping at 65535 bytes. Content::Text is unbounded today. Test Add just dev to start all services for local development #7 only covers up to 600 B — please add a vector at 65535 and one over (rejected? clamped?) so an over-size DM doesn't silently truncate to 16 bits.


Generated by Claude Code

Comment on lines +238 to +246

Group DMs produce **N independent gift wraps**, one per recipient
(including the sender themselves so their other devices see a copy,
matching NIP-17's rule). There is **no shared identifier on the
wire**: each wrap has a distinct ephemeral author, distinct
ciphertext, distinct timestamp. Clients reconstruct a "room" locally
by hashing the sorted participant set from the decrypted rumor's
`Content`-level metadata (to be defined in a follow-up — for now
group DM is just "DM to these N pubkeys").
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-recipient: the rumor must carry the participant set, or replies break.

"Clients reconstruct a 'room' locally by hashing the sorted participant set from the decrypted rumor's Content-level metadata (to be defined in a follow-up — for now group DM is just 'DM to these N pubkeys')" leaves a real correctness hole, not just a UX gap.

If Alice sends a wrap to {Bob, Charlie} and Bob replies to {Alice, Charlie}, both must end up grouped under the same local room ID. That requires the rumor itself to carry the canonical sorted participant list (Alice, Bob, Charlie) so that room_id = blake3(sorted_participants) is computed identically by every recipient. NIP-17 handles this with explicit p-tag participants on the rumor.

Without this in v1 of the spec, two recipients of the same DM cannot agree they are in the same conversation, "reply to room" cannot be implemented, and bolting it on later will require migrating already-encrypted-and-stored rumors. Recommend either (a) defining the participants field on DMRumor now (cheap), or (b) explicitly scoping this spec to 1:1 only and removing the "Multi-recipient" section until the follow-up exists.


Generated by Claude Code

Comment on lines +252 to +263

Each layer independently jitters `timestamp_hint_ms`:

```
t_wrap = now_ms - rand(0, 2 * 86_400_000)
t_seal = now_ms - rand(0, 2 * 86_400_000)
```

Independent jitter breaks the obvious "wrap.ts == seal.ts" linkage a
relay could exploit. `HLC` (`crates/messaging/src/hlc.rs`) is **not**
used for DMs — HLC is designed for totally-ordered channel history
and would leak real sender clocks.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replay window is undefined; DMInbox is implicitly unbounded state.

NIP-59 specifies randomize_time as up to 2 days in the past, so the spec matches Nostr exactly — fine. But the spec doesn't define what (if any) freshness check is applied at the recipient. Two failure modes:

  • A recipient that rejects timestamp_hint_ms < now - 2d will drop the very wraps that the jitter scheme deliberately backdates.
  • A recipient with no freshness check at all means a stored wrap can be replayed weeks later and DMInbox will accept it as long as (participant_set, rumor_id) doesn't dedup-collide. Combined with the unbounded growth of DMInbox over time, this is unbounded-state replay protection in practice.

Recommendation: define a freshness window (e.g. accept wrap timestamps in [now - 4d, now + 5min] to leave headroom over the 2d backdate), and dedup at the rumor_id level within that window. State the DMInbox retention/eviction policy explicitly.

(Independent jitter for seal vs wrap is correct — agree with the χ² test #4.)


Generated by Claude Code

Comment on lines +219 to +230
2. `apply_event()` in `crates/state/src/materialize.rs` recognises
`DMGiftWrap` / `DMSeal` and short-circuits: it does **not**
mutate `ServerState`. Both return `None` from
`required_permission()` and are listed in the catch-all comment
alongside `SetProfile` (see
`docs/specs/2026-04-12-state-authority-and-mutations.md:96–107`).
3. A separate `DMInbox` side-state (local, per-client, not shared)
collects decrypted rumors, keyed by `(participant_set, rumor_id)`
where `rumor_id = blake3(bincode(DMRumor))`.
4. **Dedup** happens at the rumor layer: two different ephemeral
wraps of the same rumor produce identical `rumor_id`s after
decryption.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

State-machine integration: strengthen test #5 to a state_hash invariant, not just ServerState equality.

crates/state/src/materialize.rs:283-298 has a documented checklist that says: "If a new EventKind variant is added and is NOT listed here or in an arm above, it will silently get no permission check. That is a bug." — a deliberate guard against accidentally-permissionless events. Adding DMSeal / DMGiftWrap to the catch-all comment alongside SetProfile (line 290) is correct, but worth stating in the spec that they are explicitly listed in that comment block, not just allowed to fall into the wildcard _ => None arm. Make this imperative ("MUST add a comment line in required_permission() listing both new variants").

Subtler concern: SetProfile is unrestricted-but-still-mutates-state. DMGiftWrap would be unrestricted and non-mutating — there is no other "no-op event" variant in EventKind today, so the apply path (apply_mutation returning without modifying ServerState) is novel.

Please add a state-machine test that round-trips a (CreateServer, DMGiftWrap, SomeOtherMutation) sequence and confirms the resulting state.hash() is identical to (CreateServer, SomeOtherMutation) — i.e. the wrap is provably inert with respect to the state-hash divergence detector. Test #5 in the table only says "leaves ServerState unchanged" which is weaker than "leaves state_hash unchanged" and won't catch a bug where someone adds a transient field that's rolled into the hash.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

@intendednull intendednull left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 2 review — should this be MLS instead?

Round 1 covered NIP-44/59/17 conformance and crypto details. This round zooms out: is Nostr the right template here at all? Having spent another pass on the comparative literature, I think this is the highest-leverage architectural question across the whole DM PR series, and I want to make the case directly that the spec as written is climbing the wrong hill.

The core problem: NIP-59 is a privacy envelope without a forward-secrecy layer underneath

The spec borrows NIP-59's three-layer structure (rumor → seal → gift wrap) more or less verbatim. That structure was itself borrowed from Signal's Sealed Sender mechanism — but Signal can get away with sealed sender precisely because every conversation already has a Double Ratchet underneath it providing forward secrecy and post-compromise security. Sealed Sender is a metadata wrapper bolted onto a session that's already FS+PCS.

Nostr does not have that underlying layer, and the Nostr community openly acknowledges this. From the NIP-44 / NIP-17 discussions: gift wrap "gives some degree of deniability/repudiation but doesn't solve forward secrecy or post compromise security — if a user's private key or calculated conversation key is compromised, the attacker will have full access to all past and future DMs sent between those users." NIP-EE (MLS-on-Nostr) was added explicitly because NIP-17 was understood to be a stopgap; NIP-EE itself is now marked as superseded by the Marmot Protocol, which is also MLS-based. The trajectory in Nostr is unambiguously away from NIP-59-as-a-DM-primitive and toward MLS.

This spec's threat-model row "Recipient's device post-compromise — Historical plaintext (no PCS — see non-goals)" reads as a trade-off, but in 2026 it is a regression relative to the state of the art. Willow would be shipping a privacy envelope that is strictly weaker than Signal (no Double Ratchet underneath) and strictly weaker than MLS for any group DM (no ratchet tree, O(N) wraps per recipient).

Matrix Megolm is the lived warning

Matrix's Olm/Megolm stack is the closest analogue to "group-chat ratchet over a gossip-ish protocol without MLS," and the operational result is a multi-year UTD ("Unable to Decrypt") saga. Element runs entire conference talks on it (Kegan Dougal's "Unable to decrypt this message," MatrixConf 2024). The Nebuchadnezzar paper documented practically-exploitable cryptographic vulnerabilities in Matrix specifically tied to Megolm key sharing. The failure modes — out-of-sync ratchet positions, missing device keys, races in session establishment — all live exactly in the seam this spec is creating: ephemeral-author wraps, per-device session state, and a one-event throwaway DAG with no replay path. Willow is about to step into that hole.

Why MLS composes well with Willow specifically

MLS (RFC 9420) is not foreign to Willow's model. A few observations from re-reading the architecture:

  1. MLS group state is per-group, not per-server. Willow already has the right boundary — a DM "room" can carry an MLS group state independently of ServerState. The ratchet tree updates on Add/Remove/Update/Commit are themselves events that can ride the existing Event/apply() pipeline; the spec's "DMSeal/DMGiftWrap leave ServerState unchanged" inertness rule is exactly what an MLS-application-message variant would also want.
  2. The RFC 9420 wire framing maps cleanly onto Willow's Event. MLSCiphertext is the analogue of the gift-wrap payload, and the per-epoch sender-data masking (RFC 9420 §6.3.2) is what gives you the metadata-hiding properties NIP-59 is reaching for — but with FS+PCS as a side effect of the ratchet tree, not an explicit non-goal.
  3. Storage costs are actually better for groups. This spec's group DM is O(N) wraps per message (see line 248–249); MLS group send is O(1) ciphertext + O(log N) tree updates per epoch. The spec's own Open Question #2 ("MLS for group DMs") concedes this.
  4. XMTP and Bluesky/Germ both picked MLS for the same problem, and both cite the same reasoning: sealed-sender-style metadata hiding without an underlying ratcheted session was insufficient for shipping. XMTP's NCC Group review (Dec 2024) is the kind of external validation an MLS-on-Willow spec could cite for free.

Multi-device is silently load-bearing

The spec punts multi-device entirely (Open Question #5). This is a meaningful product limitation that should be on the front page, not the open-questions list. The prior art here is Sesame (Signal's multi-device session management) — and Sesame is non-trivial precisely because you have to fan out Double Ratchet sessions per device while maintaining FS. Without a session abstraction (which the spec also doesn't have), "multi-device DMs" effectively means "share your Ed25519 identity key across devices," which destroys the device-compromise blast-radius story for anyone using more than one device. MLS handles multi-device natively as just-another-group-member; that is not an accident.

Briar comparison (since Willow is also fully P2P)

Briar is the closest direct analogue: fully peer-to-peer DMs with PFS over a gossip-style transport. Briar uses per-contact rotating transport keys plus a Double-Ratchet-like construction; it does not use a NIP-59-style envelope, and the reason is exactly the FS-without-an-underlying-ratchet problem. If the argument for this spec is "we're P2P so MLS is hard," Briar is a counter-example showing FS is achievable in pure P2P — and the cost is a session abstraction, which the spec admits is missing.

Recommendation

I think the right move is to defer or close this PR and replace it with an MLS-over-Willow spec. Concretely:

  • Reframe the seal/gift-wrap envelope as one possible transport encoding for MLS application messages, not as the DM primitive itself. The metadata-hiding properties from NIP-44/59 (size buckets, ephemeral wrap authors, per-recipient inbox topics) are still useful — but they belong on top of an MLS handshake, not instead of one.
  • Adopt RFC 9420 group state as the DM session abstraction. This kills Open Questions #2 (group DMs), #4 (FS / epoch rotation), and #5 (session keys) in one move.
  • Treat Open Question #6 (ephemeral-author DAG pollution) as a hint that the one-event-DAG construction is fighting Willow's data model. MLS Welcome events going through a per-recipient inbox topic, plus MLS application messages riding the existing channel transport, is a cleaner factoring.
  • Keep the NIP-44 KAT-compatible test vectors as a crypto-primitive test target if useful, but stop committing to NIP-44 as the wire format. Marmot's trajectory in the Nostr ecosystem is the relevant signal: even Nostr is moving on.

This is not a small change to the PR — it is essentially "different spec." But the cost of shipping NIP-17/59 first and then layering MLS on top later is exactly the cost Matrix paid going from Olm to (proposed) MLS migration: you carry both protocols forever and the legacy one keeps producing UTD bugs. Better to pay the design cost once, now, while the DM surface area in Willow is still zero.

I'd push back on framing this as "non-goals" rather than "regressions vs. 2026 baseline." The non-goals list reads like a 2022 spec; the rest of the messaging ecosystem has moved.

Sources


Generated by Claude Code

intendednull pushed a commit that referenced this pull request Apr 25, 2026
Apply review decisions to the relay capability document spec:

- Promote signing to v1 MUST (inline signature, RFC 8785 JCS canonical
  bytes, signature field excluded from canonicalisation).
- Specify dispatch surgery: explicit branch in dispatch_connection for
  /.well-known/willow plus OPTIONS preflight; reuse BOOTSTRAP_IO_TIMEOUT
  and MAX_CONCURRENT_BOOTSTRAP_CONNECTIONS; extend (not mirror) the
  handle_bootstrap_connection pattern.
- Drop event_schema_range (no EVENT_SCHEMA_VERSION exists in
  willow-state); list as future work.
- Resolve multi-tenant question: one shared doc per host, relay is
  topic-agnostic.
- Soften operator-metadata leakage: version is coarse semver, software
  is project name, both MAY be omitted.
- Two-tier caching by status: ok=300s, degraded/read_only=5s with
  must-revalidate.
- Recommend WS clients also send Sec-WebSocket-Protocol; JSON is
  advisory pre-connect.
- Fix port framing: relay binds one port multiplexing TCP+WS, not two.
- Drop sync_provider_only (operator vibes without a concrete
  pre-handshake check).
- Add Cross-spec coordination table pinning feature tags for #214,
  #216, #217, #218, #219, #220, #221.
- Rewrite Open Questions to keep only genuinely-open items (paid-relay
  semantics, utilisation telemetry, relay discovery, feature registry).

https://claude.ai/code/session_01XmbVXWnKTRVjPp9kmKRSBn
claude and others added 2 commits April 25, 2026 07:09
- Replace stale `EventStore` reference with `EventDag` / `ManagedDag`
  in Open Questions (legacy trait has been removed from willow-state).
- Reword multi-device section: "peer ID" -> "endpoint ID" to match
  the codebase's `EndpointId` terminology.
- Update Appendix A.1 rumor field name from `author_peer_id` to
  `author_endpoint_id` and call out that Willow uses `EndpointId`.
- Footnote Appendix A.2 NIP-44 pseudocode with the real helper names
  in willow-crypto: `identity_to_x25519` and `ed25519_public_to_x25519`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@intendednull intendednull merged commit a5c8993 into main Apr 26, 2026
5 checks passed
@intendednull intendednull deleted the claude/spec-seal-gift-wrap-dms branch April 26, 2026 07:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants