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_methodHow the session authenticated:
sasl(an RFC 4954AUTHsucceeded),peercred(a UNIX-socket peer whose uid resolved to a permitted login),client-cert(a TLS client certificate matched a pin) ormynetworks(the peer address is inMYNETWORKS). Absent when the session did not authenticate. A later SASLAUTHoverwrites an earlier connection-time method: it is the stronger claim, and the only one that carries a username.auth_mechanismThe SASL mechanism that succeeded (
PLAIN,LOGIN), upper-cased. Present only forauth_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_identityThe authentication identity: the SASL authcid, or the login a UNIX peer’s uid resolved to. Absent for
mynetworksandclient-cert, which authenticate an address or a key and name nobody.peercredtherefore yields an identity with no mechanism — the honest description of a session the kernel authenticated rather than SASL.auth_authzidThe 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 (~bob → alice, ~alice →
bob) 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:
noneNo signature.
validCryptographically sound and the key was anchored — an X.509 chain to a
ca_trustanchor, or an OpenPGP key from a ranked discovery source.valid-untrustedSound, 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.
invalidThe signature does not verify, or the signer’s certificate is expired, revoked or bound to a different address.
signature.failuresays which.unverifiableNo 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)