The owner, 2026-10-16: "Да, бери notification-mail-recheck, потом режь
2.9.0."
Server › Settings gets Re-check DNS in its page head, as an outbound
domain's page has it beside its own DNS records box; the Notification
mail box's end slot says when the records were checked ("The server's
own name · checked 3 min ago"), as the domain's DNS box does. The help's
Notifications topic says Settings shows whether the records are in place
and re-checks them. The button sits in the page head rather than in the
box head: box heads hold a note, a tag, a link or a filter form, and the
page head is where the domain page and Health put the same action.
outlines.json: settings "head" -> "head+actions". check.py: 0 violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner, 2026-10-16, on the pictures of both layouts with the records
missing and right: "Вариант 2: форма в левой колонке, кнопка под своими
полями."
Server › Settings: the form holds only the left column - 01 Log
retention, 02 Notifications, Save settings under them - so the button
sits under the fields it saves whatever the records' state. The right
column, outside the form, holds what is not a field: 03 Notification
mail (the records of the server's own name) and 04 Sending rate limits
(read-only). The boxes are numbered column by column, as before.
outlines.json rewritten for settings (check.py --write): the form moved
from around the columns into the first one; nothing else changed.
check.py: 0 violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A record DNS already holds as shown is a <details> whose summary is the
record's head: its name, its tag and "Show values" (ti-eye - the icon
subset has no chevron); the host, type and value open behind it. The
head's note is hidden while folded, so the fold stays one line in a half
column (the server's SPF on Settings); it shows when opened. Missing,
weak, mismatched and unchecked records stay open as before.
Mockups: the kit's dns_record example, out-domain (DKIM and DMARC
folded, SPF open on its mismatch), in-domain (MX), settings (DKIM
folded, SPF missing and open); the help's DNS topic says so. Outlines
unchanged (the block keeps sp-record). check.py: 0 violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner's decision (2026-10-16, security review of panel-dkim-signing,
F1): a Sender: line, shown beside From: as "on behalf of", is held to the
same rule when a message has one. The topic says how a system that writes
for people outside the server's domains does it instead (its own From:, the
person in Reply-To:), and the Outbound log topic names Sender: among the
refusals.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Health: the opendkim process row goes; the Milter sockets card shows the
panel's signer on its two sockets, dkim-signer · applications (sign.sock)
and · notifications (sign-local.sock) - Q3 of the plan; Configuration
says it reloads Postfix. Domain delete: "stop signing mail as the
domain" for the OpenDKIM reload. System log: mail.log is Postfix's log
(OpenDKIM never wrote to it), its sample line goes. Message page: the
sample's OpenDKIM line becomes a qmgr line. Help, sender check: the
From: line is read as a mail program reads it and must be one address;
DMARC holds the signature to it; the sentence the mockup carried twice
is once. Overview: 5 of 5 sockets.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The private key in certs/ is given to group 10998 on the host, no longer
998: in the trixie image systemd's packages take ids in 100-999, and 998
is systemd-network on the test server's host. The page's help.go follows
in the base-trixie commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since stage 4 the button also writes the spam filter's lists and the DMARC
maps (Postfix maps), and removes the keys of deleted domains; the box said
OpenDKIM and Postfix only (review of audit stage 4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a phone the stylesheet ordered a cards box by its place in its stack,
four to a stack; a fifth box got no rule and landed out of place with
nothing failing (owner, 2026-10-13). Each box of the layout carries its
number as data-order, and theme.css orders by it, up to twelve: an
attribute, since check.py refuses classes the pages do not use, and not a
style, which the CSP forbids. Health and the kit's cards carry it; the look
is the same.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The status list is the statuses of a row - queued, sent, deferred, bounced,
rejected, the last the three kinds of refusal together (owner, 2026-10-13).
The mockup's own list was wrong: it offered rate-limited, a reason, and had
neither queued nor rejected. And the days, from and to, the Inbound log's
control.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
out-log drew the rate-limited tag grey beside wrong From and bounced in red
(audit 2026-10-12, L18); it is is-danger now. The kit's status table names
the red statuses as the panel does: reject and rate-limited join error,
fail, mismatch, bounced (reject was in the code's list and not here).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Audit 2026-10-12, stage 2c: the sender check now reads the From: line with
OpenDKIM's own parser, so the help topic says what that means for the
reader - only the address counts; a display name or a comment around it is
not judged; a line with more than one address is refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Audit 2026-10-12, stage 2c, "Health says what is served" - the mockup
first.
TLS certificate (assumptions A-65): Postfix serves its own copy of the
mounted file, refreshed by cert-reload once a day by default, so the card
reads that copy. When the mounted file holds a renewal not taken up yet,
two facts name it (its expiry, when it was mounted) and the paragraph says
when Postfix takes it up; the help topic says the card warns once it has
waited longer than that check allows.
Wording (D-P1, D-P3): a message refused while the panel or the spam
filter does not answer is never queued, so it is not "deferred" in
Postfix's sense - the Sender check note, the sender-check and spam filter
help topics now say "refused with a temporary error". The upstream
topic's Required: mail waits in the queue while the upstream offers no
TLS or the handshake fails, and an upstream answering STARTTLS with a
permanent error bounces it (Postfix 3.7.11, src/smtp/smtp_proto.c;
smtp_trouble.c, smtp_bulk_fail()).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Topic log: a message's lines are found by queue id - and for a message
refused before it had one, Postfix's one line about the refusal (the
review: "found by queue id" no longer holds for MAIL FROM refusals).
Topic sender-check: the ≈ sentence said "at most what Postfix was up",
which reads backwards; it is the time since the panel's last sign of life,
"which may be more than Postfix was up". build.py, check.py: 0
violations. help.go follows in the next commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner, 2026-10-14 ("Может просто показывать и то, и то и не смешивать?"):
the figure that was "Messages deferred" is two - connections Postfix turned
away while the panel did not answer (refused from the connection on, before
a message is offered), and messages deferred in the middle, when the panel
stopped answering after a message had begun (cleanup's case, counted since
the M13 fix). The card has a column for each.
The audit's stage 2b also marks a day whose downtime is an estimate - a
start at which supervisord could not say since when Postfix ran: such a
period's time is shown with ≈, and the text under the table says it is at
most what is shown.
Help: topic sender-check says what each column counts and what ≈ means;
topic log names the bounced row the panel closed itself (the sweep, when
Postfix no longer held the message and mail.log never said what became of
it). build.py, check.py: 0 violations, outlines unchanged. The code follows
in the next commits; until then the help text guard fails - not pushed in
between.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The topic was added with the Health card; its entry in the page's menu was
not. check.py: 0 violations, outlines unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Accepted by the owner on 2026-10-12 from screenshots, one change at a time.
- health: a card "Sender check" (today / 30 days / 12 months: panel not
answering, messages deferred) and a row for its socket.
- health: the boxes are numbered by importance and laid out as `cards` -
two stacks, odd numbers on the left and even on the right, each closed
up. His words on the row layout: too many holes; and on stacked columns
numbered down each side: the third box was below the fold while the
fourth was not. On a phone the boxes come in the order of their numbers.
- theme.css, components: the `cards` layout; the first pair of stacks of
the components page is laid out so.
- out-log: a row refused for its From: line, tagged "wrong From".
- help: topic "Sender check"; "rejected" in the Outbound log topic names
the three reasons.
outlines.json rewritten (check.py --write): the structure of health changed,
and help has one more topic under Outbound. check.py: 0 violations.
The pages of internal/web follow in their own commit; until then the
outline guard fails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Accepted by the owner on 2026-10-12 from screenshots, after two changes of
his: DKIM for the server's own name as well as SPF ("we need the best
deliverability for these messages"), and the records themselves under
Settings with only their state on Health.
- account: a checkbox "Send me notifications" in the Profile box, under the
e-mail it writes to. No box of its own, no choice per event.
- settings: box 03 Notifications with the instance switch; box 04
Notification mail with the two DNS records of the server's own name, DKIM
and SPF, drawn with the record component of a domain's DNS page. Sending
rate limits move to the left column as box 02.
- health: card 06 Notification mail - the state of each record and a link to
Settings - beside Configuration, now 07, each half the width.
- help: a Notifications topic (what is sent, when, to whom, what makes it
arrive); Settings and Your account each gain a clause.
outlines.json rewritten with check.py --write: the structure of settings and
health changed. build.py: 35 pages; check.py: 32 screens, 52 sp- classes,
0 violations. The plan records both of his changes; step 1 of 10.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The topic said a message's lines are found in mail.log by queue id and that an
old message may show none, because that file is rotated. With
delivery-log-storage the lines are kept with the row: they stay on the page
for as long as the row does and go with it. A message accepted before 2.2.0
shows them only while they are in the current file.
Mockup source and its built page only; help.go follows with the page.
build.py: 35 pages; check.py: 32 screens, 52 sp- classes, 0 violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the security review of 2026-10-12: the allow list matches the
envelope sender, which nobody authenticates. Help's Filter lists topic
says so and points to client addresses and networks.
check.py: 32 screens, 0 violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner, 2026-10-12, on Inbound > Filter lists as built: "Note хочется
вынести в лист, подсказку, раскрывающийся блок. Тип в отдельную колонку.
Ещё полезно будет дать статистику использования правила, чтобы удалять
устаревшие." Drawn, then accepted ("Да") together with counting the
matches from the Inbound log.
- Kind is a column of its own; the tag no longer wraps under the
value.
- Note and Added leave the table for the entry's sheet, opened by the
kind tag (of his three - sheet, tooltip, disclosure - the sheet: it
exists and works under a finger).
- Matched: how often the entry decided a message, or "never". The
sheet adds the window ("in the last 90 days") and the last match.
A fifth column for the last match did not fit half the page width.
Help, Filter lists: what Matched counts and what "never" suggests.
check.py: 32 screens, 0 violations; outlines.json unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sample sheet on the components page still held facts; it shows the
table of name and value the accepted sheet is. In in-log a refused
message's sheet has no Subject row - there is no subject to show whole.
check.py: 32 screens, 0 violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner, 2026-10-11/12, on the Inbound log as built:
- "Why сделать не столбцом, а листом поверх." Opened by the decision
tag ("Тег."), drawn first, then: its content as a table ("В лист
может быть таблицей?" - "Да. Но хорошо бы ещё её отцентрировать."),
with a narrow column of names and only what the row does not show.
- "Почему Deny у того, кто rejected?" - "Убирай": a message Postfix
itself refused offers no Deny sender. Accepted mail offers Deny,
mail the filter refused offers Allow.
A new component, sheet (theme.css, shown on the components page next to
status_tag): the browser's own popover, so no script - the tag is a
button with popovertarget; Esc, a click outside and the cross close it.
It rises from the bottom edge across the window; its content is a table
of name and value in a 44rem column in the middle. A tag that opens a
sheet carries a dotted underline to tell it from a plain tag.
in-log: the Why column is gone and rows are one height. A sheet holds
who decided, the verdict and the symbols (or the reply), and the
subject whole.
Guard: check.py WIDTH_OK gains ".sp-sheet" - the one component laid out
by the window and not by the page. The owner accepted the drawing with
that said.
check.py: 32 screens, 52 sp- classes, 0 violations; outlines.json
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner, 2026-10-11, of "From · client": "Client в названии столбца
зачем? Мне кажется, оно лишним." The client address stays under the
sender, in mono and muted; an address of that shape needs no label.
check.py: 32 screens, 0 violations; outlines.json unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Guards and mockups only, ahead of the pages (inbound-antispam-panel
6/12). The owner agreed the feature ("Следующая по дорожной карте -
inbound-antispam-panel" / "Да", 2026-10-11) and started this step
("Начинай"), so the two screens are no longer "drawn but not built":
- guard_panel_test.go: in-log and in-filter-lists removed from
deferredScreens. From here TestLegacyRatchetMatchesTheEngine wants
the engine to have both pages - red until the next commit.
- guard_routes_test.go: the four routes join routes.inbound, so they
must exist with the relay on and answer 404 with it off.
Two mockup details brought to what the kit can draw, found when the
pages were built from it:
- help: the two new topics are one paragraph with a break in it, as
every topic is (panel.css has no gap between paragraphs of a topic);
- in-filter-lists: the directory in "Files" is mono at the text size -
the kit's inline code has no small variant.
check.py: 32 screens, 0 violations; outlines.json unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mockup-first step of inbound-antispam-panel 6/12. The two screens were
drawn for the redesign before the feature existed; this brings them to
what steps 3-5 built.
in-log: the lead says where rows come from and how long they are kept;
a help link in the box head; Why is the engine and the detail as the
journal holds them (rspamd: action, score / threshold, weighted
symbols; postfix and a refusing filter: the reply text). No row
invents what the journal cannot hold: the "selfpost - upstream timed
out" tempfail and the Review link to a quarantine page that does not
exist are gone, a refused message has no subject, an allow-listed one
shows SELFPOST_ALLOW_FROM(-100.00).
in-filter-lists: Remove is a POST - the kit's row action, not a link;
a domain entry is shown as stored, without "@"; "Where the lists go"
names the directory and its six files instead of allow.map / deny.map,
says rspamd rereads them by itself and that Deny wins.
settings: "Keep inbound log rows, days" in the Log retention box.
help: two topics under Inbound, Inbound log and Filter lists, and a
sentence in Settings.
outlines.json rewritten: Help's Inbound box has six topics, was four.
check.py: 32 screens, 0 violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner, 2026-10-11: the key is needed and the panel's archive must be the console's. The box no longer says a certificate file may be left out; Help says the key has to be readable by the panel (group 998 on the host) and that the panel refuses, naming the file, until it is.
check.py: 0 violations; outlines.json unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner, 2026-10-11: the user has to be told why. The box gives the reason in a sentence - the queue cannot be copied reliably while the server is running; the Help topic explains it (Postfix keeps moving messages, a copy could hold one twice or miss it), what follows (waiting mail is not carried over, a restored server starts with an empty queue) and what to do to keep the queue (stop the container, archive the project directory).
check.py: 0 violations; outlines.json unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner, 2026-10-11: the queue is left out of the command's archive as well as the panel's. The Help topic no longer says selfpost-backup takes it; the command still takes the private key the panel cannot read.
check.py: 0 violations; outlines.json unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner, 2026-10-11: the archive made from the panel comes without the Postfix queue (variant 1). The Full backup box and the Help topic say so, and name the other thing the panel may not read - a certificate file, usually the private key - and the command that takes both.
check.py: 0 violations; outlines.json unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The retention cap is counted per domain (the security commit after this one); the Ingest box says so: 500 reports a domain or 90 days.
check.py: 0 violations; outlines.json unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner, 2026-10-11, after looking at the test server: the anchors work but mean little, Health is short and there is nothing to scroll past. The five cards (Machine, Processes, TLS certificate, Milter sockets, Reverse DNS) lose their #machine ... #hostname, as the links into Domain settings lost theirs.
check.py: 0 violations; outlines.json unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the test server the form dmarc-reports+<domain>@selfpost.mixfed.ru
broke over three lines in its column (2026-10-10). It takes the
supporting size the report id and rua of a report already use.
check.py: 0 violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seen by the owner on the test server (2026-10-10): following Report
address, Rate limit or Export from the domain's menu opened Domain
settings scrolled down to that box. The page is one screen and a bit, so
the anchor only pushed the title and the first row out of view. The
links (the domain menu, the DMARC record's note, the delete page, Server
> Settings) lose their #reports / #limit / #export.
check.py: 0 violations; outlines.json unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner's decision (2026-10-10): the mailbox for the server as a
whole, dmarc-reports@<hostname>, goes. Under the three-choice scheme a
domain can only choose its own hosted address, so nothing can be told
to report to the general one. The Ingest box's fact becomes "Hosted
addresses: dmarc-reports+<domain>@<hostname>".
check.py: 0 violations; outlines.json unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner's instruction (2026-10-10). panel.js sets is-hidden, beside
the hidden attribute, on a block that a control switches off - the
fields of a mode that is not chosen, the password fields of an unticked
encryption box, the domain lists of a global user - because hidden alone
does not hide an element whose stylesheet gives it a display of its own
(a box, a grid row). No screen writes the class and it draws nothing, so
the components page has nothing to show for it; the list now names
everything the panel uses.
check.py: 0 violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two decisions of the owner, 2026-10-10.
The report address. "Three choices: no reports, the SelfPost address, a
typed address. No reports by default. A button that fills the typed
address with one's own e-mail from the profile." This replaces the
2026-09-21 design in which a domain followed a named user's default: a
co-administrator of a domain was shown another user's login name, and
three explicit choices are simpler.
- src/out-domain-settings.html: the box is a select of the three
choices and the address field with a "Use my e-mail" button.
- src/out-domain.html: the DMARC record's note names the address, with
no user in it.
- src/account.html: the DMARC reports box is gone; Account is Profile
and Password, one box per column.
- src/settings.html: the lead no longer points at Account for the
report address.
- docs/plans/panel-redesign.md: Account e-mail rewritten, the Routes
row of account without /account/dmarc. docs/schema-migrations.md:
the columns that go.
Row actions. New password and Delete in a row of the Applications table
are POSTs, so each is a form with a button that looks like the links
beside it - as Sign out became one.
- src/out-domain.html: Edit / New password / Delete per row, the box
foot about confirmations is back; the login cell does not wrap.
- theme.css: .table td.sp-actions form sits inline with the links'
spacing, and its button takes the link's colour and type.
- src/components.html shows the pattern once.
check.py: 0 violations. outlines.json: only account changed (two equal
columns, one box each).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner's decision (2026-10-10): help is needed by almost every
section, not by Overview and Outbound alone. The Help mockup had six
topics while other screens already linked to topics it did not have
(#dmarc, #inbound).
- src/help.html: seventeen topics in five boxes - Overview (checks,
certificate, rdns), Outbound (dns, apps, limits, log, queue, dmarc),
Inbound (inbound, upstream, recipients, filter), Server (backup,
users, settings), Account (account) - one paragraph each, and the
side menu lists them by group.
- A box head gets the sp-help link where a topic now exists: health,
out-domain-settings, out-app, out-log, out-message, out-queue, the
three DMARC screens, in-domains, in-domain, backup, users, user,
settings. No link on boxes that explain themselves (confirmations,
the shown-once password, sign-in) and at most one per box.
- outlines.json regenerated (check.py --write): only help changed -
Outbound holds six topics, and Inbound, Server and Account are new
boxes. 0 violations.
The dns, dmarc and account topics describe the report-address scheme the
owner decided the same day (no reports / the SelfPost address / a typed
address, no user default); the account and out-domain-settings mockups
are redrawn for it in the change that follows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Event notifications are not built (panel-notifications is a roadmap
candidate), and the outline of both screens forced a box that could only
say so. The owner's decision (2026-10-10): take the boxes out of the
mockups; they are drawn again as the first step of that feature's plan.
- src/account.html: box 03 Notifications removed, DMARC reports is 03;
the lead and the e-mail help no longer promise notifications or a
reset link.
- src/settings.html: box 02 Notifications removed, Sending rate limits
is 02; the lead no longer lists notifications.
- outlines.json regenerated (check.py --write): the two boxes are gone
from account and settings, nothing else moved. 0 violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner asked for the state to be drawn (2026-10-09) instead of the
form.sp-form without action or controls that ef8a82e used to get the
column centred.
- panel/src/login-no-admin.html: the signed-out screen with a
div.sp-form in place of the form - host kicker, the heading
No administrator yet, one muted line pointing at the one-time setup
link. No new class: div.sp-form is on the components page already.
- theme.css: the signed-out column is sized through .sp-form
(.sp-signin-form .sp-form), not through the form element, so the
column does not need a form to be centred. Same declarations.
- index.html: the screen is on the map; thirty-four screens.
check.py: 0 violations; outlines.json unchanged (bare screens have no
outline).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner's instruction (2026-10-09): in the kit Sign out had no hover tint and
stood out among Account and Help. The cause was in the design, not in the
implementation: the mockups drew Sign out as a link, the panel signs out
with a POST, and Bulma tints only a.navbar-item.
- build.py: Sign out is a form with a submit button carrying navbar-item,
as the real shell has to write it; all pages rebuilt.
- theme.css: the form stretches the button across the menu and the button
is handed Bulma's own hover / focus / active variables, so it answers
like the entries above it. No width, no colour of its own.
check.py: 32 screens, 48 sp- classes, 0 violation(s); outlines.json unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review pass over `1.4.0`…`1.9.1`: port 25 no longer shares the submission milter chain, DMARC reports are validated before they are stored, and the auto rate limit is correct for a non-default level-1 window. Schema migration `0010` (index only). Upgrading is a tag bump.
Added:
- SQLite migration `0010_send_log_app_login_index.sql` —
- DMARC aggregate reports are accepted as **zip** as well as gzip or plain XML
- e2e: `checkPort25ConfigCombinations` regenerates the Postfix configuration
Changed:
- README hero line and a **Positioning** block in `docs/product.md` record the
Fixed:
- The setup account's password is hashed at bcrypt cost `12` like every other
- Login/setup rate limiter: protecting blocked buckets from eviction is now
- `postqueue -p` output is bounded to 4 MiB (the timeout alone left the size of
- [schema-migrations.md](docs/schema-migrations.md): the schema section was
- postqueue reader is now time-bounded: `postqueue -p` runs under a 5-second
… (11 more in CHANGELOG)
Changed:
- [roadmap.md](docs/roadmap.md) now carries everything the 2026-08-19 code
- Extracted `internal/configsafe` — the empty-value and forbidden-character
- Build, deploy and migration comments no longer cite the archived
- Dropped the `parseDomainRateLimitForm` / `parseAppRateLimitForm` pass-through
- Extracted shared helpers: `internal/atomicfile` (atomic temp-write+rename) and
… (7 more in CHANGELOG)
Removed:
- Unused legacy asset `docs/assets/icon.png` (superseded by the four brand SVGs
- Plan documents for already-shipped features: `domain-stats-auto-ratelimit`,
Full detail: CHANGELOG.md (1.9.2).