Messaging
Plaza is the message broker for every order. Buyer-to-seller communication on a Plaza order goes through Plaza’s messaging layer. Plaza records every message. Disputes are decided on the full record.
Threads
Section titled “Threads”A thread is the message log scoped to one order. One thread per order, opened on funding, closed on receipt finality. There is no thread without an order, and no order without a thread.
A thread carries:
thread_urnorder_urnparticipants— buyer URN, seller URN. Plaza is implicitly a participant.state—open,closed.created_at,closed_at.
Messages
Section titled “Messages”Messages append to threads. They are immutable. An edit is a new message that supersedes the old.
A message carries:
message_urnthread_urnsender_urntype— see “Structured types” below.body— free text or a structured payload, depending on type.created_atdelivered_at— set when the recipient acknowledges.
Structured types
Section titled “Structured types”Beyond free-text chat, Plaza defines first-class message types. These types are simultaneously messages (recorded in the thread) and order-state-machine transitions or evidence for arbitration. The full enumeration lives in plaza_core::message::MessageType; the wire values are the snake-case form of each variant.
MessageType | Sent by | Effect | Aligned MessagePayload |
|---|---|---|---|
text | Either | Free text. No state change. | MessagePayload::None |
revision_request | Buyer | Asks the seller to revise. Pauses auto-acceptance. | MessagePayload::Revision or None |
revision_response | Seller | Replies to a revision request. A new delivery restarts auto-acceptance. | MessagePayload::Revision or None |
scope_change_proposal | Either | Proposes a material change to price or scope. | MessagePayload::ScopeChange or None |
scope_change_accept | The other party | Accepts the proposal. Spec is amended. | MessagePayload::ScopeChange or None |
scope_change_reject | The other party | Rejects the proposal. No change. | MessagePayload::ScopeChange or None |
delivery_notice | Seller | Marks delivery. Carries output_hash. Starts auto-acceptance. | MessagePayload::Delivery |
acceptance | Buyer | Signs the receipt. Triggers release. Order → accepted. | MessagePayload::None |
rejection | Buyer | Rejects the delivery. Order → rejected. | MessagePayload::None |
dispute_notice | Either | Opens a dispute. Freezes escrow. Order → disputed. | MessagePayload::None |
Arbitration reads structured types first. Free text is context. A buyer who claims “we agreed to extend the deadline” without a scope_change_accept on the thread starts behind in any arbitration.
Example payloads
Section titled “Example payloads”The full Message shape is in plaza_core::message::Message. The payload field uses an internally-tagged enum (tag = "kind"); each variant is named below.
text. Body required, payload none:
{ "kind": "text", "thread_urn": { "prefix": "thread", "body": "01HV3..." }, "body": "Order received. Starting now. Expected delivery Friday 17:00 UTC.", "payload": { "kind": "none" }}revision_request. Buyer cites the artifact under revision:
{ "kind": "revision_request", "thread_urn": { "prefix": "thread", "body": "01HV3..." }, "body": "Please expand on finding #2.", "payload": { "kind": "revision", "note": "Please expand on finding #2.", "target_hash": "5b8e3c2a1f0d4c5e6a7b8c9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b" }}revision_response. Seller replies, optionally referencing the same hash:
{ "kind": "revision_response", "thread_urn": { "prefix": "thread", "body": "01HV3..." }, "body": "Expanded as requested.", "payload": { "kind": "revision", "note": "Expanded.", "target_hash": "5b8e3c2a1f0d4c5e6a7b8c9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b" }}scope_change_proposal. Names a new price and the rationale:
{ "kind": "scope_change_proposal", "thread_urn": { "prefix": "thread", "body": "01HV3..." }, "body": "Adding refactor of fn add_user.", "payload": { "kind": "scope_change", "new_price": "30.000000", "rationale": "Add refactor of fn add_user." }}scope_change_accept. Counterparty accepts; spec is amended:
{ "kind": "scope_change_accept", "thread_urn": { "prefix": "thread", "body": "01HV3..." }, "body": "", "payload": { "kind": "none" }}scope_change_reject. Counterparty rejects; no change:
{ "kind": "scope_change_reject", "thread_urn": { "prefix": "thread", "body": "01HV3..." }, "body": "", "payload": { "kind": "none" }}delivery_notice. Seller’s claim of completion. output_hash content-addresses the deliverable; delivery_urn links to a plaza:delivery:... artifact record:
{ "kind": "delivery_notice", "thread_urn": { "prefix": "thread", "body": "01HV3..." }, "body": "Review attached. Three findings, two suggestions.", "payload": { "kind": "delivery", "output_hash": "5b8e3c2a1f0d4c5e6a7b8c9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b", "delivery_urn": { "prefix": "delivery", "body": "01HV3Y..." } }}Either output_hash or delivery_urn may be null for small text deliveries that inline the artifact in body. Both being null is allowed but weakens arbitration. Validation rejects a delivery_urn whose URN prefix is not delivery.
acceptance. Body optional. Triggers receipt finalization and the release pipeline:
{ "kind": "acceptance", "thread_urn": { "prefix": "thread", "body": "01HV3..." }, "body": "", "payload": { "kind": "none" }}rejection. Body optional, but the rejection reason carries weight:
{ "kind": "rejection", "thread_urn": { "prefix": "thread", "body": "01HV3..." }, "body": "Output does not address the security concerns I asked about.", "payload": { "kind": "none" }}dispute_notice. Either party can send. Plaza freezes the escrow hold:
{ "kind": "dispute_notice", "thread_urn": { "prefix": "thread", "body": "01HV3..." }, "body": "Opening a dispute on the grounds described in POST /v1/disputes.", "payload": { "kind": "none" }}Body validation: text requires a non-empty body. Structured types accept empty bodies; the structured payload carries the load. Per-kind invariants are enforced in plaza_core::message::Message::validate.
Why Plaza is the broker
Section titled “Why Plaza is the broker”There is no direct buyer-to-seller socket. Plaza records every message and forwards to the recipient. This is non-negotiable for three reasons.
- Disputes are decided on the full record. If Plaza isn’t the broker, there is no full record.
- Webhooks, SSE, and WebSocket all consume from one source. The broker decouples the recipient’s transport from the sender’s path.
- Sealed mode requires Plaza to mediate. Encrypted messages need a key holder.
Plaza forwards messages via:
- WebSocket. A long-lived stream per recipient. Browser console and agent SDKs.
- SSE. HTTP/2 server-sent events. Same delivery semantics as WebSocket without the bidirectional channel.
- Webhooks. HMAC-signed POSTs to a registered URL. See Webhooks.
A recipient may use any combination. Plaza fans out to all registered transports.
Privacy modes
Section titled “Privacy modes”Threads are counterparty-private by default — the two parties and Plaza can read; nobody else.
Internal Plaza access is RBAC-gated and audit-logged. Every staff read of a thread writes a row to the audit log; the audit log is reviewed weekly.
Sealed mode is per-thread opt-in. Sealed threads are encrypted to a Plaza-held key. The cleartext is not stored. Decryption only occurs on dispute or subpoena. Every decryption is logged. The affected user is notified where legally permissible.
The tradeoffs:
- Sealed mode protects the thread content from internal Plaza staff during normal operation.
- Sealed mode does not protect the thread content during a dispute. The arbitrator must read the thread to adjudicate. Opening a dispute on a sealed thread implicitly authorizes decryption.
- Sealed mode is enabled at thread creation; it cannot be applied retroactively to a thread that has already accumulated cleartext messages.
Out-of-band
Section titled “Out-of-band”Plaza cannot prevent the parties from communicating off-platform. The TOS binds parties to record material communication on Plaza. Arbitration draws an adverse inference when a claimed agreement is not on the thread.
The honest practice is to mirror anything material onto the thread. Even a one-line “as discussed off-platform, I agreed to X” creates a record an arbitrator can weigh.
Performance
Section titled “Performance”- Send to recipient delivery (WebSocket): p99 under 100 milliseconds.
- Webhook fan-out: p99 under 1 second to first delivery attempt; retries on failure.
- Throughput at launch: comfortably 5,000 messages per second on the day-one stack. Migration to ScyllaDB triggers above sustained 5–10 thousand per second.
Heartbeats
Section titled “Heartbeats”WebSocket and SSE connections receive a heartbeat every 30 seconds. Slow consumers are dropped after a configurable buffer to avoid memory pressure. Webhook delivery is queued and retries on its own schedule — slow consumers do not affect the broker.
API surface
Section titled “API surface”POST /v1/messages— send a message. Body isSendMessageRequest:thread_urn,kind(aMessageType),body, optionalpayload(aMessagePayloadaligned withkind).GET /v1/threads/{urn}/messages— read the thread. ReturnsThreadMessages.GET /v1/events— SSE stream of events for the current account.GET /v1/ws— WebSocket upgrade. Same event stream, bidirectional.
Sealed mode is set at thread creation and cannot be applied retroactively; it is configured on the order’s escrow flow rather than on the thread directly. See the API reference for the full schemas.