85.3.1. pepsi.state

per-message state object carried through the Pepsi pipeline

Manual section:

7

85.3.1.1.1. Name

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

85.3.1.1.2. Description

Every message in flight is one row of the pepsi.workqueue 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).

85.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.

The merge is shallow: state || {"auth": {"arc": "pass"}} would replace the whole auth object, not just its arc member. Stages that update one member of a nested object therefore read the current object, amend it, and write it back whole (that is what set_auth_verdict/set_auth_fields do for auth.arc).

The one structural rewrite is dsn.rcpt, which is an array parallel to rcpt_to: every helper that reduces a row to a subset of its recipients, or fans a group out onto a sibling row, re-slices that array in lockstep, and the alias stage rebuilds it when it writes an entirely new recipient list. A stage must never touch rcpt_to without going through those helpers.

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.

85.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.

85.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
    "from_bound":   true,               // From: bound to the account (see below)
    "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). null 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. Set only for auth_method = sasl. The non-SASL methods leave it null 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. null 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). null when they are the same, which is the normal case: RFC 4954 permits an empty authzid and clients routinely repeat the authcid there.

from_bound

true when the From: field names exactly one mailbox and that mailbox matched a concrete (wildcard-free) USERNAME_MAP entry of the authenticated account, or its default login@HOSTNAME; false otherwise, including for a session with no account grant. Being permitted to send as an address is enough to send; being bound to it is what lets a submission speak for it. pepsi-stage-encrypt(1) registers a key from the message, or runs an e-mail key command, only when auth_method is mynetworks or client-cert, or it is sasl/peercred with from_bound = true: an account granted *@example.org may send as its colleagues but must not be able to plant a key for them.

85.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)
  }
}

85.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, presented a matching TLS client certificate, or — on a UNIX socket with AUTH_PEERCRED, which is the default there — was a peer whose uid resolved to a permitted login, the path every locally submitted message takes; 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).

85.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
  }
}

85.3.1.1.4.5. bounce

(object) Written by any stage that routes a message to its BOUNCE_STAGE — the delivery stages (pepsi-stage-relay-to-internet(1), pepsi-stage-relay-to-smarthost(1), pepsi-stage-relay-to-maildir(1), pepsi-stage-relay-to-lmtp(1)) and pepsi-stage-discard(1), but equally pepsi-stage-milter(1), pepsi-stage-block-language(1), pepsi-stage-anti-spam(1), pepsi-stage-encrypt(1), pepsi-stage-decrypt(1), pepsi-stage-secretary(1) and pepsi-stage-dot-forward(1). They all build it with the same shared helpers, so the shape below is the same whichever stage wrote it. It is 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. The message-level ret rides along here too, for the same reason as envid: the bounce stage clears state as it rewrites the message, so anything it needs after that point has to be inside this object:

{ "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",
              "ret": "hdrs" } }

85.3.1.1.4.6. attempts / last_error / delay_sent

Scratch the delivery stages record when they pause a message for retry: attempts (number) is the number of delivery attempts so far, which is what drives the retry backoff, and delay_sent (boolean, present only when true) marks that the one-shot NOTIFY=DELAY DSN has already been emitted, so it is never sent twice.

last_error (string) is the most recent error text. It is not confined to the retry path: the delivery, milter, crypto, DKIM-signing and ~/.forward stages also write it when they fail a message terminally, so on a failed row it is the first thing to read (pepsi-queue(1) shows it as part of the row’s state, and pepsi-status(1) lists it under each stuck message).

85.3.1.1.4.7. temporary_failures / temporary_stage

(number / string) Written, together with last_error, by the stage scaffolding when a stage’s error was not marked permanent – the host’s fault rather than the message’s (see pepsi-dispatch(1)): the number of times the message has been paused for it so far at temporary_stage, which drives the back-off from one minute doubling up to an hour. A count recorded at another stage is not continued. Removed when the worker gives up.

85.3.1.1.4.8. failed_stage / failure_class / failed_at

The record of a failure that happened on this host rather than at the next hop. failed_stage (string) is the stage it happened at. failure_class (string) says how it ended: permanent (the stage marked the error as a defect of the message), retries-exhausted (retried until MAX_LIFETIME), crashed or timed-out (the message crashed or hung its worker three times). failed_at (string, RFC 3339) is stamped by the database whenever the row becomes failed or timeout; pepsi-failure-bouncer(1)’s MIN_AGE counts from it. pepsi-stage-bounce(1) builds a DSN from failed_stage and failure_class when no relay reported the failure (bounce above), with a generic text; last_error is never copied into a DSN.

85.3.1.1.4.9. arc_temperrors

(number) Written by pepsi-stage-arc(1) each time validating an inbound ARC chain met a temporary DNS error: the message is paused and retried, and after the third such attempt the chain is sealed cv=fail as a failed one would be.

85.3.1.1.4.10. strikes

(number) Written by pepsi-dispatch(1) each time the message crashed its worker or held it past MAX_RUNTIME; the third strike fails the message.

85.3.1.1.4.11. retry_since

(string, RFC 3339) Written by pepsi-stage-bounce(1) on the DSN it builds, which replaces the message in its row: the moment the stages’ MAX_LIFETIME is counted from instead of the row’s arrival time, so a DSN is not born already expired.

85.3.1.1.4.12. 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.

85.3.1.1.4.13. pay_deadline

(number) Written by pepsi-stage-anti-spam(1): Unix epoch seconds, 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. A value written as a JSON string is read as “no deadline”, so anything setting this by hand must write a number. The stage short-circuits its payment gate when state.paid is already set.

85.3.1.1.4.14. spam / paid

(boolean) Anti-abuse short-circuit flags.

spam is written by pepsi-stage-check-whitelist(1), which sets it false on a whitelist match (and otherwise leaves it unset), and by pepsi-ingress(1), which sets it false for a message whose only recipient is <Postmaster> (RFC 5321 §4.5.1 reachability), and by pepsi-stage-secretary(1), which sets it false on a message it releases because its sender confirmed. A false value makes pepsi-stage-block-language(1), pepsi-stage-secretary(1) and pepsi-stage-anti-spam(1) forward the message without applying their gate. A true value makes pepsi-stage-anti-spam(1) drop the message rather than gate it, sends it down pepsi-stage-secretary(1)’s timeout path, suppresses key learning in pepsi-stage-autocrypt-learn(1) unless that stage’s LEARN_FROM_SPAM is on, and blocks the RFC 3834 auto-responders that use the shared suppression rules (pepsi-stage-vacation(1), the secretary’s challenge and the mailing-list auto-responses). Only an explicit boolean counts: an absent key triggers neither behaviour.

Note that no stock stage ever writes spam = true: the writers above only ever write false. The true value is the hook a milter-driven pipeline, an operator or a site-specific stage uses — the same arrangement as paid below.

paid is written by pepsi-stage-anti-spam(1) itself, and only ever as false: it stores paid = false alongside pay_deadline on every pause, which is what marks the message as awaiting payment. When the merchant reports the order settled the stage simply advances the message, so no stage in the tree ever writes paid = true — that value is an override hook for an operator or a site-specific stage, and the payment gate is skipped when it is set (as it is when spam is false).

85.3.1.1.4.15. dot-forwarders

(array of strings) The lower-cased login names whose ~/.forward files forwarded this row, i.e. the forwarding chain that led to it, maintained by pepsi-stage-dot-forward(1) as a forwarding-loop guard. A forwarded row gets the chain of the row it came from plus the one login that forwarded it, so the list describes one forwarding path, not every ~/.forward the message has passed through. Because a forwarded message restarts the pipeline, a recipient whose user is already listed here is bounced instead of being run through its ~/.forward again, so a cycle (~bob → alice, ~alice → bob) terminates.

85.3.1.1.4.16. language

(string) Written by pepsi-stage-detect-language(1) as an Accept-Language-style string describing the detected body language(s), with q-values in preference order.

pepsi-stage-block-language(1) reads it to score the message against its allow/deny lists, but it is equally the localisation key: every stage that composes prose for a human picks its template or message language from it, falling back to its own DEFAULT_LANGUAGE when it is absent or names nothing supported — pepsi-stage-vacation(1) (the out-of-office notice), pepsi-stage-anti-spam(1) (the payment request), pepsi-stage-secretary(1) (the challenge), pepsi-stage-encrypt(1), pepsi-stage-auto-pay(1) and pepsi-stage-secure-link(1) (their reply messages) and pepsi-stage-edit-settings(1) (its confirmation or error reply). That is why a deployment that does no language blocking may still want the detection stage in the pipeline.

85.3.1.1.4.17. 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.

85.3.1.1.4.18. secretary

(object) Written by pepsi-stage-secretary(1). On a message it holds:

{ "secretary": { "challenge": "00112233445566778899aabbccddeeff",  // the cookie
                 "deadline":  1769812345,          // Unix seconds, when it expires
                 "whitelist": "correspondents" } } // where a confirmation writes

A confirmation adds "confirmed": true as it releases the row (the stage then forwards it with spam = false); a bounced challenge adds "abandoned": true, sending it down the timeout path early. A message the stage would not challenge and routed to its UNCHALLENGEABLE_STAGE carries { "secretary": { "unchallengeable": "<reason>" } } instead. The timeout path removes the key entirely: nothing of a challenge outlives it. Like pay_deadline, the deadline is kept in state because the dispatcher clears the row’s timeout when it requeues a paused message.

85.3.1.1.4.19. 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.

85.3.1.1.4.21. 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.

85.3.1.1.4.22. 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).

85.3.1.1.4.23. 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.

85.3.1.1.4.24. 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 } },
    "key_attached":      true } } }

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.

key_attached (boolean, present only when true) records that the sender’s public key went out as an application/pgp-keys file (ATTACH_KEYS_AS_FILES). client_protected (boolean, present only when true) records that the user’s own mail client had already encrypted the submission, so the stage left it alone: no encryption, signature, Autocrypt: header or key file of Pepsi’s.

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

{ "crypto": { "in": {
    "encrypted":      true,
    "outer_gossip":   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,
                        "covers_plaintext": true },
    "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, failed or for-client; 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.

for-client means the message is encrypted to the recipient’s own MUA key (an identity with custody = client, whose private half lives only in their mail client) and was passed on unopened, even when one of our own keys was a recipient too. It is not a failure: ON_DECRYPT_FAILURE is not consulted. The sibling for_client is then true and client_fingerprint names the key. Nothing was verified, so signature_verified is not set.

signature.covers_plaintext is present whenever there is a signature verdict: true when the verdict was granted by a signature over the plaintext, false when it came from a layer wrapped around ciphertext. pepsi-stage-autocrypt-learn(1) records that a correspondent holds one of our keys (pepsi.peer_has_own_key) only on a valid or valid-untrusted signature with covers_plaintext = true whose signer is the From: address, on a message we decrypted.

outer_gossip (boolean, present only when true) records that the message as it arrived already carried an Autocrypt-Gossip: field on its outer header block — something any hop on the path could have written, and which inline-PGP reassembly can carry through decryption. It is written here because the plaintext is committed over the arriving bytes, so nothing downstream could answer the question later. Together with encrypted it is what pepsi-stage-autocrypt-learn(1) consults before learning any gossiped key: an unauthenticated outer field vetoes the lot. If LEARN_GOSSIP is on and nothing is being learnt from an encrypted message, this is the key to look at.

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``; that distinction is what the stage exists to make.

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 the order matters: 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.

The storage half, crypto.store, is written by pepsi-stage-reencrypt(1) on a row whose crypto.in says the message was decrypted:

"crypto": {
  "in": { … },
  "store": { "outcome": "reencrypted", "protocol": "openpgp",
             "fingerprint": "…", "container": "seipdv2-aead",
             "downgraded": false }
}

outcome is reencrypted (sealed to the recipient’s own MUA key, named by fingerprint), plaintext (filed as it is: no usable MUA key under ON_NO_CLIENT_KEY = plaintext, or not a local recipient — reason says which) or bounced (refused under ON_NO_CLIENT_KEY = bounce; reason). The stage merges the whole crypto object, so crypto.in survives, and the presence of store is what keeps it from sealing a row twice.

85.3.1.1.4.25. 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.

85.3.1.1.4.26. 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.

85.3.1.1.4.27. 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.

85.3.1.1.4.28. crypto_policy_refused

(boolean, present only when true) Written alongside last_error by pepsi-stage-encrypt(1), pepsi-stage-decrypt(1) and pepsi-stage-secure-link(1) when they fail a message because the configured policy refused it — ENCRYPT = required with no usable recipient key and no ON_NO_KEY route, a message that arrived encrypted with no ON_DECRYPT_FAILURE route, and so on. It distinguishes a deliberate refusal from a transport or programming error, which otherwise look identical on a failed row: retrying the first one will never help.

85.3.1.1.4.29. milter_error

(string) Written by pepsi-stage-milter(1) when a filter rejected a message but neither REJECT_STAGE nor BOUNCE_STAGE was configured, so the stage had nowhere to route it. Distinct from milter.error inside the milter object above, which is the diagnostic for a filter that could not be spoken to at all.

85.3.1.1.4.30. list

(object) The mailing-list descriptor. pepsi-stage-list(1) writes it on every row it routes to a list role (the list id, the role, the address the message arrived at, and a token or verp address when the sub-address carried one); pepsi-stage-list-post(1) writes a per-member descriptor (id, member, lang, serial, duplicate) on each delivery row it fans out, plus pending_rcpt on a partially fanned-out posting and rejected on a refused one; pepsi-list(1) sets moderator_approved (and digest on a digest issue). Read by pepsi-stage-list-post(1), pepsi-stage-list-command(1), pepsi-stage-list-deliver(1) and pepsi-stage-list-bounce(1); see those pages for the fields each one uses.

85.3.1.1.4.31. dispatch_error

(string) 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), one that exceeded MAX_RUNTIME (timeout), or a row sitting at a stage the configuration does not define. pepsi-status(1) lists it under each stuck message.

85.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-secretary(1), pepsi-stage-milter(1), pepsi-stage-encrypt(1), pepsi-stage-decrypt(1), pepsi-stage-auto-whitelist(1), pepsi-stage-list(1), pepsi-tlsrpt(1), pepsi-keydisc(1), pepsi-keys(1)