Skip to content

Data model

Every mutation to syncable state is represented as a change event. On the wire an event is a COSE_Sign1 message ([RFC 9052]) — the same container SOP/1 uses for object manifests and access records, so the family carries one signature stack.

Event = COSE_Sign1(
protected = {
alg : EdDSA,
content type : "sovm/ssp-event-v1",
kid : site_id (16 bytes)
},
unprotected = {}, MUST be empty
payload = deterministic CBOR map (fields below),
external_aad = mesh_id (16 raw bytes)
)

The unprotected bucket MUST be empty: everything an event carries is covered by the signature, so there is nowhere for an unauthenticated field to hide.

The payload is a deterministic CBOR map with exactly these fields:

Field Type Description
table_schema text Destination schema name.
table_name text Destination table name.
row_key map or null Primary-key column names to values.
operation text "upsert" or "delete". "purge" is reserved and MUST NOT be emitted; see deletion.
payload map or null Column values. Null for tombstones.
hlc_physical_ms unsigned HLC physical component, milliseconds since the Unix epoch.
hlc_logical unsigned HLC logical counter.

Every field is REQUIRED. Receivers MUST reject an event with a missing or extra payload field — a receiver cannot verify a signature over fields it had to invent, and an extra field it does not interpret is a field it is attesting to blindly.

Notes on what is deliberately absent:

  • No event_id member. The identifier is derived from the message bytes (section 3), so transmitting it inside the message would be self-referential. Deriving it also removes an entire failure class: there is no transmitted identifier to mismatch against.
  • No site_id in the payload. The authoring node travels once, in the protected kid header, where it is covered by the signature. Two locations for one fact is an invitation for them to disagree.
  • No encoding or version tags. The protected content type versions the whole construction. A future change ships as sovm/ssp-event-v2, and because the content type is inside the signed structure it cannot be swapped in flight.
  • No ts member and no operation synonyms. A predecessor carried a timestamp duplicating hlc_physical_ms and accepted "insert"/"update" as synonyms of "upsert"; duplicated fields are a divergence hazard and synonyms widen the parser for no benefit.

Where this specification calls an encoding canonical, it means the Core Deterministic Encoding Requirements of CBOR ([RFC 8949] section 4.2.1): definite lengths, shortest-form integers, preferred float serialization, and bytewise lexicographic map-key ordering.

Deterministic encoding is REQUIRED wherever bytes are signed or hashed — event payloads, record payloads, and every structure named in the registry. Message envelopes that are neither signed nor hashed SHOULD use it too, but interoperability does not depend on that.

Value conventions layered on top, since CBOR has no native types for them:

Logical type Canonical representation
Timestamp ISO-8601 text string, UTC, Z suffix
Decimal / arbitrary precision Text string, to avoid binary64 rounding
Binary Byte string

NaN and the infinities MUST NOT appear, even though CBOR can encode them. A serializer MUST fail closed rather than emit them.

Byte stability. A producer MUST persist and re-transmit the exact encoded COSE_Sign1 bytes of every event it has published, rather than re-encoding on each send. Deterministic CBOR and deterministic Ed25519 signatures ([RFC 8032]) make re-encoding likely to reproduce the same bytes; exact-byte forwarding makes it certain, and the derived identifier in the next section depends on it.

event_id = lowercase hex BLAKE3 (32-byte output) of the
exact COSE_Sign1 bytes as received

event_id is derived, never transmitted. A receiver computes it by hashing the message it received; there is nothing to compare against and therefore nothing to mismatch.

This is the family’s one hashing convention, shared with SOP/1: an identifier is the BLAKE3 hash of the exact bytes of a signed structure, and no second hash namespace exists. A predecessor used a different hash over a hand-assembled pre-image with separator bytes; deriving from the signed message removes the pre-image, the separators, and the possibility of the pre-image and the message disagreeing.

event_id is an idempotency key, not a trust anchor. It deduplicates re-deliveries and nothing more. Authorship is established by the signature, and a receiver MUST NOT treat a familiar event_id as evidence of anything except having seen those bytes before.

Every event is signed by the authoring node, and a receiver MUST verify before apply.

Verification is COSE_Sign1 verification with:

  • the public key resolved from the receiver’s trusted-peer keyring by the protected kid (the authoring site_id);
  • external_aad set to the receiver’s own 16-byte mesh_id.

Four properties this construction is chosen for:

  1. The whole message is covered, protected headers included. The content type and kid cannot be altered in flight, and the empty unprotected bucket leaves no unauthenticated surface.
  2. mesh_id is bound without being transmitted. The verifier supplies its own mesh_id as external AAD, so an event captured from one mesh fails verification in any other — including a mesh that happens to trust the same key.
  3. Domain separation is structural. COSE’s Signature1 context string plus the content type make an event signature unconfusable with a vouch, a role record, a transport binding, or any raw-transcript signature in the pairing ceremony.
  4. Determinism. Ed25519 is deterministic, so the same event signs to the same bytes — which is what lets event identity be derived.

Failure handling, both fail-closed and both surfaced:

  • Unknown kid — the sender is not in the keyring: drop the event and surface it. Unknown-sender events are not queued pending future trust.
  • Invalid signature under a known key: drop the event and surface it.

Neither case holds the sender’s group or stalls a watermark. That is deliberate: events are attributed by kid, not by connection, so if verification failures held the attributed site’s group, a forger could stall the victim’s stream by sending garbage carrying the victim’s kid. A forgery must cost the forger, not the node it impersonates.

An HLC timestamp is the pair (hlc_physical_ms, hlc_logical), compared lexicographically. When two events carry equal HLC pairs the tie is broken by byte-wise comparison of the authoring site_id from the protected kid header. The comparison including the tiebreak is total: any two distinct events have a defined order, which is what makes the merge in semantics deterministic.

Each node maintains one local HLC, which it ticks to stamp a local event and observes to merge a received one. Both operations are normative. With last the stored local HLC and now the local wall clock in milliseconds:

tick — stamp a local event:

  • if now > last.physical_ms, the new HLC is (now, 0);
  • otherwise it is (last.physical_ms, last.logical + 1).

observe — merge a received remote HLC. The new physical component is max(last.physical_ms, remote.physical_ms, now). The logical component depends on which inputs share that maximum:

Inputs holding the maximum New logical component
Both last and remote max(last.logical, remote.logical) + 1
last only last.logical + 1
remote only remote.logical + 1
now alone 0

In both operations the result is stored as the new local HLC. Together they guarantee that a causally later local event sorts after everything the node has already seen, without any coordination.

Receivers MUST reject events and watermarks whose hlc_physical_ms exceeds the receiver’s current wall clock by more than 300 000 ms (5 minutes).

Without this bound a node with a broken or hostile clock can stamp an event far in the future and win every subsequent last-writer-wins comparison indefinitely — a denial-of-replication attack that no amount of correct merge logic recovers from. The bound is one-sided: timestamps arbitrarily far in the past are accepted, since a stale clock only loses comparisons and cannot deny service to others.

Rejection is fail-closed and MUST be surfaced. An implementation MUST NOT clamp the offending timestamp and apply the event anyway.