diff --git a/docs/assumptions-2026-10-14.md b/docs/assumptions-2026-10-14.md index c7f7f9e..569aefa 100644 --- a/docs/assumptions-2026-10-14.md +++ b/docs/assumptions-2026-10-14.md @@ -23,7 +23,8 @@ This register is the review's work list. It was made in two passes: 2. **Answers**: each entry is settled in the source of the version the image runs and gets a verdict (holds / does not hold / measured, with date), the citation written into the code, and, where the source contradicts the - code or a document, the fix. Answers are added under each part. + code or a document, the fix. The answers follow the inventory, part by + part (§ Answers). Entries are named by part and number: **A-8** is entry 8 of part A, **B-PS1** entry PS1 of part B, **D-S2** entry S2 of part D. @@ -2097,3 +2098,1696 @@ Overall: no claim names a source for the version in use, so nothing is CITED. Th Counts (security.md): CITED 0, MEASURED 3, GUESSED 69, WRONG? 4 (76 entries). +# Answers + +Each entry settled in the source of the version the image runs (2026-10-14). +Verdicts: **HOLDS** (the citation is now in the code beside it), **PARTLY** +(which part fails), **DOES NOT HOLD** (what the source says, the consequence, +the fix), **NEEDS A MEASUREMENT** (what to measure on the stand). Part D's +document claims are named by document and line: D-arch:40, D-guide:227, +D-sec:91. The fixes are lines of audit-2026-10-12, stage 2c. + +## Part A - answers (2026-10-14) + +Sources read: dev/upstream/ (postfix-3.7.11, opendkim-2.11.0~beta2, cyrus-sasl2-2.1.28+dfsg, +supervisor-4.2.5, logrotate-3.21.0, Debian patches applied). Where the 3.7.11 tree could not +answer: the Postfix 3.12 snapshot of 2026-09-25 (github.com/vdukhovni/postfix, master: +proto/postconf.proto, src/util/msg_logger.c), Linux torvalds/linux master read 2026-10-14 +(kernel/sched/cputime.c, fs/proc/meminfo.c, fs/proc/stat.c, arch/x86/kernel/cpu/proc.c, +net/core/net-procfs.c, net/core/dev.c), man pages proc(5), unix(7), bind(2), inode(7), +rename(2), network_namespaces(7), and certbot/docs/using.rst (main). Line numbers are of the +working tree after the edits. Nothing in the code's behaviour was changed; only comments and +fixture labels. + +### Entries + +A-1: HOLDS - Postfix 3.7.11, src/global/mail_queue.c mail_queue_enter(); src/global/mail_queue.h MQID_SH_* / MQID_LG_*; src/global/safe_ultostr.c safe_chars - short id "%05X" usec + "%lX" inode = 6..21 chars; long id 6+ sec, 4 usec, 'z', inode base 51 = 12..23 chars, alphabet "0-9 B-Z b-z" minus vowels, which is exactly the regexp's class. The lower bound {10, is looser than needed (12), harmless. NOQUEUE and warning contain vowels/non-hex. - comment at internal/logtail/lines.go:31. + +A-2: HOLDS - src/global/mail_task.c mail_task() ("%s/%s", syslog_name, process name); src/global/mail_params.c mail_params_init() exports the (-o overridden) syslog_name as MAIL_LOGTAG; src/master/single_server.c single_server_main() calls maillog_client_init(mail_task(...)) again after the -o options. Port 25 is "smtp/inet/syslog_name=postfix/inbound" (postfix-config.sh); the other listeners get the default "postfix". - comment at lines.go:41. + +A-3: HOLDS - src/smtpd/smtpd.c mail_open_stream(): msg_info("%s: client=%s...") when the queue file is opened (at MAIL FROM or the first accepted RCPT, smtpd_delay_open_until_valid_rcpt), before any other line of that id; the program tag is the listener's. - comment at lines.go:86. + +A-4: HOLDS - src/qmgr/qmgr_active.c qmgr_active_done_3_generic(): msg_info("%s: removed") after mail_queue_remove(); postsuper writes the same text (src/postsuper/postsuper.c). - comment at lines.go:86. + +A-5: HOLDS - postconf(5) enable_long_queue_ids ("long, non-repeating, queue IDs"; default no); mail_queue.h: short id = microseconds + inode, so ids repeat, and their length varies with the inode, so one can be the tail of another. - comment at lines.go:86 (and mentionsQueueID's existing text). + +A-6: PARTLY - src/global/log_adhoc.c log_adhoc(): "%s: to=<%s>", ", orig_to=<%s>" (only when strcasecmp_utf8(address, orig_addr) != 0), ", relay=%s", [", conn_use=%d"], delays, ", dsn=%s, status=%s (%s)". The shape holds. What fails: "qmgr/smtpd/cleanup lines do not match" - qmgr writes this very line for recipients it defers or bounces itself (src/qmgr/qmgr_defer.c defer_append(), src/qmgr/qmgr_bounce.c bounce_append(); log_adhoc() runs in the calling process, src/global/defer.c defer_append_intern(), bounce.c). Consequence: none harmful - those are genuine per-recipient results and the regexp (which does not restrict the program) takes them correctly; only the comment was wrong. - comment rewritten at internal/logtail/logtail.go:93 (cites log_adhoc and the qmgr case). Fix: none needed beyond the comment. + +A-7: HOLDS - log_adhoc(): the reply is the trailing " (%s)" after status=; between relay= and status= come only conn_use=, delay=, delays=, dsn= (Postfix's own fields), so the first status= after relay= is the real one. - comment at logtail.go:117. + +A-8: DOES NOT HOLD - src/qmgr/qmgr_active.c qmgr_active_done_25_generic(): + msg_info("%s: from=<%s>, status=%s, returned to sender", message->queue_id, ..., expire_status); + with expire_status "expired" (or "force-expired" after postsuper -e). That line is once per message, has no to= and no relay=, so deliveryRe never matches it; adefer_flush() then has the bounce daemon send the notice ("sender non-delivery notification", bounce_notify_service.c) without any per-recipient status line. No Postfix 3.7.11 code writes "to=<..>, ..., status=expired". Consequence: an app sends to an address whose MX keeps answering 4xx for 5 days: each attempt writes "status=deferred", the row becomes "deferred"; on expiry Postfix returns the mail and removes the queue file, but the row stays "deferred" for ever - the sweep (store CloseUnreported / ListQueuedOlderThan) only touches rows still "queued". The panel shows "deferred" for a message that bounced days ago. The "expired" case in parseDelivery is dead code and the fixture at logtail_test.go ("expired maps to bounced") is a shape Postfix never writes. + Fix: in logtail, match qmgr's "^\S+ postfix/qmgr\[\d+\]: (): from=<.*>, status=(expired|force-expired), returned to sender$" (anchored at the head like deliveryRe) and call a new store method, e.g. ExpireQueueID(queueID) = UPDATE send_log SET status='bounced', reason='expired' WHERE queue_id=? AND status IN ('queued','deferred'). Drop the "expired" case from parseDelivery. Tests: TestParseDelivery loses the composed expired case; a new test feeds a deferred line then the qmgr expiry line and asserts the row is bounced; a store test for ExpireQueueID leaving sent/bounced rows alone; a test that a sender address quoting "status=expired, returned to sender" in from=<"..."> does not trigger (anchor + external quoting). + +A-9: PARTLY - log_adhoc(): to= is the address after cleanup rewrote it (src/cleanup/cleanup_addr.c cleanup_addr_recipient(): cleanup_rewrite_internal(MAIL_ATTR_RWR_LOCAL, ...) then canonical maps), orig_to= the RCPT TO as given, shown only when they differ case-insensitively. The send-log row holds the RCPT TO the milter saw (internal/milter RcptTo). Holds whenever cleanup does not rewrite the recipient: this config sets no canonical/virtual maps, and case differences are hidden by strcasecmp + COLLATE NOCASE. Fails when trivial-rewrite qualifies the address: an app sending "RCPT TO:" (no domain; permit_sasl_authenticated lets it through) is delivered and logged as to=, orig_to=; UpdateStatus(queueID, "ops@...") matches no row, the row stays queued and the sweep closes it as bounced although it was delivered. - comment (log_adhoc citation, "only when the address was rewritten") at logtail.go:93. + Fix: capture orig_to in deliveryRe (group 3, optional) and, when present, try UpdateStatus with orig_to first, then to. Tests: TestParseDelivery case with orig_to returns both; a Run-level test with a row whose To is the bare RCPT and a line carrying orig_to= resolves it; the existing "orig_to is ignored in favour of to" case changes to expect orig_to as the row key. + +A-10: HOLDS - every queue-id line is msg_info("%s: ...", queue_id) (log_adhoc.c, smtpd.c mail_open_stream(), qmgr_active.c "%s: removed", opened.c); the id is preceded by "]: " - comment at logtail.go:522. + +A-11: HOLDS (MEASURED, now also cited) - src/util/msg_logger.c msg_logger_print(): strftime("%b %d %H:%M:%S ") of localtime(), then the first host label; postlogd writes the record as received (src/postlogd/postlogd.c postlogd_service() -> logwriter_write). The live-relay fixture is now labelled "date not recorded". - comment at logtail.go:549; fixture label in logtail_test.go TestSplitTimestamp. + +A-12: DOES NOT HOLD - Postfix 3.7.11 proto/postconf.proto has no maillog_file_format; neither does the Postfix 3.12 snapshot of 2026-09-25 (github.com/vdukhovni/postfix master, mail_version.h "3.12", RELEASE_DATE 20260925): its postconf.proto has maillog_file, maillog_file_prefixes, maillog_file_compressor, maillog_file_rotate_suffix only, and its src/util/msg_logger.c still has + strftime(..., "%b %d %H:%M:%S ", lt) + So "maillog_file_format arrived in Postfix 3.9 and selects RFC 3339" is false as far as these sources show. Consequence: none at run time (isoStampRe matches no Postfix line and falls through); but most logtail test fixtures (inbound_test, refusals_test, sendercheck_test, parts of logtail_test) use ISO stamps, so the path production actually takes - syslog stamp, year inference in lineTime() - is exercised by only a handful of cases, and an upgrade plan built on this parameter would find nothing. - comment rewritten at logtail.go:557 (states the fact, cites both trees); no citation blessing the claim. + Fix: (a) convert the fixtures' heads to the syslog form ("Oct 12 16:22:13 mail ..."), keeping the measured lines' text; (b) either delete isoStampRe or keep it labelled "no Postfix writes this" - recommendation: delete it and the RFC 3339 TestSplitTimestamp cases, since nothing produces that format; (c) rename the TestSplitTimestamp case "RFC 3339, which maillog_file_format selects on Postfix 3.9 and up". Tests: TestSplitTimestamp/TestLineTime with syslog stamps only; the inbound/refusal/sendercheck tests pass unchanged after the stamp conversion (lineTime then dates them with `now`'s year). + +A-13: PARTLY - msg_logger.c msg_logger_print(): the stamp is made by the logging process with localtime(); postlogd only writes it (postlogd_service()). The effect holds (the zone is the container's: TZ is in import_environment's default, src/global/mail_params.h DEF_IMPORT_ENVIRON, and */*/chroot=n); the attribution "what postlogd wrote it in" was wrong. - comment corrected at internal/logtail/inbound.go:269. Fix: none beyond the comment. + +A-14: HOLDS - src/smtpd/smtpd_check.c log_whatsup(): "%s: %s: %s from %s: %s;" + " from=<..>" + " to=<..>" + " proto=" + " helo=<..>", nothing after; smtpd_check_reject() builds the "%d %s ..." reply. A milter refusal at smtpd (smtpd.c check_milter_reply()) uses smtpd_whatsup(), which appends " sasl_username=<..>" only when authenticated - SASL is off on port 25 (smtp/inet/smtpd_sasl_auth_enable=no). namaddr is state->addr without "IPv6:" (smtpd.h SMTPD_BUILD_NAMADDRPORT, smtpd_peer.c). - comment at inbound.go:48. + +A-15: DOES NOT HOLD - src/cleanup/cleanup_milter.c cleanup_milter_apply(): + if (state->recip) vstring_sprintf_append(state->temp1, " to=<%s>", ...state->recip); + and src/cleanup/cleanup_addr.c cleanup_addr_recipient(): + state->recip = mystrdup(STR(clean_addr)); /* Used by Milter client */ + for every recipient, so to= is the LAST recipient (after rewriting), not the message's recipient. Consequences: (1) inbound journal: rspamd rejects a message to a@in1.example and b@in2.example at end-of-message - one row is written, for in2.example only; in1.example's journal never shows it. (2) From-refusal recovery (refusals.go): a refused message to three recipients that the panel did not record is recovered as one row with the last To; the page shows one recipient where the panel would have written three. - honest note (no blessing) at inbound.go:59 and refusals.go:52. + Fix: no log line names all recipients (smtpd logs none per accepted RCPT), so the fix is to say so rather than to guess: (1) inbound journal - keep one row but derive the domain only from to= and add "(last recipient of the message)" to Detail when it came from cleanup; (2) recovered From-refusal - mark the recovered row (e.g. Reason detail "recovered from mail.log: last recipient only"). Tests: inbound_test with a cleanup verdict line carrying the last of two recipients asserts one row and the marked Detail; refusals_test asserts the recovered row carries the mark. + +A-16: HOLDS - src/smtpd/smtpd.c mail_open_stream() connects to var_cleanup_service (cleanup_service_name, default "cleanup", postconf.proto), not overridden in postfix-config.sh; cleanup's own tag is mail_task("cleanup") with the default syslog_name. - comment at inbound.go:48. + +A-17: HOLDS - src/qmgr/qmgr_message.c qmgr_message_read(): opened() (src/global/opened.c "%s: from=<%s>, size=%ld, nrcpt=%d") when it reads a committed queue file into the active queue (every time it re-enters, which is harmless here). - comment at inbound.go:78. + +A-18: PARTLY - smtpd: smtpd_check_reject() logs "%d %s ..." (code first) via log_whatsup(); check_milter_reply() logs the milter's whole reply. cleanup: cleanup_milter_apply() logs text = resp + 4 (code dropped). Holds when the milter's reply has an enhanced status code. Fails: src/milter/milter8.c (SMFIR_REPLYCODE in milter8_event()) accepts a reply without one ("451 Try again later": buf[4] not a digit passes the check), and cleanup then logs "Try again later" - no leading 4, so the journal records a reject for what was a tempfail. Postfix's own default replies always carry one ("451 4.7.1 ..."). Whether rspamd 3.11 ever replies without an enhanced code is rspamd's source (Part B/C), not read here. - citation for the holding part at inbound.go:206. + Fix: in inboundJournal.handle, for program=="cleanup" decide tempfail/reject only when the text starts with [45]\.\d; otherwise keep Decision reject but put "(reply class not logged)" in Detail. Tests: inbound_test milter-reject with "Try again later" text gets the marked detail; "4.7.1 ..." still tempfail. + +A-19: HOLDS - postconf(5) info_log_address_format (default external: a local part with spaces or specials is quoted); src/global/info_log_addr_form.c info_log_addr_form_sender/recipient(). A quoted local part may contain "> to=<". - comment at inbound.go:155. + +A-20: PARTLY - src/smtpd/smtpd.c check_milter_reply(): msg_info("%s: %s: %s from %s: %s;%s", queue_id ("NOQUEUE"), action, where, namaddr, reply, smtpd_whatsup()), and smtpd_whatsup() starts with " from=<..>" - the regexp holds. The quoted "as the stand writes it" line cannot be verbatim: smtpd_whatsup() appends " sasl_username=<%s>" whenever the session authenticated, and a journal-milter level-2 refusal is of an authenticated application; the fixtures of the same event (refusals_test.go, sendercheck_test.go pid 1149) do carry "sasl_username=". Date of the quote not recorded. Consequence: none (the regexp is not anchored at the end). - comment at internal/logtail/refusals.go:22 (cites the source, says the quote lacks the field, "date not recorded"). + +A-21: DOES NOT HOLD - smtpd.c smtpd_whatsup(): + if (state->sasl_username) vstring_sprintf_append(buf, " sasl_username=<%s>", state->sasl_username); + The line does carry the login on 465/587. Consequence: AttachRefusalLine matches by client address, sender and time only. Two applications behind one NAT address that may both send as billing@ (sender_login_maps "billing@x app1,app2" or a shared @domain) are refused at MAIL FROM in the same 30 s window: the line can go onto the other application's row, and that row's page shows a refusal that was not its own. - comment corrected at refusals.go:22 (no longer says "the line no login"). + Fix: capture `sasl_username=<([^>]*)>$` in refusalLineRe (optional group), pass it to AttachRefusalLine, and match app_login too when present. Tests: refusals_test with two waiting rows of the same IP and sender but different logins - the line goes to the right one; a line without sasl_username still matches as now. + +A-22: HOLDS (MEASURED, now cited) - src/cleanup/cleanup_milter.c cleanup_milter_apply(): "%s: %s: %s from %s[%s]: %s;" with text = resp + 4 ("5.7.1 From: ..."), then " from=<..>", " to=<..>" (last recipient, A-15), " proto=", " helo=<..>"; the ";" is Postfix's. The quoted line is dated: e2e stand 2026-10-12 (sendercheck_test.go fixture). - comment at refusals.go:43 and :52. + +A-23: DOES NOT HOLD - src/smtpd/smtpd.c mail_open_stream(): + msg_info("%s: client=%s%s%s%s%s%s%s%s%s%s%s", ..., ", sasl_method=", ..., ", sasl_username=", state->sasl_username, ", sasl_sender=", state->sasl_sender, ", orig_queue_id=", ..., ", orig_client=", ...) + sasl_sender is set by src/smtpd/smtpd_sasl_proto.c smtpd_sasl_mail_opt() when the client sends MAIL FROM:<..> AUTH= (any non-"<>" value). Then `sasl_username=(\S+)` captures "app1," with the comma. Consequence: an app whose library sends AUTH= on MAIL FROM (several do) has a From-refusal the panel did not record recovered with AppLogin "app1," - a login no application has, so the recovered row is filed under no application. - honest note at refusals.go:61. + Fix: `sasl_username=([^,\s]+)` (panel logins hold no comma or space - check the login validator in internal/app before relying on it; if one could, use `sasl_username=(.*?)(?:, sasl_sender=|, orig_queue_id=|, orig_client=|$)`). Tests: refusals_test with "client=..., sasl_method=PLAIN, sasl_username=app1, sasl_sender=a@x" recovers AppLogin "app1". + +A-24: DOES NOT HOLD (harmless) - smtpd.c SMTPD_BUILD_NAMADDRPORT uses state->addr (smtpd_peer.c: rfc_addr is the one with "IPv6:"); cleanup's "[%s]" is MAIL_ATTR_ACT_CLIENT_ADDR, written from state->addr ("XXX Note: state->addr, not state->rfc_addr", smtpd.c mail_open_stream()). The prefix never appears in these lines, so strings.TrimPrefix(m[2], "IPv6:") at refusals.go handle() is a no-op. Consequence: none. - nothing written (would bless a false premise). + Fix: delete the TrimPrefix (or keep it with a comment "never present: smtpd.c namaddr uses state->addr"). Tests: existing refusal tests; add an IPv6 client line "unknown[2001:db8::1]" that attaches with clientIP "2001:db8::1". + +A-25: HOLDS (MEASURED, now cited) - src/milter/milter8.c milter8_connect(): msg_warn("connect to Milter service %s: %m", milter->m.name), then milter8_comm_error(): tempfail -> "451 4.7.1 Service unavailable - try again later"; smtpd connects to the milters per client after "connect from" (smtpd.c smtpd_service(): msg_info("connect from %s") then setup_milters() -> milter_create() -> milter8_create() -> milter8_connect()), and the CONNECT/EHLO events repeat the stored reply. Measured on the e2e stand 2026-10-12. - comment at internal/logtail/sendercheck.go:32. + +A-26: HOLDS (MEASURED 2026-10-14, now cited) - milter8.c milter8_read_resp(): msg_warn("milter %s: can't read %s reply packet header: %m", ..., "SMFIC_HEADER"), then milter8_comm_error() closes the stream; cleanup gets the milters per message, so one warning ends one message. - comment at sendercheck.go:53. + +A-27: HOLDS (MEASURED, now cited) - smtpd.c smtpd_service() msg_info("connect from %s"); src/master/single_server.c (one client at a time per process). - comment at sendercheck.go:32. + +A-28: HOLDS (MEASURED, now cited) - src/smtpd/smtpd_sasl_proto.c smtpd_sasl_auth_cmd(): a 4xx from milter_other_event() becomes smtpd_chat_reply("454 4.3.0 Try again later"), no msg_info. - comment at sendercheck.go:32. + +A-29: HOLDS - milter8.c milter8_event(): SMFIR_TEMPFAIL -> "451 4.7.1 Service unavailable - try again later"; milter8_comm_error() with tempfail -> the same text. The journal milter's level-2 refusal is RespTempFail (internal/milter/milter.go). So the reject lines are identical for all three. - comment at sendercheck.go:32. (The OpenDKIM fixture lines are now labelled composed.) + +A-30: PARTLY - src/qmgr/qmgr_active.c qmgr_active_done_25_generic() (expiry checked when an attempt ends) and qmgr_active_done_3_generic() (delay capped at var_max_backoff_time) - holds. Left out: a deferred message is moved back to active only by the deferred-queue scan, every queue_run_delay (src/qmgr/qmgr.c qmgr_deferred_run_event timer; qmgr_active.c qmgr_active_feed() skips files whose mtime is in the future), plus the attempt's own duration. Consequence: countStuck can count as "past the lifetime" a row Postfix is still about to try, for up to queue_run_delay (300 s default) - a transient overcount in queued_over_lifetime. - citation + note at logtail.go:228. + Fix: overLifetime := policy.LongestLifetime() + policy.MaximalBackoff + policy.QueueRunDelay (the field already exists in internal/postfix.RetryPolicy). Test: a reconcileLoop/countStuck unit test with a stub retryPolicy asserting the cutoff includes QueueRunDelay. + +A-31: PARTLY - holds for normal delivery (every recipient ends in sent()/defer_append()/bounce_append() -> log_adhoc()). Exceptions read in source: postsuper -d (src/postsuper/postsuper.c: only "%s: removed"); cleanup drops a duplicate recipient silently (src/cleanup/cleanup_out_recipient.c been_here()); a milter's smfi_delrcpt; expiry (A-8 - rows already deferred). Concrete consequence of the duplicate case: an app sends RCPT TO: twice; the journal milter writes two rows (internal/milter RcptTo appends, no dedupe; no unique key on send_log), Postfix delivers once, UpdateStatus updates only the newest row, the older stays queued, the sweep closes it as bounced and - since deliveryInLog finds the sent line - counts it as "missed", a false alarm about the tailer. - note at logtail.go:274. + Fix: dedupe recipients case-insensitively in the journal milter's RcptTo (internal/milter, Part B's area), or have UpdateStatus update all rows of (queue_id, to) rather than the newest. Tests: milter test with a repeated RCPT writes one row; or store test that UpdateStatus resolves both duplicates. + +A-32: PARTLY - src/showq/showq.c showq_service() scans maildrop, active, incoming, deferred, hold - holds. Entry format: src/postqueue/showq_compat.c showq_message(): id, then "*" (active) or "!" (hold), then "#" when forced_expire (postsuper -e), then size. internal/postfix/queue.go queueEntryRe `^([0-9A-Za-z]+)[*!]?\s+\d+\s` does not accept "#". Consequence: after `postsuper -e `, until the message actually expires, it is missing from QueueIDs and its still-"queued" rows (message never attempted) can be closed by the sweep as bounced while Postfix holds it. Narrow. - citation (the five queues) at logtail.go:65; parser outside my area, not edited. + Fix: internal/postfix/queue.go: `^([0-9A-Za-z]+)[*!]?#?\s+\d+\s`. Test: parseQueueIDs with "ABC123*# 446 Thu ..." and "ABC124# ..." lines. + +A-33: PARTLY - src/postlogd/postlogd.c pre_jail_init(): logwriter_open_or_die(var_maillog_file) once per process; post_jail_init() sets var_use_limit=0, but src/master/dgram_server.c dgram_server_timeout() still ends it after max_idle; README_FILES/MAILLOG_README "Reload Postfix so that postlogd(8) immediately closes the old logfile." So postlogd keeps writing to the renamed inode until reload (or an idle exit) - holds. What fails is the comment's "nothing in that gap is lost": logrotate renames, creates, and only then runs postrotate `postfix reload` (logrotate.c rotateSingleLog() then postrotateSingleLog()). If the tailer's tick falls between create and postlogd's exit, it drains the old inode once, switches, and whatever postlogd writes to the old inode afterwards is never read. Consequence: a delivery line in that sub-second window is lost; the row is closed by the sweep as bounced and counted "missed" (deliveryInLog finds it in mail.log.1). - citation + note at logtail.go:722. + Fix: after a rotation keep the old descriptor and drain it on each tick until it has not grown for some seconds (e.g. 10 s, well past `postfix reload`) before closing it; record Places against the old file's fingerprint. Test: a follow test (unix-only, beside TestFollowTailsAndRotates) that renames the log, creates a new one, then appends a delivery line to the OLD file after the tailer has switched, and asserts UpdateStatus is called for it. + +A-34: PARTLY - postconf(5) (3.7.11) smtpd_timeout is per read/write; smtpd_per_request_deadline "normal: no" bounds a request, not a message; nothing bounds the time from client= (first RCPT) to end-of-message. Holds for any client sending at a sane rate. Consequence when it fails: a message whose DATA phase (or the gap RCPT->DATA, NOOPs keep it alive) exceeds an hour, with a prune falling in between, loses its first stored lines (client=, cleanup's message-id) from its page - cosmetic. - note at internal/logtail/lines.go:58. + Fix: raise lineGrace to 24 h (the cost is storage of lines without a row for a day), or prune only ids qmgr has "removed". Test: TestRetentionLoopPrunesLines/PruneSendLogLines cutoff with the new grace. + +A-35: DOES NOT HOLD (comment only) - postconf(5) smtpd_client_recipient_rate_limit: "The maximal number of recipient addresses that any client is allowed to send to this service per time unit, regardless of whether or not Postfix actually accepts those recipients", default 0 (off); SelfPost does not set it. So a Postfix limit that counts them exists; it is off, and per client address only. Consequence: none - the prune-every-500 logic is right either way. - comment corrected at internal/logtail/inbound.go:97. Fix: done (comment); optionally set smtp/inet/smtpd_client_recipient_rate_limit in postfix-config.sh (Part C). + +A-36: HOLDS - build/postfix-config.sh smtpd_milters (sender, OpenDKIM default_action=tempfail; journal accept); src/milter/milter8.c milter8_comm_error(): tempfail -> "451 4.7.1 Service unavailable - try again later", accept -> no reply; README_FILES/MILTER_README "Milter error handling". - comments at cmd/panel/sendercheck.go:24 and internal/health/socket.go:25. + +A-37: PARTLY - effect holds (as A-36); "OpenDKIM runs with default_action=tempfail" misattributed - default_action is Postfix's per-milter setting (MILTER_README "Different settings for different Milter applications"). - comment corrected at internal/health/socket.go:25. + +A-38: DOES NOT HOLD - unix(7): a socket bound to a filename "creates a socket in the filesystem that must be deleted by the caller when it is no longer needed (using unlink(2))"; a SIGKILLed or crashed process cannot unlink it, and connect() to it then fails with ECONNREFUSED. opendkim.c calls smfi_opensocket(FALSE) (no removal of an existing socket by OpenDKIM; libmilter's own cleanup is in the sendmail package, not read). CheckSocket says "Listening" from os.Stat alone. Consequence: OpenDKIM crashes, supervisord restarts it with backoff or gives up (FATAL): Health > sockets shows OpenDKIM "Listening / OK" while Postfix defers every submission with 451; only the Processes table tells. Same for a SIGKILLed panel (sender/journal sockets). - note (no blessing) at socket.go:31. + Fix: for unix: sockets, dial (net.DialTimeout("unix", path, 1s)) and close, as checkMilter already does for inet:; ECONNREFUSED -> fail("Nothing is listening on the socket."), EACCES -> StatusUnknown "cannot be checked by the panel". Check before relying on it that the panel can connect to /run/opendkim/opendkim.sock (opendkim.conf UMask 007, directory opendkim:selfpost 2750 - the panel must be in group selfpost). A frozen process still accepts (kernel backlog) - that case stays with the sender-check deferral count. Tests: health_test/milter_test - a listener closed with SetUnlinkOnClose(false) leaves a socket file and must grade as missing; a live listener grades OK. + +A-39: PARTLY - src/milter/milter8.c milter8_connect(): transports "inet", "unix" and "local" (LOCAL_CONNECT). "Postfix takes two forms" is false; but build/postfix-config.sh refuses anything but inet:/unix: for INBOUND_ANTISPAM_MILTER, so a local: address never reaches CheckMilter. - comment at internal/health/milter.go:21. Fix: treat "local:" like "unix:" in checkMilter (one case), test in milter_test. + +A-40: HOLDS - src/global/pipe_command.c pipe_command(): a non-zero exit with no leading "D.S.N text" in the output and SYS_EXITS_CODE(75) -> sys_exits_detail(75) = "4.3.0" (src/global/sys_exits.c) -> PIPE_STAT_DEFER; src/pipe/pipe.c eval_command_status() -> defer_append(). The panel's output starts with the log prefix "dmarc-ingest: ", so dsn_valid() cannot take over. - comment at cmd/panel/main.go:39. + +A-41: HOLDS - exit 0 -> PIPE_STAT_OK -> sent(), "delivered via %s service" (pipe.c eval_command_status()); 4.x -> defer_append() until maximal_queue_lifetime, then qmgr_active.c returns it to the sender; 5.x -> bounce_append() and a non-delivery notice (src/global/bounce.c, bounce daemon). - comment at cmd/panel/dmarc_ingest.go:38. + +A-42: HOLDS - src/pipe/pipe.c eval_command_status(): " (%.100s)" of why->reason; src/global/pipe_command.c reads stdout+stderr (dup2 of both onto one pipe), up to VSTREAM_BUFSIZE, newlines/tabs to spaces, printable(). - comment at dmarc_ingest.go:56. + +A-43: NEEDS A MEASUREMENT - Read: src/util/set_ugid.c set_ugid() does setgroups(1, &gid): pipe(8) runs the command with the user's primary group only, so the half "pipe(8) starts the command without the group" holds. Upstream conf/postfix-files:135 installs postlog "2755 $setgid_group", and postlogd(8) allows set-gid on "(Postfix >= 3.7) postlog(1)"; if /usr/sbin/postlog is set-gid postdrop in the image, postlog gets the group by itself and the claim "postlog is no use here" is false. Debian's packaging (debian/postfix.postinst) sets dpkg-statoverride only for postdrop and postqueue; whether the .deb ships postlog set-gid (debhelper's dh_fixperms is not in dev/upstream) cannot be read here. Measure on the stand: `docker exec stat -c '%a %U:%G' /usr/sbin/postlog /var/spool/postfix/public` and `docker exec -u panel /usr/sbin/postlog -t dmarc-ingest probe-$(date +%s)` then grep the probe in /data/log/mail.log. Consequence if postlog works: the reason could be logged in full instead of the 100-byte window. - citation for the set_ugid half + "not recorded" at dmarc_ingest.go:61. + +A-44: HOLDS - src/pipe/pipe.c deliver_message(): pipe_command(... CA_PIPE_CMD_EXPORT(export_env->argv) ...) - no CA_PIPE_CMD_ENV; src/local/command.c sets "RECIPIENT" for local(8). - comment at dmarc_ingest.go:86. + +A-45: HOLDS - postconf(1) (3.7.11 man/man1/postconf.1): "By default, the postconf(1) command displays the values of main.cf configuration parameters" (default when unset), -h "Show parameter ... values without the "name = " label", -x expands $name (not used; the six retry parameters default to literal values). - comment at cmd/panel/httpserver.go:103. + +A-46: PARTLY - supervisor 4.2.5 supervisor/rpcinterface.py _interpretProcessInfo(): uptime = now - info['start'], the spawn of program "postfix" = build/postfix-wrapper.sh, which waits for the milter sockets (up to MILTER_WAIT_TIMEOUT, default 30 s) before `exec postfix start-fg`. So "uptime of postfix = time since Postfix started" is early by up to that wait. Consequence: after supervisorctl restart of panel and postfix together, senderCheckDowntime can count up to ~30 s during which Postfix was not running yet (the wrapper was waiting for the very sender socket). At container start the .alive mark is removed (entrypoint.sh), so no downtime is counted there. - citation + bound at cmd/panel/sendercheck.go:142. + Fix (cheap): take Postfix's start from master's pid file mtime (`$queue_directory/pid/master.pid`, written by master at start) instead of the supervisord uptime, falling back to supervisord. Test: senderCheckDowntime with a stub ask() returning the later start. + +A-47: PARTLY - OpenDKIM 2.11.0~beta2 opendkim/opendkim.c mlfi_eoh(): no SigningTable match (dkimf_apply_signtable() == 0) leaves no signing request, and with Mode s (build/opendkim.conf) "verify request with verify mode disabled" returns SMFIS_ACCEPT - unsigned, delivered: holds. Fails: dkimf_apply_signtable() < 0 ("signing table references unknown key", "error loading key") returns SMFIS_TEMPFAIL - a line written whose key the KeyTable lacks or that fails to load defers the message (notifications, and everything for that domain). - citation + caveat at cmd/panel/notifier.go:26. + Fix: make sure internal/domain resync writes KeyTable and the key file before SigningTable (or both atomically by rename), and that EnsureServerSigned only adds the server's line once its key file exists and parses. Test (internal/domain, Part B): resync with a missing key file does not emit the SigningTable line. + +A-48: PARTLY - Postfix 3.7.11 src/xsasl/xsasl_cyrus_server.c xsasl_cyrus_server_create(): sasl_server_new(service, var_myhostname, user_realm=NULL (smtpd_sasl_local_domain empty, smtpd_sasl_glue.c REALM_OR_NULL), ...); Cyrus SASL 2.1.28 plugins/sasldb.c sasldb_auxprop_lookup(): user_realm NULL -> serverFQDN; common/plugin_common.c _plug_parseuser(): no "@" -> realm = serverFQDN. myhostname = SELFPOST_HOSTNAME (postfix-config.sh). Holds with SASL_REALM unset. Fails: SASL_REALM set to anything else (it is read in main.go and documented in architecture.md) - the panel creates accounts in that realm, Postfix looks them up in myhostname's, and every login fails; and with SELFPOST_HOSTNAME unset the panel uses "localhost" while Postfix uses `hostname -f`. - citation at cmd/panel/main.go:209. + Fix: drop the SASL_REALM override (realm := SELFPOST_HOSTNAME) or refuse to start when SASL_REALM != SELFPOST_HOSTNAME; setting smtpd_sasl_local_domain instead would change the authenticated name to login@realm and break sender_login_maps. Test: cmd/panel test that loadConfig with a differing SASL_REALM is rejected (or ignored); envdoc_test updated. + +A-49: HOLDS - logrotate 3.21.0 logrotate.c prerotateSingleLog(): firstRotated = "%s/%s.%d%s%s" with logStart (default 1) and "" instead of compext under LOG_FLAG_DELAYCOMPRESS; logrotate.8 delaycompress. logrotate-loop.sh runs only /etc/logrotate.d/mail (no global dateext). - comments at internal/logtail/logtail.go:329 and offset.go:134. + +A-50: HOLDS - logrotate.c rotateSingleLog(): without copytruncate, rename() then, with create, createOutputFile(); with copytruncate, copyTruncate(). - comment at logtail.go:592. + +A-51: HOLDS - logrotate.c rotateSingleLog(): rename() and createOutputFile() in the same pass, no script between (postrotate is postrotateSingleLog(), after). - comment at logtail.go (blindness doc, "rename() and createOutputFile() follow each other"). + +A-52: HOLDS - msg_logger.c msg_logger_print(): every record starts "%b %d %H:%M:%S "; with the inode in the fingerprint two generations are told apart. - comment at internal/logtail/offset.go:21. + +A-53: HOLDS for what the code relies on - rename(2) moves the file (same inode; "other hard links ... unaffected"), stat(2) st_ino. The device-number caution (may change across reboots on LVM/btrfs/NFS) is not a claim the code relies on - leaving st_dev out only removes a way to fail; not settled by a man page and not needed. - comment at internal/logtail/fileid_unix.go:12. + +A-54: HOLDS - supervisor 4.2.5 supervisor/supervisorctl.py do_status(): exitstatus = LSBStatusExitStatuses.NOT_RUNNING (3) when any info state is in states.STOPPED_STATES (STOPPED, EXITED, FATAL, UNKNOWN); UNKNOWN (4) when upcheck fails or a name is unknown. - comment at internal/health/processes.go:42. + +A-55: HOLDS - supervisor/states.py ProcessStates: STOPPED, STARTING, RUNNING, BACKOFF, STOPPING, EXITED, FATAL, UNKNOWN. - comment at processes.go:60. + +A-56: HOLDS - supervisorctl.py _show_statuses(): template '%(namespec)-' + str(maxlen+3) + 's%(state)-10s%(desc)s', maxlen >= 30; state words <= 8 chars, so whitespace always separates; errors "%s: ERROR (no such process)". - comment at processes.go:74. + +A-57: HOLDS - supervisor/rpcinterface.py _interpretProcessInfo(): 'pid %s, uptime %s' % (pid, timedelta); Python's str(timedelta) gives "H:MM:SS" or "N day(s), H:MM:SS". - comment at processes.go:100. + +A-58: HOLDS - proc(5): /proc/stat "kernel/system statistics", /proc/meminfo statistics "on the system" (no namespace virtualises them; only an overlay like LXCFS would); network_namespaces(7): network namespaces isolate "the /proc/net directory". The code comment's "unless it was started with its own namespaces" was imprecise and is reworded. - comment at internal/health/machine.go:41. + +A-59: HOLDS - proc(5) /proc/stat field order (user nice system idle iowait irq softirq steal guest guest_nice); Linux kernel/sched/cputime.c account_guest_time(): guest time is added to CPUTIME_USER (or CPUTIME_NICE) as well as CPUTIME_GUEST (torvalds/linux master read 2026-10-14; the host's kernel may be older, and older versions of the function were not read). fs/proc/stat.c prints one cpuN line per online logical CPU, so the code's "one line per core" comment was corrected to "logical CPU" (the value is used as the thread fallback, which is right). - comment at machine.go:412. + +A-60: HOLDS - proc(5) /proc/meminfo MemAvailable (since 3.14) "An estimate of how much memory is available for starting new applications, without swapping"; Linux fs/proc/meminfo.c show_val_kb(): pages << (PAGE_SHIFT - 10), i.e. KiB. - comments at machine.go:282 and :539. + +A-61: HOLDS - proc(5) /proc/net/dev: 8 receive columns (bytes first) then transmit (bytes first) -> fields[0], fields[8]; net/core/net-procfs.c dev_seq_printf_stats() "%6s: %7llu ..." (master); net/core/dev.c dev_valid_name() refuses ':' in a device name, so the first colon ends the name whatever the spacing. - comment at machine.go:577. + +A-62: HOLDS - Linux arch/x86/kernel/cpu/proc.c show_cpuinfo() "processor\t: %u", show_cpuinfo_core() (CONFIG_SMP) "physical id\t: %d", "core id\t\t: %d"; proc(5) says /proc/cpuinfo items are architecture dependent - the fallback covers the rest. - comment at machine.go:441. + +A-63: HOLDS - unix(7) (the socket file "must be deleted by the caller"; "connecting to a stream socket object requires write permission on that socket"), bind(2) EADDRINUSE, inode(7) set-group-ID directory: new files take the directory's group. - comments at cmd/panel/journal.go:59 and :73. + +A-64: PARTLY - certbot/docs/using.rst "Renewing certificates": "As of Certbot 4.0.0, a certificate is considered ready for renewal when less than 1/3rd of its lifetime remains ... Prior to Certbot 4.0.0 the threshold was a fixed 30 days." Holds for 90-day certificates (30 days). With the shorter Let's Encrypt lifetimes (45-day certificates: renewal at 15 days left), the 14-day warning fires one day after the renewal point, not "comfortably past" it; with other ACME clients (Caddy, acme.sh, Traefik) the rule is theirs (Part C § 12). - comment rewritten at internal/health/cert.go:12. + Fix: grade by the certificate's own lifetime: warn when DaysLeft < max(3, lifetime/6) where lifetime = NotAfter - NotBefore (90 d -> 15 d, 45 d -> 7.5 d), i.e. well after a client renewing at 1/3. Test: TestCheckCertificate with 90-day and 45-day certificates at various remaining days. + +A-65: DOES NOT HOLD - build/postfix-config.sh copies TLS_CERT_FILE to /etc/postfix/tls-internal/fullchain.pem and sets smtpd_tls_cert_file to the copy; build/postfix-cert-reload.sh refreshes the copy and reloads only every TLS_RELOAD_INTERVAL_SECONDS (default 86400); each smtpd loads the certificate when it starts (Postfix 3.7.11 src/smtpd/smtpd.c pre_jail_init(), TLS_SERVER_INIT). CheckCertificate reads the mounted TLS_CERT_FILE. Consequence: the proxy renews a certificate 2 days before expiry; Health and the notifier read the new file ("valid for 89 days", OK) while Postfix serves the old copy for up to a day longer - and if the reload fails (cert-reload logs and retries), Postfix can serve an expired certificate while the panel shows OK. - note at cert.go:27 (no blessing). + Fix: CheckCertificate(path) reads the file Postfix serves - `postconf -h smtpd_tls_cert_file` (readable 0644) - and, when the mounted file differs, adds "A renewed certificate is waiting; Postfix picks it up at the next refresh" (warn if the served one is within certWarnDays). Tests: health_test with a served file expiring in 2 days and a mounted one valid 89 days -> warn with that detail; identical files -> as today. + +### Fixtures (Part A § 9) + +- internal/logtail/sendercheck_test.go TestDeferredCounter: seen lines (smtpd 1183 first client, cleanup 1339 sender-check pair, cleanup 1195 From refusal) were already labelled with dates; the others (smtpd 1149, 1190, 1201, 1300, the second client of 1183, the OpenDKIM warning of cleanup 1339) are now labelled composed. Note: smtpd 1149's "sasl_username=" agrees with smtpd_whatsup() - the refusals.go quote (no field) is the one that cannot be verbatim (A-20). The ISO stamps are not Postfix's format (A-12) - the label already says "the stamps are put in ISO form". +- internal/logtail/refusals_test.go: labelled composed (file-level comment); ISO stamps noted as not Postfix's. `postfix/inbound/smtpd[372]` with stranger.example composed. +- internal/logtail/logtail_test.go: file-level comment before TestParseDelivery says all lines are composed unless marked; names the shapes Postfix does not write: "to=..., status=expired" (A-8), ISO stamps incl. the zone-less one in the first case (A-12), opendkim lines in mail.log (build/opendkim.conf Syslog no). TestSplitTimestamp's live-relay line now says "Seen on the live relay, date not recorded"; the other cases composed. Not rewritten: the "RFC 3339, which maillog_file_format selects on Postfix 3.9 and up" case name repeats the false claim of A-12. +- internal/logtail/lines_test.go: labelled composed; notes "postfix/submission/smtpd" is never written by this image (no syslog_name on smtps/submission). +- internal/logtail/inbound_test.go: labelled composed; ISO stamps with microseconds noted as not Postfix's (A-12). +- internal/health/health_test.go: TestParseProcesses "Real supervisorctl output" now says where/when not recorded (columns and FATAL text match supervisorctl.py _show_statuses() and process.py spawnerr); TestLivenessFromParsedProcesses labelled composed; TestUptime labelled composed, "127:00:01" noted as not a form supervisor prints. +- internal/health/machine_test.go: labelled composed (proc(5) layouts). +- milter_test.go, cmd/panel/*_test.go, retention_test.go: no external program output. + +### Counts + +HOLDS 39 (A-1..5, 7, 10, 11, 14, 16, 17, 19, 22, 25..29, 36, 40..42, 44, 45, 49..63) +PARTLY 16 (A-6, 9, 13, 18, 20, 30, 31, 32, 33, 34, 37, 39, 46, 47, 48, 64) +DOES NOT HOLD 9 (A-8, 12, 15, 21, 23, 24, 35, 38, 65) +NEEDS A MEASUREMENT 1 (A-43) + +### DOES NOT HOLD + +- A-8 "to=..., status=expired" is never written; expiry is one qmgr "from=<..>, status=expired, returned to sender" line - expired messages' rows stay "deferred" for ever. +- A-12 maillog_file_format does not exist (3.7.11, nor the 3.12 snapshot); isoStampRe matches nothing Postfix writes; most fixtures use that format. +- A-15 cleanup's milter-reject to= is the last recipient only - inbound journal and recovered From-refusal rows miss the others. +- A-21 smtpd's milter-reject line does carry sasl_username= - refusal lines are matched without it. +- A-23 sasl_username=(\S+) takes "login," when sasl_sender follows (client used AUTH= on MAIL FROM). +- A-24 "IPv6:" never appears in name[addr] (harmless no-op TrimPrefix). +- A-35 Postfix has smtpd_client_recipient_rate_limit (off by default, per client) - comment corrected, no behaviour issue. +- A-38 a socket file that exists is not a listening milter - Health shows a crashed OpenDKIM as "Listening". +- A-65 TLS_CERT_FILE is not what Postfix serves (it serves the tls-internal copy, refreshed daily) - Health can show OK while an older or expired certificate is served. + +### NEEDS A MEASUREMENT + +- A-43 Is /usr/sbin/postlog set-gid in the image, and can user panel log through it? On the stand: `docker exec stat -c '%a %U:%G' /usr/sbin/postlog /var/spool/postfix/public`; `docker exec -u panel /usr/sbin/postlog -t dmarc-ingest probe-`, then grep the probe in /data/log/mail.log. + +## Part B answers (and the Part C entries placed in internal/milter/inbound.go) + +Read in: dev/upstream/postfix-3.7.11, cyrus-sasl2-2.1.28+dfsg, opendkim-2.11.0~beta2, supervisor-4.2.5 (Debian sources, patches applied); go-milter v0.4.1 in the module cache; rspamd 3.11.0 fetched from GitHub at tag 3.11.0 (src/plugins/lua/milter_headers.lua, lualib/lua_mime.lua, src/libserver/milter.c, src/libutil/addr.c, src/lua/lua_ip.c, conf/options.inc); Berkeley DB 5.3 Programmer's Reference "Dump output formats" (docs.oracle.com/cd/E17076_02/html/programmer_reference/dumpload_format.html, "Library Version 11.2.5.3"). + +The inventory's line numbers are approximate (some files are shorter than the numbers it gives); entries were matched by content. + +Code comments were added only where the entry holds (or to the part that holds). Code that is about to be replaced by the dkim_mail_parse port (the From: parsing in sender.go: judge()'s unfold/ParseList/mentionedDomains/unquoted "cruder second look") got no comments; entries about it are answered here only (PM12, D4's second place, D5/D6 are fixture labels and were done because the files stay with the e2e). + +### 1. go-milter v0.4.1 + +B-G1: HOLDS - go-milter v0.4.1 session.go Process() case CodeMacro (`m.macros = make(...)` per macro packet), server.go Serve() (NewMilter() per accepted connection); Postfix 3.7.11 src/milter/milter8.c milter8_event() sends a macro packet before every event, milter.c milter_macro_lookup() never returns null (so even an empty list resets the map) - citation added to the `session` doc comment in milter.go. + +B-G2: HOLDS (MEASURED 2026-10-12, now also cited) - go-milter session.go HandleMilterCommands() (`if !resp.Continue() { m.backend = m.server.NewMilter() }`), response.go SimpleResponse/CustomResponse.Continue(); Postfix milter8.c milter8_conn_event() sends CONNECT only in state READY, once per milter connection. Confirmed the note: the sender check's 'y' CustomResponse keeps the session, its RespTempFail replaces it - citations added at session.MailFrom (milter.go) and senderSession.MailFrom (sender.go); the measurement sentence stays as it was (it has where and when). + +B-G3: HOLDS - go-milter session.go case CodeEOB -> Body(), case CodeEOH -> Headers(); Postfix milter8.c milter8_eob() sends one SMFIC_BODYEOB per message - cited at session.Body, senderSession.Headers. + +B-G4: HOLDS - go-milter session.go case CodeMacro `m.macros[data[i]] = data[i+1]`; Postfix milter.c milter_macro_lookup() `argv_add(argv, name, value)` with the name as milter_*_macros spells it (defaults in mail_params.h brace the long names, "i" bare; build sets `{client_addr}` braced) - cited at macro(). The test comment of TestBracedMacros ("{i} for some versions") corrected: Postfix sends the name as configured; macros there marked composed. + +B-G5: DOES NOT HOLD - go-milter v0.4.1 session.go case CodeMail/CodeRcpt: `from := readCString(msg.Data); m.backend.MailFrom(strings.Trim(from, "<>"), ...)`; Postfix src/smtpd/smtpd.c mail_cmd()/rcpt_cmd() pass `milter_argv(state, argc - 2, argv + 2)` (the address token as the client wrote it, `<...>`, then each ESMTP parameter as a separate string; smtpd_token.c keeps `<"a b"@x>` as one token), milter8.c milter8_mail_event() sends them as MILTER8_DATA_ARGV. So cleanAddress always receives the address without brackets and without parameters: the `<` branch never runs, and the fallback `strings.IndexByte(a, ' ')` cuts `"a b"@example.net` to `"a`. No comment added. + - Consequence: an application that sends to (or from) a quoted local part with a space gets a journal row whose To (From) is `"a`; the delivery line `to=<"a b"@example.net>` matches no row and the reconcile sweep closes the row as bounced. Also the parameter-stripping is dead code. Rare, but exactly the case the stage-1 fix meant to cover. + - Fix: in cleanAddress drop the `IndexByte(' ')` cut (go-milter never passes parameters; it reads only the first argument) and keep the bracket loop only as a tolerance; comment the go-milter/Postfix forms with the citation above. Tests: address_test.go gains the forms go-milter really delivers (`"a b"@example.net` -> unchanged, `"a>b"@example.net` -> unchanged); a milter_test that drives a session through go-milter's own client (client.go in the module) with `MAIL FROM:<"a b"@example.net> SIZE=10` and checks the row. + +B-G6: HOLDS - Postfix milter8.c milter8_message(): mime_state_alloc() passes milter8_body only without SMFIP_NOBODY, header/EOH callbacks unless SMFIP_NOHDRS/NOEOH, milter8_eob always; milter8_event() handles SMFIR_REPLYCODE without looking at rq_mask; milter8_connect() accepts rq_mask 0. go-milter session.go case CodeOptNeg returns (version 2, s.Actions, s.Protocol) - cited at Serve (milter.go); ServeInbound/ServeSender point there. Note: go-milter answers protocol version 2; Postfix (milter_protocol=6) then marks v3+ events as non-protocol (milter8_connect(), `milter->version < my_version`), which only removes UNKNOWN/DATA events these milters do not use. + +B-G7: PARTLY - "go-milter has no connection-close hook" HOLDS (go-milter server.go type Milter has no close callback; session.go HandleMilterCommands() returns on CodeQuit/EOF without calling the backend) - cited at reservationTTL. "A client that drops after MAIL FROM produces neither EOM nor ABORT" DOES NOT HOLD: Postfix src/smtpd/smtpd.c smtpd_proto(), on SMTP_ERR_EOF ("lost connection") falls through to the cleanup `helo_reset(state); ... mail_reset(state); rcpt_reset(state); if (state->milters) milter_disc_event(...)`; mail_reset() calls `milter_abort(state->milters)` when SMTPD_FLAG_NEED_MILTER_ABORT is set (set in mail_cmd before milter_mail_event), and milter8.c milter8_abort() writes SMFIC_ABORT in state ENVELOPE/MESSAGE. So Abort() does arrive, then QUIT. + - Consequence: none for behaviour - the TTL is still the right backstop for what really sends nothing: an smtpd killed mid-transaction, or Postfix closing the milter socket after an error/timeout (milter8_comm_error() shuts the socket down without ABORT). Only the comment's reason is wrong. + - Fix: rewrite the reservationTTL comment: ABORT arrives on client disconnect (cite smtpd.c smtpd_proto()/mail_reset()); the TTL covers a killed smtpd and a milter socket Postfix abandons (milter8.c milter8_comm_error()). No test needed (comment only). + +B-G8: HOLDS - go-milter server.go Serve(): `if s.closed { return ErrServerClosed }` after Close() closed the listener - cited at Serve. + +B-G9: HOLDS - Postfix smtpd.c smtpd_proto() calls `milter_conn_event(state->milters, state->name, state->addr, ...)` (state->addr has no "IPv6:"; a V4-mapped address is rewritten to IPv4 in smtpd_peer.c); go-milter case CodeConn: net.ParseIP(address), nil for "unknown" or for family 'U'/'L' with no address - cited at Connect. + +### 2. Postfix 3.7.11: milter protocol, macros, replies + +B-PM1: HOLDS - build sets milter_mail_macros with {client_addr}; src/smtpd/smtpd_milter.c smtpd_milter_eval(): S8_MAC_CLIENT_ADDR -> state->rfc_addr; src/smtpd/smtpd_peer.c smtpd_peer_sockaddr_to_hostaddr(): rfc_addr = dotted quad, `concatenate(IPV6_COL, client_addr.buf)` for IPv6, V4-mapped rewritten to IPv4; smtpd_peer_no_client(): "unknown" - cited at the clientIP field and clientAddr(). + +B-PM2: PARTLY - holds: the login comes in the MAIL macros (build milter_mail_macros has {auth_authen}; mail_params.h DEF_MILT_MAIL_MACROS has it too) and is absent when not authenticated (smtpd_milter.c smtpd_milter_eval(): `IF_SASL_ENABLED(state->sasl_username)` returns 0, milter.c milter_macro_lookup() then adds nothing, so macro() gives "") - cited at session.MailFrom. Fails: "the bare login, no @realm" is not guaranteed. Postfix src/xsasl/xsasl_cyrus_server.c xsasl_cyrus_server_get_username() returns Cyrus SASL_USERNAME; with smtpd_sasl_local_domain empty (smtpd_sasl_glue.c REALM_OR_NULL -> no user_realm) Cyrus lib/canonusr.c _canonuser_internal() appends nothing, so the name is what the client typed; and plugins/sasldb.c sasldb_auxprop_lookup() -> common/plugin_common.c _plug_parseuser() accepts `login@` (splits at the '@', realm = the typed one). A client that authenticates as `shop@` is therefore authenticated and {auth_authen} = "shop@host". + - Consequence: Postfix's reject_sender_login_mismatch compares "shop@host" with owner "shop" (strcasecmp_utf8) and refuses every non-null envelope sender (553 not owned by user). For MAIL FROM:<> Postfix skips that check (PM16); the journal milter then finds no application for "shop@host" (ApplicationByLogin not found -> client-address list and app rate limit fail open), but the sender check refuses the message ("not an address this application sends as", the login is not in sender_login_maps). No mail leaves; only the journal/limit bookkeeping sees an unknown login. + - Fix (recommended, cheap): document it in session.MailFrom's comment with the Cyrus citation. Optional hardening (~10 lines + test): treat a {auth_authen} containing "@" as no application match that fails closed in authIPAllowed/overLimit. I recommend the comment only; the sender check already closes the door. + +B-PM3: HOLDS - mail_params.h DEF_MILT_EOD_MACROS "i" (build does not override it); src/cleanup/cleanup_milter.c cleanup_milter_receive() installs cleanup_milter_eval(), which answers S8_MAC_I with state->queue_id; content events of smtpd_milters run in cleanup (cleanup_milter_inspect() -> milter_message()) - cited at session.Body and inboundSession.Body. + +B-PM4: HOLDS - mail_params.h DEF_MILT_EOH_MACROS "i"; header and EOH events also carry eoh_macros (milter8.c milter8_header()/milter8_eoh()); cleanup_milter_eval() as PM3; content inspection runs after the whole message is in the queue file, so the id exists - cited at the comment in senderSession.Headers (merged under the existing prose; the prose's "{i}" is harmless since macro() reads both). + +B-PM5: HOLDS - milter8.c milter8_event() `case SMFIR_CONTINUE: ... MILTER8_EVENT_BREAK(milter->def_reply)` (null unless a quarantine set "H"); milter.c milter_message() goes on to the next milter / returns null; cleanup accepts - cited at session.Body. + +B-PM6: HOLDS - milter8.c milter8_event() SMFIR_TEMPFAIL (not connect/helo) -> "451 4.7.1 Service unavailable - try again later", no state change to REJECT_CON; smtpd.c mail_cmd(): `err = check_milter_reply(...)`, `mail_reset(state); smtpd_chat_reply(state, "%s", err); return (-1);` - the connection stays; the next MAIL FROM goes to the milters again - cited at session.MailFrom. + +B-PM7: HOLDS - milter8.c milter8_comm_error(): default_action "accept" -> reply 0, "tempfail" -> "451 4.7.1 Service unavailable - try again later"; a hang ends in a read timeout (milter_command_timeout/content_timeout via vstream timeouts) and goes through milter8_comm_error(); a failed connect through milter8_comm_error() (FREE_TRANSPORT_AND_BAIL_OUT in milter8_connect()); build sets default_action per milter (postfix-config.sh smtpd_milters) - cited in the package comment (milter.go), the inboundSession comment and the sender check's package prose. + +B-PM8: HOLDS - smtpd.c rcpt_cmd(): one milter_rcpt_event() per RCPT; a recipient smtpd refused goes only to milters with MILTER_FLAG_WANT_RCPT_REJ (SMFIP_RCPT_REJ, which these milters do not negotiate); milter.c milter_rcpt_event() stops at the first milter that refuses, so the journal (last in the chain) never sees a recipient an earlier milter refused - cited at session.RcptTo. + +B-PM9: HOLDS (and more than it says) - smtpd.c rset_cmd() -> mail_reset() -> milter_abort() (milter8.c milter8_abort(): SMFIC_ABORT); mail_reset() also runs on every refusal in mail_cmd(), at the end of every transaction (common_post_message_handling()) and at session end (smtpd_proto()) - cited at session.Abort. (So Abort also follows a completed message; the code is idempotent there.) + +B-PM10: HOLDS - milter8.c milter8_event() case SMFIR_REPLYCODE: validates `[45]dd[ -]` and an enhanced code starting with the same digit, halves "%%" and drops a single "%" (`if ((ch = *cp++) == '%') ch = *cp++;`); milter_message() returns it; cleanup_milter.c cleanup_milter_apply(): '5' -> CLEANUP_STAT_CONT + CLEANUP_MILTER_SET_SMTP_REPLY; smtpd.c common_post_message_handling() replies `STR(why)` when IS_SMTP_REJECT - cited at senderSession.Headers and printable(). + +B-PM11: HOLDS - cleanup_milter.c cleanup_milter_apply(): `"%s: %s: %s from %s[%s]: %s;"` then ` from=<%s>`, ` to=<%s>`, ` proto=`, ` helo=<...>`; the event string is "END-OF-MESSAGE" also for a refusal at end-of-headers (cleanup_milter_inspect()) - cited at printable(). (For the log tailer: the refusal line reads `: milter-reject: END-OF-MESSAGE from host[addr]: 5.7.1 From: ...; from=<..> to=<..> proto=ESMTP helo=<..>`.) + +B-PM12: PARTLY (answer only - unfold() belongs to the From: parsing being replaced) - holds: Postfix hands a folded header whole: src/global/mime_state.c joins a continuation line to the saved header with `vstring_strcat(state->output_buffer, "\n")` and milter8.c milter8_header() sends it as one SMFIC_HEADER ("Sendmail 8 sends multi-line headers as text separated by newline"). Fails: the line breaks are LF only, never CRLF or CR (smtpd strips CRLF into records); milter8_header() also eats one space after the colon unless SMFIP_HDR_LEADSPC (go-milter does not ask for it). unfold()'s CRLF/CR handling is harmless. For the dkim_mail_parse port: the value it gets has "\n" + the folding white space, and one leading space removed. + +B-PM13: DOES NOT HOLD - src/global/is_header.c is_header_buf() returns the length of the name without the blanks before ':'; milter8.c milter8_header(): `cp = STR(buf) + (header_info ? strlen(header_info->name) : is_header(STR(buf))); while (*cp == ' ' || *cp == '\t') *cp++ = 0;` - the blanks are overwritten with NUL, so the name sent is never "From ". No comment added. + - Consequence: none (the TrimSpace is a no-op); the comment states a Postfix behaviour that does not exist. + - Fix: replace the comment by the citation ("Postfix strips the blanks before the colon: milter8.c milter8_header()") and drop or keep the TrimSpace (keeping it costs nothing). If the port rewrites senderSession.Header, do it there. + +B-PM14: HOLDS (= C-P29) - milter.c milter_message(): `for (resp = 0, m = milters->milter_list; resp == 0 && m != 0; m = m->next) resp = m->message(m, fp, data_offset, ...)`; milter8.c milter8_message() seeks to data_offset and reads with rec_get() (record.h: REC_FLAG_DEFAULT includes REC_FLAG_FOLLOW_PTR, REC_FLAG_SKIP_DTXT); edits of an earlier milter are applied during its EOB reply (milter8_event() -> parent->add_header = cleanup_milter.c cleanup_add_header(), which writes the header behind the "header append" pointer record after the last header) - cited at inboundSession.Header. + +B-PM15: HOLDS - milter.c milter_message() loop stops at the first non-null reply (reject, tempfail, discard "D", and also quarantine "H"); so a message the filter refuses is never shown to the journal - cited at the inboundSession comment. (Edge: a filter "quarantine" would also not reach the journal.) + +B-PM16: HOLDS - src/smtpd/smtpd_check.c generic_checks(): REJECT_AUTH_SENDER_LOGIN_MISMATCH and REJECT_UNAUTH_... run only `if (state->sender && *state->sender)`; reject_sender_login_mismatch is defined as both (smtpd_check_init()) - cited at senderSession.domain(). + +B-PM17: HOLDS - build: smtps/submission `smtpd_client_restrictions=permit_sasl_authenticated,reject`; smtpd_check.c smtpd_check_rcpt(): with var_smtpd_delay_reject (DEF_SMTPD_DELAY_REJECT 1) the client restrictions run at RCPT and refuse every recipient; smtpd.c data_cmd(): "554 5.5.1 Error: no valid recipients"; port 25 overrides smtpd_milters; non_smtpd_milters has only OpenDKIM - cited in judge() under the existing prose. + +### 3. sender_login_maps and texthash + +B-PS1: DOES NOT HOLD - src/smtpd/smtpd_check.c reject_auth_sender_login_mismatch(): `owners = check_mail_addr_find(state, sender, smtpd_sender_login_maps, STR(reply->recipient), 0)` then the login is compared with those owners only; src/global/mail_addr_find.c mail_addr_find_opt(): each further key is tried only `if (result == 0 && path->error == 0 ...)` - the first key found (full address, address without extension, local part if the domain is local, "@domain") decides. No comment added. + - Consequence: with `billing@example.org billing` and `@example.org shop` (a list-mode and a wildcard-mode application on one domain - both modes exist in the store), Postfix refuses shop the envelope sender billing@example.org, but Bindings.Allowed("shop", "billing@example.org") is true (the "@example.org" key lists shop), so shop may put From: billing@example.org on mail it sends with its own envelope - and OpenDKIM signs it for example.org. Same for invoices+x@example.org when invoices@ is listed. + - Fix: in Bindings, keep "key exists" (the parsed table already has it) and in Allowed take the owners of the first key present in this order: full lower-cased address, address without extension (if different), "@domain"; check the login against that set only. The local-part-only keys (MA_FIND_LOCALPART_IF_LOCAL) need no modelling: the panel never writes such keys (cite that). Tests in sender_test.go: table `billing@example.org billing` + `@example.org shop`: shop refused for billing@ and for billing+x@, allowed for other@example.org; billing allowed for billing@ and refused for other@example.org. Then the package prose "by one lookup" becomes true and can be cited. + +B-PS2: HOLDS - smtpd_check.c reject_auth_sender_login_mismatch(): `strcasecmp_utf8(state->sasl_username, name)` - cited in the sender check's package prose (the prose already says the check compares as written; that stays correct because sasldb2 authentication is byte-exact - see PS3). + +B-PS3: HOLDS - same citation, added at foldLogin (validate.go). Note: Cyrus sasldb keys are compared byte for byte (sasldb/db_berkeley.c, the Berkeley DB key), so a client can only authenticate with the login's exact spelling; folding at creation keeps Postfix's case-blind comparison and the panel's view in agreement. + +B-PS4: PARTLY - holds for the file the panel writes: src/util/dict_thash.c dict_thash_open() splits the key at the first unquoted blank and trims the value; src/util/readlline.c readllines() drops a line whose first non-blank is '#'; owners are split by smtpd_check.c `mystrtok(&cp, CHARS_COMMA_SP)` - cited (key/comment/folding parts) in Bindings.fresh. Fails: (1) readllines() joins a line that starts with white space to the previous one (fresh() reads it as its own line); (2) Postfix splits owners on comma AND blank, fresh() joins blank-separated owners ("a b" -> "ab"). + - Consequence: none today - renderSenderLoginMaps never writes continuation lines or blanks inside the value (assertMapSafe forbids them). + - Fix (cheap, recommended): split the value with strings.FieldsFunc on ',' and white space, and append a line that starts with white space to the previous logical line. Test: fresh() over "a@x one two\n b@y three\n ,four" gives a@x -> {one,two}, b@y -> {three,four}. + +B-PS5: HOLDS - smtpd_check.c opens smtpd_sender_login_maps with DICT_FLAG_FOLD_FIX (maps_create(..., DICT_FLAG_LOCK | DICT_FLAG_FOLD_FIX | DICT_FLAG_UTF8_REQUEST)); src/util/dict_ht.c folds the key on update and on lookup (`lowercase()`); mail_addr_find is queried with the external (quoted) form first, then the internal one (MA_FIND_FORM_LEGACY) - cited in Bindings.fresh. (Go's ToLower is Unicode, Postfix's lowercase() ASCII; immaterial for the whitelisted addresses.) + +B-PS6: HOLDS - src/global/strip_addr.c strip_addr_internal() cuts at the last '@' and calls split_addr(); split_addr.c split_addr_internal(): `strcspn(localpart, delimiter_set)` - first delimiter; no split when it is the first character (`len == 0`); the special local parts (postmaster, MAILER-DAEMON, double-bounce, owner-/-request with '-') cannot contain '+' anyway - cited at withoutExtension. + +B-PS7: HOLDS - smtpd_check.c reject_auth_sender_login_mismatch() `mystrtok(&cp, CHARS_COMMA_SP)` - cited at RebuildSenderLoginMaps. + +B-PS8: PARTLY - holds: blank (dict_thash_open() key ends at the first unquoted blank), newline (readllines()), comma (owner separator), backslash (dict_thash_open() treats '\\' as an escape in the key scan) - cited at the forbidden-character constants. Fails: the colon "a lookup key cannot carry" has no basis - dict_thash_open() only warns when a key ENDS with ':' ("is this an alias file?"). Not listed though meaningful: '"' (toggles quoting in dict_thash_open(); an unbalanced one makes the line ignored) and a leading '#' (comment). + - Consequence: none (addresses and logins are whitelisted upstream; the colon ban is merely over-strict). + - Fix: correct the comment (drop the colon reason; keeping the colon forbidden is harmless) and add '"' and '#' to both forbidden sets as defence in depth. Test in postfix_test.go: assertMapSafe refuses `a"b@x` and `#a@x`. + +B-PS9: HOLDS - README_FILES/DATABASE_README: "If you change a regexp:, pcre:, cidr: or texthash: file then Postfix may not pick up the file changes immediately... reads the entire file into memory once"; conf/postfix-script reload: `kill -HUP` of the master; src/master/master.c on SIGHUP calls master_refresh(), and master_conf.c master_config(): "Make the current generation of child processes commit suicide whenever it is convenient. The next generation of child processes will run with the new configuration settings." That is also the source of the audit's "measured, not understood": a busy smtpd keeps the old table until it has served its client - cited at reloadViaSupervisor (postfix.go). + +B-PS10: HOLDS - conf/postfix-script start-fg: `case $$ in 1) exec $daemon_directory/master -i ;; *) $daemon_directory/master -s;; esac` - under supervisord the script's shell (postfix(1) execvp()s the script) is the supervised PID and master is its child; src/master/master.c puts it in its own session (`setsid()` unless init mode/debug/-d). A SIGHUP to the supervised PID hits the shell - cited at reloadViaSupervisor. + +### 4. postqueue, postconf, retry parameters + +B-PQ1: DOES NOT HOLD - src/postqueue/showq_compat.c showq_message(): `if (active) strcat("*") else if (hold) strcat("!"); if (forced_expire) vstring_strcat(id_status, "#");` - ids may end in "#", "*#" or "!#" (after `postsuper -e`/`-f`). queueEntryRe `^([0-9A-Za-z]+)[*!]?\s+\d+\s` misses them. No comment added. + - Consequence: QueueIDs() omits a force-expired message that is still queued; the reconcile sweep can close its row as bounced before Postfix has bounced it (it will bounce it at its next delivery attempt, "status=expired, returned to sender", so the outcome is usually right but early and without the reason). + - Fix: `^([0-9A-Za-z]+)[*!]?#?\s+\d+\s` and list "#" in the comment with the citation. Tests: TestParseQueueIDs entries `ABC123#`, `ABC124*#`, `ABC125!#`. + +B-PQ2: DOES NOT HOLD (fixture comment) - showq_compat.c showq_message(): `if ((padding = 76 - (int) strlen(show_reason)) < 0) padding = 0; vstream_printf("%*s(%s)\n", padding, "", show_reason);` - a reason reaches the left margin only when 76 characters or longer. The fixture is composed. Fixed (comment, not data): queue_test.go now says the listing is composed and why the reason is put at the margin (the hard case). Also labelled TestFakePostqueue's entries as composed. + +B-PQ3: HOLDS - src/showq/showq.c showq_service(): queue_info = maildrop, active, incoming, deferred, hold - cited at QueueIDs. + +B-PQ4: HOLDS - src/postqueue/postqueue.c show_queue(): users other than root/mail_owner pass check_user_acl_byuid(VAR_SHOWQ_ACL) with DEF_SHOWQ_ACL static:anyone (build does not set authorized_mailq_users); failures are msg_fatal_status() (non-zero exit; msg_vstream_init() writes to stderr); showq_compat.c "Mail queue is empty" - cited at listQueue. + +B-PQ5: HOLDS - src/postqueue/showq_json.c format_json(): one `{...}\n` per message with "queue_name", "queue_id", "arrival_time" (%ld epoch), "message_size", "forced_expire", "sender", "recipients" [{"address", "delay_reason"}]; json_quote() escapes - cited at QueuedMessage. + +B-PQ6: HOLDS - src/global/mail_queue.h names; showq.c showq_service() scans exactly those five - cited at QueuedMessage. + +B-PQ7: HOLDS (fixture relabelled) - shape matches showq_json.c format_json(); the fixture is composed (its first line in format_json()'s field order, the rest broken on purpose) - label added in queuejson_test.go. + +B-PQ8: HOLDS - src/global/mail_params.h DEF_QUEUE_RUN_DELAY "300s", DEF_MIN_BACKOFF_TIME DEF_QUEUE_RUN_DELAY, DEF_MAX_BACKOFF_TIME "4000s", DEF_MAX_QUEUE_TIME "5d", DEF_DSN_QUEUE_TIME "5d", DEF_DELAY_WARN_TIME "0h" - cited at DefaultRetryPolicy. + +B-PQ9: HOLDS - src/postconf/postconf_main.c pcf_show_parameters(): named parameters in argument order; pcf_print_parameter() with PCF_HIDE_NAME prints `"%s\n"`, the value unexpanded unless -x; unknown names only warn - cited at postconfRetryValues. (A main.cf value written as "$other_param" would come back unexpanded and fail ParseDuration -> defaults with the muted note; acceptable.) + +B-PQ10: DOES NOT HOLD - src/global/conv_time.c conv_time(): `switch (*end ? *end : def_unit)`; src/global/mail_conf_time.c get_mail_conf_time() takes def_unit from the default value (get_def_time_unit(name, defval): the unit letter of "5d" -> 'd', of "0h" -> 'h', of "300s"/"4000s" -> 's'). `postconf -h` prints the value as written. No comment added. + - Consequence: `maximal_queue_lifetime = 3` (3 days for Postfix) reads as 3 seconds: the Mail queue card shows "3 seconds" and LongestLifetime() (which bounds store.SendLogLines) becomes 3s, so a row's later log lines fall outside the window. Same for bounce_queue_lifetime (d) and delay_warning_time (h). Only when the owner edits main.cf with bare numbers (build/postfix-config.sh sets none of the six). + - Fix: ParseDuration(s, defUnit byte); parseRetryPolicy passes 's','s','s','d','d','h' in retryConfKeys order (cite mail_conf_time.c get_def_time_unit and the DEF_* values). Tests: parseRetryPolicy with "3" for maximal_queue_lifetime -> 72h, "4" for delay_warning_time -> 4h, "300" for queue_run_delay -> 300s. + +B-PQ11: DOES NOT HOLD - conv_time.c conv_time(): one strtol() and one unit character; the trailing-text check is commented out (`/* || (*end != 0 && end[1] != 0) */`), so "1h7m" is 1 hour; only lower-case w/d/h/m/s are units (an upper-case letter is an invalid value, a fatal error for Postfix). duration_test.go:21 pins the panel's different reading. No comment added. + - Consequence: a main.cf value "1h7m" shows as 1h7m in the panel while Postfix runs 1h; "5D" parses in the panel but stops Postfix. + - Fix: mirror conv_time(): read one number, take the next character as the unit (default per PQ10), ignore the rest; accept only lower-case units. Tests: {"1h7m", 1h}, {"5D", error}, {"10", per-key default}; update the comment with the citation. + +B-PQ12: HOLDS - src/qmgr/qmgr_active.c qmgr_active_done(): expiry at `message->create_time + (*message->sender ? var_max_queue_time : var_dsn_queue_time)` - any null-sender message, whoever submitted it; qmgr.c caps var_dsn_queue_time at var_max_queue_time (so the max() is always max_queue_time, harmlessly) - cited at LongestLifetime. + +B-PQ13: HOLDS (fixture relabelled) - the values are legal postconf -h output (it prints main.cf's text); stock prints "0h" for delay_warning_time (mail_params.h DEF_DELAY_WARN_TIME). Label added in retry_test.go ("composed by hand"). + +### 5. Inbound relay and DMARC maps + +B-PI1: HOLDS - man/man5/postconf.5 relay_domains ("a type:table lookup table is matched when a (parent) domain appears as lookup key"), relay_recipient_maps ("used as lists... Specify @domain as a wild-card"); mail_addr_find.c MA_FIND_AT_DOMAIN (exact domain, no PDMS in MA_FIND_DEFAULT); man/man5/transport.5 "domain transport:nexthop" - cited at renderInboundMaps. Note: relay_domains also matches subdomains (parent_domain_matches_subdomains default lists relay_domains), but relay_recipient_maps then refuses them (neither the address list nor "@domain" matches a subdomain) and transport has no ".domain" entry. + +B-PI2: HOLDS - src/smtp/smtp_connect.c: `lookup_mx = (... && *dest != '[')`; src/util/host_port.c host_port(): "[host]:port" and "[ipv6:addr]:port", a bare IPv6 inside brackets accepted (`split_at(cp, ']')`, then valid_hostaddr() -> valid_ipv6_hostaddr()) - cited at inboundNexthop. The inventory's open question: a bare `[2001:db8::1]` is accepted; "ipv6:" is optional. + +B-PI3: HOLDS - README_FILES/TLS_README: "the policy table lookup key is the verbatim next-hop specification from the recipient domain, transport(5) table..."; src/smtp/smtp_tls_policy.c tls_policy_lookup() ("Only one lookup with [nexthop]:port..."); src/tls/tls_level.c names "none", "may", "encrypt" - cited at renderInboundMaps. + +B-PI4: HOLDS - man/man5/access.5 "OK Accept the address etc. that matches the pattern"; transport.5 "user@domain transport:nexthop" and "A non-null transport field with a null nexthop field resets the nexthop information to the recipient domain" (pipe(8) ignores the nexthop); relay_domains as PI1 - cited at renderDMARCMaps. + +### 6. Cyrus SASL 2.1.28, sasldb2, db_dump + +B-S1: HOLDS - utils/saslpasswd.c main(): `getopt(argc, argv, "vpcdnf:u:a:h?")` (glibc getopt ends options at "--"); -p sets flag_pipe (and stdin not a tty sets it anyway), read_password() does one read() without prompt; -c -> SASL_SET_CREATE, which lib/server.c sasl_setpass() checks only against SASL_SET_DISABLE - nothing refuses an existing user; sasldb/db_berkeley.c _sasldb_putdata() `mbdb->put(..., 0)` overwrites - cited in Set(). + +B-S2: HOLDS (the inventory's WRONG? is refuted) - plugins/sasldb.c sasldb_auxprop_store(): `if (value == NULL && ret == SASL_NOUSER) { /* Deleting something which is not there is not an error */ ret = SASL_OK; }` - the SASL_NOUSER that db_berkeley.c _sasldb_putdata() returns for DB_NOTFOUND becomes OK; the SCRAM plugin's setpass (plugins/scram.c scram_setpass(), the only setpass among libsasl2-modules' plugins) deletes through the same store; lib/server.c sasl_setpass() returns SASL_OK; utils/saslpasswd.c main() exits 0 - cited at Delete(). (A measurement on the stand would confirm it cheaply - `saslpasswd2 -d -f /tmp/x -u h -- nobody; echo $?` - but the source is unambiguous.) + +B-S3: HOLDS - sasldb/allockey.c _sasldb_alloc_key(): key_len = len(user)+len(realm)+len(prop)+2, no trailing NUL; plugins/sasldb.c sasldb_auxprop_store() stores `value ? strlen(value) : 0` bytes; lib/server.c sasl_setpass() stores SASL_AUX_PASSWORD_PROP ("userPassword", include/sasl.h) in plaintext unless SASL_SET_NOPLAIN (-n, which Set does not pass) - cited at parseSASLSecret. + +B-S4: DOES NOT HOLD - lib/server.c sasl_setpass(): `if ((flags & SASL_SET_DISABLE || !(flags & SASL_SET_NOPLAIN)) && ...) { ... prop_set(..., SASL_AUX_PASSWORD_PROP, pass, passlen) ... sasl_auxprop_store(...) }` - without -n the password is kept in plaintext as userPassword (SCRAM's setpass adds derived secrets besides). password.go's "sasldb2 keeps only the hashed form" is false and contradicts Secret(). No comment added. + - Consequence: the security reasoning is wrong: sasldb2 (and every backup that carries it) holds usable application passwords; they must be treated as secrets of the same weight as the passwords themselves. + - Fix: password.go comment: "sasldb2 keeps it in plaintext (userPassword, Cyrus 2.1.28 lib/server.c sasl_setpass() without SASL_SET_NOPLAIN) - that is what Secret() exports"; check security.md / architecture.md for the same claim (not in my files). No test (comment). + +B-S5: HOLDS - Postfix src/smtpd/smtpd_sasl_glue.c `user_realm = REALM_OR_NULL(var_smtpd_sasl_realm)` (empty smtpd_sasl_local_domain -> null), src/xsasl/xsasl_cyrus_server.c xsasl_cyrus_server_create(): `SASL_SERVER_NEW(args->service, var_myhostname, user_realm ...)`; Cyrus plugins/sasldb.c sasldb_auxprop_lookup(): `user_realm = sparams->user_realm ? ... : sparams->serverFQDN` -> common/plugin_common.c _plug_parseuser() takes that realm for a login without '@'; build sets myhostname = SELFPOST_HOSTNAME - cited at NewSASLDB. + +B-S6: HOLDS - common/plugin_common.c _plug_parseuser(): `r = strchr(input, '@')` - user and realm split at the first '@', both in saslpasswd2's store (sasldb_auxprop_store()) and the lookup - cited at validateLogin. + +B-S7: DOES NOT HOLD - utils/saslpasswd.c read_password(): `n_read = read(STDIN_FILENO, buf, PW_BUF_SIZE)` (PW_BUF_SIZE 2048), then `if (0 < n_read && buf[n_read - 1] == '\n') n_read--;` - an embedded newline is kept in the password; only one trailing newline is dropped; more than 2048 bytes would be cut. No comment added. + - Consequence: none for behaviour (control characters stay refused, 1024 < 2048); the stated reason is false (also repeated in service_test.go:319). + - Fix: validate.go comment: "saslpasswd2 reads the password with one read() of at most 2048 bytes and drops one trailing newline (utils/saslpasswd.c read_password()): a trailing newline would be lost, a longer password cut; control characters are refused as no client could type them"; same correction in service_test.go:319's comment. No new test. + +B-S8: HOLDS - Berkeley DB 5.3 Programmer's Reference "Dump output formats": "VERSION=N", name=value header pairs, "HEADER=END"; for Btree/Hash "paired lines of text where the first line of the pair is the key item, and the second line ... its corresponding data item... Each of these lines is preceded by a single space"; without -p "paired hexadecimal values"; "DATA=END". Debian's sasldb2 is Berkeley DB: cyrus-sasl2 m4/sasldb.m4 (--with-dblib default auto_detect, berkeley first) with libdb in Build-Depends, debian/rules recording BDB_VERSION into berkeley_db.txt; build/Dockerfile installs db-util - cited at parseSASLSecret. (A file with several databases repeats the header; sasldb2 has one.) + +B-S9: PARTLY (fixture relabelled) - the data section matches the format above; the header does not ("the same shape" was too strong: a real dump carries more name=value lines before HEADER=END, which the parser skips). Comment corrected in sasl_test.go: composed by hand in the documented shape. + +### 7. OpenDKIM 2.11.0~beta2 + +B-D1: HOLDS - KeyTable has no type prefix, so a file: table: opendkim/opendkim-db.c dkimf_db_open() case DKIMF_DB_TYPE_FILE (key up to the first blank, '#' comment); opendkim/opendkim.c dkimf_add_signrequest() reads domain, selector, key data from the value through dkimf_db_get() with three fields, split at ':' (opendkim-db.c dkimf_db_datasplit()); `keydata[0] == '/'` -> a file - cited in renderTables. + +B-D2: HOLDS - opendkim-db.c dkimf_db_open() case DKIMF_DB_TYPE_REFILE: pattern up to the first blank, '#'/newline end the line, the rest trimmed is the data (key name); opendkim/util.c dkimf_mkregexp() - cited in renderTables and Signers.Signs. + +B-D3: HOLDS - opendkim/util.c dkimf_mkregexp(): `dst[0] = '^'`, '*' -> ".*", '.' -> "\.", `*q++ = '$'`; opendkim.c opens SigningTable with DKIMF_DB_FLAG_ICASE (-> REG_ICASE); dkimf_apply_signtable() matches "user@domain" against it. So "*@d" matches d in any case and no subdomain (the '@' is literal before d) - cited at Signers.Signs and OpenDKIM.signs. (Signing itself further needs originok etc. - irrelevant to "which names have a key".) + +B-D4: HOLDS - opendkim/opendkim.c mlfi_eoh(): SenderHeaders unset -> `from = dkimf_findheader(dfc, "from", 0)` (the first From:), then `dkim_mail_parse(...)` (libopendkim/dkim-mailparse.c) - cited in the sender check's package prose (sender.go 23-27, which stays); not at sender.go 318-323 (the "cruder second look", to be replaced by the port). + +B-D5: HOLDS (MEASURED) - the e2e re-measures on every run; the lines are composed - label added to testdata/from-lines-passed.txt ("composed by hand; what OpenDKIM signs each with is measured, by the e2e on every run"). + +B-D6: HOLDS (label) - same, label added to testdata/from-lines-other.txt. + +B-D7: HOLDS - opendkim/opendkim.c dkimf_reloader() / dkimf_sighandler(): SIGUSR1 sets `reload = TRUE` only `if (conffile != NULL)` (build passes -x); dkimf_config_reload(), called from mlfi_negotiate() at the next milter connection, reloads the configuration and reopens the tables; opendkim.8 OPERATION ("Upon receiving SIGUSR1, if the filter was started with a configuration file, it will be re-read") - cited at reloadViaSupervisor (internal/domain). Note: the reload is lazy - it takes effect at the next milter connection, not at the signal. + +B-D8: HOLDS - libopendkim/dkim.c dkim_privkey_load(): key data starting "-----" goes to OpenSSL PEM_read_bio_PrivateKey() (Debian builds against libssl-dev), which reads the traditional "RSA PRIVATE KEY" form; opendkim.c dkimf_loadkey() -> dkimf_securefile() may call a group-readable file insecure, and dkimf_add_signrequest() then only logs it unless RequireSafeKeys (build: no) - cited at writePrivateKeyPEM. (That the opendkim user can read 0640 through the selfpost group is deployment, measured by the e2e signing.) + +B-D9: DOES NOT HOLD - opendkim/opendkim-genkey.in: `$hashout = ""; if (defined($hashalgs)) { $hashout = " h=$hashalgs;"; }` and `"v=DKIM1;" . $noteout . $hashout . " k=rsa; " . $flags` - by default genkey writes `v=DKIM1; k=rsa; p=...`, no h=. No comment added. + - Consequence: none functional (h=sha256 only restricts verifiers to sha256, which OpenDKIM signs with by default); the comment's "mirrors" is false. + - Fix: comment: "the form opendkim-genkey writes (v=DKIM1; k=rsa; p=...) plus h=sha256, which genkey adds only with --hash-algorithms"; do NOT change the value (published records match it). No test. + +B-D10: HOLDS - opendkim-db.c dkimf_db_datasplit() splits the KeyTable value at ':'; the domain and selector are also path components of the key file (SelfPost's path.Join and opendkim's `keydata[0] == '/'`) - cited at opendkimForbidden. + +B-D11: DOES NOT HOLD (labels) - senderMaps is not in renderSenderLoginMaps' sorted order; signingTable's key-name column (`selfpost._domainkey.`) is not renderTables' (``). Comments corrected in sender_test.go (composed by hand, in the format but not the order / key names; the check reads only the pattern). Data unchanged. + +### 8. supervisord 4.2.5 + +B-U1: HOLDS - supervisor/rpcinterface.py signalProcess() -> process.py signal(): `options.kill(self.pid, sig)`; process.py get_execv_args()/_spawn_as_child(): shlex.split + execve, no shell; command `/usr/sbin/opendkim -f -x ...` (-f: no fork) so the pid is opendkim's - cited at reloadViaSupervisor (internal/domain). + +B-U2: PARTLY - holds: supervisord (PID 1, root) spawns `/usr/sbin/postfix reload` with no user= (build/supervisord.conf). Fails: "the start call reports its exit": supervisor/rpcinterface.py startProcess(): after spawn() and reap(), `process.transition()` moves STARTING -> RUNNING as soon as `now - self.laststart > self.config.startsecs` (startsecs=0), and then `return True` - the call returns while `postfix reload` is still running; its exit status is never reported. Two side cases: if transition() runs in the same clock tick, the deferred onwait() later sees EXITED and raises ABNORMAL_TERMINATION for a successful reload; and internal/supervisor.Start maps "already started" to success, so a rebuild whose reload is asked while the previous reload process still runs is not reloaded by a second HUP. No comment about exit added. + - Consequence: a failing `postfix reload` (master not running, postsuper error) is invisible: RebuildSenderLoginMaps / the panel's reload button report success. Impact is limited because smtpd and cleanup are short-lived and reread texthash anyway (PS9), but long-running trivial-rewrite/qmgr keep stale tables. + - Fix (internal/supervisor, not my file, + postfix.go): a RunOnce(program) that starts the program, then polls `supervisorctl status ` (or XML-RPC getProcessInfo) until EXITED/FATAL/BACKOFF and fails unless EXITED with exitstatus 0; on "already started", wait for that run to finish and start again. Test with a fake supervisorctl (the pattern queue_test.go uses for postqueue): exit 0 -> nil, exit 1 -> error, already-started -> second start. + +### 9. rspamd sidecar + +B-R1: PARTLY - holds when the routines run: rspamd 3.11.0 src/plugins/lua/milter_headers.lua routines x-spamd-result and x-rspamd-action set `remove[header] = 0` and add with order -1; lualib/lua_mime.lua modify_headers() sends them in the milter reply; src/libserver/milter.c rspamd_milter_remove_header_safe() with nhdr == 0 sends CHGHEADER (delete) for every instance, rspamd_milter_extract_single_header() with order -1 sends ADDHEADER (Postfix appends after the last header, PM14) - cited at inboundSession.Header. Fails: both routines start with `if skip_wanted(...) then return end`; skip_wanted() is true for `settings.skip_local` (default true) when `task:get_ip():is_local()` - src/lua/lua_ip.c lua_ip_is_local(): loopback, IPv6 link/site-local, or local_addrs (conf/options.inc: 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12, fd00::/8, 169.254.0.0/16, fe80::/10) - and for skip_authenticated when task:get_user() is set. deploy/antispam/rspamd/milter_headers.conf sets neither. The custom selfpost-signature routine is called without skip_wanted() (custom_routines branch), so it still signs. + - Consequence: mail from a client rspamd counts as local (a LAN host, or every client when Docker's userland proxy makes port-25 connections appear from the bridge gateway 172.x) keeps the sender's own X-Spamd-Result / X-Rspamd-Action, and rspamd still adds a valid X-SelfPost-Filter signature: the journal shows the sender's forged verdict as rspamd's (and "quarantine" if he wrote it). Journal display only - delivery is unaffected. + - Fix (deploy/, not my file): in milter_headers.conf set `skip_local = false;` and `skip_authenticated = false;` (port 25 has no SASL, but it costs nothing). To confirm on a stand: run rspamd 3.11 + Postfix, send from 127.0.0.1/172.x a message carrying `X-Spamd-Result: default: False [-50.00 / 15.00]`, check the journal shows rspamd's score, not -50. Also worth measuring once: which client address Postfix sees on the stand's port 25 (Docker NAT vs userland proxy). + +B-R2: HOLDS (= C-R13) - milter_headers.lua routine x-spamd-result: `string.format('default: %s [%.2f / %.2f]', (action == 'reject') and 'True' or 'False', score, required)`, symbols `string.format('%s(%.2f)[%s]', s.name, s.score, table.concat(s.options or {}, ','))`, joined `'; '`, folded at ';' (lua_util.fold_header) - cited at the rspamdScore/rspamdSymbol regexps. Fixture inbound_test.go:37 relabelled: composed by hand in that format, its symbol order not rspamd's. + +B-R3: DOES NOT HOLD (= C-R14) - milter_headers.lua x-spamd-result: `sort_by = 'score'` (default) -> `res = math.abs(s1.score) > math.abs(s2.score)` ("inverse order to show important symbols first"): sorted by absolute weight, so an allow entry's -100.00 comes FIRST, not last. No comment added to inbound.go. + - Consequence: none - rspamdVerdict keeps SELFPOST_ symbols wherever they are; the comment's reason is wrong (and with sort_by = 'name' the order would be alphabetical, which the code also survives). + - Fix: inbound.go comment: "rspamd 3.11 sorts symbols by absolute weight (milter_headers.lua, sort_by = 'score'), which puts -100 first; the list symbols are kept wherever they stand, so the order does not matter". The test's comment (inbound_test.go TestRspamdVerdictKeepsTheListSymbols) is corrected already (fixture claim; the -100-last order is now described as the composed hard case). + +B-R4: HOLDS (MEASURED, dev trial in a throwaway container, 2026-10-12; = C-R9) - the measurement stays a measurement (inbound_test.go TestFilterSignatureIsWhatTheFilterWrites says where and when); a pointer to it added at FilterSignature. + +### Part C entries placed in internal/milter/inbound.go + +C-P29: HOLDS - as B-PM14 (milter.c milter_message(), milter8.c milter8_message() rec_get following PTR, cleanup_milter.c cleanup_add_header()); with B-R1's caveat for "the last one read is its own" - cited at inboundSession.Header. + +C-P40: HOLDS - as B-PM3 (mail_params.h DEF_MILT_EOD_MACROS "i"; cleanup_milter.c cleanup_milter_receive() installs cleanup_milter_eval(), S8_MAC_I -> state->queue_id) - cited at inboundSession.Body. Related C-R10 (place milter_headers.conf, not edited): HOLDS - rspamd src/libserver/milter.c takes the queue id from macro "{i}", else "i", for the request it sends to the scanner. + +C-R9: HOLDS (MEASURED 2026-10-12) - as B-R4. + +C-R13: HOLDS - as B-R2; fixture relabelled. + +C-R14: DOES NOT HOLD - as B-R3. + +C-R16: HOLDS (MEASURED 2026-10-12, now cited) - as B-G2 (go-milter HandleMilterCommands(); milter8_conn_event() once per milter connection) - the citation is at session.MailFrom, which inboundSession's clientIP comment points to. + +C-R17: HOLDS - as B-G3/B-G6: with OptNoBody Postfix still sends EOB (milter8_eob()), go-milter calls Body(); Actions 0 and no Modifier calls - nothing is modified (Postfix milter8_connect() accepts rq_mask 0) - ServeInbound points to the citation at Serve. + +### Counts + +Part B (79): HOLDS 59, PARTLY 8, DOES NOT HOLD 12, NEEDS A MEASUREMENT 0. +Part C inbound.go (7): HOLDS 6, DOES NOT HOLD 1. + +### DOES NOT HOLD + +- B-G5 cleanAddress: go-milter passes the address unbracketed and without parameters; the blank cut breaks quoted local parts. Fix: drop the cut; test through go-milter's form. +- B-PM13 sender Header trim: Postfix already strips blanks before ':'. Fix: comment (trim harmless). +- B-PS1 Bindings.Allowed: Postfix uses the first key found, not any. Fix: first-key-wins lookup; tests with list+wildcard on one domain. +- B-PQ1 queueEntryRe: misses the forced-expire "#" flag. Fix: `[*!]?#?`; tests. +- B-PQ2 queue_test fixture comment: reason lines are right-aligned to column 76. Fixed (comment). +- B-PQ10 ParseDuration: default unit is per parameter (d, h), not s. Fix: per-key default unit; tests. +- B-PQ11 ParseDuration: Postfix reads one number and one unit; "1h7m" is 1h. Fix: mirror conv_time; tests. +- B-S4 password.go: sasldb2 keeps the plaintext userPassword. Fix: comment (and check security.md). +- B-S7 validate.go: saslpasswd2 keeps embedded newlines, drops one trailing one, reads at most 2048 bytes. Fix: comment (+ service_test.go:319). +- B-D9 dkimRecord: genkey writes no h= by default. Fix: comment only, value unchanged. +- B-D11 sender_test fixtures: not internal/postfix's order nor renderTables' key names. Fixed (comments). +- B-R3 / C-R14 rspamd symbol order: sorted by absolute weight, -100 first. Fix: comment in inbound.go (test comment fixed). + +PARTLY (failing part): B-G7 (ABORT does arrive on disconnect - comment), B-PM2 (login may carry @realm - comment), B-PM12 (LF only - for the port), B-PS4 (continuation lines, blank-separated owners - small parser fix), B-PS8 (colon claim; '"' and '#' missing - comment + two characters), B-S9 (fixture header - fixed), B-U2 (startProcess does not report the reload's exit - RunOnce in internal/supervisor), B-R1 (skip_local/skip_authenticated skip the header routines but not the signature - deploy config). + +### NEEDS A MEASUREMENT + +None: every entry was settled in source or documentation. Optional confirmations on the stand: B-S2 (`saslpasswd2 -d` on a missing login exits 0) and, for B-R1, which client address Postfix sees on the stand's port 25 and that a forged X-Spamd-Result from a local address survives today. + +## Part C - answers (2026-10-14) + +Sources: dev/upstream (postfix 3.7.11-0+deb12u1, opendkim 2.11.0~beta2, cyrus-sasl2 2.1.28+dfsg, supervisor 4.2.5, logrotate 3.21.0, Debian patches applied); rspamd 3.11.1 source (github rspamd/rspamd tag 3.11.1) + rspamd-docker tag v3.11.1+3 + docs.rspamd.com (unversioned, read 2026-10-14); modernc.org/sqlite v1.60.1 and go-milter v0.4.1 from the module cache; Go stdlib (local go1.27 GOROOT); CoreDNS v1.14.6 source; Traefik v3.1.0 source; caddy/certmagic master (the `caddy:2` tag floats); docs.docker.com, eff-certbot.readthedocs.io, nginx.org, docs.openssl.org/3.0, sqlite.org, RFC 7489/5321/1035/6376. + +"Comment written" = citation added in the file at the place that relies on it. Files outside my list (internal/milter/inbound.go, inbound_test.go, cmd/panel/*) were not touched. + +### 1. Postfix 3.7.11 + +C-P1: HOLDS (short form) - master.5 "-o name=value (short form)" NOTE 2; postconf_edit.c pcf_edit_master() "-Pe does not accept whitespace in parameter value"; smtpd_check.c smtpd_check_parse() and milter.c milter_new() split on CHARS_COMMA_SP. The long form `-o { name = value }` (NOTE 1) would keep whitespace; now said in the comment - comment written, postfix-config.sh master_cf_set_smtp_inet_o. +C-P2: HOLDS - master.5 SYNTAX (continuation lines); postconf_edit.c pcf_edit_master() + pcf_gobble_cf_line() re-parse a matched entry with its continuation lines and print it whole (so the awk-inserted -o survive the later -F) - comment written. +C-P3: HOLDS - xsasl_cyrus_server.c xsasl_cyrus_server_create() SASL_SERVER_NEW(service, var_myhostname, user_realm or NULL); smtpd_sasl_glue.c smtpd_sasl_activate() REALM_OR_NULL; Cyrus plugins/sasldb.c sasldb_auxprop_lookup(): no user realm -> serverFQDN; common/plugin_common.c _plug_parseuser() - comments written (HOSTNAME_VALUE and SASL block). +C-P4: DOES NOT HOLD - see detail below. No edit. +C-P5: HOLDS - smtpd.c pre_jail_init() only warns when TLS init fails; smtpd_proto() "Wrapper-mode request dropped from %s for service %s. TLS context initialization failed"; each new smtpd re-runs pre_jail_init() - comment written. +C-P6: HOLDS - mail_params.h DEF_MAILLOG_FILE_PFXS "/var, /dev/stdout"; maillog_client.c maillog_client_init(): msg_fatal "%s value '%s' does not match any prefix in %s" (postfix.c calls it, so `postfix check` dies) - comment written. +C-P7: HOLDS - MAILLOG_README "Configuring logging to file"; postfix-script check-fatal: "missing 'postlog' service in master.cf" - citation completed. +C-P8: PARTLY - relayhost= is direct MX delivery (holds). "Nothing is ever delivered locally" fails: resolve_local.c resolve_local() still treats an address literal of one of the container's own interfaces as local ("Compare the destination against the list of interface addresses that we are supposed to listen on"; inet_interfaces=all) - see detail. No edit. +C-P9: HOLDS - smtpd_sasl_proto.c smtpd_sasl_auth_cmd(): "504 5.5.4 Encryption required for requested authentication mechanism" while auth_only and no TLS; smtpd.c pre_jail_init(): wrappermode -> enforce_tls -> auth_only - comment written. +C-P10: HOLDS (Debian-specific) - debian/patches/07_sasl_config.diff: xsasl_getconfpath() returns "$config_directory/sasl:/usr/lib/sasl" (xsasl_cyrus_log.c), SASL_CB_GETCONFPATH in xsasl_cyrus_server.c; debian/README.Debian "SASL configuration goes in /etc/postfix/sasl" - comment written at the smtpd.conf heredoc. +C-P11: PARTLY - the order holds but is smtpd's, not texthash's: smtpd_check.c reject_auth_sender_login_mismatch() -> check_mail_addr_find() -> mail_addr_find.c mail_addr_find_opt(MA_FIND_DEFAULT): full, without extension, local part if local, @domain. Comment corrected and cited (no behaviour change). +C-P12: HOLDS - mail_addr_find_opt() strip_addr_internal(int_full_key, &saved_ext, var_rcpt_delim) - comment written. +C-P13: HOLDS - DATABASE_README "texthash" (no postmap; read once per process; short-running processes need no reload) - comment written. +C-P14: HOLDS - mail_params.h DEF_SMTPD_DELAY_REJECT 1; smtpd_check.c smtpd_check_rcpt() - comments written (postfix-config.sh, negative_test.go). +C-P15: HOLDS - smtpd.c mail_cmd(): anvil check skips namadr_list_match(hogger_list) = smtpd_client_event_limit_exceptions (default $mynetworks; Debian debconf default debian/templates postfix/mynetworks "127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128"); anvil_rate_time_unit is global - the unstated parts now stated, comment written. +C-P16: HOLDS - MILTER_README "Different settings for different Milter applications" (3.0+) and "Signing internally-generated bounce messages"; milter.c milter_*_event()/milter_message() loop "resp == 0 && m != 0"; mail_params.h DEF_INT_FILT_CLASSES INT_FILT_CLASS_NONE - comment written. +C-P17: HOLDS - mail_params.h DEF_MILT_MAIL_MACROS; `i` empty at MAIL (DEF_SMTPD_DELAY_OPEN 1) is harmless - comment written. +C-P18: HOLDS - mail_params.h DEF_MILT_CONN_TIME/CMD_TIME "30s", MSG_TIME "300s" - comment written. +C-P19: HOLDS - mail_params.h DEF_KNOWN_TCP_PORTS; find_inet.c find_inet_port() -> filter_known_tcp_port() - comment written. +C-P20: HOLDS - debian/patches/40_chroot_by_default.diff (chroot y); debian/configure-instance.sh copies etc/resolv.conf into the chroot (not run in the container); chroot(2) needs CAP_SYS_CHROOT, dropped by cap_drop: ALL - comment written (postfix-config.sh, deploy/docker-compose.yml). +C-P21: HOLDS - smtpd.c pre_jail_init() (encrypt -> enforce -> auth_only); postconf_edit.c pcf_edit_master() -MX drops the entry - comment written. +C-P22: HOLDS - 40_chroot_by_default.diff: "smtp inet n - y - - smtpd" uncommented - comment written. +C-P23: PARTLY - holds: pipe.c get_service_attr() "missing user= command-line attribute"; these are pipe(8) attributes. Inexact: "postconf -P refuses them" - postconf_master.c pcf_edit_master_param() only ever writes "-o name=value"; "unmatched service_name/type" is postconf_edit.c's warning for a service not in master.cf. The measurement (1.7.0-1.9.5) stays, now "date not recorded" - comment extended (postfix-config.sh, port25_config_check.go). +C-P24: HOLDS - pipe.8 ${recipient}; dmarc-ingest_destination_recipient_limit=1 - comment written. +C-P25: HOLDS - pipe.8 export_environment; mail_params.h DEF_EXPORT_ENVIRON "TZ MAIL_CONFIG LANG"; clean_env.c. Unstated part (main.cf-global) now stated - comment written. +C-P26: HOLDS - master.5 NOTE 2 "or use commas instead of spaces" - comment written. +C-P27: HOLDS - smtpd_check.c smtpd_check_rcpt(): check_recipient_rcpt_maps() unless status == REJECT - comment written. +C-P28: HOLDS - master.5 "-o: Override the named main.cf configuration parameter"; smtpd.c milter_send() hands smtpd's list to cleanup, cleanup_milter.c cleanup_milter_receive() - comment written. +C-P29: HOLDS - milter.c milter_message() list order; milter8.c milter8_event() applies SMFIR_ADDHEADER at once via cleanup; next milter8_message() reads with rec_get() (record.h REC_FLAG_DEFAULT follows PTR) - comment written in postfix-config.sh (inbound.go not touched). +C-P30: HOLDS - mail_task.c mail_task(): "%s/%s" syslog_name/program - comment written. +C-P31: DOES NOT HOLD - see detail. No edit. +C-P32: HOLDS - postfix-script "check": check-fatal || exit 1; check-fatal runs post-install create-missing - comment written. +C-P33: HOLDS - postfix.c execvp(postfix-script); postfix-script start-fg: `1) exec master -i ... *) master -s`; master.c setsid() - comment written (postfix-wrapper.sh, supervisord.conf postfix-reload). +C-P34: DOES NOT HOLD - see detail. No edit. +C-P35: HOLDS - conf/postfix-files ($queue_directory root 755, maildrop 730/public 710 $setgid_group, pid root 755); postfix-script check-warn "not owned by root" - comment written. +C-P36: HOLDS - logwriter.c logwriter_open_or_die() safe_open(..., 0644) under master.c umask(077) -> 0600 - comment written (entrypoint.sh, logrotate-loop.sh). +C-P37: HOLDS - postfix-script "logrotate" (mv, kill -HUP); master_conf.c master_config() -> master_restart_service() -> master_status_cleanup(); dgram_server.c dgram_server_abort() on MASTER_STATUS_FD; postlogd.c logwriter_open_or_die() on the path - comment written (logrotate-loop.sh). +C-P38: HOLDS - smtpd.c pre_jail_init() per process; multi_server.c multi_server_abort() on MASTER_STATUS_FD after reload; postconf -h reads main.cf - comment written (postfix-cert-reload.sh). +C-P39: HOLDS - inet_proto.c inet_proto_init() keeps IPv6 when socket(PF_INET6) works; smtp_connect.c smtp_connect_inet() loops on to the next address after a failed connect. The listener side ([::] bind with the container's IPv6 disabled) is shown by every e2e run, not read - comment written. +C-P40: HOLDS - mail_params.h DEF_MILT_EOD_MACROS "i" - place is inbound.go (not mine): no edit; same fact cited in milter_headers.conf. +C-P41: HOLDS - README_FILES/TLS_README "TLS policy table" note: the lookup key is the verbatim next-hop; ":smtp", ":25", no port are different keys - comment written at smtp_tls_policy_maps. +C-P42: DOES NOT HOLD - see detail. No edit. + +### 2. Cyrus SASL 2.1.28 + +C-S1: HOLDS - doc/legacy/options.html (pwcheck_method, auxprop_plugin, sasldb_path, mech_list) - comment written. +C-S2: HOLDS - rests on P9 (holds). +C-S3: DOES NOT HOLD - see detail. No edit. +C-S4: HOLDS - cyrus-sasl2 debian/control (libsasl2-2 Depends libsasl2-modules-db), sasl2-bin.install (usr/sbin), libsasl2-modules.install (libplain, liblogin), libsasl2-modules-db.install (libsasldb); packages.debian.org/bookworm/amd64/db-util/filelist (/usr/bin/db_dump) - comment written in Dockerfile. + +### 3. OpenDKIM 2.11.0~beta2 + +C-O1: HOLDS - opendkim.8.in "Upon receiving SIGUSR1 ... re-read"; opendkim.c dkimf_config_reload() -> dkimf_config_load() -> dkimf_db_open() for SigningTable and KeyTable - comment written. +C-O2: HOLDS - opendkim.conf.5.in SigningTable: refile keys are wildcard patterns against the From: address, in order - comment written. +C-O3: HOLDS - opendkim.c dkimf_checkfsnode(): key must be owned by root or the executing uid ("is not owned by the executing uid"), group-readable only in a single-user group ("which has multiple users"). Note the man page (RequireSafeKeys) is looser than the code; the code decides - comment written. +C-O4: HOLDS - opendkim.conf.5.in UMask (applies to the socket); inode(7) setgid directory - comment written. +C-O5: HOLDS - opendkim-db.c dkimf_db_open() (file/refile read line by line; empty file = empty table) - comment written. +C-O6: HOLDS - opendkim.8.in -f, -x; opendkim.conf.5.in UserID, Background - comment written. + +### 4. supervisor 4.2.5 + +C-V1: HOLDS - docs/configuration.rst [program:x] priority "the start and shutdown ordering"; supervisord.py runforever() transitions all groups in one pass - comment written. +C-V2: HOLDS - docs/configuration.rst [unix_http_server] chmod/chown; username "Default: No username required" - comment written. +C-V3: HOLDS - options.py drop_privileges(): os.setgroups() from grp.getgrall() - comment written. +C-V4: HOLDS - process.py Subprocess.finish(): startsecs=0 -> too_quickly never true -> EXITED, never BACKOFF/FATAL - comment written. +C-V5: DOES NOT HOLD - see detail. No edit. +C-V6: DOES NOT HOLD - see detail. No edit. +C-V7: HOLDS - docs/events.rst "Event Listener Notification Protocol" (READY\n, header line, len, "RESULT 2\nOK") - comment written in crashexit.py. +C-V8: PARTLY - holds: the listener is supervisord's child; SIGTERM shuts supervisord down (docs/running.rst "Signals"); it is PID 1. Fails: Docker restarts the same container, it does not recreate it (docs.docker.com/engine/containers/start-containers-automatically/, "Use a restart policy": "Docker restarts the container if it exits"). Mechanism cited in crashexit.py; the "recreate" wording left (fix: change "recreate it cleanly" to "restart it" in supervisord.conf and crashexit.py). +C-V9: HOLDS - process.py finish(): killing -> STOPPED; give_up(): FATAL only from BACKOFF - comment written (supervisord.conf, sender_check_test.go). +C-V10: DOES NOT HOLD - see detail. No edit. +C-V11: PARTLY - supervisorctl.py do_status(): exit 3 (LSBStatusExitStatuses.NOT_RUNNING) only for states in STOPPED_STATES (STOPPED, EXITED, FATAL, UNKNOWN), not for STARTING/BACKOFF/STOPPING; output "%(namespec)-Ns%(state)-10s%(desc)s" so fields[0], fields[1] parse holds. Harmless (exit ignored). Fix: comment in process_check.go: "exits 3 when a program is STOPPED, EXITED, FATAL or UNKNOWN". No edit. + +### 5. logrotate 3.21.0 + +C-L1: HOLDS - debian/rules --with-state-file-path=/var/lib/logrotate/status; unstated part: logrotate.c newState() records an unknown log as rotated today, so after a recreate the first rotation comes a day late - comment written. +C-L2: HOLDS - config.c readConfigFile(): as root, mode & 0022 -> "Ignoring ... writable by group or others." return 0 (exit 0); owner not root ignored the same way; "Potentially dangerous mode" (mode & 07533 != 0400) is only a warning - the loop's choice to treat it as fatal is now said - comment written. +C-L3: HOLDS - logrotate.c findNeedRotating(): "parent directory has insecure permissions" - comment written (entrypoint.sh). +C-L4: HOLDS - the measurement ("confirmed on a live container") kept, now "date not recorded", and the source added (P36) - comment written. +C-L5: HOLDS - logrotate.8 create, delaycompress - comment written. +C-L6: HOLDS - postfix-script reload/status: master -t, exit 1 while not running - comment written (delivery_log_test.go). + +### 6. Debian packaging and base image + +C-D1: DOES NOT HOLD - see detail. No edit. +C-D2: HOLDS - postfix debian/templates postfix/main_mailer_type, postfix/mailname - comment written. +C-D3: HOLDS - supervisor debian/control Depends ${python3:Depends} - comment written. +C-D4: HOLDS - packages.debian.org/bookworm/ca-certificates "Depends: openssl (>= 1.1.1)" - comment written. +C-D5: HOLDS - groupadd(8) refuses a --gid in use (the build is the check) - comment written. +C-D6: HOLDS - conf/postfix-files postdrop/postqueue root:$setgid_group 2755; Linux no_new_privs.rst - comment written. + +### 7. Linux, Docker, Compose + +C-K1: HOLDS - capabilities(7) "Capabilities list"; kill(2) for CAP_KILL; SETUID/SETGID also used by pipe(8) user=panel; SYS_CHROOT dropped - comment written (deploy/docker-compose.yml). +C-K2: HOLDS - no_new_privs.rst (execve only) - comment written. +C-K3: HOLDS - inode(7) "The set-group-ID bit" - comment written (entrypoint.sh, opendkim.conf). +C-K4: HOLDS - test(1) -S - comment written. +C-K5: NEEDS A MEASUREMENT - Docker's docs (engine/network/port-publishing, packet-filtering-firewalls) do not say what source address the container sees when docker-proxy relays (IPv6 host address to IPv4 container, hairpin, rootless). Measure on the live host: connect to 25 and 465 from an outside host over IPv4 and over IPv6 and read "connect from ...[ADDR]" in mail.log; record `docker info` (userland-proxy, ip6tables). Stakes: P15 anvil per-IP, the inbound journal, and R7 (a 172.16/12 gateway address is "local" to rspamd). +C-K6: HOLDS - docs.docker.com/reference/compose-file/merge/ "!override" ("fully replace an attribute"). No minimum version is documented or claimed in the tree - comment written (compose.override.yml). +C-K7: HOLDS - docs.docker.com/compose/how-tos/multiple-compose-files/merge/ "Paths are evaluated relative to the base file" - comment written (caddy, nginx, traefik fragments). +C-K8: DOES NOT HOLD (comment only) - see detail. +C-K9: DOES NOT HOLD (comment only) - see detail. +C-K10: HOLDS as a measurement - Docker docs do not describe it; kept, "seen on the stand, date not recorded" - comment written (smtp_client.go, port25_client.go). +C-K11: HOLDS - Go src/os/tempfile.go MkdirTemp "created with mode 0o700"; testing.TempDir uses os.MkdirTemp - comment written (entrypoint.sh, hostname_gate_test.go). + +### 8. rspamd 3.11 (rspamd/rspamd:3.11 = 3.11.1 at rspamd-docker v3.11.1+3) + +C-R1: HOLDS - map.c ev_stat watcher; conf/options.inc map_watch_interval 5min x map_file_watch_multiplier 0.1 (30 s poll without inotify); docs.rspamd.com/configuration/maps asks for atomic rename. The delay is now stated - comment written (multimap.conf, lists.go). +C-R2: HOLDS - map_helpers.c rspamd_parse_kv_list(): '#' comment, whitespace ends key - comment written (lists.go). +C-R3: HOLDS - docs.rspamd.com/configuration/maps (IP maps: v4/v6, CIDR, mixed) - comment written (lists.go). +C-R4: PARTLY - holds: hash map lookup is whole-key, case-insensitive (map_helpers.c rspamd_icase_hash/rspamd_lc_cmp); email:domain = domain part (multimap.lua apply_addr_filter()), no subdomains. Fails (unstated): multimap.lua `from` default algorithm uses the SMTP sender, and the MIME From: only when the envelope sender is absent (task:has_from('smtp') else mime) - while the journal's From is the envelope only. Also: a `from` rule without filter matches the map against addr, domain and user (match_addr()); harmless because SelfPost's from-maps hold full addresses only. Holding part cited in multimap.conf. Fix in detail. +C-R5: PARTLY - "deny wins" holds: multimap.lua calls task:set_pre_result(rule.action, message) and registers the symbol with 'passthrough' augmentation; a pre-result action stands whatever the score. Fails: `prefilter = true` is not read by 3.11.1's multimap.lua (no reference) - docs.rspamd.com/modules/multimap: prefilter "not used since Rspamd 3.3"; the comment's "(a prefilter)" mechanism is wrong. Harmless. Holding part cited in multimap.conf. Fix: drop the three `prefilter = true;` lines and the "(a prefilter)" wording. +C-R6: HOLDS - protocol.c rspamd_scan_result_ucl() puts the pre-result message into messages.smtp_message; milter.c rspamd_milter_send_task_results() replies "554 5.7.1 "; milter_headers.lua x-spamd-result writes "%s(%.2f)[%s]" - comment written (multimap.conf, lists.go). Caveat: the allow symbol is visible only when x-spamd-result is written (R7). +C-R7: PARTLY (security-relevant) - see detail. No citation written for the removal claim. +C-R8: HOLDS - milter_headers.lua: pcall(custom_routines[n], task, common) -> ok, err, to_add, to_remove, common_in; remove value 0 = all (docs.rspamd.com/modules/milter_headers) - comment written. +C-R9: HOLDS - lua_cryptobox.c rspamd_lua_hash_create(): "sha256" with key -> HMAC over EVP_sha256(), key bytes as given (the hex text). Also measured (inbound_test.go, 2026-10-12) - comment written in milter_headers.conf. +C-R10: HOLDS - milter.c rspamd_milter_macro_http(): macro "{i}" or "i" -> Queue-Id header -> protocol.c task->queue_id; Postfix DEF_MILT_EOD_MACROS "i" - comment written. +C-R11: HOLDS - rspamd-docker v3.11.1+3 Dockerfile USER 11333:11333; 0644 files in a 0755 dir readable by any uid - comment written (lists.go, antispam compose). +C-R12: PARTLY - holds: conf/worker-proxy.inc "milter = yes"; rspamd-docker patches/rspamd-docker-config.diff binds rspamd_proxy to "*:11332" (upstream conf binds localhost) - comment written. Fails: "3.11" is a moving series tag (rspamd-docker README "Tags": "3.9 | latest stable release in 3.9 series"), so "pin ... the same way the SelfPost image is pinned" is false. Fix: `rspamd/rspamd:3.11.1` (or a digest). +C-R13: HOLDS - milter_headers.lua x-spamd-result: 'default: %s [%.2f / %.2f]' and '%s(%.2f)[%s]' joined by '; '. inbound.go/inbound_test.go not mine: the fixture at inbound_test.go:37 should say "composed by hand". +C-R14: DOES NOT HOLD (comment in inbound.go, not mine) - milter_headers.lua x-spamd-result default sort_by='score': `math.abs(s1.score) > math.abs(s2.score)` - heaviest |score| FIRST, so an allow entry's -100 comes first, not last. No consequence (code does not depend on order). Fix: correct the inbound.go comment. +C-R15: DOES NOT HOLD (fixture comment) - smtpd_peer.c (smtpd_peer_sockaddr_to_hostaddr): with IPv4 enabled Postfix rewrites a v4-mapped peer to plain IPv4 ("rewriting V4-mapped address"); and inet_listen.c sets IPV6_V6ONLY. So Postfix 3.7.11 with inet_protocols=all never logs "::ffff:198.51.100.77". The case stays as a defensive one. Did: labelled the lists_test.go rows "composed by hand" (+ reject_message source). Fix: reword "(as Postfix may log it, mapped)" to "(mapped - Postfix 3.7.11 never logs it so with IPv4 on, smtpd_peer.c; kept as a guard)". +C-R16: HOLDS - measured, dated (2026-10-12); Postfix half cited: smtpd.c smtpd_proto() calls milter_conn_event() once per connection - comment written (negative_test.go). go-milter half belongs to the inbound.go agent. +C-R17: HOLDS - go-milter v0.4.1 session.go: CodeEOB -> m.backend.Body(); Postfix milter8.c sends EOB even with SMFIP_NOBODY (milter8_eob always installed) - inbound.go not mine, no edit. + +### 9. pipe(8) and dmarc-ingest (cmd/panel - not mine, no edits) + +C-M1: HOLDS - pipe.8 (`q` flag quotes, ${recipient}); pipe(8) exports only export_environment, RECIPIENT is local(8)'s. +C-M2: HOLDS - pipe.8 DIAGNOSTICS (sysexits.h); sys_exits.c EX_TEMPFAIL "4.3.0" temporary. +C-M3: HOLDS - pipe.c deliver_message(): success text " (%.100s)" - 100 bytes; pipe_command.c captures up to VSTREAM_BUFSIZE (4096) for the failure DSN; set_ugid.c setgroups(1, &gid). + +### 10. DMARC + +C-M4: HOLDS - RFC 7489 § 7.2.1.1 (application/gzip if compressed, text/xml otherwise; extension "xml" / "xml.gz"); zip is outside the RFC (M5) - comment written (ingest.go looksLikeAggregate). +C-M5: HOLDS as a measurement - unsourced sighting kept, now "where and when not recorded" + RFC note - comment written. +C-M6: HOLDS - RFC 7489 Appendix C targetNamespace "http://dmarc.org/dmarc-xml/0.1"; Go src/encoding/xml/read.go compares the namespace only when the tag names one - comment written. +C-M7: PARTLY - holds: pct default 100 (§ 6.3); dkim/spf in policy_evaluated are "The DMARC-aligned authentication result" (Appendix C), pass if either (§ 4.2) - cited at dmarcRecordPasses. Fails: an explicit 0 (allowed: "between 0 and 100, inclusive") parses to 0 and is turned into 100 (parse.go `if out.PolicyPct == 0`). See detail. +C-M8: NEEDS A MEASUREMENT - no source can say how many records real reports carry. Measure on the live installation: the largest record count of the reports received so far (per report, from the DMARC tables), and keep maxReportRecords at least 10x above it. +C-M9: HOLDS (rule now met) - fixtures labelled composed by hand: internal/dmarc/parse_test.go (sampleXML and messages), test/e2e/port25_features_test.go (aggregateXML; reportMessage with RFC 2045 § 6.8 for 76 columns). + +### 11. SQLite / backup + +C-Q1: HOLDS - sqlite.org/lang_vacuum.html "VACUUM INTO": "transactional in the sense that the generated output database is a consistent snapshot"; modernc.org/sqlite v1.60.1 sqlite.go applyQueryParams(): each _pragma run as "pragma ", busy_timeout first - comment written. +C-Q2: HOLDS - conf/postfix-files queue dirs $mail_owner 700; the incident (1.3.0-2.0.0) kept, "date not recorded" - comment written. +C-Q3: PARTLY - the atomic-rename writers hold for SelfPost's own files (atomicfile.Write uses os.Rename). sasldb2 does not: see S3 (Berkeley DB written in place, no lock environment). A backup taken during a password change can archive a torn sasldb2. Fix in S3. +C-Q4: DOES NOT HOLD - see detail. No edit. + +### 12. ACME / proxies + +C-A1: DOES NOT HOLD - see detail. No edit. +C-A2: HOLDS - eff-certbot.readthedocs.io User Guide "Automated Renewals": sample cron "0 0,12 * * *"; renew acts only near expiry - comment written (nginx fragment). +C-A3: NEEDS A MEASUREMENT - certbot docs describe only that the apache plugin "modifies Apache ... configuration" and an SSL vhost extension "-le-ssl.conf" (a new file); what it does with this *:443 vhost that has no SSLEngine is not documented. Measure: on a throwaway host with Apache, install deploy/apache/selfpost-vhost.conf, run `certbot --apache --staging -d `, diff /etc/apache2 before/after. Note also: the shipped :80 vhost already has the redirect, so "certbot adds ... redirect" is moot. +C-A4: HOLDS - certmagic storage.go KeyBuilder.SiteCert() "certificates///.crt", acmeissuer.go issuerKey() (CA host+path -> "acme-v02.api.letsencrypt.org-directory"); caddy automation.go DefaultIssuers(): ZeroSSL only with an email, the Caddyfile sets none. Read at master 2026-10-14 (caddy:2 floats) - comment written. +C-A5: HOLDS - Traefik v3.1.0 pkg/provider/acme/local_store.go map[resolver]*StoredData; store.go StoredData{Account, Certificates}; provider.go Certificate json "domain"/"certificate"/"key" ([]byte -> base64); resolver "le" from the fragment - comment written (extract-cert.sh). Docs do not document the layout. +C-A6: HOLDS - doc.traefik.io/traefik/v3.1/providers/docker/ "Routing Configuration": "Traefik uses container labels"; exposedByDefault=false needs traefik.enable=true - comment written. +C-A7: HOLDS - nginx.org ngx_http_v2_module "http2": "This directive appeared in version 1.25.1" - comment written. The floating `caddy:2` / `certbot/certbot:latest` tags are a fact of the fragments (noted in the caddy citation). + +### 13. OpenSSL + +C-X1: NEEDS A MEASUREMENT - openssl-x509(1) 3.0: -pubkey "Prints the certificate's SubjectPublicKeyInfo block in PEM format"; openssl-pkey(1) 3.0: -pubout "restricts the output to the public components" - the structure is not named, and byte-identity is not stated. The e2e stand shows it for its RSA pair on every run. Measure on the stand (the image's OpenSSL 3.0): `openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout k.pem -out c.pem -subj /CN=x`, then compare `openssl x509 -noout -pubkey -in c.pem` with `openssl pkey -pubout -in k.pem` (certbot >= 2.0 issues ECDSA keys by default). No edit. + +### 14. e2e harness + +C-E1: HOLDS - smtp-sink.c: -d "single-message file ... appending a pseudo-random hexadecimal number"; main(): "-u option is required if running as root" - comment written (sink/Dockerfile). +C-E2: DOES NOT HOLD - see detail. No edit. +C-E3: HOLDS - RFC 5321 § 5.1 implicit MX - section added. +C-E4: HOLDS - RFC 1035 § 3.3 ( 255); RFC 6376 § 3.6.2.2 (concatenate) - comment written. +C-E5: PARTLY - holds: any -F/-P/-M rewrites each matched entry with PCF_FOLD_LINE (postconf_edit.c pcf_edit_master() -> pcf_print_master_entry()), and */*/chroot=n matches every entry. Fails: postconf_master.c pcf_print_master_entry() forces a line break before each "-o" and "Keep[s] option and value on the same line", and a value has no whitespace (P1) - so it never splits an option's value nor re-flows -o lines into one paragraph; the example (two space-separated tokens) is stale. Harmless (the reader is defensive). Fix: correct the comment in port25_config_check.go parseSMTPInetOptions. +C-E6: PARTLY - holds for master.cf (master_config() only on SIGHUP). Fails: every daemon started meanwhile reads main.cf afresh (multi_server.c multi_server_main(): mail_conf_suck()), so for the length of the step new smtpd/cleanup/qmgr processes run the port-25 combinations' main.cf. Harmless (no mail at that point). Fix: say so in the comment. +C-E7: HOLDS - measurement (1.9.1) kept, "date not recorded", + master.5 NOTE 2 - comment written. +C-E8: HOLDS - postconf_master.c pcf_show_master_entries(): nothing on stdout, msg_warn "unmatched request" - comment written. +C-E9: HOLDS - milter8.c milter8_event() SMFIR_TEMPFAIL "451 4.7.1 Service unavailable - try again later"; smtpd_check.c reject_auth_sender_login_mismatch() 553 5.7.1 "Sender address rejected: not owned by user"; generic_checks() REJECT_ALL var_reject_code (DEF_REJECT_CODE 554) "%s rejected: Access denied"; smtpd.c mail_cmd() "450 4.7.1 Error: too much mail from" - comments written (negative_test.go). +C-E10: PARTLY - holds: "454 4.3.0 Try again later" at AUTH (smtpd_sasl_proto.c smtpd_sasl_auth_cmd(): milter 4xx -> 454). Fails: "451 from the connection on" - smtpd.c smtpd_proto() changes the greeting only for a 5xx or 421 milter reply (otherwise "220"), and ehlo_cmd() answers only a 421; the 451 comes at MAIL. Test asserts only 4xx: harmless. Fix: reword the comment in sender_check_test.go. No edit. +C-E11: HOLDS - measured (dated) + smtpd.c milter_send(), cleanup_milter.c - comment written. +C-E12: HOLDS - milter8.c milter8_header(): the value is the whole collected header incl. continuation - comment written. +C-E13: HOLDS as a measurement - kept, "date not recorded"; mechanism cited: DATABASE_README texthash (read once per process), multi_server.c multi_server_abort() - comment written. +C-E14: HOLDS as a measurement - incident kept, "date not recorded" - comment written. +C-E15: HOLDS - smtpd.c smtpd_service(): msg_info("connect from %s", state.namaddr) - comment written. +C-E16: pointer - logrotate_check.go rests on L5 (holds), restore_test.go on P34 (the queue IS rebuilt on start, by `postfix check` - not by the branch P34 names), cert_reload_check.go on P38 (holds). No edit. + +Also labelled: internal/backup/backup_test.go mail.log line "composed by hand". + +--- + +### Step-3 detail (DOES NOT HOLD, and the failing parts of PARTLY) + +**P4** postfix-config.sh:71-74, postfix-cert-reload.sh header: "Postfix insists the private key is root-owned and mode 0600." +Source: no mode/owner check anywhere in src/tls, src/smtpd or postfix-script; smtpd loads the key as root in pre_jail_init() (`if (getuid() == 0 || getuid() == var_owner_uid)`), so it could read the mount as it is. TLS_README only recommends: the key file "should be owned by "root" and not be readable by any other user". +Consequence: none for behaviour - the copy is still worth having (cert-reload validates a renewal on the copy before Postfix sees it). The stated reason is false, and someone may "simplify" the copy away for the wrong reasons or keep it for the wrong ones. +Fix: reword both comments: "Postfix does not check the key's mode (Postfix 3.7.11: no check in src/tls; smtpd reads it as root, smtpd.c pre_jail_init()); TLS_README recommends root-owned, unreadable by others. The copy gives that, and lets cert-reload check a renewed pair before Postfix uses it." Check: none needed (comment). + +**P8** postfix-config.sh:168-169 "Empty these so a misfiled recipient never gets delivered locally." +Source: resolve_local.c resolve_local(): after mydestination, "Compare the destination against the list of interface addresses that we are supposed to listen on" - with inet_interfaces=all an address literal of the container's own address (`x@[127.0.0.1]`, `x@[172.18.0.2]`) is local, and trivial-rewrite gives it to local(8). STANDARD_CONFIGURATION_README l.209 (null client): `local_transport = error:local mail delivery is disabled`. +Consequence: an authenticated application sending to `root@[127.0.0.1]` gets local delivery (or a local(8) bounce) inside the container instead of a refusal; with alias_maps empty `root` lands in /var/mail/root of an ephemeral container. +Fix: `postconf -e "local_transport=error:local mail delivery is disabled"`. Check: e2e - an authenticated send to `postmaster@[127.0.0.1]` is bounced with "local mail delivery is disabled" (mail.log status=bounced), nothing under /var/mail. + +**P31** postfix-config.sh:439-452, 466-467: `-o message_size_limit=${PORT25_SIZE}` sets port 25's limit (inbound default 26214400). +Source: cleanup_init.c cleanup_post_jail(): `if (ENFORCING_SIZE_LIMIT(var_message_limit)) set_file_limit((off_t) var_message_limit)` - cleanup runs with main.cf's message_size_limit, which is never set, so DEF_MESSAGE_LIMIT 10240000 (mail_params.h:1997). smtpd's own checks (smtpd.c:1995, 3657, 4047) use the -o value. A write past the rlimit is EFBIG -> cleanup_strerror.c CLEANUP_STAT_SIZE "552 5.3.4 message file too big". +Consequence: EHLO on 25 advertises SIZE 26214400, a 12 MB message to a relay domain is accepted by smtpd through DATA and then refused 552 by cleanup - every message between ~10 MB and 25 MiB to the inbound relay bounces at the sender. DMARC (5 MiB) is unaffected. +Fix: give cleanup the largest limit any listener uses, e.g. `postconf -P "cleanup/unix/message_size_limit=${max(PORT25_SIZE, 10240000)}"` when the inbound relay is on (submission keeps main.cf's limit in smtpd). Check: e2e - deliverPort25 a 12 MB message to the inbound domain, expect 250 and arrival at the sink; and the read-back of `postconf -Ph cleanup/unix/message_size_limit`. + +**P34** entrypoint.sh:171-175 "Initialise the persistent queue tree on first start or after restore" (`if [ ! -d .../active ]; then postfix set-permissions`). +Source: postfix-config.sh, which runs first, ends with `postfix check`; postfix-script "check" -> check-fatal -> `post-install create-missing`, which builds every queue directory from conf/postfix-files (`$queue_directory/active:d:$mail_owner:-:700:ucr`). By the time the test runs, active/ exists. +Consequence: the branch is dead; harmless. The comment describes a mechanism that never runs (the queue is built by `postfix check`). +Fix: delete the branch and say "postfix-config.sh's `postfix check` builds a missing queue tree (post-install create-missing)". Check: e2e restore_test (start over a /data without queue) still passes. + +**P42** supervisord.conf [program:postfix] (no stopasgroup): stopping the program stops Postfix. +Source: P33 - the supervised pid is postfix-script's shell; master.c calls setsid() (own session and process group). supervisor process.py kill(): `options.kill(pid, sig)` to the pid only (to -pid only with stopasgroup/killasgroup, and master is not in that group anyway). +Consequence: (a) `supervisorctl restart postfix`: the shell dies, master lives on orphaned; the new start-fg hits `master -t` -> "the Postfix mail system is already running", exit 1 within a second, BACKOFF x3 -> FATAL -> crashexit takes the container down. Nothing in the tree runs it today. (b) Every `docker stop`/restart: supervisord stops the program (the shell), master is never told; when supervisord (PID 1) exits, the kernel SIGKILLs the namespace - Postfix never gets its graceful SIGTERM, sessions in progress are cut. The queue is crash-safe, so no mail is lost; clients mid-DATA retry. +Fix: postfix-wrapper.sh does not exec start-fg; it traps TERM/INT and runs `postfix stop` (then waits), e.g. `trap 'postfix stop; exit 0' TERM INT; postfix start-fg & wait`. Check: e2e - `supervisorctl restart postfix` leaves one master and the container up; after `docker compose stop`, mail.log has "postfix/master[...]: terminating on signal 15". + +**S3** entrypoint.sh:92-100: smtpd reads sasldb2 while the panel's saslpasswd2 writes it. +Source: cyrus-sasl2 sasldb/db_berkeley.c berkeleydb_open(): `db_create(mbdb, NULL, 0)` and `->open(*mbdb, NULL, path, NULL, DB_HASH, flags, 0660)` - no DB_ENV, so no Berkeley DB locking; writers use flags DB_CREATE and put()/del() in place (_sasldb_putdata()). +Consequence: an AUTH during a password change can read a page mid-write - a spurious 535 or a DB error for that login; a backup (Q3) taken then archives a torn sasldb2, and a restore of it breaks AUTH for every application. +Fix: the panel writes a copy (`saslpasswd2 -f ` on a copy of sasldb2) and renames it over; and the backup reads sasl/ under the same panel mutex. Check: unit test in the panel's SASL writer (the file is replaced by rename: inode changes); e2e - change a password in a loop while authenticating in another loop, no 535 for the unchanged login. + +**V5** supervisord.conf [program:logrotate]: "Exits non-zero when the config is group/other-writable ...; autorestart surfaces BACKOFF on the Status page." +Source: process.py Subprocess.finish(): with startsecs=0 `too_quickly` is never true, so the exit is RUNNING -> EXITED; transition(): EXITED + autorestart -> spawn() at once. BACKOFF and FATAL are never reached. +Consequence: a group-writable /etc/logrotate.d/mail makes the program respawn about once a second forever (supervisord's 1 s loop), each run logging the refusal; the Status page shows it flapping RUNNING/EXITED, not BACKOFF; nothing rotates. +Fix (recommended): `startsecs=1` for [program:logrotate], so a refusal (immediate exit) is "exited too quickly" -> BACKOFF -> FATAL after startretries; and teach crashexit.py to ignore processname:logrotate (it is not on the mail path) so FATAL shows on Status instead of killing the container. Check: e2e TestImageBuildPreservesLogrotateMode sibling - chmod g+w the config in a running stand, restart the program, expect FATAL and the container still up. + +**V6** postfix-wrapper.sh:8-10, supervisord.conf:8-10: a milter-socket timeout "exits non-zero ... so supervisord/Docker see an explicit startup failure"; FATAL -> crashexit. +Source: process.py finish(): after a 30 s wait the default startsecs=1 has long passed -> RUNNING -> EXITED (unexpected), autorestart=true -> respawn at once. FATAL only for three exits within startsecs. Docker sees nothing. +Consequence: if the panel never opens its sockets, the container stays "up" forever without Postfix (only the healthcheck turns unhealthy; Docker does not restart unhealthy containers on its own) - the opposite of what both comments promise. +Fix: `startsecs=` above MILTER_WAIT_TIMEOUT for [program:postfix] (e.g. 40): a timeout exit then counts as "too quickly" -> BACKOFF -> FATAL after 3 -> crashexit -> container exits -> restart policy. Check: e2e with the panel's sender socket path pointed elsewhere (env), expect the container to exit within ~4 x 30 s. + +**V10** process_check.go:9-12, 42-44: "postfix-reload ... its healthy resting state is STOPPED". +Source: process.py finish(): after any run the state is EXITED (expected), not STOPPED; STOPPED holds only until the first start. +Consequence: the check passes only because it runs before anything asks for a reload; called after one (a later re-check) it would fail on a healthy container. +Fix: accept STOPPED or EXITED for postfix-reload. Check: run checkSupervisorProcesses after a panel action that reloads Postfix. + +**V8 (failing part)**: "Docker's restart policy can recreate it" - Docker restarts the same container. Fix: wording in supervisord.conf header and crashexit.py docstring. + +**D1** Dockerfile:33, 41-53: the versions every citation names are what the image runs. +Source: `FROM debian:bookworm-slim` (a moving tag) and `apt-get install` without versions take the current point release; nothing records or checks the installed versions. +Consequence: after a Debian point/security update (e.g. a postfix 3.7.x-0+deb12uN), a rebuild silently runs a version the citations were not read against. +Fix (recommended): a release-gate check, not a pin (pinned Debian versions vanish from the mirrors): the e2e stand runs `dpkg-query -W -f '${Package} ${Version}\n' postfix opendkim libsasl2-2 supervisor logrotate` and compares with the versions development.md names; a difference fails the gate and sends someone to re-read. Check: that e2e test itself. + +**K8** compose.override.yml:24-27: "compose build.context paths follow the project directory, unlike bind-mount volume paths". +Source: docs.docker.com/compose/how-tos/multiple-compose-files/merge/ "Paths are evaluated relative to the base file"; with --project-directory both resolve against it (the same file's ./dns-stage and ./mail-stage volumes land in .stage). +Consequence: none (comment only); it contradicts the deploy fragments' (true) statement. +Fix: "build.context and volume paths both resolve against --project-directory (.stage)". + +**K9** stage.go:57-59 "compose reads neither"; stack.go:65-69 "straight from the process environment". +Source: docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/: without --env-file Compose reads .env from the project directory (--project-directory first); shell variables take precedence over it. +Consequence: none - the .env written to .stage holds the same values, and the shell's SELFPOST_HOSTNAME wins anyway. +Fix: "compose reads .env from --project-directory too; the shell value set here takes precedence". + +**R4 (failing part)**: multimap.lua `from` default: envelope sender, MIME From: when the envelope is empty. A bounce (<>) whose From: header is on the deny list is refused, and one whose From: is on the allow list is weighted -100, while the journal records sender "" - the Filter lists page cannot attribute it (Usage matches the envelope). Fix: `extract_from = "smtp";` on the four from-rules in multimap.conf. Check: dev trial in a throwaway rspamd 3.11.1 container (`rspamc --from '' --header 'From: x@deny.example'`) expecting no SELFPOST_ symbol. + +**R7** milter_headers.conf:5-8 "rspamd removes headers of these names that a sender wrote himself before it adds its own." +Source: milter_headers.lua (3.11.1): settings skip_local = true, skip_authenticated = true; skip_wanted() returns true when `task:get_ip():is_local()` (lua_ip.c lua_ip_is_local(): loopback, or within local_addrs - conf/options.inc `local_addrs = [192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12, fd00::/8, 169.254.0.0/16, fe80::/10]`) or the task has a user; then routines x-spamd-result and x-rspamd-action return before setting `remove[...]` - nothing added, nothing removed. The custom routine selfpost-signature has no such skip and always signs. +Consequence: a client whose address is in a private range - any LAN host, or every client if Docker's proxy shows the bridge gateway 172.x.0.1 (K5) - can write its own "X-Spamd-Result: default: False [-5.00 / 15.00]" and "X-Rspamd-Action: no action"; rspamd leaves them, adds a valid X-SelfPost-Filter signature, and the inbound journal records the sender's verdict as rspamd's - the very forgery the signature was introduced to stop. +Fix: in milter_headers.conf set `skip_local = false;` and `skip_authenticated = false;` (documented module options). Check: dev trial in a throwaway rspamd 3.11.1 container: scan a message with a forged X-Spamd-Result from IP 10.0.0.5 (`rspamc --ip 10.0.0.5 --mime`), expect the forged header removed and rspamd's own added. + +**R12 (failing part)**: `rspamd/rspamd:3.11` floats across 3.11.x. Fix: `image: rspamd/rspamd:3.11.1` (or @sha256 digest), and say "pinned" only then. + +**M7 (failing part)**: parse.go `if out.PolicyPct == 0 { out.PolicyPct = 100 }` also turns an explicit `0` (RFC 7489 § 6.3: "between 0 and 100, inclusive") into 100. Consequence: a domain published with pct=0 (monitoring-only rollout) shows as 100 %. Fix: Pct as *int in feedbackXML (nil -> 100). Check: parse_test with `0` expects 0, without `` expects 100. + +**Q4** backup.go:271-276: symlinks skipped silently. +Source: addTree() skips non-regular files. eff-certbot docs "Where are my certificates?": "point your (web) server configuration directly to those files (or create symlinks)"; "--force-renewal": "symbolic links (the "live" reference) will be updated" - live/ entries are symlinks. +Consequence: an operator who follows certbot's own advice and makes certs/ a set of symlinks gets a full backup with no certificate and no warning; after a restore TLS on 465 is dead (P5). +Fix: in addDeployFiles/addTree for certs/, follow a symlink whose target is a regular file (archive the content under the link's name), or at least OnWarn("certs/ is a symlink and was not archived"). Check: backup_test with certs/fullchain.pem a symlink - the archive holds the PEM (or the warning is emitted). + +**A1** apache vhost:9-14, deploy/nginx/docker-compose.nginx.yml:35: bind-mounting certbot's `live/` gives Postfix the PEM files. +Source: certbot docs: live/ holds symbolic links to the current files ("--force-renewal": "symbolic links (the "live" reference) will be updated to point to the new certificate"); they point into archive/, which is not mounted. Inside the container the links dangle: postfix-config.sh's `[ -f "$TLS_CERT_SRC" ]` is false, no copy is made, smtpd_tls_cert_file points at a dangling path -> "Wrapper-mode request dropped ... TLS context initialization failed" (P5) on every 465 connection. +Consequence: an operator following the Apache or nginx scenario as written gets a relay with no working TLS on 465 from the first start. +Fix: mount the whole letsencrypt directory read-only (`/etc/letsencrypt:/etc/letsencrypt:ro`, nginx: `./nginx/certbot-etc:/etc/letsencrypt:ro`) and set TLS_CERT_FILE/TLS_KEY_FILE to `/etc/letsencrypt/live//{fullchain,privkey}.pem`; fix the vhost comment. Check: e2e stand variant with a certbot-shaped tree (live//*.pem symlinks into archive//*1.pem), mount the parent, expect a TLS greeting on 465. + +**E2** dns/Corefile:5-9, dns_zone.go:39-42: reload "polls db.zone's mtime/serial"; "CoreDNS's `reload 2s` ... picks up the new mtime". +Source: CoreDNS v1.14.6 plugin/file/reload.go Reload(): without reload_by_mtime it re-parses with the current serial, and file.go Parse() returns serialErr "no change in SOA serial" when the serial is unchanged - reload is by SOA serial only. coredns.io/plugins/file: reload "if the SOA version changes"; default one minute. The Corefile has `reload 1s`, not 2s. writeZone sets the serial to time.Now().Unix(). +Consequence: two writeZone calls within one second keep the serial; CoreDNS never serves the second (publishTXT's poll fails after its timeout). Today the writes happen to be seconds apart. +Fix: writeZone keeps a strictly increasing serial (max(last+1, now)); comments say "SOA serial" and "reload 1s". Check: a unit test in test/e2e - two writeZone calls in a row yield increasing serials (parse the SOA line). + +### DOES NOT HOLD +P4, P31, P34, P42, S3, V5, V6, V10, D1, K8, K9, R14 (inbound.go, not mine), R15 (fixture comment), Q4, A1, E2. + +### PARTLY (the failing part needs a fix) +P8, P11 (comment corrected), P23, V8, V11, R4, R5, R7 (security-relevant), R12, M7, Q3, E5, E6, E10. + +### NEEDS A MEASUREMENT +K5 (client address through Docker's proxy, IPv4 and IPv6 - live host), M8 (largest record count in received reports - live DB), A3 (certbot --apache on the shipped vhost - throwaway host), X1 (x509 -pubkey vs pkey -pubout for an ECDSA P-256 pair - on the stand's OpenSSL 3.0). + +## Answers, Part D sections 1-5 (store, htmx, browsers, html/template, Postfix claims in help/health text) + +Sources read: modernc.org/sqlite v1.60.1 in the module cache (driver.go, conn.go, sqlite.go, stmt.go, tx.go, error.go, +result.go) and its bundled SQLite 3.53.4 (lib/, transpiled C with the C comments kept; SQLITE_VERSION "3.53.4"); +sqlite.org pages fetched 2026-10-14 (lang_transaction, wal, pragma, foreignkeys, datatype3, lang_upsert, lang_altertable, +lang_datefunc, lang_corefunc, lang_expr, lang_select, partialindex, rescode, fileformat2, c3ref/changes, +last_insert_rowid, busy_handler, prepare); htmx v2.0.4 src/htmx.js (github tag v2.0.4); Fetch Standard, W3C Fetch +Metadata, CSP Level 3, HTML Living Standard (multipage), W3C Clipboard API, RFC 6265, draft-ietf-httpbis-rfc6265bis +(editor's copy of 2026-10-14), RFC 6797, RFC 9111, MDN browser-compat-data 6.0.0; Go html/template, text/template, +net/http fs.go of release-branch.go1.26 (identical to the local 1.27 for the functions cited); Postfix 3.7.11 and Cyrus +SASL 2.1.28 in dev/upstream; rspamd 3.11.1 (conf/options.inc, src/libserver/cfg_utils.cxx, src/libserver/maps/map.c). + +Code changes are comments only: no behaviour changed. Migrations were not touched (docs/schema-migrations.md: a shipped +migration is never edited), so the citations for what they rely on sit at store.go migrate(). help.go and every visible +wording were left alone. + +### 1. SQLite / modernc.org/sqlite + +D-S1: HOLDS - modernc v1.60.1 conn.go newConn() -> sqlite.go applyQueryParams() (the _pragma list, busy_timeout sorted +first, on every new connection); foreignkeys.html § 2 ("must be enabled separately for each database connection"); +wal.html § 3.3 (WAL is persistent) - citation at store.go Open (DSN). + +D-S2: PARTLY - the conclusion holds (one pooled connection: database/sql serialises this process, so no in-process +SQLITE_BUSY), the premise does not: modernc does not serialise writes across connections (conn.go: `sync.Mutex` is +embedded per conn; nothing process-wide). Between processes it is SQLite's single write transaction +(lang_transaction.html § 2.1) plus busy_timeout. - Comment at store.go SetMaxOpenConns rewritten to the true reason, with +the citations. + +D-S3: DOES NOT HOLD - lang_transaction.html § 2.2: "DEFERRED means that the transaction does not actually start until the +database is first accessed"; modernc tx.go newTx(): plain "begin" unless `_txlock` is set. migrate() reads user_version +with s.db.QueryRow outside the transaction. Concrete case: after an image upgrade the panel starts and, at the same +moment, Postfix pipes a DMARC report to `panel dmarc-ingest`; both read user_version = 8, both Begin; the first migration +statement of one waits for the other's write lock (busy_timeout), then runs 0009 again on the migrated file: "duplicate +column name: refusal_line" -> Open fails (the ingest exits, Postfix retries; or the panel start fails, supervisord +restarts). Nothing is corrupted (the failing transaction rolls back), but "each migration runs at most once" is not what +the code guarantees - it is "at most once successfully". No citation written. Fix (~15 lines): add `_txlock=immediate` to +the DSN (modernc sqlite.go: sets conn.beginMode, tx.go then issues "begin immediate"; a BEGIN IMMEDIATE waits through the +busy handler because the connection holds no transaction yet - btree.c btreeBeginTrans()), and in migrate() re-read +user_version inside each migration's transaction and skip (commit nothing) when it is already >= target. Test: create a +WAL file, hold `BEGIN IMMEDIATE` on a raw *sql.DB, start two goroutines calling store.Open on the path, let both read +user_version, release the lock; today one Open fails with "already exists"/"duplicate column", after the fix both succeed +and user_version == len(migrations). `_txlock=immediate` also makes D-S11 structural. + +D-S4: HOLDS - modernc v1.60.1 stmt.go stmt.exec(): "FALLBACK PATH: Multi-statement script" loops prepareV2() over the +tail and steps every statement - citation at store.go migrate(). + +D-S5: HOLDS - SQLite 3.53.4 pragma.c sqlite3Pragma(), case PragTyp_HEADER_VALUE: the value is the literal's text +(sqlite3Atoi(zRight)), written by the setCookie program = OP_Transaction (write) + OP_SetCookie, so it is part of the +write transaction and a rollback undoes it - citation at store.go (the PRAGMA user_version exec). + +D-S6: HOLDS - fileformat2.html § 1.3.15 (application id at offset 68); btree.c newDatabase(): memset(data+24, 0, 76), so a +file reads 0 until it is set - citation at store.go migrate(). + +D-S7: NEEDS A MEASUREMENT - busy_timeout semantics are documented (c3ref/busy_timeout), but whether 5000 ms outlasts the +longest write is a property of this data. Measure on the stand, on a database filled to the retention limit (90 days at +the highest traffic the owner expects): the duration of (a) migration 0008's UPDATE of all of send_log (one-off), +(b) DeleteSendLogBefore after a long gap, (c) PruneSendLogLines, (d) InsertSendLogLines with one pass's worth of lines, +(e) the backup tool's read/VACUUM INTO if it writes; and whether dmarc-ingest ever logs SQLITE_BUSY while the panel runs +(c). Anything above ~2 s is the signal to raise the timeout or batch the delete. + +D-S8: HOLDS - lang_transaction.html § 2.1: a write transaction is started by INSERT/UPDATE/DELETE, and there is only one at +a time; the INSERT ... SELECT ... WHERE NOT EXISTS is one statement, so its read happens inside the write transaction +(in WAL, on the newest snapshot). c3ref/changes.html: an insert of no row sets changes() to 0. - citations at +users.go CreateGlobalUser (canonical), sendlog.go AttachRefusalLine and RecoverRefusal, inboundspam.go +InsertInboundSpamOnce, sendloglines.go (`once`). In this process SetMaxOpenConns(1) serialises the two requests anyway. + +D-S9: HOLDS - lang_transaction.html § 2.2 ("If the first statement after BEGIN DEFERRED is a write statement, then a write +transaction is started") + § 2.1 (one at a time); a write transaction begun with no earlier read in it reads the newest +snapshot - citation at users.go SaveUser (merged into "A write first"), DeleteUser points at it. +(users_tx_test exercises database/sql's pool, as the inventory says; the SQLite property is what the citation covers.) + +D-S10: HOLDS (and is stronger than the comment says) - SQLite 3.53.4 btree.c btreeBeginTrans(): the busy-handler retry +loop runs only while `pBt->inTransaction == TRANS_NONE`, so upgrading a read transaction returns SQLITE_BUSY at once, +not only after the other connection committed (SQLITE_BUSY_SNAPSHOT, rescode.html#busy_snapshot) but also while it merely +holds the write lock; c3ref/busy_handler.html says the handler is skipped where it could deadlock. - citation at +sendlog.go UpdateStatus. + +D-S11: HOLDS - every multi-statement transaction in internal/store opens with a write: applications.go AddApplication +(INSERT), UpdateApplication (UPDATE); inbound.go x2; dmarc.go (DELETE); users.go AddUser, SaveUser, DeleteUser, +setUserReach/writeUserReach (UPDATE); sendlog.go UpdateStatus; sendercheck.go (INSERT); sendloglines.go (Prepare, then +INSERT - prepare.c sqlite3InitOne() commits the read transaction it opens to load the schema, so Prepare leaves none +open). - a one-line "The first statement is a write (see UpdateStatus)." at each Begin, the rule stated once at +UpdateStatus. (Structural option: `_txlock=immediate`, see D-S3.) + +D-S12: HOLDS - modernc builds SQLite with SQLITE_DEFAULT_SYNCHRONOUS = SQLITE_DEFAULT_WAL_SYNCHRONOUS = 2 (lib/, FULL) and +the DSN sets no synchronous; wal.html § 2.3: "Writers sync the WAL on every transaction commit if PRAGMA synchronous is +set to FULL" - citation at sendloglines.go InsertSendLogLines. + +D-S13: HOLDS - wal.html § 2.2 / § 1: "readers do not block writers and a writer does not block readers". Within the +panel the read still waits for the single pooled connection - citation at sessions.go LookupSession, with that note. + +D-S14: HOLDS - foreignkeys.html § 2 (per connection) and § 4.3 (CASCADE, SET NULL), with foreign_keys ON on every +connection (D-S1) - citation at domains.go DeleteDomain; applications.go DeleteApplication and dmarc.go +InsertDMARCReport point at it. (Migrations 0001/0008 not edited.) + +D-S15: HOLDS - foreignkeys.html § 4.2: "Foreign key constraints are immediate by default"; a violation of an immediate +constraint fails the statement - citation at users.go writeUserReach. + +D-S16: HOLDS - lang_altertable.html § 4: "If foreign key constraints are enabled and a column with a REFERENCES clause is +added, the column must have a default value of NULL"; pragma.html#pragma_foreign_keys: "This pragma is a no-op within a +transaction" (so a migration cannot switch FKs off) - citation at store.go migrate(). + +D-S17: HOLDS - lang_altertable.html § 8: the only direct schema changes are rename table/column, add/drop column; anything +else is the rebuild procedure - citation at store.go migrate(). + +D-S18: HOLDS for the migration's claim - Postfix 3.7.11 src/smtpd/smtpd_check.c:3913 reject_auth_sender_login_mismatch(), +:3936 strcasecmp_utf8(state->sasl_username, name). NOCASE (ASCII) and strcasecmp_utf8 agree because logins are ASCII +only. development.md's example was already corrected in 811055d. - citation at applications.go ErrLoginExists. + +D-S19: HOLDS - datatype3.html § 7: "NOCASE ... only ASCII characters are case folded" - citation at domains.go +DomainExists (names validated ASCII by validate.Domain) and sendlog.go AttachRefusalLine. For to_addr/from_addr see S20. + +D-S20: DOES NOT HOLD (the hedge) - Postfix 3.7.11 does not change the case of a recipient between RCPT TO and the delivery +line: smtpd.c rcpt_cmd() passes the client's argument to the milter (milter_argv(), :2499-2518, :3088), the queue file +holds that address after cleanup's rewriting, and no code on the way lowercases it (grep lowercase/casefold over +cleanup/, trivial-rewrite/, qmgr/: the only hit is qmgr_message.c:1309, which lowercases the *queue name* used for +scheduling, not the recipient); global/log_adhoc.c:111 logs `to=<%s>` from recipient->address through +info_log_addr_form_recipient(), which only quotes (quote_822_local) and never folds case. log_adhoc.c:114 even compares +address and orig_addr with strcasecmp_utf8 - Postfix treats them as one, but prints the address as stored. The journal +milter (internal/milter/milter.go cleanAddress) keeps the case too. So the row and the line carry the same text. Consequence +of matching NOCASE anyway: for one message to "User@x" and "user@x" (distinct mailboxes by RFC 5321 § 2.4), both delivery +lines update the newest of the two rows, and the other stays "queued" until the reconcile sweep closes it as bounced on a +guess. Rare, but a wrong result. No citation written. Fix: replace "Postfix may normalise address case ..." with the source +answer above and match the recipient exactly (drop COLLATE NOCASE in UpdateStatus' `newest`, and the EqualFold in +internal/logtail/logtail.go:321, which belongs to another agent). Test: a queue id with recipients "User@x" and "user@x", +one delivery line each -> each row gets its own status. If the owner prefers to keep NOCASE as harmless, the comment must +still lose the "may". + +D-S21: HOLDS - Cyrus SASL 2.1.28 lib/canonusr.c _canonuser_internal() only trims whitespace and appends the realm; +sasldb/allockey.c _sasl_alloc_key() copies the identity byte for byte into the user\0realm\0prop key; db_berkeley.c opens a +DB_HASH (exact keys). So "OPS" cannot authenticate as "ops", and the login reaches {auth_authen} and the log in its stored +case - citation at sendlog.go loginDomainID (used by stats.go and notify.go too). + +D-S22: HOLDS - datatype3.html § 7 ("BINARY - Compares string data using memcmp()"); every writer formats UTC with +time.RFC3339 (checked all Format( calls in internal/store: the non-UTC-looking ones all take `now := time.Now().UTC()`; +sendercheck.go's fractional stamp is compared only with itself; dayLayout is fixed-width) and no migration uses +datetime()/CURRENT_TIMESTAMP - citation at sendlog.go ListQueuedOlderThan, pointed to from DeleteSendLogBefore and +sessions.go DeleteExpiredSessions. + +D-S23: HOLDS - lang_datefunc.html § 2 "Time Values": formats 5-7 take the literal "T", and formats 2-10 "may be optionally +followed by a timezone indicator of the form [+-]HH:MM or just Z" - citation at sendlog.go AttachRefusalLine. (A test that +tells nearest-by-julianday from fallback-by-id is still the evidence the inventory asks for.) + +D-S24: HOLDS - lang_corefunc.html#substr: "begins with the Y-th character ... The left-most character of X is number 1 ... +If X is a string then indices count UTF codepoints" - citation at stats.go sendStats. + +D-S25: HOLDS for today's callers - same page: substr counts codepoints of TEXT; len() counts bytes; equal for "dns:" and +"limit:" - citation at notify.go ListNotifyStateKeys. + +D-S26: HOLDS - lang_upsert.html § 2: excluded. is the would-be inserted value; "The only use for the WHERE clause at the +end of the DO UPDATE is to optionally change the DO UPDATE into a no-op"; all upserts use VALUES (no § 2.2 parsing +ambiguity) - citations at sendercheck.go (the WHERE one) and settings.go SetSetting. + +D-S27: HOLDS - c3ref/changes.html ("Only changes made directly by the INSERT, UPDATE or DELETE statement are considered - +auxiliary changes caused by triggers, foreign key actions or REPLACE constraint resolution are not counted"); for an +UPDATE that changes no value: update.c sqlite3Update() writes every row its WHERE finds through +sqlite3CompleteInsertion(), which sets OPFLAG_NCHANGE (insert.c), so it is counted; modernc result.go newResult() takes +sqlite3_changes - citations at users.go SaveUser / DeleteUser / CreateGlobalUser, domains.go DeleteDomain. + +D-S28: HOLDS - c3ref/last_insert_rowid.html (per connection, the most recent successful INSERT); modernc conn.go +lastInsertID() = sqlite3_last_insert_rowid - citation at applications.go AddApplication. + +D-S29: HOLDS - modernc conn.go newConn(): c.extendedResultCodes(true) on every connection; error.go Error.Code() returns +the rc of the step, i.e. the extended code; rescode.html: SQLITE_CONSTRAINT_FOREIGNKEY 787, _PRIMARYKEY 1555, _UNIQUE 2067 +(a unique index, NOCASE included, fails as UNIQUE) - citation at domains.go isForeignKeyViolation/isUniqueViolation. + +D-S30: HOLDS - lang_expr.html § 4: a plain "?" gets "a number one greater than the largest parameter number already +assigned"; modernc conn.go bind() + argFinder.find(): ?NNN binds argument NNN, a plain ? the argument of its index - +citation at stats.go appByLogin (sendlog.go AttachRefusalLine mentions ?1 reused). + +D-S31: HOLDS - lang_select.html § 5: "If the LIMIT expression evaluates to a negative value, then there is no upper bound" +- citation at inboundspam.go PruneInboundSpam. + +D-S32: PARTLY - holds: the literal makes the partial index usable at the first prepare (partialindex.html § 3: the query's +term must match the index's WHERE term exactly) - citation at notify.go LimitRefusalsSince. Does not hold: migration +0005's implication that a bound `status = ?` would not let SQLite use it. SQLite 3.53.4 expr.c sqlite3ExprCompare(): +"If pParse is not NULL and SQLITE_EnableQPSG is off then TK_VARIABLE terms in pA with bindings in pParse->pReprepare can +be matched against literals in pB"; where.c whereUsablePartialIndex() passes pParse; exprCompareVariable() sets the +variable's expmask bit, so binding it expires the statement and c3ref/prepare.html: "the statement will be automatically +recompiled ... on the first sqlite3_step() call following any change to the bindings" - with the value 'rejected' the +re-prepared plan uses the index. Consequence: none for behaviour (the literal is the cheaper form - no re-prepare per +call); only the migration's sentence overstates. Fix: none in code (the migration is never edited); the notify.go comment +now states only what holds. An EXPLAIN QUERY PLAN test over the real query string (audit stage 3) is what would pin it. + +### 2. htmx 2.0.4 + +D-H1: HOLDS - src/htmx.js, the document ready handler runs mergeMetaConfig() then insertIndicatorStyles(), which inserts +the