# `Msg` — PollyWeb Message Envelope

`pollyweb.msg` implements the PollyWeb message envelope (`pollyweb.org/MSG:1.0`): a signed, tamper-evident JSON structure that domains exchange to communicate securely.

**See also:** [`DNS`](dns.md), [`Domain`](domain.md), [`KeyPair`](keypair.md), [`Schema`](schema.md)

For the protocol-level message spec, see [PollyWeb Message](https://github.com/pollyweb-org/pollyweb-docs/blob/main/4%20%E2%9A%99%EF%B8%8F%20Solution/30%20%F0%9F%A7%A9%20Data/Messages%20%F0%9F%93%A8/%F0%9F%93%A8%20Message/%F0%9F%93%A8%20Message.md).

---

## Sending via a [`Domain`](domain.md) (recommended)

```python
import pollyweb as pw

msg = pw.Msg(
    To="receiver.dom", 
    Subject="Hello@Host", 
    Body={"text": "hi"})

pair = pw.KeyPair()

domain = pw.Domain(
    Name="sender.dom", 
    KeyPair=pair, 
    Selector="pw1")

signed = domain.sign(msg)

# If you need to publish DNS first:
dns_record = domain.dns()
# publish the TXT value at: {selector}._domainkey.pw.sender.dom
# this lives under the PollyWeb branch: pw.sender.dom
# domain.sign(msg) will write that selector into signed.Selector
```

## Sending with direct signing (not recommended)

```python
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
import pollyweb as pw

private_key = Ed25519PrivateKey.generate()

msg = pw.Msg(
    From="sender.dom", 
    To="receiver.dom", 
    Subject="Hello@Host", 
    Selector="pw1", 
    Body={"text": "hi"})

signed = msg.sign(private_key)

# Receiver: validate
public_key = private_key.public_key()

signed.verify(public_key)  # True, or raises pw.MsgValidationError
```

## Receiving and verifying from another domain

```python
import pollyweb as pw

# raw_message is the wire-format JSON or YAML received from the remote domain
received = pw.Msg.parse(raw_message)

# Recommended: let PollyWeb resolve the sender's DKIM key from DNS
# by validating pw.{received.From} first and then using
# received.From + received.Selector.
# This requires the sender domain to publish the DKIM TXT record with DNSSEC enabled.
received.verify()  # True, or raises pw.MsgValidationError
```

The DNS lookup used by `verify()` is:

```text
{Selector}._domainkey.pw.{From}
```

Example:

```text
pw1._domainkey.pw.sender.dom
```

Before trusting that TXT record, PollyWeb validates the sender's `pw.{From}`
branch with DNSSEC. The TXT record must then contain the sender's public key in
DKIM format:

```text
v=DKIM1; k=<key-type>; p=<base64-encoded public key>
```

`Msg.verify()` currently supports these standards-aligned combinations:

- `Algorithm=ed25519-sha256` with `k=ed25519`
- `Algorithm=rsa-sha256` with `k=rsa`

For legacy PollyWeb messages that predate the `Algorithm` field, `verify()` can still validate Ed25519 signatures by inferring `ed25519-sha256`.

If you already trust or cache the sender's public key, you can validate without DNS:

```python
received.verify(public_key)
```

---

## `Msg` fields

`Msg` is a **frozen dataclass** — all fields are immutable after construction. Methods that modify state return a new instance.

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `To` | `str` | ✅ | — | Receiver's domain name. Must be a syntactically valid domain string. |
| `Subject` | `str` | ✅ | — | Method to invoke on the receiver (e.g. `Hello@Host`). Must be a string. |
| `From` | `str` | — | `""` | Sender identifier. Must be `""`, `Anonymous`, a domain string, or a UUID. Set by [`Domain.sign()`](domain.md). If omitted when serializing or parsing, it is treated as `Anonymous`. |
| `Selector` | `str` | — | `""` | Name of the sender's public key in DNS (e.g. `pw1` → `pw1._domainkey.pw.sender.dom`, under `pw.sender.dom`). When signing through a [`Domain`](domain.md), this selector is derived from [`Domain.dns()`](domain.md) and written by [`Domain.sign()`](domain.md). If empty, it may be omitted from the wire format. |
| `Algorithm` | `str` | — | `""` | Signature algorithm for the message, using DKIM-style values such as `ed25519-sha256` or `rsa-sha256`. `sign()` fills this automatically when omitted. |
| `Body` | `dict` | — | `{}` | Arbitrary JSON-serialisable payload. |
| `Correlation` | `str` | — | UUID4 | Unique message ID generated by the sender, used for deduplication. Must be a UUID string. |
| `Timestamp` | `str` | — | UTC now | ISO-8601 UTC timestamp ending in `Z` (e.g. `2025-06-01T12:00:00.000Z`). |
| `Schema` | [`Schema`](schema.md) | — | `pollyweb.org/MSG:1.0` | PollyWeb schema code. Strings are accepted and normalized to canonical schema-code form. |
| `Hash` | `str \| None` | — | `None` | SHA-256 hex digest of the canonical form. Set by `sign()`. |
| `Signature` | `str \| None` | — | `None` | Base64-encoded signature bytes. Set by `sign()`. |

`Msg()` rejects any `To` value that is not a syntactically valid domain string, any `From` value that is not `""`, `Anonymous`, a domain string, or a UUID, any `Subject` value that is not a string, any `Schema` value that is not a valid [`Schema`](schema.md) code, any `Correlation` value that is not a UUID string, and any `Timestamp` value that is not a UTC timestamp ending in `Z`. String schema inputs are normalized to canonical form, so `.MSG` becomes `pollyweb.org/MSG:1.0`. `verify()` and `validate_unsigned()` always require `To`, `Subject`, `Correlation`, and `Timestamp` to be non-empty. If `From` is omitted, it is normalized to `Anonymous`. `Selector` is required only when signature validation needs DNS to resolve the sender key.

---

## Methods

- [`msg.canonical() → bytes`](msg/canonical.md) — canonical JCS bytes used for hashing and signing.
- [`msg.sign(private_key) → Msg`](msg/sign.md) — returns a new signed message with `Algorithm`, `Hash`, and `Signature`.
- [`msg.send() → HTTPResponse`](msg/send.md) — validates the message and POSTs it to the receiver inbox.
- [`msg.verify(public_key=None) → bool`](msg/verify.md) — validates structure, hash, and the configured signature algorithm.
- [`msg.validate_unsigned() → bool`](msg/validate_unsigned.md) — validates structure and hash without checking the signature.
- [`msg.validate_signature(public_key=None) → bool`](msg/verify.md) — backward-compatible alias for `verify()`.
- [`msg.to_dict() → dict`](msg/to_dict.md) — serialises the message to the PollyWeb wire-format mapping.
- [`Msg.parse(value) → Msg`](msg/parse.md) — parses a message from an existing `Msg`, mapping, EventBridge payload, SNS payload, SQS payload, API Gateway payload, Kinesis payload, JSON text, YAML text, or bytes.
- [`Msg.load(value) → Msg`](msg/load.md) — backward-compatible alias for `Msg.parse(value)`.
- [`Msg.from_dict(d) → Msg`](msg/from_dict.md) — constructs a `Msg` from a wire-format dictionary.

---

## Wire format

```yaml
# YAML shown for readability; wire format is compact JSON
Header:
  Schema: pollyweb.org/MSG:1.0
  From: sender.dom
  To: receiver.dom
  Subject: Hello@Host
  Correlation: 3fa85f64-5717-4562-b3fc-2c963f66afa6
  Timestamp: 2025-06-01T12:00:00.000Z
  Selector: pw1
  Algorithm: ed25519-sha256

Body: { ... }

Hash: ee6ca2a43ec05d...
Signature: Lw7sQp6zkOGyJ+OzGn+B...
```

---

## Error handling

`MsgValidationError` is raised by `verify()` and `validate_unsigned()` with a descriptive message:

| Message | Cause |
|---|---|
| `Unsupported schema: X` | `Schema` field is not `pollyweb.org/MSG:1.0` |
| `Missing From` / `To` / … | A required header field is empty |
| `To must be a domain string` | `To` is not a syntactically valid domain name |
| `From must be empty, Anonymous, a domain string, or a UUID` | `From` is not one of the supported sender formats |
| `Subject must be a string` | `Subject` is not a string value |
| `Schema must be a string` | `Schema` is not a string value |
| `Schema must match {authority}/{code}[:{major}.{minor}]` | `Schema` is not a valid PollyWeb schema code |
| `Correlation must be a UUID` | `Correlation` is not a UUID string |
| `Timestamp must be a Z timestamp` | `Timestamp` is not a UTC timestamp ending in `Z` |
| `Unsupported signature algorithm: X` | `Algorithm` is present but not supported by this PollyWeb version |
| `Missing Hash` | `Hash` is `None` (message not signed) |
| `Missing Signature` | `Signature` is `None` |
| `Hash mismatch` | Body or header was tampered with after signing |
| `Invalid signature` | Signature does not verify (wrong key or tampered content) |
| `Malformed signature: …` | `Signature` is not valid base64 |

---

## Design notes

**Immutability** — `Msg` is a frozen dataclass. `sign()` returns a new instance rather than mutating in place, making it safe to pass unsigned messages around without risk of accidental modification.

**Algorithm agility** — PollyWeb now stores the signature algorithm in `Header.Algorithm` and validates it against the DKIM key type (`k=`) published in DNS. This matches the direction of standard DKIM, where the signature algorithm and DNS key type are related but distinct pieces of metadata.

**Ed25519 remains the default sender path** — [`KeyPair`](keypair.md) and [`Domain`](domain.md) still generate Ed25519 keys and publish `k=ed25519` DKIM records. `Msg.sign()` also supports RSA private keys and emits `Algorithm=rsa-sha256`.

**JCS canonicalisation** — Keys are sorted and whitespace is removed before hashing, following RFC 8785. This guarantees the same bytes regardless of how the dict was constructed.

**`Header` as a wire concept only** — In Python, all fields sit flat on `Msg`. The `Header` object only appears inside `to_dict()` / `from_dict()` to match the JSON wire format.

**CamelCase fields** — Field names on `Msg` match the wire-format names (e.g. `From`, `To`, `Subject`) for consistency between the Python API and the JSON representation.

**`From`, `Selector`, and `Algorithm` are optional at construction** — `From` and `Selector` default to `""`, and `Algorithm` also defaults to `""`, so a `Msg` can be built before the sender or signature method is known. `From` may also be set explicitly to `Anonymous`, a domain string, or a UUID. When `From` is omitted on the wire or during serialization, it is treated as `Anonymous`. [`domain.sign()`](domain/sign.md) fills `From`, derives `Selector`, and signs with the domain's Ed25519 key. `verify()` requires `Selector` only when it must resolve the sender key from DNS.
