Skip to content

chat-xdk: sign, check replies and retry feature reads as the native clients do: v1 chats interoperate with X clients - #76

Open
santiagomed wants to merge 30 commits into
mainfrom
cursor/v1-parity-fixes-c6d7
Open

santiagomed wants to merge 30 commits into
mainfrom
cursor/v1-parity-fixes-c6d7

Conversation

@santiagomed

@santiagomed santiagomed commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Brings the SDK's v1 behavior in line with the native clients in every binding (Rust, Python, JS/WASM, Go, JVM, .NET, C ABI). Every fix lands once in crates/core. The bindings gain the retry loop for the feature-switch read, three optional fields a v1 send needs when the caller holds no original event (see New optional fields), and their parallel tests. Nothing else in the public API changes.

  • Signatures
    • Every signed action follows the send format: version 7 while a chat sends v0, and 10 while it sends v1. This covers key changes, group creates (their type and TTL are then signed), member adds, removals and leaves, deletes, read receipts, and admin, invite, join, TTL and screen-capture changes. A member add with a description still signs at 8 while sends are v0.
    • Removals and leaves no longer sign at 11, so 0.5.x readers verify them while sends are v0.
    • All signing goes through the one payload builder the verifier uses.
    • A group change that removes a title, description or avatar now verifies: the native clients sign it with null in the value's place.
    • An event whose signature carries no version, or one that isn't an integer, fails verification instead of verifying as 7.
    • Key versions of messages and key changes sign as integers at every version.
  • Replies
    • A preview is valid only when the first 140 characters of its text, counted in UTF-16 code units, match the quoted text, and its entities are exactly the quoted message's (or its edit's).
    • Replies no longer embed key changes (raw_event_ckces), and decrypt never reads them from a reply.
  • v1 sends
    • The encoded signature lists the signing keys the message relies on (message_signing_key_info_list): the sender's, plus the quoted message's and its edit's for a reply.
    • A reaction asks for a notification only in a one-to-one conversation; a reaction removal or an edit asks for none.
  • Files: payment receipts take no position when attachments without an id are numbered.
  • Feature switches
    • A failed startup read is retried in the background: 1 s, then doubling, at most 5 min apart, 10 times. The schedule lives in the core and every binding follows it.
    • The first answer that arrives sets the switches, so a chat reaches v1 once a retry succeeds.
    • set_feature_switches / chat_xdk_set_feature_switches accept a later answer after a failed one.
  • Docs: each change is folded into the 0.7 migration guide for every binding and into why.md, with install pins moved to 0.7.2. API.md (including how to hash a file in each binding), CRYPTO.md, ARCHITECTURE.md, the READMEs, AGENTS.md (the bounded retry replaces the fetch-once rule) and the CHANGELOG are updated too. The guides now build a reply from explicit fields with reply_to_message_id.

Before / after

left = chat.prepare_group_leave(GroupLeaveParams("g100"))
[s["message_event_signature"]["signature_version"] for s in left["action_signatures"]]

# Before: the removal signs at 11 and the delete at 7, whatever the send format
# ['11', '7']

# After: every action follows the send format
# ['7', '7']    while the chat sends v0 (0.5.x readers verify both)
# ['10', '10']  once v1_send is on, as the native clients sign

# Feature switches, startup read fails once:
# Before: every switch off for the chat's lifetime, sends stay v0
# After:  retried 1 s later, the answer turns v1_send on for the chat

New optional fields

Each one is optional in every binding, and callers that don't set it build the same sends as before.

  • reply_to_message_id on EncryptReplyParams (replyToMessageId in JS and on the JVM, ReplyToMessageID in Go, ReplyToMessageId in .NET). It names the quoted message by its signed message_id. X clients mark a v1 reply that embeds no message-key original and names no original invalid, so a reply built from the explicit preview fields needs it. With reply_to_event, the id comes from the event and the field overrides it.
  • target_message_id on EncryptEditParams (targetMessageId in JS and on the JVM, TargetMessageID in Go, TargetMessageId in .NET). It names the edited message. With target_event, the id comes from the event and the field overrides it. Without target_event, the SDK has only the target's sequence id, so a v1 edit built from conversation_id and target_message_sequence_id needs it. A v0 edit names none, as in 0.5.x.
  • plaintext_sha256 on the media attachment descriptor. It is the base64 SHA-256 of the file's plaintext bytes, which the message carries and signs. The message is built before encrypt_file sees the file, so the caller hashes the file. A value that is not the base64 of 32 bytes fails the send with an invalid-input error that names it. Decrypted media attachments return it as plaintext_sha256 (plaintextSha256 in JS), or null when the sender sent none. The JVM and .NET get an eight-argument media / Media factory, so existing calls keep compiling. Go gets AttachmentDescriptor.PlaintextSHA256.

Rust source change: AttachmentDescriptor::Media gains the plaintext_sha256: Option<String> field. Code that builds the variant with a struct literal must add plaintext_sha256: None, and a pattern that lists every field must add it or end in ... The CHANGELOG notes this.

Notes for review

  • A removal at 7 or 10 does not sign the new key version its event names (11 did). The key change that carries the new key signs its version, and nothing reads a key from the removal.
  • A v1 edit asks for no notification. The native clients notify on an edit only behind a switch of their own; if that switch is on by default, edits should notify instead.
  • The committed Go prebuilts are rebuilt by the prebuilt sync workflow on push.

Testing

  • make ci passes: fmt, clippy -D warnings, cargo-deny licenses, workspace tests and the bindings gate.

  • The fuzz targets compile.

  • Locally, the Python, JS/WASM, Go, JVM and C ABI suites pass, each with a new test that a leave signs at 7, then at 10 once v1 is on. The .NET suite (updated the same way) couldn't restore packages here, so CI runs it.

  • The migration-guide snippet checks pass for all seven languages.

  • The 0.5.0 / 0.5.2 compat check passes unchanged.

  • New core tests:

    • known-answer payloads for every action at versions 7 and 10, plus key changes, group creates and member adds;
    • null removals, missing and non-integer versions, and integer key versions;
    • exact preview text and entities, including a surrogate pair at the 140-unit limit;
    • no embedded key changes, the signing-key list, notification flags and payment-receipt numbering;
    • the retry schedule, joining chats, dropped chats, late answers and answers that hold.
  • The new fields have core tests for v0 and v1 sends, overrides of the derived ids, the decrypted hash, and invalid hashes in a message, a reply and a reply preview. There is a C ABI invalid-input case, and every binding has a round trip that reads each field back from the decrypted send.

  • For that change, cargo test --workspace, clippy -D warnings, fmt, the Go and Python suites, and the Rust, Python, TypeScript, Go and C guide snippet checks pass locally. The JS, JVM and .NET suites run in CI.

Open in Web Open in Cursor 
cursoragent and others added 16 commits October 29, 2026 21:06
…every binding

The core keeps the whole policy: a read that fails, times out or answers
anything but a 200 features object is retried after 1 s, then after twice
the previous wait, never more than 5 minutes, at most 10 times, and the
first answer that is a features object holds for the chat's lifetime.
Chats created while a read is in flight or waiting for its retry share it.
Each binding performs the GET and every retry the core hands back after
its delay_ms; Rust and C ABI callers hand over a later answer after a
failed one.

Co-authored-by: Santiago Medina <santiagomed@users.noreply.github.com>
…signed null

When the native clients remove a group's title, description or avatar
they send the change without the value and sign it as the string null.
The verifier rebuilt an empty string, so decrypt_event rejected those
changes under the default policy. It now rebuilds null for an absent
value and keeps an empty one empty, and the decrypted change carries an
empty value.

Co-authored-by: Santiago Medina <santiagomed@users.noreply.github.com>
…versions as integers

As the native clients do, a signature that carries no version, or one
that is not an integer, no longer verifies (it was read as version 7),
and a message's conversation-key version signs as its integer value, or
null, at every version, not only from 8. A key change's version signs as
its integer value too, and a legacy key change signs only the participant
fields it carries.

Co-authored-by: Santiago Medina <santiagomed@users.noreply.github.com>
… and leaves included

While v1_send is on, key changes, group creates, member adds, removals
and leaves, deletes, read receipts, settings, invites, admin, join and
metadata changes all sign at signature version 10, as messages do, in
the layout of version 10: a group create signs its group type and TTL,
and a member add its description slot. While it is off each signs at its
legacy version: 7, or 8 for a member add that carries a description, a
member removal and a leave included. Every action is signed over the
payload the verifier rebuilds from its event, so the signer and the
verifier share one layout per version.

Co-authored-by: Santiago Medina <santiagomed@users.noreply.github.com>
…ng them

A reply still opens its original with the key changes passed in
reply_to_ckces, but no longer embeds them as raw_event_ckces, and a
received preview's original opens only with the keys the receiver holds
or the message key the reply carries, as the native clients do.

Co-authored-by: Santiago Medina <santiagomed@users.noreply.github.com>
…ed text and its exact entities

A preview's text must match the first 140 characters of the quoted text,
counted in UTF-16 code units as the native clients count them, an absent
text counting as empty, and its entity list must be the quoted message's
exactly. A shorter prefix or a subset of the entities no longer passes.

Co-authored-by: Santiago Medina <santiagomed@users.noreply.github.com>
…ttachments

An attachment without an id derives its key from its position, and the
native clients leave payment receipts out of a message's attachments
before numbering them, so a file after a receipt takes the receipt's
position. Other attachments the SDK does not surface keep theirs.

Co-authored-by: Santiago Medina <santiagomed@users.noreply.github.com>
…s it quotes

As the native clients do, a v1 send's signature carries
message_signing_key_info_list: the author and signing-key version of a
reply's quoted original and edit, then the sender's own, each once, for
the X API to fill in the keys a recipient needs to verify the quoted
events. v0 sends carry no list, as 0.5.x sent them.

Co-authored-by: Santiago Medina <santiagomed@users.noreply.github.com>
…ents do

While sends are v1, a reaction asks for a notification only in a
one-to-one conversation, removing a reaction never does, and an edit
never does: the native clients notify on an edit only behind a switch of
their own, which the SDK does not have. v0 reactions and edits keep
should_notify true, as 0.5.x sent them.

Co-authored-by: Santiago Medina <santiagomed@users.noreply.github.com>
…mat's version

The binding suites expect a member removal and a group leave at signature
version 7 while sends are v0, and each checks a leave signs its removal and
conversation delete at version 10 once v1_send is on.

Co-authored-by: Santiago Medina <santiagomed@users.noreply.github.com>
A valid answer with every switch off holds like any other; only a failed or
late response is retried or leaves a chat open to a later answer. Go's
constructors no longer say the switches are read once.

Co-authored-by: Santiago Medina <santiagomed@users.noreply.github.com>
PREVIEW_TEXT_UNITS counts UTF-16 code units, the unit the comparison
takes the first 140 of.

Co-authored-by: Santiago Medina <santiagomed@users.noreply.github.com>
…r send-format version

A removal names the new key version without signing it, a leave's
conversation_key_version is named on its removal, a member add signs at
10 while sends are v1, and Rust callers retry a feature read that fails.

Co-authored-by: Santiago Medina <santiagomed@users.noreply.github.com>
…hecks into the 0.7 guides

Every guide and why.md say where each topic already lives: actions sign at
the send format's version (removals and leaves included, so only a member
add with a description needs readers upgraded first while sends are v0), a
failed feature read is retried with bounded backoff, a v1 payload lists its
signing keys and asks for notifications as X clients' do, a reply preview
must quote its message exactly and carries no key change, and a change that
removes a title, description or avatar decrypts to an empty value. Install
lines and snippet markers pin 0.7.2.

Co-authored-by: Santiago Medina <santiagomed@users.noreply.github.com>
…signing and retried feature reads

API.md and CRYPTO.md give every action's version (7 while sends are v0, 10
while they are v1), the removal's unsigned key version, integer key
versions and null for a removed group value at every version, payment
receipts' place in attachment numbering, and the exact reply-preview
check without embedded key changes. ARCHITECTURE.md, the READMEs and
AGENTS.md describe the bounded retry of a failed feature-switch read.

Co-authored-by: Santiago Medina <santiagomed@users.noreply.github.com>
… parity fixes

The changes since 0.7.1 go under Unreleased; the entries that shipped in
0.7.1 move under its own heading.

Co-authored-by: Santiago Medina <santiagomed@users.noreply.github.com>
@CLAassistant

CLAassistant commented Oct 29, 2026 •

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 3 committers have signed the CLA.

✅ santiagomed
❌ cursoragent
❌ github-actions[bot]
You have signed the CLA already but the status is still pending? Let us recheck it.

@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor


Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


I have read the CLA Document and I hereby sign the CLA


1 out of 2 committers have signed the CLA.
✅ (santiagomed)[https://github.com/santiagomed]
❌ @cursoragent
You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

… explicit fields

A v1 send the caller builds without the original event can now say what
X clients need, in every binding:

- EncryptReplyParams.reply_to_message_id names the quoted message by its
  signed message_id. X clients mark a v1 reply that embeds no message-key
  original and names no original invalid; with reply_to_event the id
  comes from the event and the field overrides it.
- EncryptEditParams.target_message_id names the edited message. A v1 edit
  names the message it edits; with target_event the id comes from the
  event and the field overrides it. A v0 edit names none, as in 0.5.x.
- A media AttachmentDescriptor takes plaintext_sha256, the base64 SHA-256
  of the file's plaintext bytes, which the message carries and signs. The
  message is built before its files are encrypted, so the caller hashes
  the file. One that is not the base64 of 32 bytes fails the send with an
  invalid-input error. Decrypted media attachments return it.

The JVM and .NET add an eight-argument media factory so existing callers
keep compiling. Rust's AttachmentDescriptor::Media gains a field, so a
struct literal of it adds plaintext_sha256.
@santiagomed santiagomed changed the title chat-xdk: sign, check replies and retry feature reads as the native clients do: v1 chats interoperate with X clients, with no API change Oct 30, 2026
github-actions Bot and others added 6 commits October 30, 2026 19:18
…reply by sequence id can name the original's id
- features: a chat that joins after a GET's deadline starts a GET that
  also answers the chats still waiting on the expired one, which used to
  be dropped with their retries. A GET a binding could not start
  (`bindings::abandon_fetch`, used by Python when its thread will not
  start) schedules no retry, and the next chat's GET answers its chats;
  before, a phantom retry took every chat created in the next 3 s.
- signatures: a conversation-key change never signs below the highest
  version of a key change the chat signed or verified in that
  conversation. A chat sends v0 until its feature read answers, so it
  could sign a key change at 7 after one at 10, which X clients that
  enforce a key-change signature-version floor reject as a downgrade.
- replies, edits: a `reply_to_message_id` or `target_message_id` that is
  not the message id the reply's or edit's event carries fails with
  InvalidInput; recipients reject a preview whose id is not its embedded
  original's. Every binding tests the error.
- reply validation compares the preview's entities with the quoted
  message's without copying them.
- comments state the rule instead of pointing at other clients.
- docs/API.md types the descriptor's `plaintext_sha256` as
  `string | null`, as index.d.ts does.
github-actions Bot and others added 4 commits October 30, 2026 23:43
…t; audit comments and docs

Feature switches
- A chat reads GET /2/chat/features again 5 minutes after each answer, so a
  change to v1_send reaches running chats without a restart or a new chat.
  Each valid answer replaces the one before; a failed read keeps the
  current answer and is retried after 1 s, doubling, capped at 5 minutes,
  while a chat waits. Reads stop once no chat sharing them is alive.
- With a session identity the request carries ?bucket=NNNN, the last four
  digits of the user id, so the gateway buckets a sender the same way
  wherever it runs; without one it buckets by address as before. Every
  binding's set_identity starts the read for the new URL. Chats that read
  the same URL share one read, and a chat that joins takes its latest
  answer at once. A repeated start rejoins a read its binding abandoned.
- Rust and the C ABI add feature_switches_url / chat_xdk_feature_switches_url
  and FEATURES_REFRESH_MS / CHAT_XDK_FEATURES_REFRESH_MS; each answer handed
  to set_feature_switches replaces the one before. The internal configure
  hook takes refresh_ms. The Rust example bot reads its sender's URL and
  refreshes and retries on that schedule.
- A prepare call that signs several actions reads the send format once, so
  a refresh between its signatures can't split them across versions.

Audit
- Comments describe local behavior, keep siblings symmetric and match the
  code; stale text about one-time reads, ten retries and query-free
  requests is gone from code, READMEs, guides, API, ARCHITECTURE, CRYPTO,
  AGENTS.md and the CHANGELOG.
- Guides cover the key-change signature floor, the edit-side
  target_message_id, and the invalid-input error for an explicit id that
  differs from the event's.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

4 participants