mail-address-semantics/mas-modes-v0.1-spec/CONTRACT.md
tegwick 001cab57d1 Mode contract v0.1 final (MAS-WP-0014 finished)
Founder decision after the independent JS email side. Draft markers
removed (bundle, address spec v0.2 section 7.1, reuse surface), CHANGELOG
v0.1 with MCP-1..6 and known limits. SCOPE: all workplans but MAS-WP-0007
finished.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5787@bnt-lap001
Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd
2026-10-19 13:59:27 +02:00

16 KiB

MAS mode contract v0.1

How email hands a conversation to another communication mode, and how email works inside a control flow it does not own. The bundle is versioned separately from the MAS address specification. In MAS terms, ticket!callback@D requests a handoff to callback (address spec §7). This contract defines what can happen to that request.

The keywords MUST, MUST NOT, SHOULD and MAY are used as in RFC 2119.

1. Concepts

  • Case. A unit of communication. It is addressed by a canonical MAS resource address (ticket=gs-1@D), and identified by that address and a key. Email chooses the key from the address-spec equivalence of the target (§8), so equivalent addresses get the same key. Other parties treat the key as opaque and echo it back unchanged (MCP-4).
  • Thread. The durable record of a case: inbound and outbound mail, plus every mode status.
  • Mode. A communication channel other than email (callback, chat, ...), reached through a mode endpoint that speaks this contract.
  • Handoff. One attempt to continue a case in a mode. It has a handoff_id and a status (§4).
  • Role. For each resource type, email is either master or slave (§3). The role is fixed for the life of a case.
  • Outside master. In the slave role, the system that owns the control flow (a CMS, ticketing or support system, or a framework).

2. Parties and transport

There are three parties: email, mode and master. Messages are JSON objects, sent as HTTP POST with Content-Type: application/json. Every message carries the envelope:

Field Meaning
mas_modes_version "0.1"
type message type (§5)
id sender-chosen unique id; the idempotency key
case {address, key}: canonical resource address and email's opaque case key (§1). Absent only in case.intake and case.assign.
  • Idempotency. A receiver MUST treat a repeated id as already processed, and SHOULD return the original response.
  • Ordering. handoff.status carries seq, increasing per handoff. A receiver MUST ignore a status whose seq is not greater than the last one it accepted, and answers {"ok": true, "ignored": "stale"}.
  • Responses. 2xx with {"ok": true, ...} means the message was accepted. 4xx with {"ok": false, "error": CODE, "detail": ...} means it was refused and MUST NOT be retried unchanged. 5xx or no answer means the sender MAY retry with the same id.

3. Roles

3.1 Email as master

Email owns the case, the thread and the timeouts.

  1. Mail to an intake address creates a case. Email then sends the participant one acknowledgement from the case address, so they learn it (MCP-1); principle 10 applies. Mail to the case address is recorded on that case. On !MODE to an intake or resource address, email creates or finds the case and sends handoff.request to the mode endpoint configured for that resource type and mode. If the statute does not enable the mode, email records refused (mode-not-enabled) itself. For a new case, the acknowledgement says so and no separate resume mail follows. If no endpoint is configured, or the endpoint refuses or cannot be reached, email records refused (mode-unavailable) and resumes (item 3).
  2. Email sets a deadline for each handoff. If no terminal status (§4) has arrived by then, email records timed-out, sends handoff.cancel to the mode, and resumes by email.
  3. Every status is recorded in the thread. On every terminal status, email resumes: it sends the case's participant one mail from the case address, saying what happened. The mail MUST name the outcome: the terminal status as written in §4 (completed, timed-out, ...) and, when present, the reason or detail (MCP-3). Principle 10 (§6) applies.
  4. Details (MCP-2). A detail is a line Field: value in a mail body. Field names are compared ignoring case, and the first occurrence wins. Email reads the fields the role configuration names (fields) from the mail that requests a handoff, and puts them in handoff.request's details. negotiating with needs makes email ask the participant with one mail that lists each needed field on its own line as Field: ..., with the field name capitalized. A later mail on the case that supplies needed fields is sent to the mode as handoff.update.

3.2 Email as slave

The outside master owns the control flow, case identifiers and timeouts.

  1. Case identifiers come from the master. Intake mail (no case yet) is reported as case.intake with an intake_ref. The master answers with case.assign, giving the resource id, which email adopts as the case address TYPE=ID@D. Email MUST NOT invent case identifiers in this role. Until assigned, the intake stays pending. case.assign MAY carry the case's initial policy. A mode requested in the intake mail (requested_mode) is handled under that policy once the case exists.
  2. Inbound mail on a case is reported as case.message.
  3. Sending. Email sends only on mail.send from the master, unless the case policy (case.policy) sets autonomous_send: true. Then email MAY also send acknowledgements and negotiation questions itself. A mail.send is always a reply to a message already recorded on the case (in_reply_to): the recipient is that message's sender. A requested send can still be refused under §6, and the refusal is the response. Policy fields the master does not set take the email side's defaults for the resource type.
  4. Transfers. The case policy sets transfers to "block" or to {"webhook": URL, "peer": ID} (the peer must be known to email, §7.3). With block, a !MODE request is reported as handoff.requested with "forwarded": false, followed by one handoff.status refused with reason blocked, and nothing else happens (MCP-6). With a webhook, email sends handoff.request to that URL, reports handoff.requested with "forwarded": true, and relays every status the mode sends (and one it records itself, such as mode-unavailable) as handoff.status. It numbers them with its own seq. Email's own requested is not relayed: handoff.requested stands for it. Email does the negotiation (3.1 item 4) only when autonomous_send is true.
  5. Timeouts belong to the master. Email sets no deadline and never records timed-out. The master MAY end a handoff with handoff.cancel.

4. Handoff status machine

From Allowed next statuses
(new) requested
requested negotiating, scheduled, running, refused, failed, cancelled, timed-out
negotiating negotiating, scheduled, refused, expired, cancelled, timed-out
scheduled running, attempted, expired, cancelled, timed-out
attempted scheduled, failed, expired, cancelled, timed-out
running completed, failed, cancelled, timed-out

Terminal statuses are completed, failed, expired, refused, cancelled and timed-out. Nothing follows a terminal status.

  • requested: recorded by email when it sends handoff.request.
  • negotiating: the mode needs details. needs lists field names (phone, time, ...).
  • scheduled: has at (RFC 3339) when known. Going from attempted to scheduled is a retry.
  • attempted: an attempt did not reach the participant. outcome, e.g. no-answer.
  • refused: has reason, one of mode-unavailable, mode-not-enabled, declined, out-of-hours, blocked.
  • timed-out: recorded only by the timeout owner: email as master, or the master's own bookkeeping. A mode MUST NOT send it.
  • cancelled: after handoff.cancel.

A receiver MUST refuse a transition that the table does not allow (error: invalid-transition).

5. Message types

Type From → to Payload
handoff.request email → mode handoff_id, mode, details (known fields), status_url (where statuses go), participant ({address})
handoff.update email → mode handoff_id, details
handoff.cancel email → mode, master → email handoff_id, reason
handoff.status mode → email, email → master (relay) handoff_id, seq, status, plus needs / at / outcome / reason / detail
case.intake email → master intake_ref, resource_type, event (gateway event), requested_mode
case.assign master → email intake_ref, resource_id, optional policy ({autonomous_send, transfers})
case.message email → master event, message_ref
case.policy master → email autonomous_send (bool), transfers ("block" or {"webhook": URL, "peer": ID})
handoff.requested email → master handoff_id, mode, forwarded (bool)
mail.send master → email in_reply_to (a message_ref), subject, body

status_url contains the per-handoff token (§7.3). event is the normalized gateway event (schema/mas-event-v0.1.schema.json). message_ref is the email side's id for a recorded inbound message.

mail.send responses: {"ok": true, "sent": true}, or {"ok": false, "error": "refused", "reason": R} with R one of unauthenticated-sender, automated-sender, send-cap-reached, unknown-message. With send-cap-reached the response also carries held (bool, §6).

case.assign responds {"ok": true, "case": {address, key}, "message_ref": M}, where M is the intake message, now recorded on the case. When autonomous_send is on, the response also has acknowledged (bool). When the intake requested a mode, it has handoff_id.

5.1 Other errors

Besides §7.4, a receiver answers with these codes (MCP-5):

Code HTTP Meaning
schema 400 the message does not validate against the schema
invalid-resource-id 400 case.assign id does not make a valid resource address
unknown-handoff 404 no such handoff at this URL, or the handoff_id / case do not match it
unknown-case 404 no case with this key
unknown-intake 404 no intake with this intake_ref
invalid-transition 409 the status may not follow the current one (§4)
already-assigned 409 the intake already has a case
case-exists 409 a case with the same key exists
refused 409 mail.send not sent, with reason (§5)

6. Principle 10 in both roles

Email MUST NOT send mail to a sender that did not pass DMARC (as read from trusted Authentication-Results), to automated mail (Auto-Submitted other than no, or Precedence: bulk | list | junk), or to the null sender. It MUST NOT send once its send cap is reached. These rules hold whoever asks for the send: email itself as master, the case's autonomous policy, or a master's mail.send.

The send cap is one cap per deployment. Every mail email sends, in either role and from any application behind it, counts against the same total. Over the cap, a mail is held: recorded unsent, and reported to whoever asked as send-cap-reached. Held mail MUST be deleted after a configured maximum age (default 7 days). It MUST NOT be stored once held mail would exceed a configured maximum size (default 100 MB); the mail is then reported as not held. Mail actually sent is never purged, because it is what the cap counts.

7. Peer authentication and transport security

Contract messages steer real mail (mail.send, case.assign, case.policy) and change what customers are told (handoff.status). Every party MUST therefore know who sent a message before acting on it.

7.1 Peers and keys

Each party has a peer id (email, cms-1, callback-1, ...). Every pair of parties that exchange messages shares a secret key of at least 32 random octets. Keys are pairwise: email holds one key per master, mode endpoint and gateway it talks to, and the other party holds the same key for email. Keys are configuration, never part of a message.

7.2 Signed requests (MUST)

Every contract request MUST carry three HTTP headers. Gateway events (gateway → email) MUST carry them when the gateway and the email side are on different hosts. A deployment MAY leave them off for events on loopback; the email side is then configured to accept unsigned events, and only from loopback.

Header Value
MAS-Modes-Peer the sender's peer id
MAS-Modes-Timestamp the time of sending, as integer Unix seconds
MAS-Modes-Signature v1= followed by the lower-case hex HMAC-SHA256, keyed with the pair's key, of TIMESTAMP + "." + BODY, where BODY is the exact request body octets

The receiver MUST:

  1. look up the key for MAS-Modes-Peer. If the peer is unknown, refuse with 401 unauthenticated-peer;
  2. recompute the signature over the received octets and compare it in constant time. On a mismatch or missing header, refuse with 401 unauthenticated-peer;
  3. refuse a timestamp more than 300 seconds from its own clock with 401 replayed;
  4. only then parse and act on the body. A repeated message id inside the window gets the original response (§2), so a captured request cannot cause a second effect.

Responses are not signed. They travel on the connection the signer opened. Mutual TLS MAY be used in addition, but it does not replace signing: the peer id and the authorization in §7.3 come from the signed headers.

7.3 Authorization

Authentication says who sent a message. Email then also checks that this peer may send it:

  • Master commands (case.assign, case.policy, mail.send, handoff.cancel to email) are accepted only from the peer configured as master for the case's resource type (for case.assign, the intake's resource type). Anyone else gets 403 wrong-peer.
  • Mode statuses. Each handoff is bound to the peer it was sent to. handoff.request carries a status_url that contains an unguessable per-handoff token (at least 128 bits). Email accepts a handoff.status only at that URL, and only from that peer (403 wrong-peer). A handoff_id alone is not enough.
  • Primed webhooks (slave role). transfers.webhook MUST name the receiving mode's peer, which MUST be a peer email already has a key for. Otherwise case.policy and case.assign are refused with 400 unknown-peer. A master can therefore direct handoffs only to modes the email operator has provisioned. It cannot make email sign requests to an arbitrary URL with a key it chose.
  • Mode endpoints accept handoff.request, handoff.update and handoff.cancel only from email's peer id.
  • Masters accept case.*, handoff.requested and relayed handoff.status only from email's peer id.

7.4 Errors

Code HTTP Meaning
unauthenticated-peer 401 unknown peer, missing or bad signature
replayed 401 timestamp outside the window
wrong-peer 403 authenticated, but not allowed to send this
unknown-peer 400 a webhook names a peer email has no key for

A sender MUST NOT retry 401 or 403 unchanged.

8. Conformance

conformance/ holds valid and invalid messages, the transition table as data, the callback scenarios (scenarios.json), and scripted flows. Each flow is a sequence of inbound mail, contract messages and clock ticks, with the expected email-side reaction, defined in conformance/FLOWS.md and runnable from the bundle alone. conformance/auth.json holds signing vectors and verification cases for section 7; every party MUST pass them. Flows a-* check authorization at the email side. An email-side implementation claims MAS-MODES-EMAIL-MASTER or MAS-MODES-EMAIL-SLAVE by passing the flows for that role.