Mutable messages
Versioned message actions, aggregation, endpoints, authorization, limits, and error behavior.
Mutable messages are an existing Protocol V2 subsystem. They let one logical message evolve while preserving an ordered version log for history and recovery.
Actions
| Action | Event | Meaning |
|---|---|---|
create | sockudo:message.create | Create the logical message and reserve its original history identity. |
update | sockudo:message.update | Shallow patch name, data, extras, or clear fields. |
delete | sockudo:message.delete | Mark/delete visible fields while preserving the version chain. |
append | sockudo:message.append | Append a string fragment to existing string data. |
summary | sockudo:message.summary | Emit a reduced summary/projection event. |
Wire constants live in sockudo-protocol/src/versioned_messages.rs; store semantics live in
sockudo-core/src/versioned_messages.rs and sockudo-core/src/version_store.rs.
At commit time Sockudo also records a protocol-neutral message envelope in the version record and new history payloads. It preserves the client message ID, publisher identity and timestamp, typed stream/history/delivery positions, complete encoding chain, user-visible extras, and operation metadata. Older stored payloads remain readable through the envelope migration fallback.
Aggregation
Sockudo stores each operation as a version. Latest reads and history substitution return the latest visible state, not a raw operation log, when versioned messages are enabled.
createestablishesmessage_serialand originalhistory_serial.updateanddeleteuse tri-state field semantics: keep, clear, replace.appendconcatenates string fragments and rejects empty/non-string append payloads.latest_by_historyreturns one latest version per logical message sorted by original history serial.- Replay continuity requires contiguous delivery serials.
Every create or mutation commits through one VersionStore transaction. The
transaction validates the exact predecessor, reserves the next delivery
position, writes the version, and stores any idempotency receipt together. A
failed transaction exposes neither a version nor a delivery gap. Concurrent
appends retry from the newest aggregate, so each accepted fragment appears once
and in commit order. Accumulated-byte, append-count, open-stream, and terminal
state checks are part of that transaction; in-memory counters are observability
only.
op_id is scoped by app, channel, message, and action. Replaying the same ID
with the same canonical operation returns the original acknowledgement without
fanout. Reusing it with a different operation is an idempotency conflict.
Memory, PostgreSQL, MySQL, DynamoDB, ScyllaDB, and SurrealDB implement the same
compare-and-apply contract.
HTTP surfaces
Signed app HTTP auth can create mutable messages through /events. Mutation and read surfaces are:
| Method | Path |
|---|---|
POST | /apps/{appId}/channels/{channel}/messages/{messageSerial}/update |
POST | /apps/{appId}/channels/{channel}/messages/{messageSerial}/delete |
POST | /apps/{appId}/channels/{channel}/messages/{messageSerial}/append |
GET | /apps/{appId}/channels/{channel}/messages/{messageSerial} |
GET | /apps/{appId}/channels/{channel}/messages/{messageSerial}/versions |
The optional Ably compatibility facade projects the pinned SDK shape over the same service:
PATCH /channels/{channel}/messages/{messageSerial}
Content-Type: application/json{
"serial": "message-serial",
"action": "message.append",
"data": " fragment",
"version": {
"serial": "optional-version-serial",
"clientId": "verified-or-server-attributed-client",
"timestamp": 1710000000000,
"description": "stream fragment",
"metadata": { "source": "agent" }
}
}message.update and message.delete use the same envelope. JSON and MsgPack
requests, successes, and errors all use the negotiated compatibility encoder.
The path and body serial must identify the same logical message.
Mutation bodies may include socket_id and client_id to bind the operation to a connected V2
actor. Without socket_id, signed app-key HTTP requests are privileged server operations.
In server_role = "api" mode, socket_id cannot be verified (no local WebSocket connections)
and the request is rejected with HTTP 400. See Server role.
Authorization
Capabilities are channel-pattern maps:
{
"message_append_own": ["private-ai:user-42:*"],
"message_update_own": ["private-ai:user-42:*"],
"message_delete_own": ["private-ai:user-42:*"],
"history": ["private-ai:user-42:*"]
}*_any grants are checked first. *_own grants require an identified actor, an identified
original creator, and a constant-time match between actor client_id and original creator
client_id. Trusted app-key server requests bypass as privileged operations.
For token-authenticated compatibility clients, an operation-supplied clientId
is metadata only after the authenticated actor and capability check succeeds;
it is never identity proof.
Limits
| Limit | Default |
|---|---|
versioned_messages.max_page_size | 100 |
versioned_messages.retention_window_seconds | 0, no expiry |
versioned_messages.purge_batch_size | 1000 |
ai_transport.max_accumulated_message_bytes | 1048576 |
ai_transport.max_appends_per_message | 4096 |
ai_transport.max_open_streaming_messages_per_channel | 1024 |
| serial length | 1..=128 bytes, no whitespace |
Error parity
Mutation errors use the same HTTP envelope as other app APIs:
{ "error": "Connection is not allowed to append message on channel 'private-ai:x'", "code": "auth_failed", "status": 401 }Feature-disabled, not-found, malformed-input, payload-too-large, and authorization failures are kept distinct so SDKs can preserve behavior across languages.