Skip to main content

Text message

Scope and identity

This contract covers ordinary durable text messages in the Rust account runtime and its authored Android API: send, authenticated receive, edit, deletion, lookup, paging, search, context, conversation preview and reopen. The same message can have several outer encrypted copies. Its identity is the logical rumor ID, not a wrapper ID, command ID or position in a list.

Reply/forward relationships, groups and attachment metadata remain supported by the existing APIs. This first contract set verifies their interaction with message identity and edit retention only where explicitly stated below. It does not claim complete contracts for those other use cases or the other platforms.

Supported behavior

Message identity

message.text.identity — A message has one original author, logical ID, original timestamp and conversation. The canonical writer derives participant tags and recipient-copy inputs from the conversation. Edits change the effective text of that message; they do not create a new message identity or move it to an edit timestamp in the timeline.

The authenticated-event contract owns encryption and authentication. The storage contract owns the relationship between logical rumors and signed outer wrappers. Wire tag order, reader compatibility and exact NIP-59 framing remain in the event profile.

Durable send

message.send.durablesend validates a stored conversation and content, then commits the local message and outgoing operation before transport can publish its recipient copies. The caller retains one command ID for a retry. Replaying the same send command with the same semantic input returns the same logical operation and message identity, without creating new recipient copies. Reusing that ID for different semantic input fails with command_conflict.

A result may expose the message's current effective state; replay does not promise that every mutable result field equals an earlier snapshot. A locally visible message is not proof of relay acceptance or recipient delivery; use the durable-delivery contract.

For an ordinary text-only send, text must be non-empty. The account input bound is 64 KiB of UTF-8 text, but that is not a promise that every string below that bound fits its serialized rumor and both encrypted envelopes. Complete-event preflight applies independently before committing an outgoing operation. Messages and commands owns the current callable recipe and exact operation-level input/error summary.

Composer draft

message.send.draft — When a composer supplies an exact saved draft snapshot to consume, Rust compares it with the stored draft and clears it in the same transaction as the new message only if it still matches. A newer autosave with different content is preserved. Replaying an already committed send must not consume another draft. Cancelling a UI observer is not evidence that a command did not commit.

Duplicate receive

message.receive.idempotent — Receiving the same authenticated message again, including through another valid encrypted wrapper, retains one logical message. An edit or deletion arriving before its target must remain available for interpretation when that target arrives. Arrival order must not select a different final effective message from the same accepted history.

The shared storage contract owns deduplication and provenance. The message history cases verify their effect on this semantic type rather than repeating cryptographic vector tests.

Effective edit

message.edit.latest-authorized — An edit refers to an original message and is eligible only when authored by that message's author. Among eligible edits not themselves deleted by an authorized deletion, the later created_at wins; the lexicographically lowest event ID breaks an equal-time tie. Deleting the winning edit restores the previous eligible edit or the original text.

An edit changes text and custom-emoji interpretation. Original attachments and reply/forward relationships remain properties of the original message. The local account API rejects replacement attachments, a non-owned or deleted parent, and empty replacement text for a text-only original. Clearing an attachment caption is an existing supported boundary case, not a new media contract here. A valid deletion of the original dominates all its edits.

Edit replay

message.edit.replay — A previously committed edit can be retried with its original command ID and identical input even after the parent message has been deleted. Rust resolves that committed command before applying the parent-state preconditions for a new edit. A different payload with the retained command ID still fails with command_conflict; a new edit of the deleted parent fails with invalid_account_operation. None of these replay/rejection paths creates another outgoing operation or advances the account revision.

Effective deletion

message.delete.authorized — A deletion affects a target only when its author is that target's author. Deleting an original message leaves a tombstone in direct lookup, timeline pages and context, with deleted=true; it excludes the message from search and live conversation previews. Deleting an edit removes that edit's eligibility instead of deleting the original message.

Protocol deletion is not physical database erasure. The account message view can retain text and attachment fields while deleted=true, and exact retained event data remains separately accessible. Android must render a deleted marker instead of presenting those retained fields as live content. The lower-level pure projection API can represent a tombstone differently; callers must use the contract of the API they consume.

deleteMany accepts 1–100 distinct locally authored messages from one conversation, creates one deletion operation and commits the complete selection atomically. A retry with its retained command and selection does not create a second operation. There is no per-item partial-success result.

Persistence semantics

The original rumor, eligible and ineligible authenticated relation events, wrapper provenance, projections and outgoing intents remain distinguishable. An edit does not overwrite the original authenticated message. Deletion does not remove the history needed to reproduce the authorized effective state.

The selected effective edit must be interpreted consistently by product queries and the derived full-text index. The internal effective_message_edits view centralizes this selection. Concrete schema/table details remain private to Rust storage; Android never updates those tables directly.

History reopen

message.history.reopen — After closing and reopening the same account, retained authenticated history must produce the same message identity, effective edit/deletion state and query visibility. In-memory caches or an open UI must not be required to recover that result.

History migration

message.history.migration — Opening a schema-15 account with a stale full-text entry for a subsequently deleted edit must rebuild the effective search state from retained authenticated history during the upgrade. It must not change the logical message's identity or recreate a deleted edit as the current text.

This is a concrete compatibility case, not a claim that every historical schema has been tested. The fixture contains public test identities and generated authenticated records, not a production database.

Migration rollback

message.history.migration-rollback — A failure inside the schema-15 upgrade transaction must roll back its partial schema/data changes and leave the prior schema usable for an explicit retry. The regression injects a failing migration statement, checks the old state, then removes that failure and successfully retries. This is deterministic transaction-failure evidence; it does not simulate disk exhaustion, process termination or power loss.

Observable API and Android behavior

Effective queries

message.query.effective — At a stable account state, direct message lookup, message page and context must agree on a live message's effective text, selected edit and deletion flag. Search must match the effective live text, not a deleted or superseded edit. Conversation preview must refer to the latest live original message and display that message's effective text.

These APIs have intentionally different inclusion policies:

APIOriginal deletedWinning edit deleted
messages.getReturns a tombstone when the original is retained.Returns the previous eligible edit or original.
messages.page / messages.contextRetains the timeline tombstone.Keeps the original timeline position with restored effective text.
messages.searchExcludes the original.Finds restored effective text; does not match the removed edit's text.
Conversation previewSelects a live message, or no last message.Displays the restored text if this original is the preview message.

A series of queries during concurrent writes need not share one revision. Observable state defines snapshot and observation semantics; it does not promise emission of every intermediate revision.

Stable pagination

message.query.pagination — Message paging uses the original timestamp and logical ID as a stable tuple. Ordering is newest timestamp first, then lowest ID. Following the returned cursor over an unchanged history must not drop or duplicate messages with equal timestamps. The caller uses the returned cursor, not a timestamp-only cursor or a local list index.

This does not promise a frozen historical snapshot across concurrent inserts between page requests.

Android integration

message.android.integration — The Android consumer must receive current and committed message snapshots through the actual packaged AAR. Its integration scenario checks initial observations, send/edit/delete effects, coherent effective queries and reopening the account. The app consumes authored SDK values; it does not choose edit winners or maintain another authenticated message projection.

This test checks the account/Flow/artifact boundary. Full Compose interaction, notification-reply routing, account switching and lifecycle race matrices remain separate Android responsibilities and are not inferred from this scenario.

Verification and known limits

The Rust history scenarios cover original/edit/delete-edit arrival permutations, multiple edits, equal-time ordering, unauthorized edits/deletions, deletion of the original and duplicate wrappers. They compare effective query results, exercise close/reopen, a schema-15 fixture upgrade and rollback/retry of an injected migration statement failure. A separate regression checks edit replay after parent deletion and rejects a conflicting replay or new edit. The Kotlin facade test checks typed queries and reopen; Android instrumentation checks actual packaged observations and queries. The same tests can support several invariants without being executed again for each documentation row.

The first contract set does not establish exhaustive property-based histories, disk-full or corruption recovery, termination at every commit boundary, all migration versions, every device/API/page-size combination, or a full live relay send/receive journey. Replay guarantees for other operation families must be assessed independently rather than inferred from the tested send, edit and deletion commands. Other semantic families remain pending.

Execution status below comes only from the bundled report. A listed test case that has not run on the required revision is not marked verified.

No execution report is bundled with this documentation. Linked test cases describe verification scope; their existence is not a passing result.

InvariantRequired environmentsExecution evidence
message.text.identity
rust-core
rust-hostmissing
1 mapped test cases
  • chat::tests::creates_group_reply_and_sender_copy_inputs_in_canonical_ordersemantic; sdk, cargo:softchat
Shared requirements
message.send.durable
rust-core
rust-hostmissing
1 mapped test cases
  • runtime_facade::product_tests::send_is_payload_idempotent_and_returns_the_original_logical_messagesemantic; sdk, cargo:softchat
Shared requirements
message.send.draft
rust-core
rust-hostmissing
1 mapped test cases
  • runtime_facade::product_tests::sending_can_consume_only_the_draft_owned_by_the_new_commandsemantic; sdk, cargo:softchat
Shared requirements
message.receive.idempotent
rust-core
rust-hostmissing
1 mapped test cases
  • message_contract_v1_event_history_is_convergentsemantic; sdk, cargo:softchat
Shared requirements
message.edit.latest-authorized
rust-core
rust-hostmissing
2 mapped test cases
  • message_contract_v1_event_history_is_convergentsemantic; sdk, cargo:softchat
  • message_contract_v1_deleted_edit_restores_original_textsemantic; sdk, cargo:softchat
Shared requirements
message.edit.replay
rust-core
rust-hostmissing
1 mapped test cases
  • message_contract_v1_edit_retry_after_deletion_is_idempotentsemantic; sdk, cargo:softchat
Shared requirements
message.delete.authorized
rust-core
rust-hostmissing
2 mapped test cases
  • message_contract_v1_event_history_is_convergentsemantic; sdk, cargo:softchat
  • runtime_facade::product_tests::delete_many_is_atomic_bounded_and_retry_idempotentsemantic; sdk, cargo:softchat
Shared requirements
message.query.effective
rust-core
rust-host, android-jvmmissing
2 mapped test cases
  • message_contract_v1_event_history_is_convergentsemantic; sdk, cargo:softchat
  • com.softcose.softchat.sdk.MessageProjectionContractTest#effectiveMessageQueriesFollowEditDeletionAndReopenfacade; sdk, gradle:library:test
Shared requirements
message.query.pagination
rust-core
rust-hostmissing
1 mapped test cases
  • runtime_facade::product_tests::message_tuple_cursor_is_lossless_for_equal_timestampssemantic; sdk, cargo:softchat
message.history.reopen
rust-core
rust-host, android-jvmmissing
2 mapped test cases
  • message_contract_v1_history_survives_reopensemantic; sdk, cargo:softchat
  • com.softcose.softchat.sdk.MessageProjectionContractTest#effectiveMessageQueriesFollowEditDeletionAndReopenfacade; sdk, gradle:library:test
Shared requirements
message.history.migration
rust-core
rust-hostmissing
1 mapped test cases
  • message_contract_v1_schema_fifteen_rebuilds_stale_search_from_authenticated_historysemantic; sdk, cargo:softchat
Shared requirements
message.history.migration-rollback
rust-core
rust-hostmissing
1 mapped test cases
  • message_contract_v1_migration_failure_is_atomic_and_retryablesemantic; sdk, cargo:softchat
Shared requirements
message.android.integration
platform-adapter
android-devicemissing
1 mapped test cases
  • com.softcose.softchat.MessageSdkContractTest#packedAarPublishesInitialAndCommittedMessageSnapshotsintegration; android, instrumentation:com.softcose.softchat.devicetest
Shared requirements

Direct replies and attachment lookup

Android messages.replies(messageId, cursor, limit) and Rust message_replies return direct replies in the root conversation, newest first with the same tuple cursor and 1–100 limit as message pages. Unrelated history and forwards cannot consume the page. Deleted replies remain tombstones; unknown roots produce an empty page. Descendants are reached by opening a reply's own thread. context remains a chronological conversation window and must not be used to enumerate a thread.

Android media.downloads(messageId) and Rust message_downloads return existing operations for the live message's current attachments, in attachment order, independently of the recent-operation window. The read never prepares a transfer, schedules HTTP or changes the database revision. Unknown or deleted messages return an empty result. The app still verifies the authenticated cache before rendering or exporting bytes. observeDownloads refreshes this exact query after account revisions.

The Rust command regressions exercise distant replies, tuple ties, pagination, forwards, deletion, unknown roots and side-effect-free media lookup. One authored Kotlin witness checks API wiring and observation; Android owns rendering, cache and operating-system export tests.