70.3.1. pepsi.state

per-message state object carried through the Pepsi pipeline

Manual section:

7

70.3.1.1.1. Name

pepsi.state - the JSON state object attached to every Pepsi message.

70.3.1.1.2. Description

Every message in flight is one row of the pepsi.ingress table. Besides the envelope and the message body, each row carries a free-form JSON object — the state column (PostgreSQL JSONB) — that travels with the message from ingress to its terminal stage. The state is the only channel through which the stages of the pipeline communicate: one stage records a verdict or a piece of provenance, a later stage reads it.

This page documents the layout of that object: the keys the stock stages write, when they are written, and which stages consume them. It is a data-format reference, independent of any configuration: the pipeline graph and the options that drive each stage are described in pepsi.conf(5), while the meaning of each key is described here.

70.3.1.1.3. Merge semantics

state is built up incrementally. Every terminal stage helper that mutates a row merges its new keys into the existing object (the SQL terminal functions evaluate state || $new), rather than replacing it. Consequently a key written early — the origin provenance seeded by ingress, the dsn parameters — survives for the whole lifetime of the message unless a stage deliberately overwrites that specific key.

There is exactly one exception: pepsi-stage-bounce(1) clears state to null when it rewrites a message into a delivery-status notification, because the bounce is a brand-new null-sender message that inherits none of the original’s provenance.

70.3.1.1.4. Top-level keys

The keys below are written by the stock pipeline. A stage that does not recognise a key simply leaves it untouched (the merge rule preserves it), so the set is open-ended; custom stages may add their own.

70.3.1.1.4.1. origin

(object) The SMTP-origin metadata of the connection that delivered the message, seeded by pepsi-ingress(1). It is read by pepsi-stage-arc(1) (which needs the authserv_id and the rendered ar_results fragment to reproduce the ARC Authentication-Results identity without re-running SPF/DKIM/DMARC) and by the relay stages, whose per-hop 8BITMIME/SMTPUTF8 downgrade decision consults the original BODY=/smtputf8 declarations:

{
  "origin": {
    "remote_ip":    "203.0.113.7",      // connecting client IP (null for local injection)
    "helo":         "mail.example.com", // HELO/EHLO name announced by the client
    "esmtp":        true,               // client used EHLO (ESMTP) rather than HELO
    "smtputf8":     false,              // SMTPUTF8 requested on MAIL FROM
    "body_8bit":    false,              // BODY=8BITMIME declared on MAIL FROM (RFC 6152)
    "declared_size": 12345,             // SIZE= declared on MAIL FROM (null if absent)
    "tls":          { "version": "TLSv1.3",
                      "cipher":  "TLS13_AES_128_GCM_SHA256",
                      "sni":     "mx.example.net" },  // null on a cleartext session
    "listener":     { "local_addr": "0.0.0.0:25", "mode": "starttls" },
    "reverse_dns":  "host.example.com", // PTR name of remote_ip (null if none)
    "iprev":        "pass",             // RFC 8601 iprev / FCrDNS verdict
    "auth_method":  "sasl",             // how the session authenticated (null if not)
    "auth_mechanism": "PLAIN",          // SASL mechanism (sasl sessions only)
    "auth_identity":  "alice",          // authentication id / peer login
    "auth_authzid":   null,             // authorization id, when distinct
    "authserv_id":  "mail.example.org", // this receiver's id (for pepsi-stage-arc)
    "ar_results":   ";\r\n\tdkim=pass header.d=example.com;\r\n\tdmarc=pass ..."
                                        // rendered Authentication-Results body, reused by
                                        // pepsi-stage-arc for the AAR (no SPF/DKIM/DMARC re-run)
  }
}

The four auth_* members describe how the session identified itself, as opposed to the sibling auth key below, which is about what the message claims. They are recorded because local_origin is a single boolean and collapses two very different statements: “an account we authenticated submitted this” and “this arrived from an address we chose to trust”. A policy stage — or a milter, which is handed them as the RFC 4954 {auth_type} / {auth_authen} / {auth_author} / {auth_ssf} macros — cannot recover that distinction from the boolean.

auth_method

How the session authenticated: sasl (an RFC 4954 AUTH succeeded), peercred (a UNIX-socket peer whose uid resolved to a permitted login), client-cert (a TLS client certificate matched a pin) or mynetworks (the peer address is in MYNETWORKS). Absent when the session did not authenticate. A later SASL AUTH overwrites an earlier connection-time method: it is the stronger claim, and the only one that carries a username.

auth_mechanism

The SASL mechanism that succeeded (PLAIN, LOGIN), upper-cased. Present only for auth_method = sasl. The non-SASL methods leave it absent rather than putting a non-mechanism token in a field a filter may be comparing against a list of real mechanisms.

auth_identity

The authentication identity: the SASL authcid, or the login a UNIX peer’s uid resolved to. Absent for mynetworks and client-cert, which authenticate an address or a key and name nobody. peercred therefore yields an identity with no mechanism — the honest description of a session the kernel authenticated rather than SASL.

auth_authzid

The authorization identity, recorded only when the client asked to act as somebody other than whom it authenticated as (SASL PLAIN’s authzid field). Absent when they are the same, which is the normal case: RFC 4954 permits an empty authzid and clients routinely repeat the authcid there.

70.3.1.1.4.2. auth

(object) The inbound authentication verdicts ingress computed, also reflected in the prepended Authentication-Results header. The arc verdict is seeded as none and overwritten by pepsi-stage-arc(1) once it re-evaluates the inbound ARC chain (its spf/dkim/dmarc siblings are left untouched). Read by pepsi-stage-check-whitelist(1) (whose dkim_required gate accepts a row on auth.dkim or auth.arc):

{
  "auth": {
    "spf":   "pass",   // SPF verdict (pass/fail/softfail/neutral/temperror/permerror/none)
    "dkim":  "pass",   // DKIM verdict (strongest of the message signatures)
    "dmarc": "pass",   // DMARC verdict (pass/fail/temperror/permerror/none)
    "arc":   "none",   // inbound ARC chain verdict (filled in by pepsi-stage-arc)
    "arc_sealers": []  // d= of each inbound ARC-Seal (filled in by pepsi-stage-arc)
  }
}

70.3.1.1.4.3. local_origin

(boolean) Seeded by ingress: true when the delivering session was authenticated (the client was in MYNETWORKS, completed a SASL AUTH, or presented a matching TLS client certificate — see the [pepsi-ingress-listener-*] sections in pepsi.conf(5)), false otherwise. It marks mail originating from a trusted sender and is preserved through the pipeline for downstream stages to test (the per-address settings layer keys on the sender for local_origin messages, and pepsi-stage-edit-settings(1) only treats a message as a control message when it is local_origin).

70.3.1.1.4.4. dsn

(object) The RFC 3461 delivery-status-notification parameters the sender requested, seeded by ingress: the message-level ret/envid and a per-recipient array parallel to rcpt_to of notify/orcpt. Every stage must preserve and honour it — the relay stages propagate the parameters to the next hop only when it advertises DSN, and pepsi-stage-bounce(1) consults the per-recipient notify to decide whether to emit a report at all:

{
  "dsn": {
    "ret":   "hdrs",                     // RET= (full|hdrs), if given
    "envid": "QQ314159",                 // ENVID= xtext, if given
    "rcpt":  [ { "notify": ["success", "failure"],   // NOTIFY= keywords
                 "orcpt":  "rfc822;user@example.com" } ]  // ORCPT= xtext
  }
}

70.3.1.1.4.5. bounce

(object) Written by a delivery stage (pepsi-stage-relay-to-internet(1), pepsi-stage-relay-to-smarthost(1), pepsi-stage-relay-to-maildir(1)) or pepsi-stage-discard(1) when it routes a message to its BOUNCE_STAGE, and consumed by pepsi-stage-bounce(1) to build the report. Its kind is permanent (a failure bounce, Action: failed), success (a positive report, Action: delivered) or delay (Action: delayed); diagnostic and failed_recipient are the human-readable reason and the affected recipient, and the optional notify/orcpt/envid are copied from the recipient’s state.dsn entry so the bounce stage can honour NOTIFY and echo the original ENVID/ORCPT. The relay stages also record structured next-hop detail (remote_mta, smtp_code, enhanced_status, phase, reply_text) so a bounce template can state precisely why the next MTA refused the message, and enhanced_status feeds the DSN Status: field:

{ "bounce": { "kind": "permanent",
              "diagnostic": "550 5.1.1 user unknown",
              "failed_recipient": "bob@example.net",
              "remote_mta": "mx.example.net",
              "smtp_code": 550,
              "enhanced_status": "5.1.1",
              "phase": "rcpt",
              "reply_text": "user unknown",
              "notify": ["failure"],
              "orcpt": "rfc822;bob@example.net",
              "envid": "QQ314159" } }

70.3.1.1.4.6. attempts / last_error / delay_sent

Scratch the delivery stages record when they pause a message for retry: attempts is the number of delivery attempts so far, last_error the most recent transient error, and delay_sent (once set) a flag marking that the one-shot DELAY DSN has already been emitted so it is never sent twice.

70.3.1.1.4.7. srs

(object) Written by pepsi-stage-srs(1) when it rewrites the envelope sender, recording the address it replaced:

{ "srs": { "original": "alice@example.org" } }

It exists for a DSN this deployment originates itself. SRS necessarily runs before the delivery stage — rewriting the envelope for the hand-off is its whole purpose — so by the time a delivery stage calls complete_success, or a relay routes a failure to its BOUNCE_STAGE, the row’s mail_from is one of our own SRS aliases. pepsi-stage-bounce(1) would then address the DSN to that alias, sending it out to the next hop and back in through our own MX to be decoded — surviving the entire inbound pipeline as a null-sender message — only to reach an address that was in the row all along. Where that round trip does not work, the DSN is lost with nothing reporting it.

The bounce stage therefore prefers this value over mail_from. It is recorded rather than recovered by reversing the alias because Srs::reverse needs the SRS HMAC secret, and a stage whose job is composing a bounce has no reason to hold key material: the sender is ordinary message metadata, which is what state is for.

Written only when absent, so a message rewritten twice keeps the address that names a real correspondent rather than the intermediate alias. A bounce (null sender) is never rewritten and so never records one; an empty value reads as absent, and the bounce stage falls back to the envelope sender.

70.3.1.1.4.8. pay_deadline

(string) Written by pepsi-stage-anti-spam(1): the deadline by which the pay-to-send order must be settled. It is kept in state (rather than the row’s timeout column) because the dispatcher nulls timeout whenever it re-queues a paused message. The stage short-circuits its payment gate when state.paid is already set.

70.3.1.1.4.9. spam / paid

(boolean) Anti-abuse short-circuit flags. pepsi-stage-check-whitelist(1) sets spam = false on a whitelist match (and otherwise leaves it unset), and pepsi-stage-anti-spam(1) skips the payment gate when spam is false or paid is true.

70.3.1.1.4.10. dot-forwarders

(array of strings) The lower-cased login names whose ~/.forward file has already processed this message, maintained by pepsi-stage-dot-forward(1) as a forwarding-loop guard. Because a forwarded message restarts the pipeline, a recipient whose user is already listed here is dropped instead of being run through its ~/.forward again, so a cycle (~bobalice, ~alicebob) terminates.

70.3.1.1.4.11. language

(string) Written by pepsi-stage-detect-language(1) as an Accept-Language-style string describing the detected body language(s), and read by pepsi-stage-block-language(1) to score the message against its allow/deny lists.

70.3.1.1.4.12. vacation

(object) Written by pepsi-stage-vacation(1) when it has answered a message on the recipient’s behalf, recording what it did:

{ "vacation": { "notified":  true,
                "recipient": "alice@example.org",   // who is away
                "range":     "2026-08-01:2026-08-14",
                "language":  "de" } }               // the notice's language

Nothing reads it: it exists so the decision is visible in pepsi-queue(1), so pepsi-stage-if(1) can branch on it, and so a support question about a surprising out-of-office reply is answerable from the row rather than from the log. Absent on every message that was not answered.

70.3.1.1.4.13. keydisc

(object) What a parked message is waiting for: the correspondent address whose key could not be found in the cache, and when the wait began:

{ "keydisc": { "address": "bob@example.com",   // the address a key is wanted for
               "since":   1769812345 } }       // Unix seconds, when the park happened

It is written by the key-discovery park terminal, which in one round-trip commits whatever the stage had already rewritten, merges this key, sets the row paused with a retry timeout, and enqueues a request in pepsi.key_request. A pepsi-keydisc(1) service that settles that request matches waiters on keydisc.address — through a partial index on exactly that expression — and flips them back to pending, so a stage must not rename or hand-write the key. The retry timeout is the safety net for a deployment running no discovery service at all: it is always set later than the discovery deadline, so the release normally happens first.

The release only moves the row’s status and clears its timeout, so the key survives it: a resumed message still carries the record of the wait that has just ended, and a stage that parks again simply overwrites it.

70.3.1.1.4.14. tlsrpt

(boolean) A loop-guard flag set on a message that is itself an SMTP TLS Reporting report (pepsi-tlsrpt(1)). The relay stages skip recording a pepsi.tls_session row for such a message, so a report about a failed report delivery cannot recurse.

70.3.1.1.4.15. auth.arc

Overwritten by pepsi-stage-arc(1) with the verdict of the inbound ARC chain it re-evaluated, replacing the none ingress seeded under auth (see the auth key above).

70.3.1.1.4.16. auth.arc_sealers

(array of strings) Written by pepsi-stage-arc(1) alongside auth.arc: the d= of every ARC-Seal the message arrived carrying, lower-cased, trailing root dot removed, de-duplicated and ordered by instance so the ADMD closest to the originator comes first. Our own ARC_DOMAIN is never present — the list is read before this stage’s own set is rendered:

{ "auth": { "arc": "pass", "arc_sealers": ["lists.example.org", "mx.forwarder.example"] } }

It exists because auth.arc alone cannot support a policy decision: RFC 8617 §8.4 states that a valid chain conveys no trustworthiness, only that the named ADMDs handled the message, and leaves the rest to the receiver. Naming an acceptable intermediary is that local policy, and this is what such a rule is matched against — the sealer_domain rows of pepsi-stage-check-whitelist(1).

The array is written whether or not the chain validated, because it is the record of what the message claimed; a seal parses without verifying. Every reader must therefore require auth.arc = pass before trusting an entry, which check-whitelist does by discarding the whole list otherwise.

70.3.1.1.4.17. crypto

(object) What the end-to-end cryptography stages did. The outbound half, crypto.out, is written by pepsi-stage-encrypt(1):

{ "crypto": { "out": {
    "signed":            true,
    "protocol":          "openpgp",
    "signer":            "alice@example.org",
    "signer_fingerprint": "ABCD…",
    "recipients": {
      "bob@example.net": { "outcome":           "encrypted",
                           "protocol":          "openpgp",
                           "fingerprint":       "1234…",
                           "key_source":        "wkd",
                           "trust":             "wkd-advanced",
                           "content_algorithm": "seipdv2-aead",
                           "downgraded":        false } } } } }

signer is the From: author, never the envelope sender: the signature is about the author the recipient sees. Each recipient’s outcome is one of encrypted, signed-only, cleartext, secure-link, bounced or oversize, and route names the ON_NO_KEY/ON_OVERSIZE path taken when it was not the ordinary one. reason carries a short phrase for the operator’s log and for the bounce.

content_algorithm and downgraded are recorded so an operator can answer “who are we still talking to in SEIPDv1?” without reading logs. downgraded appears only beside a content_algorithm: false on a recipient that was not encrypted at all would read as “we encrypted, without downgrading”, which is a different claim.

key_source and trust are both own when the recipient is an address this deployment holds its own identity for. That is not a rank on the discovery ladder: our own key pre-empts the pepsi.peer_key cache entirely for such an address, so no MIN_TRUST floor applies and no discovered key was consulted. See pepsi-stage-encrypt(1).

Because the stage splits divergent recipients one per row, a row’s recipients object normally holds exactly the recipients in that row’s rcpt_to.

The inbound half, crypto.in, is written by pepsi-stage-decrypt(1):

{ "crypto": { "in": {
    "encrypted":      true,
    "decrypted":      true,
    "decryption":     "decrypted",
    "protocol":       "openpgp",
    "decrypted_with": { "identity":    7,
                        "address":     "bob@example.org",
                        "fingerprint": "ABCD…" },
    "signature":      { "status":      "valid",
                        "signer":      "alice@example.net",
                        "fingerprint": "1234…",
                        "key_source":  "wkd",
                        "trust":       "wkd-advanced",
                        "algorithm":   "ed25519",
                        "signed_at":   1785000000 },
    "layers": [ { "kind": "encrypted", "protocol": "openpgp",
                  "encoding": "pgp-mime", "verdict": "good",
                  "container": "seipdv1-mdc", "downgraded": true },
                { "kind": "signed", "protocol": "openpgp",
                  "encoding": "pgp-mime", "verdict": "good" } ] } } }

decryption is not-encrypted, decrypted or failed; on failed a sibling failure key carries the machine-readable reason (no-decryption-key, decryption-failed, too-large, …). route names the ON_DECRYPT_FAILURE/ON_BAD_SIGNATURE path taken when it was not the ordinary one.

signature.status is one of:

none

No signature.

valid

Cryptographically sound and the key was anchored — an X.509 chain to a ca_trust anchor, or an OpenPGP key from a ranked discovery source.

valid-untrusted

Sound, but the key could not be tied to anything: a first-seen key, one learnt from the message itself, or a certificate from a CA this deployment holds no anchor for. Never reported as ``valid`` — the distinction is the point of the stage.

invalid

The signature does not verify, or the signer’s certificate is expired, revoked or bound to a different address. signature.failure says which.

unverifiable

No key was available for the claimed signer.

signature.key_source and signature.trust here carry the crypto layer’s own provenance vocabulary (anchor, dane, wkd, autocrypt, pinned, attached, unknown), which is coarser than the outbound half’s ladder names. A signature checked against our own identity for the claimed sender reports pinned — the strongest value that vocabulary has, and the right one, since the key is on record for that address. It is the same pre-emption the outbound half spells own: for an address this deployment holds a key for, no peer_key row is offered to the verifier at all. See pepsi-stage-decrypt(1).

A message carrying several signature layers takes the worst of their verdicts — among the layers that cover the plaintext. A signature layer marked covers_ciphertext (see below) is consulted only when there is no other, and even then can never yield valid.

layers is ordered outermost first, and that order is load-bearing: it is what the subject tags are rendered from, so encrypted(signed(body)) and signed(encrypted(body)) are distinguishable. It therefore survives a pause, which is why the stage records it rather than recomputing it. container and downgraded appear on ciphertext layers only, so an operator can see how much inbound mail is still pre-AEAD.

"covers_ciphertext": true appears on a signature layer that has a ciphertext layer nested inside it — the outer signature of signed(encrypted(body)). It is present only when true. Such a signature was computed over the ciphertext, so it says that the signer transmitted the blob and nothing about what came out of it; anybody who obtains an encrypted message can wrap it in their own signature and forward it. The layer keeps its own honest verdict, but it cannot raise signature.status to valid and so cannot set signature_verified. In signed(encrypted(signed(body))) only the outer layer is marked, and the inner one — the one that speaks for the content — decides the verdict.

70.3.1.1.4.18. encrypted

(boolean) Set to true by pepsi-stage-encrypt(1) when the message on this row really was encrypted to a recipient key, and by pepsi-stage-decrypt(1) when the message arrived encrypted. pepsi-stage-auto-whitelist(1) copies it into the new whitelist row’s signature_required column, so that a correspondent who was reached under encryption is later held to the same standard. Absent (rather than false) when nothing was encrypted.

70.3.1.1.4.19. signature_verified

(boolean) Set to true by pepsi-stage-decrypt(1) for the valid signature verdict, and only that one. pepsi-stage-check-whitelist(1)’s signature_required gate tests it, so letting valid-untrusted set it would open a gate the operator meant to hold for a trusted key — and, for the same reason, a signature that only covers ciphertext (covers_ciphertext, above) never sets it either. Absent (rather than false) otherwise.

70.3.1.1.4.20. milter

(object) What pepsi-stage-milter(1) did — the filter’s verdict, the protocol version negotiated, how long the conversation took, and every modification applied, labelled:

{ "milter": { "stage":             "spam-filter",
              "verdict":           "reject",
              "version":           6,
              "elapsed_ms":        42,
              "actions":           ["addheader:X-Spam-Status", "replbody"],
              "reply_code":        "550 5.7.1 blocked",
              "quarantine_reason": "virus found",
              "rejected_recipients": 1 } }

verdict is continue, accept, reject, tempfail, discard or — when the filter could not be spoken to at all — failed, in which case a sibling error carries the diagnostic and the routing followed the stage’s ON_FAILURE. reply_code, quarantine_reason and rejected_recipients are present only when they apply.

Nothing reads it. It exists so pepsi-queue(1) can show why a message was tagged, so pepsi-stage-if(1) can branch on it, and so a support question about a surprising header is answerable from the row rather than from the log — the same rationale as vacation above. A rejected message additionally carries the usual bounce object, with the filter’s own SMTP code, RFC 3463 enhanced status and text split into their own fields.

70.3.1.1.4.21. dispatch_error

Written by pepsi-dispatch(1) when it forces a row to a terminal state it did not reach on its own — the diagnostic for a stage program that crashed (failed) or exceeded MAX_RUNTIME (timeout).

70.3.1.1.5. See Also

pepsi.conf(5), pepsi-ingress(1), pepsi-dispatch(1), pepsi-stage-arc(1), pepsi-stage-bounce(1), pepsi-stage-check-whitelist(1), pepsi-stage-anti-spam(1), pepsi-stage-detect-language(1), pepsi-stage-block-language(1), pepsi-stage-vacation(1), pepsi-stage-milter(1), pepsi-stage-encrypt(1), pepsi-stage-decrypt(1), pepsi-stage-auto-whitelist(1), pepsi-tlsrpt(1), pepsi-keydisc(1), pepsi-keys(1)