Matrix logo

Envelope & Wire Protocol

Package matrix/mcl/envelope is the canonical wire codec for all 15 MCL message kinds. Every message Matrix sends or receives rides inside an Envelope: a typed header, an opaque...

Package matrix/mcl/envelope is the canonical wire codec for all 15 MCL message kinds. Every message Matrix sends or receives rides inside an Envelope: a typed header, an opaque CBOR body, and an ed25519 signature over the canonical encoding.

Source files: MCL/envelope/envelope.go, MCL/envelope/kinds.go, MCL/envelope/body.go, MCL/envelope/json.go, MCL/envelope/keyresolver.go.


Design decisions

CBOR, not JSON, on the wire. The body and envelope headers are encoded with github.com/fxamacker/cbor/v2 using CoreDetEncOptions() — the canonical deterministic mode. This gives compact binary encoding with byte-identical output for the same input, which is required for signature stability.

Body is opaque. The Body field is cbor.RawMessage — the envelope codec doesn't know or care what's inside. This allows a single round-trip to preserve body bytes exactly, which is what the D11 replay invariant requires.

Integer keyasint CBOR tags. Every field on Envelope uses integer CBOR keys (0, 1, 2, ...). Adding a new field at an unused integer tag is non-breaking. Deleting a field requires a SchemaVersion bump.

SchemaVersion in signed bytes. The SchemaVersion field is included in the unsigned bytes (the thing that gets signed). This means a signature from schema v1 cannot be verified as valid under schema v2 — replay attacks across schema versions are blocked at the cryptographic level.

JSON on disk. For journal/logs readability, envelopes are written to disk as JSON via EnvelopeJSON (in json.go). The JSON representation is a thin wrapper that re-encodes fields — it's never the canonical form used for signatures.


The 15 message kinds

There is no chat.message kind — that would be the bug MCL is designed to prevent. Every user input is an intent.draft (new goal) or an intent.answer/intent.correct (continuing an existing one).

KindDirectionPurpose
intent.draftUser → AgentInitial NL goal + optional slot pre-fills
intent.compiledAgent → UserTyped Intent IR for user review
intent.clarifyAgent → UserStructured questions for unknowns
intent.answerUser → AgentSlot patches answering clarify questions
intent.acceptUser → AgentSigned sign-off — transitions to accepted
plan.proposedAgent → UserDecomposition into steps before execution
plan.stepAgent → Agent/ToolSingle step execution (executor-internal)
plan.outputAgent → UserStreaming intermediate output
intent.correctUser → AgentPatch an Intent or plan mid-flight
intent.dispatchAgent → AgentSub-intent to a delegated agent
intent.attestAgent → User/ChainSigned completion receipt
intent.failAgent → UserTyped failure
intent.cancelUser → AgentRevoke before completion
policy.gateAgent → UserHuman-in-loop checkpoint
policy.gate.resolveUser → AgentApprove or deny a gate

Envelope structure

type Envelope struct {
    SchemaVersion   uint8          // field 0 — schema version for replay protection
    ProtocolVersion string         // field 1 — "mcl/0.1"
    Kind            string         // field 2 — one of the 15 kinds
    ID              string         // field 3 — ULID for this message
    At              string         // field 4 — ISO-8601 timestamp
    From            string         // field 5 — sender principal (matrix://agent/<did>)
    To              string         // field 6 — recipient principal (omitempty)
    Intent          string         // field 7 — matrix://intent/<id>
    CorrelationID   string         // field 8 — request/response correlation (omitempty)
    CausationID     string         // field 9 — causation trace (omitempty)
    Body            cbor.RawMessage // field 10 — kind-specific payload
    Signature       []byte         // field 11 — ed25519 sig over UnsignedBytes (omitempty)
}

Every message belongs to exactly one Intent (Intent field required). This is how the cortex journal maintains per-intent event streams.

CorrelationID links request/response pairs — intent.clarify.IDintent.answer.CorrelationID. CausationID traces multi-hop causation chains for audit.

Required header fields: ID, At, From, Intent. Missing any of these causes NewEnvelope / Sign / Verify to return an error.


Creating and signing an envelope

// Create an unsigned envelope from a typed body
env, err := envelope.NewEnvelope(envelope.KindIntentDraft, envelope.IntentDraftBody{
    Prose:      "Build a deployment pipeline for my Node.js app",
    SlotValues: map[string]string{"target": "my-app"},
})

// Populate header fields
env.ID        = ulid.Make().String()
env.At        = time.Now().UTC().Format(time.RFC3339)
env.From      = "matrix://agent/" + actorDID
env.To        = "matrix://agent/" + executorDID
env.Intent    = "matrix://intent/" + intentID

// Sign with actor's ed25519 private key
err = envelope.Sign(env, privateKey)

NewEnvelope validates that the body type matches the kind via the kindBodyType map. Mismatched types return ErrBodyTypeMismatch immediately.


Verifying an envelope

err := envelope.Verify(env, keyResolver)

Verify runs the full chain:

  1. SchemaVersion matches the package constant
  2. Required header fields are populated
  3. Kind is in the closed 15-kind set
  4. KeyResolver.ResolveKey(env.From) returns a public key
  5. ed25519.Verify(pub, UnsignedBytes(env), env.Signature) passes

Body shape is not validated by Verify — that's ValidateBody's job after you decode. Verify only checks "this was sent by env.From and hasn't been tampered with."

KeyResolver interface

type KeyResolver interface {
    ResolveKey(principal string) (ed25519.PublicKey, error)
}

Implementations typically look up the DID document for the principal and return the public key. For tests, envelope.StaticKeyResolver(map[string]ed25519.PublicKey{...}) works.


Decoding the body

// Decode into the matching typed struct
var body envelope.IntentDraftBody
err := env.DecodeBody(&body)

// Or use ValidateBody for strict kind↔type checking in one step
typed, err := envelope.ValidateBody(env)
if draft, ok := typed.(*envelope.IntentDraftBody); ok {
    // ...
}

// Or allocate the right type dynamically
out := envelope.NewTypedBody(env.Kind) // returns *IntentDraftBody etc.
err = env.DecodeBody(out)

Body types reference

IntentDraftBody

type IntentDraftBody struct {
    Prose          string            // NL goal
    SlotValues     map[string]string // pre-filled slots from UI form
    PreferredSkill string            // optional skill hint (matrix://skill/... ref)
}

IntentCompiledBody

type IntentCompiledBody struct {
    IntentJSON       []byte // canonical JSON of ir.Intent
    CompileLatencyMs int64  // compilation time (for display)
}

The IntentJSON is the canonical JSON encoding of the ir.Intent struct. Receivers decode with json.Unmarshal into ir.Intent. This is not re-encoded in CBOR because the canonical JSON IS the content address — re-encoding would lose that property.

IntentClarifyBody

type IntentClarifyBody struct {
    Questions []ClarifyQuestion // one per unmet unknown
}

type ClarifyQuestion struct {
    UnknownID string   // matches Intent.Unknowns[].ID
    Field     string   // SlotPath the answer patches
    Prompt    string   // user-facing question text
    Type      string   // expected answer type
    Required  bool     // must the user answer this?
    Options   []string // enum-like suggestions
    Default   string   // suggested default
}

IntentAnswerBody

type IntentAnswerBody struct {
    Patches  []byte // RFC 6902 JSON Patch bytes
    AnswerOf string // correlation_id of the intent.clarify being answered
}

Patches are RFC 6902 applied against the Intent IR. The patch/ package handles the typed SlotPatch → RFC 6902 compilation (D8).

IntentAcceptBody

type IntentAcceptBody struct {
    IntentHash      string // sha256 of canonical-JSON Intent
    AcceptedAt      string // ISO-8601
    AnchorRequested bool   // opt-in chain anchoring
}

The outer Envelope.Signature is the acceptance signature. The body pins IntentHash so receivers can verify the signed hash matches the local IR before treating the acceptance as valid.

PlanProposedBody

type PlanProposedBody struct {
    PlanJSON []byte // canonical JSON of ir.PlanTree
}

Same posture as IntentCompiledBody — the canonical JSON is the content address.

PlanStepBody (executor-internal)

type PlanStepBody struct {
    PlanID    string
    NodeID    string
    Status    string // "started", "completed", "failed", "cancelled"
    Result    []byte // opaque JSON step output
    Error     string
    LatencyMs int64
}

Rarely user-visible. Used for inter-component messaging within the executor.

PlanOutputBody (streaming)

type PlanOutputBody struct {
    PlanID   string
    NodeID   string
    Sequence uint64 // monotonic counter within (PlanID, NodeID)
    Chunk    []byte
    Channel  string // "stdout", "stderr", "result", "progress"
    Final    bool   // marks last chunk
}

The only streaming kind. Multiple plan.output messages may share the same PlanID + NodeID and are distinguished by Sequence. Final=true marks the last chunk in the stream.

IntentCorrectBody

type IntentCorrectBody struct {
    Target    string // "intent" or "plan"
    Patches   []byte // RFC 6902 JSON Patch bytes
    Reason    string // structured reason code
    RetryFrom string // PlanNode.ID to resume from (empty = restart from root)
}

IntentDispatchBody

type IntentDispatchBody struct {
    SubIntentJSON  []byte // canonical JSON of child ir.Intent
    ScopeURI       string // CortexScope grant (empty for in-process)
    PaymentChannel string // payment stream for external dispatch (empty for in-process)
}

IntentAttestBody

type IntentAttestBody struct {
    Outcome     string   // "success", "failure", "partial"
    CitedURIs   []string // load-bearing cortex URIs → feeds salience EMA
    EvidenceJSON []byte  // structured evidence
    CompletedAt string
    AnchorTx    string   // chain tx hash (empty if not anchored)
}

CitedURIs are the matrix://cortex/... URIs that were load-bearing during execution. They feed into cortex.Attest() for salience EMA updates — memories that were useful get higher salience for future retrievals.

IntentFailBody

type IntentFailBody struct {
    Reason       string   // structured failure reason
    Message      string   // human-readable elaboration
    EvidenceJSON []byte
    FailedAt     string
    PartialURIs  []string // work products that landed before failure
}

Failure reasons: blocked_by_constraint, tool_error, policy_denied, deadline_exceeded, budget_exceeded, subagent_failed, ambiguous_after_clarify, correction_invalid, x:custom.

PolicyGateBody / PolicyGateResolveBody

type PolicyGateBody struct {
    RuleRef   string   // matrix://rule/<id>
    PlanID    string
    NodeID    string
    Question  string
    Options   []string // empty = free text
    ExpiresAt string   // auto-deny deadline
}

type PolicyGateResolveBody struct {
    GateOf     string // correlation_id of the policy.gate
    Decision   string // "approve" or "deny"
    Answer     string // chosen option or free-text answer
    ResolvedAt string
}

Self-hash and content addressing

hash, err := envelope.SelfHash(env)

Returns sha256(UnsignedBytes(env)) as a hex string. Works before or after signing. Used as the content-address for journal storage and as the Merkle anchoring input when the agent posts an intent.attest on-chain.


Encoding round-trip

// Encode to wire bytes (CBOR, including signature)
wire, err := envelope.Encode(env)

// Decode from wire bytes
var env2 envelope.Envelope
err = envelope.Decode(wire, &env2)

The canonical enc/dec modes are cbor.CoreDetEncOptions() — the same options used in the cortex scope codec. They guarantee byte-identical encoding for the same logical value.