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.durable — send 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:
| API | Original deleted | Winning edit deleted |
|---|---|---|
messages.get | Returns a tombstone when the original is retained. | Returns the previous eligible edit or original. |
messages.page / messages.context | Retains the timeline tombstone. | Keeps the original timeline position with restored effective text. |
messages.search | Excludes the original. | Finds restored effective text; does not match the removed edit's text. |
| Conversation preview | Selects 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.
| Invariant | Required environments | Execution evidence |
|---|---|---|
message.text.identityrust-core | rust-host | missing1 mapped test cases
|
message.send.durablerust-core | rust-host | missing1 mapped test cases
Shared requirements |
message.send.draftrust-core | rust-host | missing1 mapped test cases
Shared requirements |
message.receive.idempotentrust-core | rust-host | missing1 mapped test cases
Shared requirements |
message.edit.latest-authorizedrust-core | rust-host | missing2 mapped test cases
Shared requirements |
message.edit.replayrust-core | rust-host | missing1 mapped test cases
Shared requirements |
message.delete.authorizedrust-core | rust-host | missing2 mapped test cases
Shared requirements |
message.query.effectiverust-core | rust-host, android-jvm | missing2 mapped test cases
|
message.query.paginationrust-core | rust-host | missing1 mapped test cases
|
message.history.reopenrust-core | rust-host, android-jvm | missing2 mapped test cases
Shared requirements |
message.history.migrationrust-core | rust-host | missing1 mapped test cases
Shared requirements |
message.history.migration-rollbackrust-core | rust-host | missing1 mapped test cases
Shared requirements |
message.android.integrationplatform-adapter | android-device | missing1 mapped test cases
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.