; Weald relay wire format, deterministic CBOR (RFC 8949 section 4.2.1).
; Normative prose: /protocol/relay/wire.md
;
; Two layers are described here and they are NOT the same trust domain.
;   Envelope  is what the relay stores and validates. All of it is cleartext
;             except `ct`.
;   Payload   is what is inside `ct` after MLS decryption. The relay cannot
;             read, produce or verify any of it.
;
; If a field is not in Envelope or a named cleartext control frame, the relay
; does not know it exists.
;
; Encoding rules, enforced by the conformance suite in ./vectors:
;   - Definite-length maps and arrays only.
;   - Integer keys, sorted ascending. No text keys on the wire.
;   - Shortest-form integer encoding.
;   - No indefinite-length strings, no tags, no floats anywhere.
;   - An unknown map key in Envelope is a reject, not an ignore. An unknown
;     `kind` in Payload is stored and ignored, so an old client never loses
;     data written by a new one.

start = Envelope / Frame

; ---------------------------------------------------------------------------
; Layer 1/2. The envelope. The only unit the relay stores.
; ---------------------------------------------------------------------------

Envelope = {
  1 => protocol-version   ; v
  2 => enc-mode           ; enc
  3 => group-id           ; group
  4 => epoch              ; epoch
  5 => seq                ; assigned by the relay on accept. ADVISORY.
  6 => relay-ts           ; relay receipt time, ms. ADVISORY ONLY.
  7 => content-hash       ; hash
  8 => ciphertext         ; ct
}

protocol-version = 1              ; currently the only accepted value
enc-mode         = enc-none / enc-mls
enc-none         = 0              ; Phase 2 only. Rejected when MIN_ENC=mls.
enc-mls          = 1

group-id     = bstr .size 32      ; opaque to the relay
epoch        = uint .size 8       ; MLS epoch, needed only for key routing
seq          = uint .size 8       ; per-group, monotonic, sync cursor ONLY.
                                  ; Gaps are legal. Nothing above layer 2
                                  ; reads seq for correctness.
relay-ts     = uint .size 8
content-hash = bstr .size 32      ; BLAKE3 over (v, enc, group, epoch, ct)
ciphertext   = bstr .size (1..MAX_CT)

; MAX_CT is the relay's configured envelope ceiling. Exceeding it is
; reject/envelope_too_large. See registries/error-codes.md.

; The relay validates exactly and only:
;   version, enc against WEALD_RELAY_MIN_ENC, group exists, hash correct,
;   ct under the size limit, authenticated session device inside the access set.
; The encrypted payload author is intentionally not consulted: agent authors
; proxy through their issuing device and are opaque to the relay.
; There is deliberately NO relay-maintained prev chain. Ordering and tamper
; evidence live inside the ciphertext where the relay cannot reach them.
; See decisions/ADR-0002.

; ---------------------------------------------------------------------------
; Layer 3. The plaintext payload. Inside ct, after MLS decryption.
; ---------------------------------------------------------------------------

Payload = {
  1  => Hdr            ; copy of (v, enc, group, epoch) from Envelope
  2  => event-kind     ; kind
  3  => principal-key  ; author
  ? 4 => Certificate   ; cert, present when author is an agent
  5  => counter        ; ctr, per (author, group), monotonic, starts at 0
  6  => prev-self      ; hash of this author's previous envelope in this group
  7  => sent-at        ; author clock, ms
  8  => body           ; kind-specific, see below
  9  => signature      ; Ed25519 by author over fields 1..8
}

Hdr = {
  1 => protocol-version
  2 => enc-mode
  3 => group-id
  4 => epoch
}

principal-key = bstr .size 32
counter       = uint .size 8
prev-self     = bstr .size 32   ; all-zero for ctr == 0
sent-at       = uint .size 8
body          = bstr
signature     = bstr .size 64

; Sign-then-encrypt. A group member cannot re-attribute a decrypted message.
; ctr increments by EXACTLY one and prev_self names this author's own previous
; envelope hash. Both are inside the signature and inside the encryption. That
; density is what makes relay withholding detectable.

Certificate = {
  1 => principal-key      ; subject (the agent)
  2 => principal-key      ; issuer (the issuing device)
  3 => uint               ; not-before, ms
  4 => uint               ; not-after, ms
  5 => [+ capability]     ; delegated capabilities
  6 => bstr .size 64      ; issuer signature
}

capability = tstr         ; verification rules in /protocol/relay/identity.md

; ---------------------------------------------------------------------------
; Event kinds. Kind numbers are PERMANENT.
; ---------------------------------------------------------------------------

event-kind =
    k-doc-change          ; 0x0001
  / k-doc-snapshot        ; 0x0002
  / k-chat-message        ; 0x0010
  / k-ticket-op           ; 0x0011
  / k-roster-update       ; 0x0020
  / k-roster-revoke       ; 0x0021
  / k-dm-welcome          ; 0x0022
  / k-media-ref           ; 0x0030
  / k-git-patch           ; 0x0040
  / k-git-status          ; 0x0041
  / k-tombstone           ; 0x0050
  / k-groupinfo-publish   ; 0x0060
  / k-recovery-wrap       ; 0x0061
  / k-history-publish     ; 0x0062
  / k-recovery-directory  ; 0x0063
  / k-head-attest         ; 0x0070
  / k-checkpoint          ; 0x0071
  / k-chain-reset         ; 0x0072
  / k-snapshot            ; 0x0073
  / k-media-retain        ; 0x0080
  / k-agent-card          ; 0x0090
  / k-agent-invoke        ; 0x0091
  / k-agent-lifecycle     ; 0x0092
  / k-agent-lease         ; 0x0093
  / k-ask                 ; 0x00A0
  / k-ephemeral           ; 0x00F0
  / unknown-kind

k-doc-change         = 1
k-doc-snapshot       = 2
k-chat-message       = 16
k-ticket-op          = 17
k-roster-update      = 32
k-roster-revoke      = 33
k-dm-welcome         = 34
k-media-ref          = 48
k-git-patch          = 64
k-git-status         = 65
k-tombstone          = 80
k-groupinfo-publish  = 96
k-recovery-wrap      = 97
k-history-publish    = 98
k-recovery-directory = 99
k-head-attest        = 112
k-checkpoint         = 113
k-chain-reset        = 114
k-snapshot           = 115
k-media-retain       = 128
; The four agent kinds. Numbers are fixed by the table in
; /protocol/relay/wire.md; their bodies are specified with the rest of the
; networked-agent protocol and are not schematised here yet, so an envelope
; carrying one is a well-formed envelope of a known kind whose body this grammar
; does not constrain. The relay never reads a body, so nothing it does depends on
; the difference.
k-agent-card         = 144
k-agent-invoke       = 145
k-agent-lifecycle    = 146
k-agent-lease        = 147
; One person asking one other person to look at one thing. A request and its
; resolution are the same kind, told apart by `replyTo` and a terminal `state`,
; so there is one codec and one authorization rule rather than two
; (`/protocol/relay/wire.md`, `specs/asks.md`).
k-ask                = 160
k-ephemeral          = 240

unknown-kind = uint   ; stored and ignored. Forward compatibility is a
                      ; contract, not a courtesy.

; 0x00F0 is RESERVED AND NEVER USED. It was defined as the only kind the relay
; may drop and the only one never written to Postgres, and that instruction has
; no implementation and never could have one: under enc 1 the kind is inside ct,
; so a relay told to drop one kind and keep every other cannot tell them apart.
; Ephemeral traffic is the LIVE frame (tag 21), where the routing and shedding
; decisions are visible and the content is not, and now also the CALL and MEDIA
; frames (tags 23 and 24), which are ephemeral in exactly the same sense. The
; number stays allocated forever. See /protocol/relay/presence.md and
; /protocol/relay/calls.md.

; ---------------------------------------------------------------------------
; Bodies whose shape is load-bearing for a security property.
; The rest are opaque to this schema by design.
; ---------------------------------------------------------------------------

HeadAttestBody = {                       ; kind 0x0070
  1 => [+ GroupHeads]
}

GroupHeads = {
  1 => group-id
  2 => [+ AuthorHead]                    ; every author this client has seen,
                                         ; INCLUDING itself, and including every
                                         ; agent whose key this device holds
}

AuthorHead = {
  1 => principal-key
  2 => counter                           ; highest ctr seen
  3 => content-hash                      ; matching envelope hash
  ? 4 => proxied-for                     ; set when this device attests on
                                         ; behalf of an agent it issued
}

proxied-for = principal-key

; Expected attesters for a group are its current DEVICE leaves, read from the
; client's own ratchet tree. The relay is never consulted and cannot shrink the
; set. Agent leaves are never expected attesters and never unattested: the app
; that proxies an agent attests on its behalf, because that app is the component
; that actually saw those envelopes leave.

ChainResetBody = {                       ; kind 0x0072
  1 => counter                           ; last link this author can prove
  2 => content-hash                      ; its hash
  3 => counter                           ; the counter it resumes at
  4 => uint                              ; declared-at, ms
}

; A self-fork observed WITHOUT a matching chain.reset remains evidence and is
; surfaced loudly. The distinction between a crash and an attack is made by the
; author, in signed form, before the fact.

RecoveryWrapBody = {                     ; kind 0x0061
  1 => bstr                              ; sealed current epoch secret
  2 => bstr                              ; sealed current GroupInfo
  3 => blinded-tag                       ; per-epoch, NOT the recovery key
}

blinded-tag = bstr .size 32
; Stored by the relay under a per-epoch blinded tag so that the existence of a
; recovery principal is not itself a queryable fact.

DmWelcomeBody = {                        ; kind 0x0022
  1 => blinded-tag                       ; BLAKE3("weald dm welcome v1",
                                         ; key_package_ref)
  2 => bstr                              ; MLS Welcome
}

; Two fields, and the omission is the shape. There is NO group-id here: the
; joiner reads the dm group id out of the Welcome, which only the holder of the
; referenced key package can open. A group-id in this record would let every
; member of the workspace root group learn which pair opened a conversation and
; when, from a log they are all entitled to read. See
; /protocol/relay/private-messaging.md.

; Access-set rotations are NOT Payload events: Payload is encrypted. ACCESS is
; a separately named cleartext control frame so the relay can validate its
; version chain and revoke an authenticated device without reading workspace
; content. See /protocol/relay/wire.md.
AccessPublishFrame = {
  0 => 9,                                ; ACCESS frame type
  1 => AccessSet
}

AccessSet = {
  1 => group-id,                          ; workspace root group
  2 => uint,                              ; exactly prior version + 1
  3 => content-hash,                      ; prior set hash; all-zero at genesis
  4 => uint,                              ; issued-at ms
  5 => [+ salted-principal-hash],         ; connection-authorized principals
  6 => [+ principal-key],                 ; device authorizers
  7 => [+ principal-key],                 ; recovery principals
  ? 8 => RecoveryQuorum,                  ; confirm-only external keys
  9 => [* salted-principal-hash],         ; recovery rotation pending removals
  10 => principal-key,                    ; signer
  11 => bstr .size 64                     ; signature over keys 1..10
}

salted-principal-hash = bstr .size 32

RecoveryQuorum = {
  1 => uint,                              ; threshold, 1 <= m <= keys.len()
  2 => [+ principal-key],                 ; unique sorted keys
  ? 3 => [+ QuorumSignature]              ; confirmation only
}

QuorumSignature = {
  1 => principal-key,
  2 => bstr .size 64
}

MediaRetainBody = {                      ; kind 0x0080
  1 => [* content-hash]                  ; ciphertext hashes still referenced
  2 => uint                              ; asserted-at, ms
}

; ---------------------------------------------------------------------------
; Transport frames. WebSocket over TLS, port 443, v1. Transport-agnostic so
; QUIC can be added later without touching anything above layer 1.
; ---------------------------------------------------------------------------

; Every tag this grammar names, in one place. LiveFrame, KeysFrame, CallFrame and
; MediaFrame were specified below and never listed here, which made this union a
; thing a validator could pass for the wrong reason: a frame absent from the union
; is a frame `start` does not admit, and four of them had been on the wire since
; version 2. Added with WakeFrame rather than after it, because the fix and the
; addition are the same edit and splitting them would leave one release where the
; union was still wrong.
Frame = ConnectFrame / AuthFrame / AccessPublishFrame / SubFrame / ReconFrame
      / PushFrame / SendFrame / BlobFrame / ErrorFrame
      / LiveFrame / KeysFrame / CallFrame / MediaFrame / WakeFrame

ConnectFrame = { 0 => 1, 1 => protocol-version, 2 => [* group-id] }
AuthFrame    = { 0 => 2, 1 => bstr, ? 2 => bstr .size 64, ? 3 => AuthState }
SubFrame     = { 0 => 3, 1 => [+ group-id], ? 2 => seq, ? 3 => bool }
ReconFrame   = { 0 => 4, 1 => group-id, 2 => [* Fingerprint] }
PushFrame    = { 0 => 5, 1 => Envelope }
SendFrame    = { 0 => 6, 1 => Envelope }
BlobFrame    = { 0 => 7, 1 => BlobTicket }
ErrorFrame   = { 0 => 8, 1 => ErrorBody }

AuthState = {
  1 => uint                 ; relay time, ms. Observed, never trusted.
  2 => uint                 ; this device's own unused key package count.
                            ; Its own number, so this discloses nothing.
  3 => write-mode           ; full or read_only
  4 => enc-mode             ; the relay's configured MIN_ENC floor
  ? 5 => tstr               ; non-content reason code when read_only
}

write-mode = "full" / "read_only"

Fingerprint = { 1 => seq, 2 => seq, 3 => bstr .size 32 }   ; negentropy range

BlobTicket = {
  1 => content-hash         ; ciphertext hash. The relay never sees a key.
  2 => uint                 ; size
  3 => tstr                 ; presigned url
  4 => uint                 ; expires-at, ms
}

ErrorBody = {
  1 => error-class
  2 => tstr                 ; stable code. registries/error-codes.md
  ? 3 => bstr .size 32      ; current state hash, so the client can rebase
  ? 4 => uint               ; retry-after, ms. Required for retry, quota and limit.
}

error-class = "retry" / "reject" / "denied" / "quota" / "version" / "limit"

; `limit` arrived with protocol version 4 and is the one class this protocol has
; ever added, which is a breaking change by the mechanical test in
; contracts/governance.md section 3 and rides the same version bump the WAKE frame
; does. It is a per-principal ceiling on a write the client can simply stop making,
; which is neither a quota against a workspace's paid allowance nor a transient
; retry, and clients branch on the class before the code. Like `retry` and `quota`
; it carries retry-after. See registries/error-codes.md.

; ---------------------------------------------------------------------------
; Protocol version 2: the two frames added, and the two bodies they carry.
; ---------------------------------------------------------------------------

; Frame tag 21. The same shape in both directions, like HANDSHAKE. `ct` is a
; sealed LiveBody and the relay cannot read it.
LiveFrame = [21, [group-id, epoch, live-ct]]

live-ct  = bstr .size (1..4096)   ; a beat is a signed struct, not a payload

; The sealed content of a LIVE frame. Signed by the device it describes: a relay
; that could synthesise one could show a member as at their keyboard when they
; were not.
LiveBody = [
  live-kind,        ; 1 presence, 2 typing. 3 and 4 reserved, never sent.
  member,           ; the author's device key
  live-state,       ; 1 active, 2 idle, 3 away. Zero on a typing claim.
  live-channel,     ; channel slug, typing only
  at,               ; the author's clock in ms. Compared, never rendered.
  ttl,              ; seconds this claim is good for, clamped to 120 by the receiver
  cert,             ; delegation certificate, or null
  sig
]

live-kind    = 1 / 2
live-state   = 0 / 1 / 2 / 3
live-channel = bstr / null
member       = bstr .size 32
at           = uint
ttl          = uint .size 4
cert         = bstr / null
sig          = bstr .size 64

; Frame tag 22. Five forms, discriminated by the leading byte, because they are
; one conversation about one shelf.
KeysFrame = [22, [keys-form, keys-fields]]

keys-form   = 1 / 2 / 3 / 4 / 5
keys-fields =
    [* key-package]              ; form 1 publish, form 4 bundles
  / [uint]                       ; form 2 published: remaining
  / [device-key, fetch-count]    ; form 3 fetch
  / []                           ; form 5 none

key-package = bstr
device-key  = bstr .size 32
fetch-count = uint .size 1        ; capped at 8; higher is enumeration

; ---------------------------------------------------------------------------
; Protocol version 3: the two call frames. /protocol/relay/calls.md.
; ---------------------------------------------------------------------------

; Frame tag 23. The same shape in both directions, like LIVE and HANDSHAKE.
;
; `kind` is cleartext and is the one field of either call frame the relay
; interprets, because it decides call membership and membership is what lets
; MEDIA be routed without a database read. `body` is sealed under the group's
; MLS exporter and the relay cannot read it. The set of kinds is closed: a value
; outside it is refused with reject/malformed_header rather than forwarded, so a
; future client cannot change routing semantics without a version bump.
CallFrame = [23, [call-id, group-id, epoch, call-kind, call-body]]

call-id   = bstr .size 16         ; client-chosen, compared, never derived from
call-kind = 1 / 2 / 3 / 4         ; offer, answer, decline, bye.
                                  ; 5 (candidate) is reserved for the P2P step
                                  ; and is not accepted by this version.
call-body = bstr .size (0..4096)  ; sealed; a signalling body is a small struct

; Frame tag 24. One encrypted audio frame.
;
; There is deliberately no group here. The group was checked when a CALL admitted
; this connection to this call-id, and repeating the check on a path carrying
; fifty frames a second per stream would put a Postgres read into the media path.
;
; `seq` is the sender's own per-stream counter, copied by the relay and never
; interpreted. It is NOT the per-group seq: that one is an UPDATE ... RETURNING
; inside a transaction, and routing audio through it would serialise every writer
; in the group behind the call. The receiver's jitter buffer is its only reader.
MediaFrame = [24, [call-id, stream-id, media-seq, media-ct]]

stream-id = bstr .size 4          ; also the first half of the client's AES-GCM
                                  ; nonce, so the wire width and the nonce width
                                  ; are one number rather than two that drift
media-seq = uint                  ; per stream, client-side, uninterpreted
media-ct  = bstr .size (0..1500)  ; AES-256-GCM frame. One Ethernet MTU, which is
                                  ; four hundred times a 20 ms AAC-ELD frame: it
                                  ; bounds an attacker rather than fitting a codec

; ---------------------------------------------------------------------------
; Protocol version 4: the WAKE frame. /protocol/relay/push.md.
; ---------------------------------------------------------------------------

; Frame tag 25, the next free integer: tags are permanent and 21 through 24 went
; to LIVE, KEYS, CALL and MEDIA. The frame is named WAKE and not PUSH because tag
; 10 has been PUSH, the relay-to-client envelope delivery frame, since version 1,
; and reusing the word in a second place would be a defect waiting for a reader.
;
; Six forms discriminated by a leading integer, the shape KEYS already uses,
; because these are one conversation about one row. `Ready` only, like every frame
; except JOIN. No form carries a device identifier, deliberately: the relay learns
; which principal is registering from the authenticated session, and a field it
; would have to trust is a field one admitted device could use to claim another's
; wakes.
;
; The relay stores the handle and never a token. An APNs device token is a durable,
; cross-installation, Apple-resolvable identifier and there is no field here or in
; the relay's schema that could hold one; the component that talks to Apple is
; separated and addressed by URL. See decisions/ADR-0012-push-via-a-separate-ringer.md.
WakeFrame = [25, [wake-form, wake-fields]]

wake-form   = 1 / 2 / 3 / 4 / 5 / 6
wake-fields =
    [handle, categories, expires-at]  ; 1 Register,   client to relay
  / [expires-at]                      ; 2 Registered, relay to client
  / []                                ; 3 Clear,      client to relay
  / []                                ; 4 Cleared,    relay to client
  / []                                ; 5 Query,      client to relay
  / [enabled, register-url]           ; 6 Capability, relay to client

handle       = bstr .size 16      ; minted by the ringer for one device in one
                                  ; workspace, opaque to the relay, never derived
                                  ; from a token, a device key or a workspace, and
                                  ; never written to a log at any level
categories   = uint .size 1       ; bitmask: 1 message, 2 call, 4 handshake. At
                                  ; least one bit set and no undefined bit. The set
                                  ; is closed, so a future client cannot widen what
                                  ; a wake means without a version bump
expires-at   = uint .size 8       ; ms since the unix epoch. Refused if already past
enabled      = bool               ; false means push is off here, which is the
                                  ; default and a supported deployment rather than
                                  ; a degraded one
register-url = tstr .size (0..512)   ; https only, and empty when enabled is false.
                                  ; The relay states the ringer because a device
                                  ; that guessed would register a self-hoster's
                                  ; users with a ringer their operator never chose

; A WAKE frame never exceeds 1024 bytes; longer is reject/envelope_too_large,
; refused on the declared frame length before any field is read. A wrong-length
; handle, an illegal bitmask, an elapsed expires-at or a register-url a client
; would refuse are all reject/push_handle_malformed, a reject and not a denial
; because each one is permanently wrong as sent. The conformance bytes for all six
; forms and for every refusal named here are in ./vectors/push-frames.json.

; Kind 0x0022. Travels in the workspace root group as an ordinary envelope.
; The dm group id is deliberately absent: the joiner learns it from inside the
; Welcome, and naming it here would let every root member learn which pair
; opened a conversation and when.
DirectWelcome = [
  dm-tag,      ; BLAKE3("weald dm welcome v1", key_package_ref)
  welcome      ; the MLS Welcome, opaque
]

dm-tag  = bstr .size 32
welcome = bstr

; Kind 0x0020. Self-asserted and signed by the device it describes, admitted by
; a reader only when that device key is in the access set.
RosterEntry = {
  1 => device-key,       ; 32 bytes
  2 => tstr,             ; handle, normalized
  3 => tstr,             ; display name
  4 => tstr,             ; machine id
  ? 5 => tstr,           ; email
  ? 6 => tstr,           ; device label
  7 => uint              ; claimed at, ms
}
