Change history
A running log of documentation-affecting changes, newest first. Each entry links to the section that changed (or that describes the change) so you can jump straight to it instead of re-reading the whole page.
Versioning. Sections are tagged with a date and a protocol version
(MAJOR.MINOR.PATCH):
- MAJOR — a breaking change to the SithBit public ABI (removing or
reordering an instruction variant, changing an account layout that clients
read, or repurposing an error code). Instruction enums and error codes stay
append-only and the protocol is pre-launch, so MAJOR remains
0for now. Pre-launch, breaking changes ride the MINOR digit — the cargo 0.x convention, where the leftmost non-zero digit is the one a break moves — and the entry carries the word BREAKING in its heading and states what stopped working in its first line, the way v0.7.0 did for alias-transfer consent. A break is not confined to the on-chain ABI: retiring a credential shape clients still hold breaks them just as squarely, and is tagged the same way. - MINOR — an additive public-ABI change (a new instruction, error, or
field), a significant change to the economic model that changes how end
users use the system (fees, pricing, prepayment rules), or — widened at
v0.10.0 — a significant additive capability or default-behavior change that
affects deployments (a new enforcement default, a new storage/config
backend kind). Earlier entries tagged such changes PATCH. It is also where
a breaking change lands while MAJOR is held at
0, per the rule above — the digit is shared, so the heading is what tells the two apart. - PATCH — documentation-only or otherwise non-behavioral changes.
A run of documentation-only edits no longer keeps one version across several dated sections: since v0.71.4 each such change has taken its own PATCH bump, so MAJOR.MINOR tags the protocol state while PATCH tracks the docs’ own movement and the date tags when they moved. One dated section is still shared when several changes of the same class land together on the same date.
2026-09-04 — v0.119.1 (three overnight-#224 product decisions land; an audience-focused prose pass clarifies the reader paths)
PATCH: closes the three product decisions v0.119.0
and earlier waves parked rather than answered. No on-chain ABI, instruction,
or economic-model change; campaign search gains one CLI-only flag and an
interactive terminal behavior, both additive and backward compatible.
The same docs-only section also records an audience-focused clarity pass based on the four reader roles in the market research: recipients, payers, owners, and builders. It changes no product behavior or protocol semantics.
- The entry path now serves ordinary recipients first. The welcome page leads to the GUI getting-started flow, states the alpha status up front, and summarizes postage, key ownership, and the no-token model in shorter sentences. The GUI overview now makes the command line explicitly optional, while the CLI quickstart identifies its developer and power-user audience before setup instructions.
- Trust-sensitive claims now carry their limits beside the benefit. The privacy summary distinguishes a wallet-derived mail password from an operator-readable stored password; campaign guidance distinguishes a funded mailbox from proof of a unique human; and the domain and name-marketplace introductions distinguish on-chain authority from DNS ownership and hosting. The server guide now pairs potential settlement income with volume, infrastructure-cost, and alpha caveats.
- Core explanations are easier to scan. Addresses now separates bare wallet addresses, domain-qualified addresses, aliases, and sender trust; mailbox settings use short labeled entries; and repeated client-dashboard descriptions use one item per tab. The standards overview and components tour replace universal compatibility and privacy claims with links to the exact coverage and trust boundaries.
gc_grace_secs = 0stays legal, documented as deliberate. Theipfs.cluster.gc_grace_secsrustdoc, the scaling.md GC bullet, and the sithbitd configuration reference now all say0is legal (this project’s own tests rely on it to make GC immediately observable) and thatspawn_gclogs atracing::warn!at startup when it is0. Nothing rejects or clamps the value.campaign searchpages instead of hard-capping at 100. A new--offset <N>flag (default 0) joins the existing--limit <N>(still 100, now “per page”). On a real terminal, reaching the end of a page prompts — Enter for the next page,qto quit; piped or redirected output (including the CLI integration test) is not a terminal, so it prints exactly one page and exits, matching the prior single-shot behavior byte-for-byte.quote’s andsend’s--limithelp, and the CLI reference, now say plainly that those two commands still default to the whole match set, unlikesearch.- The
no_ipfsparticipant gap is now stated, not just true. The marketplace participant section adds that a mailbox withno_ipfsset is skipped by every campaign send — it can still publish a beacon and appear in searches, but will never receive a campaign message or earn postage.
2026-09-04 — v0.119.0 (a GC grace of N now means N; the sweep counts undatable blocks apart)
MINOR: a behaviour change in the IPFS cluster’s garbage collector, no ABI or
instruction moved. gc_grace_secs = N now makes an unreferenced block
eligible for the next sweep only once its apparent age exceeds N
(the in-grace test is <=, not <), so with both clocks floored to whole
seconds a grace of N guarantees at least N and at most N + 1 real
seconds before eligibility; deletion then waits for the next sweep, up to
gc_interval_secs later. The v0.116.1 statement that a grace of N
protected only N - 1 seconds, and the v0.118.0 note that the off-by-one
was “still open”, describe the code before this change and stay as written.
Clock skew between a writer and a shared object store is still unaddressed
and still documented as such.
- Where the prose moved. The GC bullet under
IPFS: the shared-bucket cluster
and the
ipfs.cluster.gc_grace_secsrow of the sithbitd configuration reference now state the floor, that eligibility is not deletion, and that a grace of0keeps only blocks written in the current whole second (nothing rejects0— whether it should is an open product decision, not taken here). The two example TOMLs say the same in their comments. - The counter split.
GcOutcome::kept_young(blocks inside the grace, which drains as they age) no longer absorbs undatable blocks (a store that reports no write time, kept at every grace forever): those count as the newkept_undatable, and the sweep’s debug log line names all four counters, so a steady non-zerokept_undatablereads as a store-side dating gap rather than youth. - Tests.
ipfs_repo’s unit tests fence the boundary (age exactlyNis kept,N + 1dies), the zero-grace edge (age0at grace0is kept), and the split (two young and one undatable count2and1); the cluster test’s timing model and its 26-second reclaim deadline were re-derived against the new comparison.
2026-09-01 — v0.118.3 (a JMAP feasibility recon lands, deciding nothing)
PATCH: documentation-only. One new page,
JMAP feasibility — 2026-09, wired into
SUMMARY.md under Appendix: Reference beside its two structural
siblings, the dependency audits and the
RFC-updates recon. No code, no config, no
protocol surface moved — and, like the recon page, nothing on it is a
decision: it records findings and recommendations as inputs to a future
planning conversation.
- What it assesses. Whether a JMAP server surface (RFC 8620 core, RFC 8621 mail, eight published extension RFCs, plus RFC 9698 JMAPACCESS on the IMAP side) is feasible here, what the storage kernel can answer today, and what it would cost. Verdict: feasible, with a good HTTP substrate fit — but roughly half the work is backend capability the kernel has never needed, principally an account-scoped change log with expunge tombstones.
- The finding that is not an engineering cost. JMAP assumes the server can parse every message; for sealed-at-rest accounts that is only true inside a live session holding a reading secret. The page states the three ways through and notes that the choice gates the schema design, so it cannot be deferred into a build.
- An adoption section, because the answer is not obvious. JMAP is a finished standard with a healthy server ecosystem (Stalwart, Apache James, Cyrus) and essentially one provider of scale (Fastmail); Thunderbird, Apple Mail and Outlook do not speak it today. The page says plainly that adding JMAP would not in the near term make those clients work better against a SithBit mailbox.
- One prior decision re-read rather than inherited. RFC 8474 (OBJECTID)
is recorded declined, final in the recon page, which calls it “the
JMAP-era resync feature set” — but the stated reason was an
imap-typesfork block, which does not transfer to JMAP. The page records that the decline is not a precedent against this work, while the underlying storage capability remains unbuilt.
2026-09-01 — v0.118.2 (the campaign quote stops calling itself a ceiling)
PATCH: documentation-only, plus two shipped strings — campaign quote --sender’s help and the note the quote prints when no sender is given. No
pricing behaviour changed; only the sentences describing it did.
v0.118.1
retired the last “every match” over-promise about who a campaign reaches.
This entry retires a second, separate over-promise about what a campaign
costs.
- “The conservative ceiling” was not a ceiling. Without
--sender, the quote said every recipient was priced as a zero-spend first contact and called that the conservative ceiling. Two things were wrong. Recipients after the first are not priced at zero spend: each first-contact escrow folds into a simulated running spend that prices the next recipient, which the campaign guide already described correctly. And the result is not an upper bound in either direction that matters — pricing is not monotone in recorded reputation spend. A higher recorded spend earns a cheaper first contact, which folds a smaller escrow, which can leave a later recipient below a reputation tier boundary the zero-spend run had already crossed. A worked case: a senderless quote of 2.898 SOL against 3.029 SOL actually paid by a sender carrying 0.18 SOL of recorded spend — 4.5% over the quoted “ceiling”, for the same three recipients. - What the surfaces say now. The senderless quote states what it actually
assumes — no fromboxes are read and the sender is treated as having no
recorded spend — and says plainly that this is an assumption and not an
upper bound, so a real sender can be quoted more. The campaign guide’s
--senderentry carries the mechanism and points at the fold it already documented. - The excluded classes are no longer half-enumerated. An intermediate
wording named the unmailable and IPFS opt-out classes as the ones excluded
rather than priced, and omitted the over-
--max-postageclass — which is the one that fires most visibly, and which excludes recipients that are otherwise perfectly mailable. A vague over-promise had become a precise false list. The shipped text no longer enumerates a subset.
2026-09-01 — v0.118.1 (the campaign lifecycle diagram and the last CLI surfaces stop promising every match)
PATCH: documentation-only, plus two shipped --help strings and one module
rustdoc comment — all non-behavioural. Nothing about who receives a campaign
message changed; only the sentences describing it did.
v0.117.1
closed with a bullet saying the diagram was not swept and that the label
and its alt text had to move together or neither should. They have now moved
together, so that bullet no longer describes open work.
- The picture said what the prose had stopped saying. The campaign
lifecycle diagram’s stage-3 box read “Quote the matched set, then send one
bountied message each” — the same claim
v0.117.1
removed from six text sites, and the reason it was left there: correcting
the alt text alone would have made the alt text contradict the image. The
in-box label, the SVG’s
<title>, and the page’s alt text were rewritten as one change. The box now reads “Quote the matched set, note the skips, then one bountied message to each remaining match”, and the<title>and alt text — which have no width limit — name the three skipped classes outright. - Two CLI surfaces still carried it, and one of them shipped.
sithbit campaign quote --helpenumerated the pricing classification but named only five of its six classes, omitting the IPFS opt-out entirely, so the one class a reader could not have guessed was the one left out. Andsithbit campaign --helpdescribedsendas delivering “bountied mail those recipients can claim”, where “those recipients” pointed back at the full matched set. Both now name the classes a send actually reaches — the prepaid, top-up and first-contact matches the quote keeps — and the three it never does. - The CLI reference’s opening sentence, and one module comment. The
sithbit campaignreference page introduced the tree by saying a campaign wallet “prices a bountied send to the matched set, and sends it”, while the same page stated the exclusion rule correctly two hundred lines below; the lede now defers to that section. Thecampaignmodule’s own rustdoc had the identical construction and is corrected the same way. - A diagram note for whoever edits it next. The stage-3 box is now six
lines at a 16px pitch, and its longest line measures 118px inside a 156px
box. There is no seventh line: further detail belongs in the
<title>and the alt text, which are unbounded. The label is five — now six — sibling<text>elements, because SVG text does not wrap and every line break is a separate element.
2026-09-01 — v0.118.0 (an undatable block is never garbage-collected, at any grace)
MINOR, not PATCH: this changes what a GC sweep deletes. No ABI, instruction,
error-code or configuration-key change — gc_grace_secs is still the same
key with the same 3600 default — but the outcome of an unchanged
configuration moves at one value, so it is a behaviour change and takes the
digit that says so. It supersedes the note in
v0.116.1
that said correcting the code was backlogged.
- The contract said never, and the arithmetic said otherwise. A backend
that cannot date a block reports it as
u64::MAX— “just written” — so that age-based consumers never treat it as old enough to delete. The sweep computednow.saturating_sub(u64::MAX), which saturates to0, and tested0 < grace_secs. At a grace of0that is false, so the block took the delete branch: the one value at which the guarantee was needed was the one value at which it did not hold. - The sentinel is now matched on its own terms, ahead of the arithmetic, rather than relying on a subtraction that happens to be large. The alternative — rejecting a zero grace at config load — was considered and not taken: it would have closed the reachable path while leaving the stated contract false for any direct caller.
- Where such a block is counted. As for any block, on the mark set: a
referenced one counts as
live, an unreferenced one askept_young. Both arms are executed by tests rather than asserted in prose. - This is a floor that does not drain, and the reason matters to an
operator. Two of the three producers of the sentinel re-derive it from the
same stored object on every listing — an unparseable S3
LastModifiedand an absent Azurelast_modified— so over such a bucket a standing non-zerokept_youngin the sweep’s telemetry is expected steady state, not a growing backlog. The third, an unreadable local file mtime, is plausibly transient. The sweep cannot currently tell an operator which kind it is looking at. - Not fixed here, and still open: the grace is off by one against its own configured value, because two independently floored clocks are subtracted. v0.116.1 documents that at seven sites and it remains accurate; the comparison it describes is untouched here. Whether a fix should address truncation only or clock skew too is an open question, not a decision this change made.
2026-09-01 — v0.117.1 (the campaign docs and CLI stop promising every mailable recipient)
No ABI, instruction, error-code, economic or configuration-key change, and no
behaviour moved: the excluded classes are the three sithbit campaign send
has enforced since v0.117.0, and this entry only makes what we say about
them true. A PATCH bump — two prose pages, one --help string, two rustdoc
comments and one printed parenthetical, all non-behavioural.
- “Every mailable recipient” was never true after v0.117.0. An IPFS opt-out recipient is mailable in the plain sense the docs define at the quote classes — it has a mailbox — yet the batch excludes it. The claim survived in six places, of which the original filing named one. Each now names the classes that are sent to (prepaid, top-up and first contact) rather than implying the complement of “unmailable”.
- The worst of the six was shipped
--help.sithbit campaign send --helpenumerated the excluded classes and named only two, omitting the IPFS opt-out entirely — so the CLI’s own help contradicted the release note that introduced the class. The send reference’s lede carried the same over-promise eleven lines above the paragraph that corrects it. - The advertiser-facing page promised reach it does not deliver. For advertisers said you “reach exactly that set”, and the page mentioned the IPFS opt-out nowhere at all — its only opt-out is closing a beacon. The targeting claim survives, because it is true and is the point: the filter is a bitmap AND over self-declared tags, never a lookalike model. What moved is that the quote is now where the advertiser learns which matched wallets the send will skip, and that those cost nothing.
- The two sample transcripts had never agreed. The quote block reported 5 matched recipients while the send block reported 3, for the same invocation, since before v0.117.0. Both print through one function over the full matched set, so they must agree; the send block’s every other figure already described the 5-recipient match. The header is now 5.
- Not swept, and deliberately so. The campaign-lifecycle diagram’s stage-3 label carries the same claim, and the page’s alt text mirrors it — correcting the text alone would make the alt text contradict the image, so both move together or neither does.
2026-08-31 — v0.117.0 (campaign quote and campaign send honour the recipient’s no_ipfs opt-out)
No ABI, instruction, error-code or configuration-key change: the opt-out is the mailbox flag that already existed, read where the campaign already read the mailbox. MINOR because it is a new enforcement default — a class of recipient the campaign used to quote, bill and mail is now excluded outright, so the outcome of an unchanged command moves.
- Why a campaign cannot honour the opt-out any other way. A campaign send
seals nothing and uploads nothing: it composes the message in the clear
(
Subject:header, blank line, raw body) and puts that plaintext’s client-computed content address on chain. There is no operator store behind the body to keep the copy privately, the way ordinary SMTP delivery does, so publishing the address is exactly what theno_ipfsopt-out refuses. The directsithbit mail sendalready refused an opted-out recipient for the same reason; a batch excludes rather than aborting. - The exclusion is unconditional. It is decided from the mailbox flag alone, before the sender’s frombox is looked at, so a recipient with prepaid stamps standing — whose send would otherwise cost nothing to arrange — is excluded too. A paid stamp does not buy the right to publish an address the owner opted out of.
- What
campaign quoteshows. A sixth recipient class alongside prepaid / top-up / first contact / unmailable / over max-postage: the per-recipient line readsopted out of IPFS body storage — excluded, and the per-class summary gains anipfs opt-outrow. It is counted in its own row rather than folded intounmailable— these recipients do have a mailbox — and contributes zero lamports to every subtotal and to the grand total. - What
campaign senddoes. The opted-out wallets drop out of the batch, joining unmailable and over---max-postagerecipients; the page’s “excluded from the batch” sentence now names three classes rather than two. The unprepaid-stamp warning is unaffected — it counts only kept top-up and first-contact recipients. - Two pages corrected in the same release: the direct-send refusal was
explained wrongly. The
no_ipfsopt-out section — the page this entry links to for the reason — and themail sendpreconditions both saidsithbit mail sendpins the body client-side. It does not:--pathcomputes a local file’s content identifier from its bytes and--cidtakes an identifier the sender already holds, and the send commits that identifier on chain while uploading and pinning nothing. On the send-mail page the claim also contradicted that page’s own--pathnote (“does not upload or pin the content anywhere”) further up the same page; it now cross-references that note instead. That page’s lead-in read “Two preconditions must hold or the transaction is rejected on-chain” while listing three, so it now says three and marks the IPFS one as enforced by the CLI before it builds the transaction rather than on chain. The refusal itself did not change — only its explanation was wrong. Sealing and pinning belong to the spooler’sencrypt → IPFS pin → SendMailpipeline, not to either CLI path; pages describing that pipeline were outside this sweep.
2026-08-31 — v0.116.1 (gc_grace_secs is not the hard minimum it was documented as)
No ABI, instruction, error-code, economic or configuration-key change, and no
default moved: the key is still gc_grace_secs and it still defaults to
3600. A PATCH bump — prose only, plus the three Rust doc comments, across
two crates, that the prose paraphrases; those are equally non-behavioural.
No sweep behaviour changed, and one behavioural defect the sweep to-do list
now carries is deliberately documented rather than fixed here.
- The grace is approximate, not a floor. The cluster GC sweep decides
eligibility by subtracting a block’s recorded write time from the sweeping
node’s own clock, and both are whole unix seconds, floored independently.
The difference of two floors can already reach
Nwhen barely more thanN - 1seconds of real time have passed, so a configured grace ofNguarantees onlyN - 1seconds and a grace of1guarantees essentially none. - On a shared bucket the two are not even the same clock. A cluster’s
canonical storage is the shared S3/Azure bucket, where the block’s write
time is the object store’s
LastModifiedwhilenowis the node’sunix_now(). They drift independently, and that skew is signed: it moves the boundary in either direction, a store stamping ahead of the node lengthening the margin while a node running fast — or a store stamping behind — shortens it and can erase the grace outright. Every site this entry lists either states that mechanism or points at the site that does. The standing advice that goes with it, keep the default’s slack instead of tuning the value down to a pin’s measured upload time, is spelled out on the prose page and in both crates’ rustdoc; the reference cell and the two example configs stay terse and leave it to them. - Where this was corrected. The
[ipfs]reference table, the shared-bucket cluster’s GC invariant, both example configs (sithbitd.example.toml,sithbit_ipfsd.example.toml), and three rustdoc comments in two crates — the field’s own comment inipfs_swarm’sClusterConfig, plus both the module doc and thegcfunction doc inipfs_repo, which stated the hard minimum where the code lives. The[cluster]table on the IPFS services page needed nothing: it defers to sithbitd’s section by reference rather than restating the claim. This list is what was swept, not a claim that no other page mentions the setting. - A separate defect, documented not fixed:
gc_grace_secs = 0deletes undateable blocks. A backend that cannot date a block reports it asu64::MAXand the docs promised it was never collectable. At a grace of0that is false —now.saturating_sub(u64::MAX)is0, and0 < 0is false, so the block takes the delete branch. The sweep’s rustdoc now says “never eligible at any non-zero grace” instead. Correcting the code is backlogged; the default and every documented value are non-zero, so no shipped configuration hits it.
2026-08-31 — v0.116.0 (beacon tag vocabulary 24 → 71; campaign quote reads real prices)
No ABI or instruction change (tag bits are client-defined; no program redeploy). Two related changes land together:
- The participant-beacon tag vocabulary grew from 24 to 71 tags — append-only,
bits 24–70. Two new groups join interest/skill/age/region:
language.*(12 ISO 639-1 codes) androle.*(6 coarse occupation stages); region gains 9 subregions alongside the three broad bands. See CLI campaigns → Tags, the marketplace client, and the participant-marketplace note. sithbit campaign quoteandcampaign send’s confirmation now read each matched recipient’s live chain state instead of assuming the 1 SOL default: an existing frombox’s standing price and prepaid stamps, the reputation-scaled first-contact price, the stamp-purchase protocol fee the old quote omitted, the once-per-campaign sender-reputation account rent, and both signature fees for unprepaid recipients.--postageis replaced by--max-postage(a per-recipient ceiling that excludes too-expensive recipients);quotegains optional--sender; recipients whose owner has no mailbox are reported and skipped. See CLI campaigns and economics → Campaigns.
2026-08-31 — v0.115.1 (the fixture guard’s README catches up with the guard)
No ABI, instruction, error-code, economic or configuration-key change, and no book page changed a byte — a PATCH bump, v0.73.6’s class: docs tooling only, one section of the screenshot rig’s README.
- “Checking the chain-account fixtures” now describes the checker that
exists.
check-fixtures.mjsoutgrew its own documentation across the #205/#206 hardening: the README still described four assertions and a four-item self-test, while the checker runs a stderr fence plus nine named audits (thirteenaudit*functions counting the helpers inside them) and its self-test drives eighteen seeded regressions inMUTATIONS— plus the direction the old text did not know existed, a one-entryEQUIVALENTSlist of byte-changing but meaning-preserving edits the guard must NOT flag. The live equivalent is the internaldate spelling (+00:00vs.000Z, one instant either way), and the direction carries the #206 lesson the rewritten section states: a guard that reds on correct input is worse than no guard, because it fails an author for doing the right thing and the cheapest fix is weakening the guard. The section also keeps the #205 reason the served-mail audits exist, the stale-frames limit, and the exit-2 rule for self-test scaffolding that no longer matchesfixtures.mjs. No checker code changed a byte.
2026-08-31 — v0.115.0 (the folder rail pins INBOX/Sent/Drafts, marks special folders, and folds a long list behind More)
No ABI, instruction, error-code or economic change. MINOR per the 2026-08-30 decision and the wave-1/3 precedent: More/Less is a new client affordance, not a restyle.
- The rail opens with a pinned section. INBOX, then Sent, then Drafts, in that fixed order, each hoisted with whatever nests under it, above a divider rule; the rest of the tree follows unchanged. A key the account has no folder for contributes nothing — the rail still never invents a row the server did not list.
- Special-use folders draw an icon. Sent, Drafts, Archive, Trash and
Junk/Spam are matched on the whole folder name, case-insensitively, the
same rule
imap_session’sSpecialUse::for_mailboxapplies on the server; nested names never match, and INBOX carries no role. Folders of your own draw none. - A long list folds behind More. When the section below the rule holds more than five top-level folders the rail draws the first five, each with its own subtree, and a More link appears beside New folder; it reads Less while the rest are showing. Only that section is ever cut: the More link never hides the pinned three, nor the folder you are currently reading, even when it sorts past the cut — a subtree you folded yourself stays folded in either section, so your own fold can still hide the folder you are reading. The link renders only while a row is genuinely hidden — an account one folder over the line with that folder selected draws everything and offers no link.
- Creating a folder navigates to it. The rail reloads, unfolds any parent you nested it under, and selects what you made, so a folder created past the cut is drawn rather than hidden behind it. That selection closes whatever message the reader had open, exactly as picking any folder does.
- The webmail three-pane section documents all of it. The committed inbox frame is a genuine re-shoot: the fixture account grew four ordinary folders so the rail actually exceeds the threshold, and the frame AE-reads 12428 against its predecessor — 11900 of that in the rail itself, the rest a wider reader Move to… control, whose width tracks the longest folder name.
2026-08-30 — v0.114.2 (folder rails bold the names of folders holding unread mail)
No ABI, instruction, error-code, economic or configuration-key change — a PATCH per the 2026-08-30 decision: a client-side restyle, not a new capability (the rail’s unseen counts shipped with the original shell).
- Folders with unread mail render their name bold. The webmail and Outlook folder rails bold a row’s name (weight 700, the unread message-row cue) when that folder’s own unseen count is above zero — no subtree bubble-up: an unread subfolder does not bold its parent, because the server computes no per-subtree aggregate. The webmail three-pane section now says so; the Chrome popup stays unstyled per its plain-list precedent. The committed inbox frame is unchanged by measurement, not neglect: a fresh eight-frame re-shoot AE-compared 0 everywhere (12-px known gradient noise aside), because the fixtures’ one unread folder is the selected INBOX, whose weight-600 highlight already rasterizes with the rig font’s only bold face.
2026-08-30 — v0.114.1 (sibling one-step .p12 import claims swept behind the Thunderbird known issue)
No ABI, instruction, error-code, economic or configuration-key change — a PATCH: documentation prose only, aligning sibling pages with the v0.113.1 known issue.
- No page claims an unqualified one-step
.p12import anymore. Thecreate-certreference keeps Thunderbird in its SASL EXTERNAL client list but points straight at the known issue, and its.p12bullet now says what the bundle is for rather than promising a single-step import. The Outlook walk-through hedges rather than condemns: its certmgr / Keychain import path is untested here so far, while Thunderbird’s store is known to refuse the bundle. The Thunderbird auth-paths diagram’s title and its alt text now mark SASL EXTERNAL as currently unusable in Thunderbird. The.p12/ PEM pair stays documented as a valid credential for other clients.
2026-08-30 — v0.114.0 (the web clients’ folder rail manages folders: create, rename, delete)
No ABI, instruction, error-code, economic or configuration-key change — a MINOR for a client-side capability, the v0.84.0/v0.96.0/v0.113.0 class: the shared mail panes (webmail, Outlook task pane, Chrome popup) gained behavior users see.
- The folder rail can create, rename, and delete folders now. A
New folder affordance under the list opens a dialog with a
nest-under parent picker (any listed folder, or the top level), and
each row’s ⋮ menu — revealed on hover or keyboard focus in the styled
shells — offers Rename, Delete, and New subfolder. Rename edits the
folder’s full name in a single input, so changing the path part
re-parents it and its subtree moves along (the dialog warns when
there is one); a selection the rename moved follows to its new name.
Delete confirms first, then takes the one mailbox and its messages
and keeps its subfolders — an orphaned child re-renders flat under
its literal full name, the rail’s no-phantom-parents rendering. A
\Noselectplaceholder row offers only New subfolder: there is no real mailbox there to rename or delete. Failures stay on each dialog’s own error line with the typed input intact. See the webmail three-pane view — the third of the folder-management waves filed 2026-08-30.
2026-08-30 — v0.113.1 (known issue: Thunderbird cannot import the SithBit .p12)
No ABI, instruction, error-code, economic or configuration-key change — a PATCH: documentation prose only, correcting a client walk-through claim against live evidence.
- The Thunderbird one-step
.p12import claim is now a documented known issue. A live walk on Thunderbird 140 ESR showed Your Certificates → Import refusingsithbit mailbox create-cert’s password-less bundle (“Failed to decode the file”), and a repack into NSS’s preferred shape (sha256 MAC, PBES2-shrouded key bag) still failing (“The PKCS #12 operation failed for unknown reasons”) — consistent with NSS refusing Ed25519 private-key import into its soft token. The Thunderbird client-certificate section now states plainly that SASL EXTERNAL from Thunderbird is effectively unusable until NSS accepts the key, keeps the mint instructions and the.p12’s purpose for other clients, and points at the mail password as the working Thunderbird sign-in. The certificate writer is unchanged by decision (2026-08-30) — its output stays RFC 7292-legal and deterministic, and the PEM pair remains valid everywhere.
2026-08-30 — v0.113.0 (the web clients’ folder rail renders the folder hierarchy)
No ABI, instruction, error-code, economic or configuration-key change — a MINOR for a client-side capability, the v0.84.0/v0.96.0 class: the shared mail panes (webmail, Outlook task pane, Chrome popup) gained behavior users see.
- The folder rail is a tree now, not a flat list of full names.
Folder names nest on the
/hierarchy separator the/v1/mailsurface already carried: nested rows indent and show only their last path segment, and a parent row gains a disclosure triangle that folds its subtree shut and open (the fold survives the count refresh a successful send fires). A folder whose parent chain is not in the listing renders flat under its literal full name — the rail never synthesizes a parent row the server did not list. A\Noselectplaceholder (deleted-with-children) stays unselectable but still folds. See the webmail three-pane view; the first of the folder-management waves filed 2026-08-30 (the rest: pinned special folders, folder create/rename/delete, unread styling).
2026-08-30 — v0.112.2 (concurrent first boot of a fresh SQLite store is safe; the boot-lock sidecar)
No ABI, instruction, error-code, economic or configuration-key change — a PATCH: a defect fix with one operator-visible artifact.
- Starting
sithbitdandaccount-apitogether against one fresh SQLite store no longer kills the loser. Both processes run schema migrations at open, and nothing serialized them across processes (the observed deaths:duplicate column name,table … already exists); the same window could generate two different credential keys.Storeopen now takes an exclusive advisory lock for its first-boot work, so the pair boots in either order or simultaneously. - A
<database>.boot-locksidecar file appears beside the SQLite database — documented in the[store]table. It is never written, only locked; it releases with the process (a crashed holder cannot wedge later boots) and is harmless to leave in place.
2026-08-30 — v0.112.1 (audience-facing prose revised against the four-cluster market research)
No on-chain ABI, instruction, error-code, economic or configuration
change — a PATCH: prose only, reorganized and reworded so each of the four
audiences in branding/marketing/market-research.md (recipients, payers,
owners, builders) meets its own appeal and proof points.
- The Introduction is reorganized. It now leads with fraud and AI-written phishing alongside spam (with the FBI IC3 and vendor figures linked), and its sections follow the message hierarchy: Sender-pays postage, Get paid to be emailed, Paid is delivered, No token, Your address is yours, and your mail is sealed, Anyone with a domain can run one, and a closing Find your path that routes each kind of reader to their chapters. “Blockchain Email” and “Priced in SOL — No New Token to Trust” are gone as headings.
- The 1 SOL default is never shown bare. Every mention in the edited pages — the landing page, Getting started step 3, Economics, Mailboxes and Fromboxes — now says it is a wall the owner lowers per sender.
- Three facts are stated where they were only derivable: the recipient keeps roughly 90% of postage (Economics, landing page, Introduction); aliases and domain authorizations never expire and carry no renewal fee (Aliases, Trading names); and the operator’s 10% share now appears on Domains and at the top of Running a mail server, with the point that chain-native delivery has no IP reputation in its path.
- Spam is no longer the only value word. Phishing, invoice fraud and deliverability enter Verified-sender attestation, Addresses, Standards, the Prelude and Lockbox; Campaigns and Beacons carry the advertiser and participant appeals (“quote the whole campaign before a lamport moves”, “you keep the postage even if you never reply”).
- New appendix, Tracking pixels, consent, and the paid inbox, under Trust & Security: takes the tracking-pixel and consent problems the Introduction’s cited regulator report describes and answers each with the mechanism SithBit already has (the sandboxed webmail reader, the on-chain delivery record, reply bounties, beacons as separate revocable consent), plus what it does not fix. The Introduction footnotes it.
- “Blockchain” is no longer the lead noun on What’s public and private or Addresses; the landing page keeps its structure and card order, with “no token” and the 90% share added to the hero.
2026-08-30 — v0.112.0 (the account API trusts a private Redis CA: [cache] redis_ca)
No on-chain ABI, instruction, error-code or economic change; a new
configuration key that changes what a deployment can connect to — a
MINOR, like v0.111.0’s redis_auth.
account_api.toml’s[cache]section gainedredis_ca— a key source (file /akv/asm/gsm, theredis_authshape) whose bytes are one or more PEM certificates that replace the platform root store for therediss://connection. It exists for Google Memorystore, whoseSERVER_AUTHENTICATIONTLS presents an instance-specific Google-managed CA (server_ca_certs) no platform store holds; ElastiCache and Azure Cache for Redis are publicly signed and need nothing. Refused underkind = "local", beside a plainredis://URL, and — the fence that matters — when the value is unreadable, empty or holds no certificate: therediscrate would otherwise build an empty root store that trusts nothing and fails open on every read, indistinguishable from a down server. Row in the account-api configuration table and a “The server’s CA” bullet under A shared backend for a fleet.iac/gcpprovisions the trust whendeploy_redisis on: the instance’sserver_ca_certsis written to a module-created Secret Manager secret (sithbit-account-api-cache-ca, outputredis_ca_secret— public material already in state, not a token), mounted into the Cloud Run service as a secret volume at/etc/sithbit/redis-ca/ca.pem, andACCOUNT_API_CACHE__REDIS_CAnames that path; the service account getssecretAccessoron it. Theiac:terraform-gcpsuite now asserts sixACCOUNT_API_CACHE__*entries, the secret version’s PEM, the grant and the volume/mount pair (a plantedserver_ca_certsviaoverride_resource). The Memorystore subsection drops its “not yet solved in the image” caveat;iac/README.md’sdeploy_redisrow and “Validation gates” say so. Still no live end-to-end deploy (alpha).
2026-08-29 — v0.111.2 (terraform test fences for the iac/aws and iac/gcp modules)
No on-chain ABI, instruction, error-code, economic or configuration change — a PATCH: the two Terraform gate legs grew a test step, and the docs that name them say so.
iac:terraform-awsandiac:terraform-gcpnow end withterraform test, overiac/aws/tests/account_api_cache.tftest.hclandiac/gcp/tests/redis.tftest.hcl. The suites run under a mock provider — no credentials, no network, nothing created — and evaluate whatterraform validatestructurally cannot: thelifecycle { precondition }blocks and thelocal.*env and policy maps (a misspelled attribute on a null-defaulted object variable validates green; the suites turn red on it). Each fences the opt-in summary cache: off by default with no[cache]env entry, half-configured refused by the preconditions, fully configured wiring exactly the fiveACCOUNT_API_CACHE__*selector entries with the token never in an env value. The leg count stays 38. The Hosting on AWS and Hosting on Google Cloud cache subsections andiac/README.md’s “Validation gates” name the step; no managed cache has been deployed end to end yet (alpha) — these fences are the only exercise the two modules get.
2026-08-29 — v0.111.1 (managed Redis for the account API’s shared cache in all three iac/ modules)
No on-chain ABI, instruction, error-code or economic change, and no new configuration key — a PATCH: three infrastructure modules and the deploy chapter that documents them.
iac/gained an opt-in managed cache per cloud for the shared summary-cache backend ([cache] kind = "redis"), each off by default and each wiringACCOUNT_API_CACHE__KIND/__REDIS_URL/__REDIS_AUTH__*into the account-api unit with a cloud key-source selector, so the token never appears in an env value:iac/aws/elasticache.tf(create_account_api_cache, a one-node ElastiCache Valkey group in the private subnets behind a security-group pair — Hosting on AWS),iac/gcp/redis.tf(deploy_redis, Memorystore for Redis BASIC 1 GB over private-services-access — Hosting on Google Cloud), andiac/azure/main.bicep(deployRedisCache, Azure Cache for Redis Basic C0 behind a private endpoint and private DNS zone on a BYO undelegated subnet — Hosting on Azure). The deploy chapter gained the “Hosting on AWS” and “Hosting on Azure” sections these live under;iac/README.md’s mapping tables gained the rows.- Each cloud has an operator step for the token, because no module
reads or writes a secret store (the no-vault rule): on AWS the same
token goes in
account_api_cache_auth_tokenand the Secrets Manager secret named byaccount_api_cache_auth_asm; on Google Cloudterraform output -raw redis_auth_stringis copied into the Secret Manager secret named byaccount_api_cache_auth_gsm; on Azure the cache’s primary key is stored in the account-api Key Vault underaccountApiCacheSecretName, which the vault-wide secret-read grant already covers. One caveat is recorded plainly: Memorystore’sSERVER_AUTHENTICATIONTLS presents a Google-managed CA the container must trust (server_ca_certs), a step the image does not yet provision. None of the three caches has had a live end-to-end deployment yet (the project is alpha); each is validated statically by itsiac:*gate leg.
2026-08-29 — v0.111.0 (the account API’s shared cache takes its password from a key source: [cache] redis_auth)
No on-chain ABI, instruction, error-code or economic change. A MINOR bump (the v0.110.0 precedent: a new configuration key on a deployment surface).
account_api.toml’s[cache]section gainedredis_auth— a key source selector (a file path, or anakv/asm/gsmcloud secret, the shapejwt.key_fileuses) whose bytes become the Redis password, so a managed cache’s token never sits inredis_url. The row is on the configuration reference and the semantics under A shared backend for a fleet: trailing whitespace is trimmed, the selector wins over a password embedded in the URL, an unreadable or empty secret refuses to start (an empty password would sendAUTH ""), and setting it underkind = "local"refuses too rather than silently ignoring a secret. The managed-Redis sentence there no longer points atBACKLOG.md: the per-cloud units land in this release series and the deploy chapter records each as it arrives.- The workspace gate gained three
iac:*legs —iac:terraform-aws,iac:terraform-gcp(fmt -check, offlineinit,validate) andiac:bicep-azure(az bicep build+build-params) — so the three infrastructure trees are statically validated on every run (38 legs, from 35). Two defects that had sat outside any gate’s reach were fixed on the way in: two@descriptionstrings iniac/azure/main.bicepwith unescaped apostrophes (the template did not compile), andiac/gcp/mail_grpc.tf’s alignment (terraform fmt -checkfailed).
2026-08-28 — v0.110.0 (the account API’s plaintext summary cache can be shared across replicas: [cache] kind = "redis")
No on-chain ABI, instruction, error-code or economic change. A MINOR bump (the v0.109.0 precedent, and the v0.10.0 rule: a new configuration surface and a new optional deployment component).
account_api.toml’s[cache]section gained three keys —kind("local", the default, or"redis"),redis_urlandredis_ttl_secs— so a fleet ofaccount-apireplicas behind a load balancer can share one plaintext summary cache in a Redis server instead of warming N private ones. The rows are on the configuration reference and the semantics under its new A shared backend for a fleet: the sealed-summary cache and the reading secrets stay per replica by design; the backend is fail-open (a down server is a miss, bounded at about two seconds per call, never a refusal to start — only a malformed URL is); keys aresithbit:summary:v1:<blob key>with JSON values;redis_ttl_secsbounds the server’s memory, withmaxmemory-policy allkeys-lruas an optional second bound;rediss://is TLS.- The
rediscargo feature is off by default.kind = "redis"on a binary built without it refuses to start, naming the feature; the docker image’sallfeature set includes it. docker-compose.prod.example.ymlcarries a commented-outredis:service and the two commentedACCOUNT_API_CACHE__KIND/ACCOUNT_API_CACHE__REDIS_URLlines onaccount-api; a managed Redis iniac/(ElastiCache, Memorystore, Azure Cache for Redis) is deferred to its ownBACKLOG.mdentry.- Scaling out’s account-API cache item now
says which cache a fleet can share and which two cannot, and that
summary_capacityis unused under"redis". - Monitoring:
under the shared backend
sithbit.api.cache.hits/missesare still tallied per process, whileevictions,entriesandcapacityread0forcache="summary"— the server does not report them to one client, so the four-state sizing table does not apply there. - The conformance emulator set gained Redis (
redis:7-alpine, exported asSITHBIT_TEST_REDIS_URL), so the backend’s live round-trip test runs under the same env-gated skip idiom as the cloud stores.
2026-08-28 — v0.109.1 (eviction no longer scans, so a bigger account-API cache costs only memory)
Documentation-only, and a PATCH: no ABI, no configuration key, no default and no economic change. What changed underneath is an implementation detail with no user-visible behaviour — eviction still picks exactly the least-recently-used entry — but it invalidates cost guidance the previous two entries gave, so the guidance is corrected here.
- The “cost is not monotonic in the size” advice is retired. Both
account-API summary caches used to find their eviction victim by
ranking every resident entry, so one eviction did work proportional to
the capacity, while holding the one lock every request to that cache
contends for. A stamp-ordered index now names the victim directly, and
an eviction examines a single entry at any capacity. The practical
consequence for an operator: sizing
summary_capacityis a memory question and a hit-rate question, and no longer a lock-contention one — the farm values are a straight memory trade. See Tuning the account-API caches. - The
summary_capacity = 65536row on the configuration reference no longer warns of “a proportionally longer locked eviction scan”, because there is no longer a scan to lengthen. - Still true, and still the first thing to read: sitting just below the working set remains the worst place to be, because nearly every insert evicts. That was always about the eviction rate, which this change does not touch — only the cost of each one.
2026-08-28 — v0.109.0 (the account API’s summary caches are configurable: the [cache] section, and smaller zero-config defaults)
No on-chain ABI, instruction, error-code or economic change. A MINOR bump (the v0.89.0 precedent): four new configuration keys, and two default sizes changed, which an operator sees as memory.
account_api.tomlgained a[cache]section —summary_capacity,session_summary_capacity,max_cached_sessionsandmax_session_secrets— sizing the four bounded in-memory caches that were compile-time constants until now. Every key is optional and defaulted; the section’s meaning, what each bound does when it is exceeded, and the two startup validations (a too-small plaintext cache is clamped with a warning; a too-small per-session quota or a zero bound refuses to start) are in the configuration reference.- Two zero-config defaults changed, to size the small self-contained
deployment a no-config startup represents. The shared plaintext
summary cache grew from 1024 to 4096 entries (a single session’s
working set now fits, which also stops the eviction scans an under-sized
cache paid on every insert), and the sealed-summary cache’s session
bound fell from 32 to 8 full-quota sessions (its ceiling from 32 768 to
8 192 summaries, the biggest single saving). Net, the API’s resident
memory ceiling is smaller than before; a deployment holding more
than eight concurrent keyed sessions sees the least-recently-used
session re-decrypt on its next read, and sets
max_cached_sessionsback up. The per-session quota and the session-secret bound did not change. - Farm-scale starting values, and the per-replica trap, are recorded
beside the section:
summary_capacity = 16384or65536andmax_cached_sessions = 32or more, sized by the concurrent sessions one replica sees — each replica holds its own cache, so sizing from the fleet total over-provisions memory N-fold. The shipped AWS production document (iac/appconfig/aws/account-api.toml, and the Azure kvset generated from it) now writes those values out. - The account API exports its cache metrics, so the
[cache]values can be decided from telemetry instead of taken on trust. Ten new series in the OTLP metrics table:sithbit.api.cache.hits/.misses/.evictions/.entries/.capacity, onecachelabel (summaryfor the shared plaintext cache,session_summaryfor the per-session sealed cache, whosecapacityis its ceiling); the unlabeledsithbit.api.cache.session_dropsfor whole-session drops at themax_cached_sessionsceiling, kept apart from quota evictions; andsithbit.api.session_secrets.entries/.capacity/.evictionsfor the reading-secret stash, where an eviction logs a user out. All are per process — each replica holds its own caches — and cost nothing with no[observability.otlp]section (the instruments are observable readings of tallies the caches keep under the lock they already hold). - A tuning guide reads those series into actions. The new
Tuning the account-API caches
section derives the hit rate, gives a four-state decision table
(never fills / thrashing / hot set captured / steady state) naming the
[cache]key each action changes, a sizing estimate off the measured grid (hit rate H at capacity C puts the working set near C / H, an upper bound to step toward, not jump to), the non-monotonic cost (just below the working set is the worst place: a poor hit rate and an O(capacity) locked eviction scan that stops once it fits), a memory conversion (~0.5–1 KB per summary, an estimate), and the per-replica trap. Four pages now point at it: eachcache.*row of the configuration reference (“how to decide the value”), a new Sizing the summary caches section on the service page, a Known seams bullet on the scaling page, and the day-2 handoff at the end of the deploy guide.
2026-08-27 — v0.108.4 (the 1232-byte packet statements are qualified as legacy/v0 now that reads admit transaction v1)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump: doc comments and two mdBook sentences.
- The “1232-byte transaction packet” statements on the two DNSSEC-witness
pages and in the three witness-size constants now say which transaction
format they describe. The RPC read path’s
max_supported_transaction_versionceiling was raised to admit transaction v1 (SIMD-0385, feature gatetxv1aq4pp281K9um3tnPgkfX8UqtFT6wcVW3hNezGLL, inactive on every cluster as of this date), with v1 decode/ingest tests fencing it. v1 raises the serialized envelope from the ~1232-byte legacy/v0 packet to 4096 bytes and carries compute config in the message header, so a bare “1232-byte packet” became format-specific prose. The proof-of-behaviour appendix and the authorize-by-proof page now say legacy/v0, note the v1 4096-byte envelope, and state that SithBit’s own producers still emit legacy transactions — so the witness chunking and the inline-witness caps stay sized to 1232 until a v1 emit path lands. TheMAX_PROOF_WITNESS_LEN/MAX_PROOF_WITNESS_CHUNK/MAX_RRSIG_WITNESS_LENdoc comments and the alias-create packing budget’s doc comment say the same; no constant changed.
2026-08-27 — v0.108.3 (the IMAP4rev2 row says exactly which rev2 fold-ins are shipped, and which are not)
- The IMAP standards table’s
RFC 9051 row now enumerates rev2’s Appendix E fold-ins against what SithBit
ships. Implemented: NAMESPACE, UNSELECT, UIDPLUS, ENABLE, IDLE, SASL-IR,
MOVE,
LITERAL-, the RFC 5530 response codes and the SPECIAL-USE attribute list. Deferred behind the parser fork:ESEARCH, SEARCHRES,LIST-EXTENDED,LIST-STATUS, BINARY’s FETCH side,STATUS SIZE/STATUS DELETED, 64-bit sizes and theCLOSEDresponse code — the last four of those were previously tracked nowhere. Two framings corrected:CONDSTORE/QRESYNCare not part of rev2 (it borrows only theCLOSEDresponse code), and rev2 keepsSTARTTLSandLOGINDISABLEDmandatory — implicit-TLS-only is a project choice under RFC 8314, not a rev2 requirement. No behaviour changed.
2026-08-27 — v0.108.2 (the placeholder-PEN promise is enforced at mainnet preflight)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump: two appendix sentences and a deploy-tooling guard.
- The mainnet deploy preflight refuses to run while the node-delegation
OID’s enterprise arc is still the placeholder. The
service-discovery appendix
and the
conformance appendix
have said since the appendix was written (2026-07-09) that the
1.3.6.1.4.1.58888arc is an unregistered placeholder that MUST be replaced by a real IANA Private Enterprise Number before mainnet. That was prose; nothing checked it.scripts/preflight-deploy.sh chain-mainnetnow carries a “PEN arc is registered” row that fails whilenode_cert’s constant is58888or its doc comment still calls the arc a placeholder, so the deploy guard blocks a mainnet deploy the same way it blocks a committed upgrade authority. Both pages note the enforcement; the honest limitations bullet says so too. The remaining three limitations on that list stay recorded caveats with named reopen triggers (a user decision recorded in the workspace’s durable record), and a certificate form needing no registration remains a considered alternative.
Operator action: none until a mainnet deploy is planned; then register a PEN (or adopt the registration-free form) before running the preflight.
2026-08-27 — v0.108.1 (the default-value fence follows a field’s serde rename)
No on-chain ABI, instruction, error-code, economic or behavioral change, and no book page changed a byte — a PATCH bump, v0.73.6’s class: docs tooling only, one checker and the gate README.
check_config_keys.py’s value fence looks a row up by its wire spelling. A field carrying#[serde(rename = "…")]is documented under the name a TOML file uses — the account API’sstatic_filesis the[[static]]row — and the fence, which matched rows to fields by the Rust name alone, filed such a field as “no row states a default for” while its row sat one name away. Harmless while that cell is prose; silent the day a renamed field gains a value cell. The rename is now read off the struct declaration on both parse paths and the row is found by it; findings keep quoting the Rust field name. On the live reference the summary moves one field from undocumented to a prose row. Two self-test cases (renamed-doc-drift, and a row keyed by the Rust name of a renamed field, which is nobody’s row) join the stand-in’scleanproof; the README’s counts move to 158 and forty-nine.
Operator action: none.
2026-08-27 — v0.108.0 (Address resolution is literal-first everywhere)
What changes: two resolvers — the sithbit library’s C entry point
resolve_alias and the gRPC gateway’s ResolveAlias — looked the alias
registry up before recognising a local part as a wallet address, so an
alias registered under an address’s lowercased spelling (which the
case-folded alias namespace accepts) redirected mail sent to that address.
Both now answer a wallet literal with itself before any alias lookup,
through one shared ordering rule, matching what every server path
(MX, spooler, DSN, account API, CLI send) already did. The
Mailboxes
and Aliases pages,
and the Create an alias
and Reserve aliases in bulk
references, no longer describe the automatic self-alias as a spoof guard:
it is kept, deliberately, as a namespace reservation.
Operator action: none. Non-Rust hosts embedding the sithbit shared
library get the corrected resolve_alias on their next rebuild.
2026-08-26 — v0.107.2 (configuration reference: two footnotes and a swarm row)
What changes: documentation only. The domain-sithbit
and mail-grpc pages each
referenced a Solana-CLI-config footnote that only the env-files page
defined, so the marker rendered as literal text; each page now carries its
own definition. The sithbitd [ipfs.swarm] table gains the
ipfs.swarm.reprovide_interval_secs
row (default 22 h) it previously only mentioned in passing, worded as the
sithbit-ipfsd table’s row is.
Operator action: none.
2026-08-26 — v0.107.1 (configuration reference: three settings the examples never showed)
What changes: documentation only. Three settings that existed in
code but in no example file or reference table are now in both:
account-api’s mail.auto_mark_seen
(mark a fetched message \Seen, off by default) and sithbit-ipfsd’s
reprovide_interval_secs and kad_protocol
under [swarm]. Every per-binary table of the reference also closes
with the [health], [observability] cross-reference row the gateway
and standalone-server pages already had, pointing at the shared section
and at the Monitoring table that lists each binary’s health port.
Operator action: none.
2026-08-26 — v0.107.0 (sithbitd refuses report sections without a reporter identity)
What changes: a new enforcement default. sithbitd now refuses to
start when [spooler.dmarc_report], [spooler.dmarc_ruf] or
[spooler.tlsrpt] has enabled = true while org_name or email is
empty after trimming, with an error naming the section, the empty
setting and the remedy (set both — they are the reporter identity — or
disable the section). A disabled section may stay empty, as before.
Until now an enabled reporter with no identity started cleanly and sent
reports whose org_name/organization-name and From: were blank. The
six org_name / email
rows
state the refusal, and the annotated example TOML carries it beside the
REQUIRED (public) when enabled marker.
Operator action: a deployment that enabled any of the three
reporters without an identity is refused at its next restart — fill in
org_name and email, or set enabled = false.
2026-08-26 — v0.106.0 (sithbit-migrate refuses mismatched credential keys)
What changes: a new enforcement default. sithbit-migrate now compares
the [source] and [target] credential_key_file keys before any
migration step — dry run and --commit alike — and refuses the run when
they differ, with an error naming both settings and the remedy (copy the
source key file to the target). Sealed mail secrets are copied as
ciphertext and never re-sealed, so before this a target opened under a
different key ran to a successful-looking summary and left every stored
mail password unreadable, to be discovered by a user who could no longer
log in. The target store is opened before the check fires, so a target
pointed at a key file that does not exist yet has one generated fresh, is
refused as a mismatch, and leaves that generated file behind — copying
the source key over it resolves both. The
target.credential_key_file row
no longer says “nothing compares the two keys”, and the
v1 caveats keep
the not-re-sealed limitation while stating the refusal.
Operator action: none for a correctly carried key. A migration that
previously “succeeded” with an orphaned key would now be refused — copy
the source credential.key to the target’s configured path before
re-running.
2026-08-26 — v0.105.0 (domain-sithbit names its RPC endpoint)
What changes: domain-sithbit gains a
json_rpc_url
setting — the Solana JSON-RPC endpoint the delegate’s on-chain domain
authorization is sent through. Unset or blank is no override: the
endpoint resolves the way the sithbit CLI does, from the Solana CLI
config (~/.config/solana/cli/config.yml) or a bare JSON_RPC_URL
environment variable, and failing both from the CLI’s default cluster,
mainnet-beta. That last fallback is the reason the setting exists: a
container with no CLI config previously signed against mainnet
silently. The row carries the REQUIRED (public) marker, the
annotated example TOML shows it commented out, and the iac/appconfig/
production document names it with a CHANGE placeholder (the Azure
flavor inherits it unchanged — a URL has no cloud-specific form).
Operator action: any domain-sithbit deployment without a Solana
CLI config on the host should set json_rpc_url (or
DOMAIN_SITHBIT_JSON_RPC_URL) to the intended cluster’s RPC provider.
2026-08-26 — v0.104.0 (sithbit-ipfsd refuses a non-loopback bind without a token)
What changes: a new enforcement default. sithbit-ipfsd now validates
its configuration before it listens: a non-loopback bind_addr with
auth_token unset (or blank) is refused at startup with an error naming
both settings and the remedy — set the token, or bind a loopback address.
IPv4 and IPv6 loopback are exempt, so an empty config still runs the
zero-config dev instance; an IPv4-mapped [::ffff:127.0.0.1] is refused,
fail-closed, as mail-grpc does. The pin API stores and unpins blocks for
whoever calls it, and an absent token passes every request, so before this
a reachable daemon with no token silently handed that write surface to the
network. The
configuration reference
legend now names two binaries that couple a non-loopback bind to a
credential — mail-grpc ([auth]) and sithbit-ipfsd (auth_token) —
and the sithbit-ipfsd rows, the
shipped example TOML, the iac/appconfig/ production documents (both
clouds) and the recipient pin provider
page all say so instead of “nothing enforces it”.
2026-08-26 — v0.103.1 (configuration reference: what must change when going public)
What changes: documentation only. Every per-binary page of the
configuration reference
now marks the rows an operator has to revisit before a listener leaves
loopback — REQUIRED (public) for a setting a reachable deployment is
unsafe or non-functional without (bind addresses, the TLS behind them,
hostnames, shared stores, secrets, the gateway’s mutual-TLS tables) and
RECOMMENDED (public) for a safe default that a public deployment
should choose consciously (rate limits, blocklists, sender policy,
quotas, telemetry). Unmarked rows keep the shipped default. The same
markers head the matching comment blocks in the annotated example
TOMLs and the iac/appconfig/ production documents, so all three
surfaces read alike. The legend states the one fact that keeps the
markers honest: they are operator obligations, not startup checks —
only mail-grpc couples a non-loopback bind to its [auth] section.
Three sentences were corrected on the way: the standalone SMTP
server’s shipped file never set require_tls = false (it defaults off
in MX mode), the DMARC-report and TLSRPT org_name/email rows said
“required when enabled” where nothing enforces it, and account-api’s
[chain.grpc_tls] row scoped its “refuses to start” to the
scheme-versus-table mismatch the process can actually detect.
Operator action: none; the markers describe the checks the production deployment chapter already expects.
2026-08-26 — v0.103.0 (the gateway’s callers speak mutual TLS)
What changes: v0.102.0 stated the gateway authenticates its callers;
this entry is where every side of that becomes real. The gateway now
enforces [auth] at accept time — an unknown or missing client
certificate is dropped during the handshake, before any request is read —
and the three callers gain the matching client half:
sithbitd’s [grpc.tls],
the standalone SMTP server’s
[grpc_tls], and
account-api’s [chain.grpc_tls].
Each takes cert and key (the caller’s own PEM Ed25519 certificate, a
path or a cloud secret source) and gateway_key — the base58 Ed25519 key
in the gateway’s certificate, pinned: no CA, no hostname check, one
key written on each side. The endpoint dialed becomes https://, and a
scheme that disagrees with the table refuses to start rather than fail
per-call.
Two consequences worth reading twice. The gateway’s [auth] cert must
be an Ed25519 certificate, since that is the key type callers pin.
And sithbit-console carries no client table: it reaches only an
unauthenticated (loopback) gateway.
Operator action: every caller of a non-loopback gateway needs its own
certificate, its key, and the gateway’s key; the iac/ templates carry
them for account-api (account_api_chain.grpc_tls / accountApiChainTls*)
and the app-config documents for sithbitd. Absent the table, every caller
keeps its plaintext dev shape.
2026-08-26 — v0.102.0 (BREAKING: the gRPC gateway authenticates its callers)
What stopped working: allow_remote_bind is removed. A mail_grpc.toml
(or MAIL_GRPC_ALLOW_REMOTE_BIND environment variable) that still sets it now
fails to load, because the config struct rejects unknown fields. A gateway
bound to a non-loopback address no longer starts by asserting its network
segment is private; it starts by authenticating its callers.
What changes: a new
[auth] section configures mutual TLS on
the gRPC surface. The rule is: loopback with no [auth] serves
unauthenticated — which keeps a zero-config dev run and the test harness
working — while any non-loopback bind requires [auth] and is refused at
startup without it. An [auth] section that is present is enforced on every
bind, loopback included, so configured authentication is never silently
ignored. A half-written section is refused rather than served unauthenticated.
Callers are identified by the Ed25519 public key in their client certificate,
checked against authorized_keys — the same 32-byte transport identity the MX
servers already bind for SASL EXTERNAL. An empty authorized_keys is not
“allow all”: it is an incomplete section and startup is refused.
Why the reversal: the gateway’s private-network-only posture was recorded twice, most recently in August 2026 when a security review raised the unauthenticated write surface and three guardrails were taken instead of authentication. That decision named its own reopen trigger — a gateway reachable across a segment that is not trusted — and it has been exercised deliberately, on a zero-trust reading of the network. The gateway spends its fee-paying wallet on behalf of whoever calls it, so reachability alone is no longer accepted as the boundary.
Operator action: every non-loopback deployment needs a certificate, a key
and an allow-list before it will start. The iac/ templates for all three
clouds carry the new settings as operator-supplied variables, and
iac/appconfig/aws/mail-grpc.toml shows the shape.
2026-08-26 — v0.101.0 (a whole message’s attachments can be bounded)
What changes: a new, off-by-default
aggregate_bytes
setting on [spooler.offload], capping the decoded attachment bytes one
message may leave inline in total. Delivered bytes are unchanged unless an
operator sets it.
The gap it closes. threshold_bytes is compared per part and only per
part, so twenty 1 MB attachments under a 5 MiB threshold ride inline as a
20 MB message — every part innocent on its own. That is a property of the
per-part rule, not a defect introduced by any recent change, and until now the
only way to bound such a message was to refuse it outright
(max_message_size
or max_wallet_bytes). aggregate_bytes
shrinks it instead.
Which parts leave. The largest eligible parts are offloaded first, and the pass stops the moment what remains inline fits the budget — the ordering that reaches the budget while turning the fewest attachments into links, so a recipient keeps as many inline files as the arithmetic allows. The two size rules compose as a union: the threshold takes what it takes, and the budget tops the selection up from what is left.
Three things it deliberately does not do. It does not count the text or
HTML bodies — they can never be offloaded, and a budget metered on something
the feature cannot shrink would be unsatisfiable by construction, so a
body-heavy message can still exceed it. It does not override
content_id:
a part held inline counts toward the budget but is never taken to satisfy it,
which makes the budget a target rather than a guarantee. And it never refuses
mail — a budget it cannot meet takes what it may and delivers.
Arming. Either size rule now arms the offload on its own, so
aggregate_bytes with threshold_bytes left at 0 is a valid “cap the
total, ignore part size” policy. Both at 0 remains the default and delivers
today’s bytes unparsed. As before, a size rule set on a daemon with no pinning
provider is inert and says so at boot — that warning now fires for either
rule, not just the threshold.
See Large attachments and IPFS offload → Two size rules, and why the second exists.
2026-08-26 — v0.100.0 (oversized inline parts can be offloaded)
What changes: a new, off-by-default
content_id
setting on [spooler.offload], and a new X-SithBit-Offload-Cid header on
every offload placeholder. Delivered bytes are unchanged unless an operator
sets content_id, apart from that one added header.
The gap it closes. The offload refused any part carrying a
Content-ID, at any size, because an HTML body referencing it as cid:…
would be left pointing at nothing — so a 40 MB inline image rode inline and
the threshold could not save you from it. But most mail clients stamp a
Content-ID on every part they build, so the rule also pinned down
ordinary oversized attachments that nothing in the message ever referenced.
spooler.offload.content_id("never", the default) keeps that behavior exactly."orphaned"offloads aContent-IDpart no body references — nothing points at it, so nothing can dangle."all"offloads referenced ones too, rewriting each<img>that rendered one into a link.- Under
"all"the inline rendering is lost and cannot be kept: the file is sealed and its key rides in the link’s#fragment, which is never sent to a server, so no<img src>could ever render it. A link is the honest degradation; a broken image is not. - A reference that cannot be rewritten holds its part inline. A CSS
url(cid:…), abackground=attribute, a plain-text body, or anycid:reference resolving to no part of the message leaves everything where it is. See What is offloaded — and what never is. X-SithBit-Offload-Cidcarries the bare cid beside the existing-Urlheader, for clients and re-gatewaying tools that need the content address rather than one operator’s hostname. It does not repeat the key.- The threshold stays per part:
content_iddecides which parts are eligible, never how big one must be.
2026-08-26 — v0.99.0 (stored-password accounts can be sealed at rest)
What changes: a new, off-by-default
[account_keys]
section on sithbitd. It gives the daemon an operator-held root from
which each account’s at-rest key is derived, so the one population
at-rest sealing leaves in
plaintext can be sealed too.
The gap it closes. Sealing encrypts a stored body to the account’s own reading key — but an account that has ever configured a stored mail password is deliberately excluded, because its CRAM-MD5 and APOP logins prove a password and never carry a key that could unwrap a sealed body. Those accounts’ mail has therefore been stored in the clear, protected only by whatever the backend encrypts at rest, and the threat model has said so since v0.76.0. Nothing closed it until now.
account_keys.root(unset = off) is the operator root secret, given as a key source: a file path or a cloud secret-manager entry. Material over 32 bytes is stretched through a key-derivation function, so it need not be a formatted key.
What it protects. A stolen bucket, a leaked table export or a restored backup yields ciphertext, because the root lives outside the store. It does not protect against a compromised running server: these accounts’ mail must be readable on demand, which is what reading mail with a password means, so the root is necessarily in the daemon’s memory. The threat model now states that boundary explicitly.
Existing mail is not migrated. Only deliveries made after the root is configured are sealed; each stored body is read according to its own at-rest header, so plaintext and sealed rows coexist with no flag day and the exposure shrinks as mailboxes turn over.
Failure behavior differs by side, deliberately. A configured root that cannot be loaded fails startup, rather than silently storing mail in the clear — the one outcome an operator cannot detect from outside. At read time an unresolvable key refuses that single message transiently and leaves the rest of the mailbox listable.
2026-08-25 — v0.98.0 (on-chain publication can be rate-limited)
What changes: a new, off-by-default
[spooler.chain_budget]
section on sithbitd. It bounds how fast delivered mail is published to
Solana, and with it how fast the daemon spends from the gateway’s
fee-payer wallet.
The gap it closes. Recipients pay postage, but the transaction fee
for every SendMail comes from the operator’s wallet. [smtp] postmaster_wallet must accept mail from any sender (RFC 5321 §4.5.1) and
so skips the postage check by design — leaving an unauthenticated sender
able to drive fee-payer spend at whatever rate the connection limits
allow. v0.76.0 metered that (sithbit.chain.sendmail) and wrote it into
the threat model; nothing bounded it until
now.
spooler.chain_budget.max_per_window(0= unlimited) caps publications per window across every sender. This is the budget that bounds spend, and the one to set wheneverpostmaster_walletis set.spooler.chain_budget.max_per_sender_per_window(0= unlimited) caps one envelope sender. Fairness only — on the exempted path the sender is unauthenticated and freely varied, so it cannot bound total spend on its own. The threat model now says so explicitly rather than leaving it to be discovered.spooler.chain_budget.window_secs(60) is the reset span.
Over-budget mail is paced, never refused. It is accepted, stored and readable over IMAP/POP immediately; only the chain job waits, enqueued invisible until its turn. Nothing bounces, and a sustained flood grows the chain queue rather than being shed — mail is not destroyed to protect the wallet.
Nothing changes for an existing deployment. Both budgets default to
0, which is unlimited and costs no store round trip, so a daemon whose
operator sets nothing publishes exactly as immediately as before. The
production config documents spell the section out at those defaults; they
do not set postmaster_wallet, so they have no exposure to size a budget
against.
Two properties worth reading before relying on it: the window is fixed, so a burst straddling a boundary can reach up to twice the budget; and a store that cannot answer a charge fails open, publishing unpaced rather than stalling mail. Report traffic the daemon generates itself (DSNs, DMARC forensic, TLS-RPT) is deliberately unbudgeted, as it is already exempt from the per-wallet storage cap.
2026-08-25 — v0.97.1 (the durable switch is reachable from the production config documents)
What changes: documentation, and the shipped deployment artifacts it
describes. v0.97.0 gave both account-API rate-limit budgets a
durable
switch, but iac/appconfig/’s ready-to-import production documents carried
no rate-limit section at all — so on both clouds the switch could be set
only through an ACCOUNT_API_RATE_LIMIT__DURABLE environment variable, not
through the config store the rest of the deployment is configured from.
iac/appconfig/aws/account-api.toml now spells
[rate_limit]
and
[nonce_rate_limit]
out in full, and the Azure key/value set is regenerated from it, so the
switch is one edit and one regeneration away on either cloud.
The durable section gains a paragraph naming where to set it, and the
ordering that goes with it: iac/aws pins the account-api service to a
single task because, without a shared JWT signing key, each replica mints
its own and a token issued by one is rejected by the next. Give the
replicas a shared signing key first, scale out second, turn the switch on
third. The login-challenge budget’s example block also gains the durable
line its own key table already documented.
What you need to do: nothing. Both sections ship at the in-code
defaults — durable = false included — so no deployment’s behaviour
changes, and an operator who had already set the environment variable
still wins, since the real process environment outranks the cloud
app-config tier.
2026-08-25 — v0.97.0 (rate-limit budgets can be shared across API replicas)
What changes: the account API’s two rate-limit budgets —
[rate_limit]
over the sensitive account mutations and
[nonce_rate_limit]
over login-challenge issuance — gain a
durable
switch. Their windows have always lived in the API process’s own memory, so
two replicas behind a load balancer granted the same wallet two budgets and
three granted three; the effective ceiling scaled with the instance count.
With durable = true the windows move into the [store] every replica
already shares, and the budget is one budget however many instances charge
it. Every backend supports it — SQLite, PostgreSQL, DynamoDB, Azure Tables,
Turso/libSQL and Cloudflare D1.
- Defaults off, and nothing changes for an existing deployment. A single replica gains nothing from a store round trip per guarded request, and off is what lets a developer run with an empty config file and no store wiring. The two budgets carry the switch independently.
- It trades away the monotonic clock, which is forced rather than chosen. An in-memory window is timed by a monotonic instant no wall-clock change can move. A window shared between processes has to be comparable between them, and a monotonic instant means nothing outside the process that read it — so a durable window rides the charging replica’s wall clock. A backwards jump there can reopen a window early, and replicas whose clocks disagree disagree about a window’s edge. Keep the fleet on NTP, which every lease in the store already requires.
- A store error admits, and logs. The limiter blunts abuse; it is not an authorization boundary, and refusing during a store outage would lock every account out of its own settings and every user out of logging in. That is the same fail-open direction the in-memory table already takes when full.
- Lapsed windows are swept on a timer (five minutes, not a knob), because the stored table has no fixed size and the login-challenge budget is keyed on an unauthenticated, caller-supplied pubkey. On DynamoDB the sweep is the store’s own TTL rather than a delete, so removal is asynchronous and can lag by up to about two days — that bounds growth, which is the point, and a lapsed window is already ignored by the charge whether or not its row is gone.
Who is affected: operators running more than one account-api replica
against one store, who can now size a budget once rather than per replica.
Single-replica deployments and every client are unaffected: the refusal
wording, the 429, the Retry-After header and both budgets’ defaults are
unchanged.
2026-08-25 — v0.96.0 (the client-side attachment offload is actually reachable)
What changes: the client-side large-attachment offload described at v0.84.0 now runs in the shipping plugins. It was complete and tested but inert: both the Thunderbird and Outlook shells built their lockbox sealer without a pin function, and the offload is skipped whenever one is absent — so no SithBit client could produce an offloaded attachment, while the documentation described the capability as shipped. Reading offloaded mail was unaffected and always worked, including mail offloaded by a server-side sender.
- What a user sees. With a pin service saved in connection settings, attachments over 1.5 MiB are encrypted, pinned, and replaced by a reference before the 12 MiB envelope cap is measured — so messages that previously refused at the cap now send. Without one, nothing changes.
- What “configured” means, now that it decides something. A pin service URL must have been saved in connection settings; the field being pre-filled with the loopback development default is not enough. Arming on the value alone would point every install at a port that usually has nothing behind it and turn a large-attachment send that seals fine today into a failure.
- Where an offloaded attachment is pinned. Under
offload/{uuid}— the same pin namespace the server-side offload uses, so one operator sweep over the prefix covers both producers, and the name discloses neither sender nor recipient. Operators should note that a client-pinned attachment has no spooler job behind it, so nothing releases it automatically: it belongs to the same operator-sweep population as a relayed submission’s pins, and the shared prefix is deliberately what makes one sweep cover both.
Who is affected: plugin users sending large attachments with a pin service configured — for whom the feature now works rather than silently sealing inline or refusing at the cap. No server, deployment, or wire change.
2026-08-25 — v0.95.0 (stored messages keep their final CRLF)
A truncation fix, not a format change. The adopted smtp-proto grammar’s
DATA receiver was deleting three bytes at the <CRLF>.<CRLF> terminator where
it should delete one. Two of those three were the message’s own final CRLF:
RFC 5321 §4.5.2 has the sender end its
content on a CRLF and then append the terminating dot, so that line break is
body content. Every message SithBit received over DATA was therefore stored
one line break short, and a trailing blank line was lost outright. Upstream
fixed this in smtp-proto 0.2.2; the workspace pin has now moved to =0.2.3.
What changed for you. A message stored from now on ends exactly as its sender wrote it. In practice this is a single trailing CRLF, visible mainly where a body deliberately ends in a blank line or where a MIME closing boundary was left without its line break. Nothing already stored is rewritten, and nothing that worked before stops working.
What is not affected. Relayed mail never carried the defect: the outbound path re-adds a trailing CRLF when one is missing, so bytes leaving the relay were correct under both versions. DKIM signatures are unaffected in both directions — relaxed body canonicalization absorbs a trailing CRLF, so the body hash is identical either way, which is now pinned by a test rather than inferred from the RFC.
2026-08-25 — v0.94.0 (BREAKING: the CLI now content-addresses on CIDv1, as documented)
This is a conformance fix, not a new contract. The docs already specified
one import profile: SithBit “uses the modern CIDv1 form of these fingerprints
exclusively”, and the glossary
defines a single UnixFS import
profile — CIDv1, sha2-256, raw
leaves, 256 KiB balanced dag-pb, byte-identical to Kubo. The self-hosted node,
the gateway and the spooler all matched it. The sithbit command-line tool
did not: it computed CIDv0 with dag-pb leaves (Qm…), so those two sentences
were untrue of CLI-sent mail. The CLI now matches, and they are true.
What changed for you. sithbit mail send, sithbit campaign send and
sithbit campaign detail commit a different — and now correct — CID for the
same body: a 59-character bafk…/bafy… address instead of a 46-character
Qm… one. Nothing already sent is altered; a message’s CID is fixed in its own
on-chain record and stays resolvable wherever its bytes are pinned.
What breaks. sithbit mailbox pin
refuses a message whose on-chain CID is a legacy Qm…, by name rather than as
a mismatch, and does not pin it. This build cannot recompute a CIDv0, so it
cannot distinguish substituted content from a stale address — it fails closed
rather than misreport the cause. Messages sent by the CLI before this version
must be re-sent to gain a verifiable address.
The bug this closes. A body pinned through a self-hosted node
([ipfs] kind = "remote") was already addressed as CIDv1. sithbit mailbox pin recomputed it as CIDv0 and rejected it as a “CID mismatch” — reporting a
corrupt gateway when the real cause was two disagreeing profiles inside
SithBit itself. One profile, computed in one place, is what closes it.
2026-08-25 — v0.93.0 (BREAKING: the two report cadences become fixed periods)
What stopped working: window_hours and interval_hours under
[spooler.dmarc_report]
and
[spooler.tlsrpt]
no longer change anything. Both aggregate-reporting periods are now compiled
constants fixed at 24 hours. An operator who had set any of the four to another
value will find their reports move to a daily cadence on the next restart.
Your config still boots. The four keys are deliberately still accepted —
deleting the fields would make deny_unknown_fields refuse the whole document
at load, so an upgrade would stop the daemon over a setting that no longer
matters. Instead each key that was set to something other than 24 is named in a
warning at boot, with the value being dropped. A document that never mentioned
them, or that spelled out 24, warns about nothing.
Why fix them. A daily period is an interoperability contract rather than a local preference: RFC 9990 §3.1 aggregate reporting and RFC 8460 TLS reporting are both daily, and the receivers and report-ingest tooling on the other end expect a day. The pair could also be set to disagree with each other — the window is metadata claiming which period a report covers, so a drain cadence that differed from it shipped a report whose date range and rows did not match. One constant per protocol removes that. The two constants are independent despite both reading 24: they answer to different RFCs, and one moving must not silently move the other.
Scope. This is the first item of the configuration review’s Tier 3
(knob → compiled constant). The other thirteen candidates were considered and
deliberately kept configurable — the 0-means-disable escape hatches on the
server handshake and write deadlines, the cluster-sizing cadences a growing
fleet needs, the RPC- and billing-sensitive poll intervals, and [smtp] greeting, which is branding text. Only these four were fixed by a spec rather
than by preference.
2026-08-25 — v0.92.0 (sithbit-console joins the roster; every config-taking binary is now listed)
What changes: the configuration reference now lists the admin TUI’s
annotated example file, mail_console/sithbit_console.toml, and carries its
sithbit_console.toml / SITHBIT_CONSOLE_CONFIG / SITHBIT_CONSOLE row in
the per-binary table. Its four settings were already documented under
sithbit-console and
are unchanged. No key is added, removed or renamed, and no default moves.
What was wrong. This page said two binaries “ship no annotated example file”. That was true of the migrator until v0.91.0 — and it was never true of the console, whose example file has shipped all along with every entry commented out at its default. The sentence is gone, along with the last reason to keep either binary off the roster: eleven binaries take an annotated TOML file of their own, and the page now names all eleven.
Why it is worth an entry at all. The roster is not decoration — it is what the docs gate diffs. A binary on it has its example file’s key set checked against this reference in both directions, so a setting can no longer ship undocumented and this page can no longer describe a key the file does not show. Both newly-rostered binaries were outside that check until now, and the console’s own parse test said so in a comment for as long as it was true.
2026-08-25 — v0.91.0 (sithbit-migrate becomes a documented, example-shipping service)
What changes: the store-migration tool now ships an annotated
mail_migrate/sithbit_migrate.example.toml and has its own page on the
configuration reference,
sithbit-migrate settings.
It is the tenth binary on the reference’s roster — the intro count and the
canonical-example list both name it, and the per-binary table carries its
sithbit_migrate.toml / SITHBIT_MIGRATE_CONFIG / SITHBIT_MIGRATE row.
Nothing about the tool’s behaviour changes: no key is added, removed or
renamed, and a deployment that already runs it needs no edit. What changes is
that its settings are now documented and fenced rather than described in
passing.
Why it needed both halves at once. sithbit-migrate sat in the docs
gate’s EXCLUDED_PREFIXES with the recorded reason “no annotated example
TOML on the reference page”. Shipping the example file without writing the
page would have made that exclusion a lie; writing the page without the file
would have left the roster row asserting a file that does not exist. They land
together, and the exclusion is deleted.
The migrator’s shape, for the reader who meets it here first. Every other
binary carries one [store] section; the migrator reads one store and writes
another, so it carries two — [source] and [target], each a complete
[store] shape with the same per-backend sub-sections and the same defaults.
The new page documents the two and points at
[store]
for the sub-sections, since only the prefix differs. The one shape that does
not simply follow the prefix is Cloudflare, whose blobs come from
[target.cloudflare.r2] rather than [target.blobs].
Moving a store between backends keeps the operational
narrative — what moves, dry-run versus --commit, and the caveats — and now
points at the reference for the keys.
A fence the tool had been missing. Every other config-taking crate has a test that uncomments its shipped example one level and deserializes the result, so a renamed field cannot first surface on an operator’s uncommented line. The migrator had no example file and so no such test; it has both now.
2026-08-25 — v0.90.0 (the last two shared listener values: enable_stored_passwords and max_message_size)
What changes: sithbitd.toml gained the two top-level settings that
v0.89.0 could not carry —
enable_stored_passwords
and
max_message_size.
Both are additive defaults, like the four before them: a listener that
writes its own always wins, and a config file that never mentions either
behaves exactly as it did. Nothing is removed and no default moves, so an
existing deployment needs no edit.
enable_stored_passwords fills in [smtp], [submission] and [pop] —
the three listeners that advertise CRAM-MD5. [imap] has no such key,
since IMAP never offers the mechanism. Retiring stored mail passwords
fleet-wide remains a two-document change: this covers the daemon’s
listeners, and account-api’s key of the same
name is what stops new passwords
being stored.
max_message_size fills in [smtp], [submission] and [imap] — the
three that carry mail; [pop] accepts no uploads and has no ceiling. The
SMTP and IMAP ceilings were already documented as deliberately the same
number, so that a message which arrived can always be uploaded back; this
is what lets a deployment state it once instead of three times and keep it
true.
Why these two took a second pass. hostname and local_domains each
have a sentinel no operator writes on purpose — the built-in "localhost",
an empty list — so “wrote nothing” is recognisable. A lone bool and a
lone number have none: enable_stored_passwords = true on a section is
byte-identical to that section saying nothing. Both listener fields are
therefore optional in the code and read everywhere else through an
accessor supplying the default, so a shared value fills in the silent
listeners without overwriting one an operator deliberately wrote out.
One input is refused rather than inherited. A top-level
max_message_size = 0 fails startup, naming the key. To the SMTP listeners
0 means advertise SIZE with no fixed limit, while [imap] feeds the
same number to its literal cap and advertises it as
APPENDLIMIT,
where 0 refuses every APPEND — one shared key cannot mean both. Per
section it is untouched: [smtp] max_message_size = 0 still means what it
always did.
One doc row changed spelling, and the reason generalizes. The
[smtp]/[submission] table’s max_message_size row is now written
smtp.max_message_size, submission.max_message_size rather than bare.
A bare Key cell resolves at the top level as well as under its section, so
once a top-level key of the same name existed the bare row read as
documenting that one too. Any future top-level setting sharing a name with
a listener key meets the same wall.
2026-08-24 — v0.89.0 (sithbitd writes shared listener values once: top-level [tls], hostname, local_domains, [quota])
What changes: sithbitd.toml gained four top-level settings that fill
in the listener sections which named none of their own. Every one is an
additive default — a section that wrote the setting is untouched, so an
existing config file behaves exactly as it did. What changes is how much a
new one has to repeat.
| New key | Fills in | Left alone when |
|---|---|---|
[tls] | [smtp.tls], [submission.tls], [imap.tls], [pop.tls] | the listener wrote its own [*.tls] |
hostname | [smtp], [submission], [imap], [pop], [spooler] | the section names a hostname of its own |
local_domains | [smtp], [submission], [spooler] | the section’s own list is non-empty |
[quota] | [smtp.quota], [submission.quota] | the role wrote its own table |
Four listeners on one host almost always terminate the same wildcard
certificate — they must, since clients reach mail., imap. and pop.
of the same domain — so writing it four times meant four places to miss on
a renewal. The shipped production documents now name that certificate’s
two secrets once instead of four times, and the deployment’s hostname once
instead of five times. [imap] and [pop] keep their own hostnames,
which is the override path doing its job.
hostname and local_domains are bare keys, so — like
login_requires_mailbox and max_wallet_bytes before them — they must be
written above the first section header, or TOML reads them as keys of
whichever section precedes them. [tls] and [quota] are tables and can
sit anywhere.
A [submission] section now runs as submission without saying so.
mode = "submission" under a table named submission was ceremony; the
daemon fills it in when the table names no role. Writing mode = "mx"
there is still honoured and still runs a second MX listener — the value is
filled in, never forced, because that choice also decides the listener’s
sender-authentication posture and silently flipping it would change more
than a label.
Who is affected: nobody is required to change anything — this release
adds defaults, it does not remove settings. Operators writing a new
sithbitd.toml (or trimming an existing one) can collapse the repeated
blocks. One note for anyone copying the shipped production document: its
[submission] section previously named neither hostname nor
local_domains and fell back to boot-time chain discovery; it now
inherits the explicit shared values written at the top. That is the same
identity in every deployment the document describes, stated rather than
discovered.
2026-08-24 — v0.88.0 (BREAKING: two dead config keys removed; a silently-ignored gateway setting now says so)
What changes: three settings that parsed and then reached nothing are
gone, and one that quietly does nothing under sithbitd now announces
itself at boot.
[store.cloudflare] kv_namespace_idis removed. It was retired on 2026-07-19 when leases moved from Workers KV to D1’sleasestable, and kept as an accepted-but-ignored key for a migration window. That window is over: the section rejects unknown keys, so a config still naming it now fails at load with a message naming the key instead of starting while the operator believes a KV namespace is in use. Delete the line.[spooler.dmarc_ruf] extra_contact_infois removed. It was documented as “informational only”, which overstated it — the value was never copied into the forensic reporter’s settings, and the ARF forensic format has no field that could carry it. It now fails at load.[spooler.dmarc_report]keeps its ownextra_contact_info, which is real and is published.[smtp] grpc_endpoint/[submission] grpc_endpointundersithbitdnow warn at boot. They are read only by the standalonesmtp-serverbinary; under the daemon the gateway comes from the top-level[grpc]section, and these parsed and vanished. They still parse — the standalone binary needs the field — but the daemon now says it is ignoring them and points at[grpc] endpoint, the way it already does for a per-protocolauth_rate_limit. Silence there read as “the gateway is wired”, which was exactly backwards.
Also: sithbit-console’s shipped sithbit_console.toml gained the
uncomment-and-parse test every other shipped example already had, and the
duplicate mail_grpc/mail_grpc.toml and domain_sithbit/domain_sithbit.toml
are deleted — each was a byte-identical copy of the .example.toml beside
it, sitting under the exact filename its binary loads from the working
directory.
Who is affected: deployments carrying either removed key — a one-line
deletion each, and the load error names the key. Deployments that set a
per-role grpc_endpoint under sithbitd see a new boot warning describing
configuration that was never in effect; nothing about their behaviour
changes.
2026-08-24 — v0.87.0 (BREAKING: the account API’s [tls] keys are certs / key, matching every other listener)
What changes: account-api’s
[tls] section is now
spelled the way the mail listeners have always spelled theirs —
certs and key instead of cert_file and key_file. The section is
the same shape everywhere because it is now literally the same type: one
[tls] block definition shared by smtp-server, imap-server,
pop-server, sithbitd’s four listeners and the account API, so the two
names cannot drift apart again. Nothing else about the section changes —
both entries are still required when it is present, each is still a key
source
(a local file by default, or an akv / asm / gsm secret holding the
PEM), and an absent section still means plain HTTP.
There is no compatibility alias. An account_api.toml that still names
cert_file / key_file under [tls] fails at startup rather than
quietly listening on plain HTTP — the section rejects unknown keys, which
is what turns a stale name into a loud error instead of a silent downgrade
from https. Rename the two keys before upgrading.
Who is affected: only deployments that terminate TLS on the account API
itself — a two-line rename in one file. Deployments fronted by a reverse
proxy (the production recommendation) carry no [tls] section and are
unaffected, as are all mail-listener configs, which already used these
names.
2026-08-24 — v0.86.0 (the dashboards fit without scrolling: a five-tab account strip and a Mail / Account / Marketplace switch)
What changes: every graphical client’s signed-in dashboard — webmail, the Thunderbird and Outlook extensions, and the Chrome popup — used to be one long column: ten account panes, the extension-local Certificate sign-in section, and the marketplace, stacked, so routine actions sat well below the fold. The shared panes now sit under a five-tab strip — Wallet (Balances, Encryption key), Mailbox (Mailbox, Do not disturb, Close mailbox), Names (Aliases, Domains), Services (Reply bounties, Pinning leases) and Sign-in (mail password Settings, plus each client’s Certificate sign-in and connection settings) — and every client gains a top-level Mail / Account (Settings) / Marketplace switch, so the marketplace is a view of its own rather than the tail of every column. Hovering a tab for half a second shows a plain-language note of what it holds. A Lease this message click still lands on the Pinning leases pane: the shell switches to the account view and the strip opens its Services tab. Nothing moves on-chain or in any configuration: the panes, their controls, and the wire contracts are unchanged; only where they sit on the page is.
Who is affected: users of the four graphical clients, who find every control one or two clicks away instead of a scroll down. Nobody else — the CLI, the servers, and the protocol are untouched.
2026-08-24 — v0.85.1 (the Thunderbird message pane’s attachment save button actually saves)
What changes: nothing about which messages open or what they show. v0.85.0’s
Sealed attachments save button
looked correct in every test that did not run inside a real Thunderbird: a
live-verify walk found the button silently did nothing, because the
message-display script’s document is not the dashboard tab, and Thunderbird
does not let that content-script context trigger a download itself — the
identical anchor-click shape works fine from the dashboard. The already-
decrypted bytes are now handed to the extension background instead, which
saves them via the downloads API — the one context always running and
privileged enough to do it, needing no wallet and no dashboard tab.
Who is affected: Thunderbird extension users saving a sealed attachment
from the message pane, who previously got no file and no error. The
extension requests one additional permission (downloads) to save it.
2026-08-24 — v0.85.0 (lockbox mail opens in Thunderbird’s own message pane)
What changes: the Thunderbird extension now opens Lockbox mail where Thunderbird users actually read — the message pane itself — rather than only in the dashboard. Opening a sealed message replaces the armored block with its decrypted text under a notice line, lists the envelope’s Sealed attachments with per-file save (inline attachments decode locally; offloaded ones fetch from your own IPFS gateway and decrypt on your machine), and says so plainly when a message was sealed to a different recipient.
Two limits are deliberate and documented rather than worked around. The message pane holds no wallet: it hands the sealed block to the SithBit tab, which must be open with a wallet unlocked, so key material stays in the one context that already had it — closed or locked degrades to the armored block plus an unlock hint. And a sealed message’s rich HTML is not rendered in the message pane: the web clients have a sandboxed view for that and this pane does not, so sender-authored HTML is never parsed there.
Who is affected: Thunderbird extension users reading sealed mail. The
extension requests one additional permission (scripting) to install its
message-display script. No server, deployment, or on-chain change.
2026-08-24 — v0.84.0 (lockbox mail opens in the web clients, and large attachments offload client-side)
What changes: two client capabilities on the lockbox page, no ABI or wire change — MINOR per the widened rule.
- Reading sealed mail in the web clients. The shared mail reader — the webmail app, the Chrome extension popup, and the Outlook task pane — now opens a sealed message with the unlocked wallet: decrypted text and rich HTML render through the existing sandboxed views, a notice line names the state (opened, sealed to another recipient, or unlock-to-read), and the envelope’s attachments are listed under Sealed attachments with per-file downloads. An offloaded attachment is fetched from the recipient’s own configured IPFS gateway and decrypted locally; a fetch whose bytes disagree with the declared size is refused. Previously the web clients showed the armored block as plain text and no client rendered a sealed envelope’s attachments at all.
- Client-side large-attachment offload at send time. When the sending client has an IPFS pin service configured, attachments over 1.5 MiB (an eighth of the 12 MiB envelope cap, derived from it) are individually encrypted, pinned, and replaced in the envelope by a content id + key + size reference before the cap is measured — so messages that could not previously be sealed now can. The reference carries no URL (a content id outlives any gateway hostname), and configuring the pin service is the entire on/off switch. Without one, behaviour is unchanged: inline seal or a clear refusal at the cap.
Who is affected: web-client users (sealed mail and its attachments are now readable there); plugin users sending large attachments with a pin service configured. No server or deployment change.
2026-08-23 — v0.83.5 (classic Outlook: the XML manifest is the real path, and sealing fails closed there)
What changes: the Outlook client page no longer
claims classic Windows desktop Outlook runs the add-in via the unified
JSON manifest: live probes on classic desktop Outlook (16.0.20326) proved
that host never invokes the unified manifest’s messageSending handler,
its JS-only send runtime accepts no ES modules, and it has no outbound
network from the send hook. Classic Windows desktop now installs via the
add-in-only XML manifest (rendered to package/manifest.xml), its send
hook is a self-contained script, and Lockbox sealing is not available
at send time there — the hook states the reason in the message and the
send proceeds as typed. The sideloading section gains the classic-desktop
path; the Outlook-for-Mac XML fallback is unchanged.
Who is affected: classic Windows desktop Outlook users (their mail sends unsealed, with the reason stated); operators sideloading the add-in on classic desktop.
2026-08-23 — v0.83.4 (the dependency audit’s wildcard follow-up lands)
What changes: the 2026-08 dependency audit’s
Follow-up: the remaining wildcards
section now records its own completion: mail_client’s four wildcard
dev-dependencies were pinned on 2026-08-23 (assert_cmd = "2",
predicates = "3", regex = "1", and tempfile moved to workspace
inheritance of the root’s tempfile = "3"), with Cargo.lock
byte-identical — a notation-only manifest tightening, closing the last
wildcard anywhere in the workspace’s manifests.
Who is affected: contributors reading the audit; no runtime or test behavior changed.
2026-08-23 — v0.83.3 (the configuration reference splits into subtopic pages)
What changes: operate/configuration.md had grown to 2325 lines
covering every binary’s settings in one page; it is now a slimmed-down
overview — resolution order, key sources, cloud app-config sources, the
shared [health]/[observability] section, and a
What’s on each page index —
plus ten subtopic pages under operate/configuration/: sithbitd-core,
sithbitd-smtp, sithbitd-imap-pop-security, and sithbitd-spooler
(the daemon’s own settings, which were the bulk of the original page);
account-api (with sithbit-console, which shares its region);
domain-sithbit; ipfs-services (sithbit-ipfsd + [swarm] +
sithbit-gateway); mail-grpc; standalone-servers
(pop-server/imap-server/smtp-server); and env-files. Every setting
table and every cross-reference into the old single page — across the rest
of the book and this change history — moved with the section that
documents it; no setting’s default, meaning, or key spelling changed.
Who is affected: operators and integrators reading the docs; no runtime behavior changed.
2026-08-23 — v0.83.2 (mail-grpc and domain-sithbit get route indexes)
What changes: operate/mail-grpc.md and operate/domain-sithbit.md each
gain a Route index section, extending the
pattern account-api just adopted to the
other two services: every mail-grpc SolanaMail RPC (20 methods, not the
6 the page’s intro previously named — ListAliases, RefundMail,
ClaimBounty, RefundBounty, GetTransactionStatus, FindMessage,
GetMailDomain, ListAuthoritativeDomains, BrowseListings, ListSales,
ListParticipants, GetSenderAttestation, GetPinLease, and
GetSenderReputation were already served and undocumented at this level),
grouped by area with request/response type and one-line purpose; and every
domain-sithbit HTTP route, including GET /domain/{domain} — a DNS-only
verification-status check that needs no delegate key, previously undocumented
anywhere on the page.
Who is affected: operators and integrators reading the docs; no runtime behavior changed.
2026-08-23 — v0.83.1 (account-api gets a route index)
What changes: operate/account-api.md gains a
Route index section — every
account-api route, grouped by resource, with method + path + auth +
one-line purpose, linking into the page’s existing behavioral sections where
one exists. It replaces reading the crate’s lib.rs module doc comment (or
grepping the router) as the fast way to answer “does this route exist and
what does it need” without touching the Rust source; the crate’s own
lib.rs doc comment gained the three /v1/chain routes and the
POST /v1/mail/send route it had drifted out of sync with the router on
(browse_listings, sales, account, send_message — all already served,
none of it new behavior).
Who is affected: operators and integrators reading the docs; no runtime behavior changed.
2026-08-23 — v0.83.0 (the 2026-08 audit’s ready tier lands, with a bounded DMARC report decode)
What changes: the three dependency bumps the
2026-08 audit marked ready are taken —
jsonwebtoken 10→11, base64 0.22→0.23, and mail-auth 0.11→0.12. Two are
manifest-only. The third carries the one behavioral change, which is why this
is a MINOR rather than a PATCH entry: mail-auth 0.12 requires a max_size
bound on parse_rfc5322, so inbound DMARC aggregate reports are now decoded
under a 25 MiB decompressed ceiling.
That ceiling is a new enforcement default. Aggregate reports arrive as gzip/zip
attachments, so the wire size the SMTP SIZE limit already bounds says nothing
about the cost of expanding them; without a cap a small message could
decompress without limit. The value is deliberately the same 25 MiB as
[smtp] max_message_size,
keeping one size vocabulary instead of introducing a second. It carries no
knob: exceeding it is benign by construction, because ingestion is best-effort
— an unparsed report still delivers to the mailbox as ordinary mail, exactly
as a malformed one always has.
Who is affected: operators running [spooler.dmarc_rua_ingest], and only
those receiving aggregate reports above 25 MiB decompressed — such a report is
no longer parsed into the GET /v1/admin/dmarc-reports surface, though the
message itself is unaffected. Nobody else sees a change.
Where to read more: ingesting DMARC aggregate reports states the bound; the audit’s Tier 3 table records the remaining backlog and two corrections the bumps turned up.
2026-08-22 — v0.82.1 (a live, MSRV-aware dependency audit)
What changes: documentation and dependency hygiene only — no protocol, ABI, or behavioral change. A new Dependency audit — 2026-08 supersedes the 2026-07 audit, which now carries a pointer forward. The new pass reads “latest” from the live crates.io sparse index rather than the local index cache, covers all 121 workspace entries rather than only the major-version-relevant ones, and adds two dimensions the earlier audit had no coverage of.
The workspace is built by two toolchains nine Rust versions apart — the host
at rustc 1.98, and cargo build-sbf --tools-version v1.53 at rustc 1.89 — and
the workspace gate deliberately excludes the build-sbf legs. A dependency
raising its MSRV past 1.89 therefore breaks the on-chain programs while every
gate leg stays green. In this audit MSRV blocked nothing: the only packages
above 1.89 are the AWS SDK cluster at 1.94.1, none of which reaches the
bytecode.
What did block it was a wincode split. The solana crates are mid-migration
between two majors of that serialization-schema crate: solana-address,
solana-hash, solana-message, solana-signature, solana-transaction and
two others have moved to wincode 0.6, while
solana-transaction-status-client-types still requires wincode 0.5 — at its
newest release as well as its current one. A blanket cargo update resolves
cleanly and then fails to compile, because the two copies of the
wincode::SchemaWrite trait are different types. The solana stack must move
as one coordinated set, and today it cannot move at all.
Landed alongside the audit: its safe tier — 43 non-solana packages, lockfile
only, verified through both toolchains — and a fix for mail_client’s
ipfs-cid = "*", the last wildcard in a production dependency table, which
resolved safely only because the newer major happens to be unreachable through
a yanked transitive.
2026-08-22 — v0.82.0 (Lockbox: the plugins seal HTML and attachments, not just the body)
What changes: the Thunderbird and Outlook plugins now read the whole
composed message out of the host mail client and seal it as one unit — the
text body, the rich HTML body, and every attachment — where before they handed
the sealing envelope only the plaintext body. The envelope format itself is
unchanged: it has carried html and attachments since the engine landed, so
this release is the clients catching up to it, and messages sealed by earlier
plugin versions remain readable. Attachment bytes ride inside the envelope,
so the originals are detached from the outgoing message once the seal
succeeds, and the 12 MiB pre-seal cap now bites on body-plus-attachments
together. A plain-text message with no attachments still seals to exactly the
bytes it did before.
Two message shapes now refuse to seal, both failing closed — the message is sent unencrypted, exactly as typed, and the plugin says why; it is never sent partly sealed, and the send is never blocked:
- inline (embedded) images, which live in the body as
cid:references that neither host’s plugin API can enumerate — so they cannot be sealed, and rewriting the body around them would either destroy the image or leave it readable beside a sealed body; - cloud attachments and attached messages, which the host hands over as a link or an opaque item rather than as bytes.
Both are described, with the workaround for each, in Lockbox mail.
2026-08-20 — v0.81.0 (IMAP QUOTA: clients can read the storage cap)
What changes: the reference IMAP server now serves
RFC 9208 QUOTA — GETQUOTA and
GETQUOTAROOT over one per-wallet quota root (the conventional ""),
advertised as QUOTA plus QUOTA=RES-STORAGE after authentication. The
root exists exactly while
max_wallet_bytes
sets a cap; an unbounded account (the default, 0) reports no quota roots.
SETQUOTA parses but is always refused — quota limits are operator
configuration — and QUOTASET is never advertised. STORAGE reports the
enforced, per-blob-deduplicated stored-bytes meter (the very number
[OVERQUOTA] refusals are measured against) in 1024-octet units, usage
rounded up and the limit rounded down; the deliberate deviation from
RFC 9208’s per-copy sum, and the client-reconciliation caveat it implies,
are recorded in
the conformance appendix.
Enforcement itself is unchanged — this release makes the cap visible to
mail clients, it does not alter what is refused.
2026-08-19 — v0.80.0 (the oversized-body refusal becomes machine-readable)
What changes: the 422 Unprocessable Entity that account-api answers when
a request body crosses
the request-body ceiling
now carries the machine-readable token {"error":"body_too_large"} in place
of the prose {"error":"request body too large"}. The status, the ceiling
itself (2 MiB, axum’s own default named explicitly), and which requests are
accepted are all unchanged — only the body string moved, from a sentence to a
token a client can branch on, in the step_up_required mold. The old prose
was never documented as a contract; the token now is, byte-exact.
What does not move with it: every other 422 on the surface keeps its
per-route prose — the token belongs to the ceiling alone, so matching the
exact string is safe where matching the bare status is not. And 413 still
means over storage quota and only that (v0.79.0’s cap, item 64’s split), so
the client contract stays one line: 413 = free space, 422 body_too_large = send less.
2026-08-19 — v0.79.0 (an aggregate per-wallet storage cap, enforced on every delivery path but one)
What is new: max_wallet_bytes, a top-level setting on both
sithbitd
and account-api, capping the aggregate bytes one wallet may hold — every
mailbox it owns, with a blob counted once per wallet however many of that
wallet’s mailboxes reference it. 0, the default, is unbounded, and an
uncapped deployment issues no extra store read at all, so no existing
deployment changes behaviour or cost.
What a full wallet now sees. An SMTP delivery answers 452 4.2.2 Mailbox full, an IMAP APPEND answers NO [OVERQUOTA], and account-api’s compose
answers 413 Payload Too Large. All three are transient: a recipient who
prunes receives the mail on the sending server’s next retry, with no bounce in
between. Neither the configured limit nor the attempted total appears in any of
those replies — they reach the operator’s log and nothing else.
Bounce reports are exempt, and that is a decision rather than an oversight.
A delivery-status notification for a message the wallet itself sent is written
directly to its INBOX, never through the capped path, because the one report
that must survive a full mailbox is the one saying the mailbox is full. The
cost is a bounded trickle — a sender can push their own stored bytes slightly
past the ceiling by sending mail that bounces, bounded by their own outbound
volume, which [quota] already limits.
Why two config surfaces rather than one. POST /v1/mail/send builds its own
spool from account-api’s config, not from sithbitd’s, so a ceiling held only by
the mail servers is one any sender walks around by composing in webmail. The two
keys must be kept in step by the operator; no process reads the other’s file, so
nothing checks the agreement at startup.
The SMTP verdict answers for the whole envelope, as delivery verdicts always have: a message to five recipients, one of them full, is refused to all five and re-sent to all five.
2026-08-19 — v0.78.1 (a bad key-source kind value and a wrong field type are named)
What changes: nothing about which configurations load. A
key source
whose kind names no kind (kind = "avk"), and one whose field carries the
wrong type (path = 5), were already refused — but both failed while the table
was still being parsed, where the untagged form retried its other shapes and
reported only serde’s “data did not match any variant”. Both are now named:
kind = "avk" is not a known kind; expected one of "file", "akv", "asm", "gsm" and table field `path` must be a string, found integer. The list of
kinds quoted in the message is the same list that accepts a spelling, so a kind
can never be accepted without appearing in the error that rejects its
neighbours.
This completes what v0.77.0 and v0.78.0 began. Those two named a field belonging to a different kind and to no kind — both about the field’s name. This one covers its value and its type, which were the remaining ways to reach the opaque message.
Kind spellings are exact and case-sensitive, and are now fenced against
widening as well as narrowing: a matcher that grew laxer would let
kind = "AKV" reach a cloud secret manager without any configuration
breaking, so nothing would report it.
Still not named: a key that is neither a string nor a table (key = 5)
matches no shape at all and still reports the untagged message.
2026-08-19 — v0.78.0 (BREAKING: a key-source table refuses a key belonging to no kind)
What stops working: a key source
table carrying a key that no kind defines — a typo such as regoin for
region — now refuses to load, naming the key. Two shapes that
previously loaded clean are affected: a misspelled optional field,
which used to be discarded and leave the setting at its default, and a
stray key alongside an otherwise-valid table. A misspelled required
field already failed, but blamed the missing field rather than the typo
that caused it; it now names the typo.
This completes what v0.77.0
began. That entry made a table refuse a field belonging to a different
kind; this one makes it refuse a field belonging to no kind. The two
messages differ so an operator can tell the mistakes apart —
kind = "asm" does not accept `path` versus
table has an unknown field `regoin` .
Every documented shape parses exactly as before — bare string, kind-less
table, and all four explicit kinds — and no checked-in config, example, or
IaC profile in this repository carries an unknown key, so nothing in-tree
changes behavior. As with v0.77.0 the break is for out-of-tree deployments,
and the likeliest shape is a typo that has been silently doing nothing for
some time: the setting an operator believed they had configured was never
in effect, and the refusal is how they find out.
Still not named: a kind whose value is unknown (kind = "avk") and
a field of the right name but the wrong type (path = 5) are refused, but
report only serde’s “data did not match any variant”. Both fail while the
table is still being parsed, where the untagged form retries its other
shapes; naming them needs those fields widened and validated afterwards.
(Superseded by v0.78.1 above, which did exactly that — both are now
named. This paragraph is kept as the record of what was true at v0.78.0.)
2026-08-19 — v0.77.1 (the nonce budget’s table is described as its own)
The paragraph introducing
[nonce_rate_limit]
listed the ways the login-challenge budget matches [rate_limit] and
included “the same bounded fail-open table”. Read on its own that phrase
suggests the two budgets share one map. They do not: each limiter
builds its own table, and the sentence already said so a few words later
(“a separate budget: spending one never touches the other”). The
phrase now reads “a table of its own, bounded and fail-open the same
way”, so the sameness claim is about the design and cannot be misread
as a shared instance. Wording only — no behavior, defaults, or knobs
changed.
2026-08-19 — v0.77.0 (BREAKING: a key-source table refuses a field belonging to a different kind)
What stops working: a key source
table that mixes kinds — vault_uri under kind = "file", project
under kind = "asm" — now refuses to load, with an error naming the
offending field. That combination previously parsed: the chosen kind’s
fields were read and every foreign field was silently discarded, so a
half-finished migration between secret managers went on reading from the
source the operator believed they had left behind. No checked-in config,
example, or IaC profile in this repository sets a foreign field, so
nothing in-tree changes behavior; the break is for out-of-tree
deployments carrying a stale one. The likeliest shape is an environment
override rather than a TOML edit — {PREFIX}_..._KIND switched while the
previous kind’s ..._VAULT_URI or ..._SECRET_ID stayed exported, since
the env tier layers on top of the file.
A MINOR bump under the pre-launch rule: the break rides the MINOR digit, and the change is a new enforcement default affecting deployments.
This brings key sources level with the [store.blobs] table, which
already refused a foreign field by name; the two are now validated the
same way, each rejecting on the first foreign field in declaration order.
One narrower gap stays open and is documented where it bites: an
unknown key (a typo such as pth = …) is still ignored for key
sources, because the form is an untagged serde enum — any failure inside
a variant makes serde retry the next one and report only “data did not
match any variant”, which would destroy the field-naming diagnostics the
missing-field errors depend on. Foreign-field rejection therefore happens
after deserialization, where the field names are still known.
2026-08-18 — v0.76.1 (the MODSEQ-parentheses wait: upstream fix merged, release still pending)
No behavior change; the QRESYNC blocker’s status moved. The
RFC 7162 conformance section’s
documented wire deviation — imap-codec (pinned 2.0.0-alpha.9) encodes
the FETCH MODSEQ data item without RFC 7162 §7’s required parentheses
— now records that
imap-codec#723, the fix
for the tracking issue this project filed
(imap-codec#722),
merged into upstream main on 2026-08-18. Checked the same day:
crates.io’s newest published imap-codec version is still 2.0.0-alpha.9
(2026-07-19) — the fix has not shipped in a release yet, so the pin and
the deviation are unchanged. The RFC 7162 QRESYNC follow-up wave stays
queued; its unblock signal moves from “fix merged” to “fix released” —
see HANDOFF.md’s backlog entry for the wave.
2026-08-18 — v0.76.0 (BREAKING: proxy_protocol requires a proxy_trusted allowlist; gateway spend guardrails)
What stops working: a listener configured with proxy_protocol = true and
no proxy_trusted entries now refuses to start. That combination previously
ran, trusting every peer to assert any client address. Operators running PROXY
protocol must name the balancer’s CIDRs — or, to keep the old
trust-everyone behavior deliberately, list ["0.0.0.0/0", "::/0"]. No shipped
config, compose file, or IaC profile in this repository enables
proxy_protocol, so nothing in-tree changes behavior; the break is for
out-of-tree deployments that turned it on.
A MINOR bump under the pre-launch rule: the break rides the MINOR digit, and the security remediations below are default-behavior changes affecting deployments.
proxy_trustedis now required wheneverproxy_protocolis on. The allowlist is what keeps a reachable port from accepting a spoofed client address, and a spoofed address feeds DNSBL decisions, per-peer connection limits, SPF, the cross-connection login budget, and audit attribution alike. An empty allowlist now permits no peer rather than every peer, and the proxy-on/empty-list combination fails at server construction with an error naming the key and the wildcard escape hatch. The four shipped example configs show a populated allowlist, and Scaling out, Deployment and the glossary state the requirement.- Two new mail-grpc keys guard the
gateway’s wallet. The gRPC surface authenticates nobody by design —
reachability is the boundary
— so
allow_remote_bind(defaultfalse) makes widening that boundary explicit: a non-loopbackbind_addrnow fails startup naming the address and the key.max_bounty_lamports(default100000000, 0.1 SOL) caps aSendMailrequest’sbounty_lamports, which the gateway escrows from its own wallet and which was previously unbounded and caller-chosen; over-cap requests are refused withINVALID_ARGUMENTbefore reaching the chain. Everyiac/template sets the opt-in beside its0.0.0.0bind, so no shipped deployment changes behavior. The topology appendix gains the guardrails and hot-wallet funding guidance for the signing key. - A fee-payer balance floor stops a drained gateway from failing every
write the slow way.
fee_payer_floor_lamports(default10000000, 0.01 SOL) refuses the five write RPCs withUNAVAILABLEand reports/readyznot-ready once the signing wallet falls below it, so an unfunded gateway is one refusal and an alert rather than a queue of preflight failures. The balance is sampled on a background timer, so the check adds no per-request round trip, and a balance never read successfully permits writes. Monitoring gains thefee_payerreadiness flag. - Chain rejections and infrastructure trouble now reach callers as
different gRPC codes. A transaction the cluster or program rejected
answers
FAILED_PRECONDITION(resubmitting the same bytes can never land), while an exhausted poll budget, repeated blockhash expiry, or a drained fee payer answersUNAVAILABLE(an identical request may succeed later). Both previously arrived as an opaqueUNKNOWN, which left callers unable to tell doomed work from recoverable work. No proto change — the RPC signatures are untouched. - A rejected copy no longer burns eight retries to reach the same
answer. The chain worker reads the codes above: a rejection marks the
copy
chain_failedand buries the job on the first answer, while transient trouble retries as before (an unrecognized code stays transient, so an older gateway behaves exactly as it does today). Previously every failure — including a permanent one like a missing stamp — consumed all eight attempts, each up to five submissions and a minute of polling inside the gateway. - New metric
sithbit.chain.sendmailcounts submissions by outcome (sent/deduped/fatal/retry) — the volume signal behind the gateway’s wallet spend. See Monitoring. - The threat model states who pays for chain
writes. Stamps price the sender, but the transaction fee comes from
the operator’s gateway wallet, so cheap postage moves that cost onto the
operator. The new section names what bounds it and the two gaps left
open deliberately: the
postmaster_walletexemption enqueues chain jobs without a postage check, and there is no per-sender chain-submission budget. - Operators can now retire stored mail passwords. A new
enable_stored_passwords(defaulttrue, so nothing changes unless set) appears on account-api, wherefalserefuses to store a new password, and on the SMTP and POP listeners, wherefalsestops advertising CRAM-MD5. Setting all of them makes a deployment wallet-signature-only and therefore sealed at rest for every account — previously an operator had no way to decline the mode, and any account that set a password got readable server-side copies. Passwords already stored keep verifying until cleared withDELETE /v1/account/password; clearing and declaring the wallet-auth state stay allowed with the switch off, since both move accounts toward sealed storage. IMAP never offered CRAM-MD5 and is unaffected. The threat model gains the entry it was missing on what a stored password costs, and privacy states the trade in the reader’s terms.
2026-08-17 — v0.75.2 (operator setup moves out of the GUI-clients pages into the technical reference)
A PATCH bump: nothing behavioral moves. No on-chain ABI, instruction,
error-code, economic or default-behavior change, no config key added, removed
or renamed, and no command, route or [[static]] rule reworded. What moves is
which page states the operator’s half of the browser clients. The
GUI clients topic is written for the person using a
client, and four of its pages opened with a What the operator must run
section — account-api config, [chain] requirements, [[static]] mounts — that
a reader who was handed a URL has no use for and cannot act on. Those sections
are operator content sitting in an end-user topic.
- New page: Serving the browser clients. Under
Operating a SithBit Server → Go-live essentials, beside the
configuration reference and
DNS setup. It collects, per client, what account-api must
have configured, and the
[[static]]mounts andbuild.shinvocations for the two bundles an operator actually serves — the webmail app and the standalone marketplace page. The[[static]]array-of-tables rule, which the three server-hosted pages each restated in their own words, is now stated once and points at the[[static]]list for the refusals. - The four What the operator must run sections are gone from the client pages, replaced by what a reader needs to know: which panes go unavailable when their operator has not configured the chain surface, and an operator-addressed pointer to the new page. Webmail’s Building and serving and the marketplace’s Building and serving the standalone page moved wholesale — there is no end-user in either, since both bundles are served, not installed.
- The three extensions keep their build-and-install sections, because with
no store listing published yet (Chrome,
Outlook, Thunderbird) building
the package is the only way a user installs one today. They are reworded as
the install route they are rather than left reading as deployment steps. The
one operator-only fragment among them, Outlook’s
[[static]]mount for the taskpane origin, moved to the new page. - Nothing was deleted. Every moved paragraph is on the new page; the only
prose that changed is the framing sentences around it and the three
[[static]]restatements collapsed into one. - The pin lifecycle caveat stays where it is — it already announces its audience in its own heading, and it is an argument about a client’s behavior that a reader of the trustless page needs in place. The new page links to it instead.
2026-08-17 — v0.75.1 (a throttled login challenge is no longer told it made too many account changes)
A PATCH bump: no on-chain ABI, instruction, error-code, economic or
default-behavior change, no config key moves, and no budget switched, sized
or keyed differently. What moves is the sentence inside one refusal body —
the machine-readable half of that response, the 429 status and the
Retry-After header, is byte-identical to before, so a client that reads a
429 the way these pages have always told it to notices nothing. It corrects
the one thing
v0.75.0
left reading oddly: the new per-pubkey budget reused the mutation budget’s
refusal verbatim, so a caller who had asked for nothing but a login challenge
was told it had made too many account changes. That entry stands as the
record of what v0.75.0 shipped; this one states the change.
POST /v1/auth/noncewords its own 429. Over-budget challenge issuance now answers{"error": "too many login challenges; retry in N seconds"}. The five step-up-gated mutations still answer{"error": "too many account changes; retry in N seconds"}, byte for byte, so nothing that matches the mutation string moves. Each limiter now names the budget it enforces and stamps that identity into every refusal it hands back, which is what lets the prose name what the caller actually spent: the two are wordings of one shape rather than one shared string, and everything else about them is unchanged —429,Retry-Afterin whole seconds floored at1, and a fixed window per budget, each spent without touching the other. The[nonce_rate_limit]section no longer claims the two share a rendering, and the refusal table splits its shared row in two — one budget per row, both carrying the header. That split is why the table’s heading is now Which refusals carryRetry-After, and which cannot: the count came out of the heading deliberately, an ordinal being the one thing a heading cannot keep once a row is split. The advice both pages now give a client author is the durable form of all this — key off a 429’s status and its header, never off its prose.
2026-08-17 — v0.75.0 (login-challenge issuance is rate-limited per pubkey, and the daemon sweeps expired challenges hourly)
A MINOR bump on the count v0.10.0 widened the digit to cover: two default-behavior changes that affect every deployment — a rate limit that ships on for a public route, and a new unconditional background deleter. No on-chain ABI, instruction, error-code or economic change. Both changes close the residue v0.74.0 left stated in the open — the challenge row written unauthenticated, on an un-rate-limited route, with no expiry sweep — on its second and third counts; the row itself is still written unauthenticated, which is what a login challenge is.
- New config section, on by default:
[nonce_rate_limit].POST /v1/auth/noncenow answers to a budget of its own — the same knobs, defaults (30 per 300-second fixed window) and semantics as the account API’s[rate_limit], but keyed on the ed25519 pubkey in the request body, since the route is unauthenticated and names no wallet. It is charged after the pubkey parses (a malformed request never spends a slot) and before the store is touched (a refused caller never writes a row). The refusal is the mutation budget’s exact rendering —429withRetry-Afterin whole seconds, and the shared body prosetoo many account changes; retry in N seconds, deliberately reused rather than forked even though no account change was asked for. Two honest boundaries ride the section’s docs: the counters are per-replica in memory (two replicas grant a pubkey two budgets), and the budget caps per-key hammering only — each fresh pubkey arrives with a fresh budget, so the challenge-row residue’s distinct-keys bound is not tightened. The step-up challenge (POST /v1/auth/step-up) stays deliberately unlimited: it demands a valid token and can only clobber its holder’s own slot. See[nonce_rate_limit]and Which refusals carryRetry-After. - Every deployment running
sithbitd: expired login challenges are now deleted hourly. The daemon grows an unconditional nonce-prune worker — no config knob, on purpose: each challenge row carries its own ~300-second expiry set at issuance, so an expired one is garbage by definition and there is no retention policy to configure, unlikedead_retention_days’ buried jobs an operator may want to inspect. It runs even with[spooler] enabled = false, because a listeners-only instance shares the store the rows live in. The one posture unchanged: a deployment running the account API with no daemon over its store still retains expired challenges between logins — the API deletes a challenge only when it is consumed. Noted with the daemon’s other prunes under[spooler], and in the residue paragraph of When the account row is created, which no longer claims the route is un-rate-limited or the rows retained unconditionally — both halves of that sentence went false this release.
2026-08-17 — v0.74.0 (asking for a login challenge no longer creates an account, and an IMAP or POP login can be made to require an on-chain mailbox first)
A MINOR bump on both counts the versioning preamble names: a default-behavior change that affects every deployment, and a significant additive capability. No on-chain ABI, instruction, error-code or economic change. Both halves close one finding — a SithBit login is self-proving, so the credential proves a keypair rather than an account, and until now anyone who could generate a keypair could make the servers write storage for the address it names.
- Every deployment, whatever it configures:
POST /v1/auth/nonceno longer creates the account row. The account API used to write the row while handing out the login challenge — before any signature had been looked at — so an unauthenticated caller minted an account row for any address merely by asking for a challenge. Provisioning now runs insidePOST /v1/auth/token, the moment the signature verifies and ahead of the JWT; a store failure is a500with no session handed out rather than a token for an account that was never written. No operator action, no migration, and nothing an honest client can notice — first-use provisioning moved, it did not go away — but the observable effect is real: a run of challenge requests for addresses nobody holds a key for now leaves no accounts behind. See When the account row is created. The residue is stated there too, because it is still open: the challenge row itself is still written unauthenticated, on an un-rate-limited route, with no expiry sweep. - New switch, off by default:
login_requires_mailbox. With it set,sithbitdadmits an IMAP or POP session only for a wallet that owns an on-chain mailbox, asked of the chain gateway before the session-open writes anything — the INBOX row on IMAP, the maildrop lease and the INBOX row on POP. It is a top-level key rather than one under[imap]or[pop], so the two protocols cannot end up with different postures, and it shipsfalse: nothing about an existing deployment changes until an operator turns it on. Seelogin_requires_mailbox— the on-chain mailbox login gate. - What a refused login is told, and what an outage is told instead. The
refusal is one text on both protocols, byte for byte —
no on-chain mailbox for this account; create one before logging in— underNO [AUTHORIZATIONFAILED]on IMAP and-ERR [SYS/PERM]on POP. Both codes say authorization, not authentication, because the credential proved out and sending the holder back to re-enter a working password would be a lie; the remedy issithbit mailbox create. A gateway or chain outage is told something else entirely — an IMAP temporary authentication failure, a POP[SYS/TEMP]— since a passing outage must never lock a real account out. - The trap to read before setting it.
login_requires_mailbox = trueon a daemon with no[grpc]section has no gateway to ask, and the gate then admits everyone. The daemon warns loudly at boot and starts anyway: refusing to boot would break the zero-config dev stack, which is a promise the whole configuration layering rests on. Treat that warning as “the gate is off”. - What the gate does not reach. SMTP submission authentication is not gated — it creates no account storage of its own — and neither is the account API’s wallet-challenge login, a separate binary with no switch of its own. That surface answers the same finding the other way, in the first bullet above.
- The example file shows the new key at its default.
sithbitd.example.tomlcarries# login_requires_mailbox = falsecommented out with the rest, above the first section header — where a top-level key has to be written, or uncommenting it in place would file it under whichever section came before.
2026-08-17 — v0.73.20 (the scaling page stops naming store-backend gate variables, and the book’s last restated roster family goes to its owner)
No on-chain ABI, instruction, error-code, economic or behavioral change — a
PATCH bump, v0.73.19’s class one day later. The version rolls because the docs
moved again, not because anything about the protocol did. It closes the move
v0.73.18
opened and
v0.73.19
continued: of the three SITHBIT_TEST_* gate-variable families (key sources,
cloud app-config, store backends), the store-backend family was the last one
this book still restated anywhere, and it now defers to its owner the same
way the other two do.
- What already just works and
Which stores support which
split no longer spell out
store-backend gate variables. The scaling page held the family’s two
remaining mentions: the POP lease provenance caveat waved at the roster
with a bare
SITHBIT_TEST_*glob, and the postgres role-split walk namedSITHBIT_TEST_POSTGRES_URLin full. Both now send the reader tomail_store/README.md’s Tests section, written as a repository path rather than a link with the “(outside this book)” note, the way v0.73.18 wrotekey_source/README.mdand v0.73.19 wroteapp_config/README.md— and this owner is the strongest of the three: the crate README spells all nine names out and is pinned name-for-name bymail_store/src/readme_pin.rs, a scan that lives outside every cargo feature gate precisely so a backend-slim build cannot compile the fence away. The glob went too, not just the spelled-out name, matching the zero the precedent pages kept. The semantics stay: the caveat still says the conformance suite runs in every build on SQLite, Turso and D1 while the DynamoDB, Azure Tables and Postgres implementations wait on configured test endpoints, and the role-split walk is still gated on a postgres endpoint booting from the checker-tracked[store] kind = "postgres"citation, which is untouched byte for byte. The book’s only remainingSITHBIT_TEST_spellings are this page’s own historical entries, which record moves rather than restating a roster.
2026-08-16 — v0.73.19 (the cloud app-config section stops restating its live probes’ environment variables too, and the book’s last unfenced copy of a gate-variable roster goes)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump, v0.73.18’s class and v0.73.18’s date. The version rolls because the docs moved again, not because anything about the protocol did. It finishes, one paragraph further down the same page, the move v0.73.18 made for the key sources — and it is the case v0.73.18 examined and declined, reopened on the argument that a copy nothing fences is worth retiring before it rots rather than after.
- Cloud app-config sources
no longer names the
#[ignore]d live probes’ environment variables. Unlike the key-source copy, this one had not drifted: all eight names it spelled out still agreed withapp_config/README.mdname for name on the day it was replaced, which is exactly why v0.73.18 left it standing. What moves it anyway is that nothing was holding it there:app_config’s own guard — the testreadme_documents_live_test_gate_vars, which scans the crate’s sources forSITHBIT_TEST_*literals and fails if any is missing from the crate README — sees the crate’s two copies and is structurally blind to a third inside this book, which is precisely the mechanism that had already cost the key-source paragraph two names before v0.73.18 found them missing. Agreeing today is a fact about today, not a fence. The prose keeps what a pointer cannot say — that the mapping logic (bootstrap parsing, TOML merge, key nesting) is unit-tested against injected fake fetches, so CI covers it without credentials; that the AWS probe can be aimed at an emulator instead of the real service; and that the AWS round trip needs ambient AWS credentials while the Azure one needs an ambient managed identity — and sends the roster itself toapp_config/README.md’s Tests section, written as a repository path rather than a link, the way v0.73.18 wrotekey_source/README.md. A checker pinning this page to the crate was declined here for the same reason it was there: fencing a third copy keeps three copies, and one owner is the cheaper shape. v0.73.18’s closing note that this paragraph was deliberately left alone records the book as it stood that day, not as it stands now.
2026-08-16 — v0.73.18 (the key-source section stops restating the live probes’ environment variables and defers to the crate that owns the roster)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump, v0.73.17’s class and v0.73.17’s date. The version rolls because the docs moved again, not because anything about the protocol did. It is the same move as v0.73.16’s — a page that had been restating an enumeration hands it to the place that owns it — made for the first time toward an owner that lives outside this book.
- Key sources
no longer names the
#[ignore]d live probes’ environment variables. The closing paragraph spelled six of them out, and it had already drifted:key_source/README.mddocuments eight, the two missing here being the optional Key Vault secret name (which defaults totest-secret) and the optional GSM secret version (which defaults to the API’slatest) — both omitted, not renamed, so a reader following this page alone could not tell a probe to read anything but the default. That made this the third copy of one roster, the crate’s tests holding the first and its README the second, and only those two are fenced against each other: a test inkey_sourcescans the crate’s sources for gate-variable literals and fails if any is missing from the README, which is exactly why the README is the copy that cannot fall behind and this page was the copy that had. The prose now keeps only what a pointer cannot say — that there is no local Key Vault emulator, so the AKV round trip is only ever an#[ignore]d probe against a real vault; that an ASM probe can aim at LocalStack instead; and that per-kind dispatch is unit-tested against a fake fetcher, so CI covers the dispatch while a real cloud covers the round trip — and sends the roster itself tokey_source/README.md’s Tests section, written as a repository path rather than a link, the wayiac/README.mdandwebclients/README.mdare named elsewhere in the book. A checker pinning this page to the crate was considered and declined: fencing the third copy keeps three copies, and one owner is the cheaper shape. The cloud app-config paragraph a screen below still spells its ownSITHBIT_TEST_AWSAPPCONFIG_*/_AZAPPCONFIG_*variables out and was deliberately left alone — it matchesapp_config/README.mdname for name, so there is nothing rotted there to fix.
2026-08-16 — v0.73.17 (the icon legend’s frozen table loses the method line beneath it, and the sweep that table scoped reads as finished)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump, v0.73.16’s class and v0.73.16’s date. The date does not roll and the version does: the docs moved a second time today, and nothing about the protocol moved at all. Both edits are the same page settling after v0.73.16 dated its term-frequency table — prose that the dated paragraph had made redundant, and a lead-in still written as though the work that table scoped were ahead of the reader rather than behind them.
- The icon legend’s frozen
term-frequency table no longer carries a method line beneath it. The
sentence read “These counts are a point-in-time snapshot of the term
distribution that justified the icon set, not a live tally — adding pages
naturally shifts them — and each was measured with
grep -rlwi <term> mail_docs/src --include=*.md”, and every proposition in it is already made, and made better, by the dated-provenance paragraph standing above the table — which additionally names the day (2026-07-12) and the docs version (v0.2.1) this line never did. What the line added over that paragraph was the command, and a command printed under a table that is frozen on purpose promises a reproducibility no commit delivers: replaying it against the commit that introduced the table reproduces seven of the fourteen rows and not the other seven, twenty-five consecutive commits were scanned without one matching all fourteen, and the mismatches run in both directions — so it is not a denominator effect that a corrected command would fix. The honest record of a figure nothing reproduces is its date, which the paragraph above already carries. The sentence went whole rather than being trimmed to its still-accurate half: a fragment would have left a weaker restatement of the paragraph above sitting under the table, which is the same-page duplication v0.73.16 spent two entries removing. Not one row of the table moved, and none was re-measured. - The same page’s lead-in stops calling a finished sweep “later”. The sentence under which the table sits said the count “also scopes the later book-wide application sweep — the higher the count, the more pages the sweep touches”, and that sweep finished when the icon set landed, on 2026-07-12; the present tense read as though a book-wide edit were still pending. It now reads retrospectively — the count “also scoped the book-wide application sweep that followed — the higher the count, the more pages that sweep touched”. The argument is unchanged: the table’s ranking is still what sized that sweep, and saying so in the past tense is the only form of it that is true.
- The IPFS note’s “the same command” now resolves to the command that can
produce the figure it cites. This one is a consequence of the deletion above
rather than an edit of its own, and it fixed a defect nobody had filed. The
note says IPFS was measured “with the same command”, and until the method line
went away that line was its nearest antecedent — a
grepcarrying no--exclude=SUMMARY.md, which counts the table of contents as a content page and so structurally cannot yield the 106 the very same sentence asserts. The intended antecedent was always the campaign and beacon command written out in full by v0.73.15, which does excludeSUMMARY.mdand states 106 itself. With the method line gone that is the only command on the page, so the reference has one thing it can mean. The two paragraphs are joined by that: pruning the campaign and beacon method would now strand the IPFS note with no antecedent at all.
2026-08-16 — v0.73.16 (documentation-only: pages say where their figures and lists come from)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump, v0.73.15’s class and v0.73.15’s date. The version rolls because the docs moved again, not because anything about the protocol did. The entries below are of one kind: something a page had been stating on its own authority now names where it came from instead — a date and a docs version for a measurement, the page that owns an enumeration for a list.
- The icon legend’s term-frequency table is dated now, and says why it is frozen. The lead-in described the table as counting distinct Markdown pages “out of the 66 content pages measured when the set was chosen” — a phrase that names no day and no version, so a reader had no way to tell how old the figures are and read them as a count of the book in front of them, which has since grown well past that denominator. Not one figure moved, and none was re-measured: the table is a deliberate snapshot, and v0.73.13 already recorded why refreshing a row of it would be worse than leaving it alone — a table mixing two denominators states nothing. What the page gains is the provenance: those figures landed with the page on 2026-07-12, at docs v0.2.1, against the book as it stood that day, and no row has moved since. The contrast with the campaign and beacon figures further down is deliberate — those carry a date and a version because they are measured against the book of their own day, and that is the form to copy when a count has to be current.
- The privacy page’s send-metadata bullet defers the
Emailaccount’s field list to the reference row that owns it — and the reference page stops restating that row one paragraph below it. What a send writes was enumerated in three places: the exposure bullet under On-chain: public and permanent, the “what a send leaks on-chain” paragraph in the field reference, and theMessage (Email)row a screen above that paragraph. As with the beacon lists v0.73.15 reconciled, the copies had already drifted: the privacy bullet itemized the sender wallet but left the recipient to trailing prose (“which wallet mailed which wallet”), the appendix paragraph itemized both, and neither namedbounty_lamports,expires_atorreply_to_hash, which the row has carried all along. The privacy bullet now names the account’s key (the recipient wallet), the sender wallet, the hashedfromand the timestamp — the four facts its address book, not the message argument rests on — and hands the rest to the row by link, down to the body’s storage locator. The appendix paragraph keeps only what the row structurally cannot say: that the recipient wallet is the PDA seed rather than a stored field, and what the frombox instructions andReclaimFromboxStampscarry in their payloads, which is instruction leakage rather than account state and belongs to no row. The one fact that paragraph held alone — thatcidtakes itsb3:local-only form when the recipient setno_ipfs— moved into the row’s notes rather than being dropped. Deferring rather than deleting, again: neither site is reduced to a bare cross-reference, and a field added to theEmailaccount now has exactly one prose place to be added.
2026-08-16 — v0.73.15 (the icon legend’s campaign and beacon figures become a dated measurement instead of a current fact, and the privacy page’s two beacon field lists defer to the reference row that owns them)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump, v0.73.14’s class and v0.73.14’s date. The version rolls because the docs moved again, not because anything about the protocol did.
- The icon legend’s campaign and
beacon figures are dated now rather than current. The paragraph read
“measured with the same command against the book’s current 106 Markdown
pages”, and a count stated as current stops being true the moment a page
lands — nothing re-measures it, so the word promises what the page cannot
keep. It now reads as the measurement it is: taken on 2026-08-16 at v0.73.15,
campaign on 13 content pages and beacon on 10 out of the 106 the book then
held. The figures themselves did not move — 106 was right, and re-measuring
found the same 13 and 10 — so what changed is that a reader can now tell when
they were taken and check them, the
grep -rlwiandfindcommands being written out in full rather than referred to as “the same command”. Both excludeSUMMARY.md, which is the content-page convention v0.73.13 fixed the IPFS note onto and the one every count on the page is now stated in. The-wgets a sentence of its own, because whole-word matching is what makes the numbers mean what they say — beacon does not match beacons, POP does not match POP3. The argument the paragraph exists to make is why it is dated rather than a casualty of the dating: campaign and beacon carry icons because marking them wherever they appear was judged worth it, which is a call and not a count, and a count that drifts unannounced is the weaker half of that sentence pretending to be the stronger one. - The privacy page’s two
ParticipantBeaconfield lists defer to the field reference instead of repeating it. The beacon account’s fields were enumerated in three places — the exposure bullet under On-chain: public and permanent, the closing digest under The honest limitations that v0.73.14 added, and the reference row that is meant to be the authoritative inventory. Three copies is three things to keep in step, and they were already out of step: the digest named the wallet, the tags and the publish/change times but not the CID of the attached profile, so it read as though nothing about that profile was exposed, where the reference row says its existence and update times are public and only its content is sealed. Both privacy-page sites now name your wallet and the topic tags you picked — enough concreteness for the argument each is making to land on a reader — and hand the full enumeration to the reference row by link, so there is one place a field can be added to. The exposure bullet keeps the timestamps as a class rather than a list (“down to the timestamps”) because when you last touched it is part of what the bullet is warning about; the digest, being a one-line-claim list, keeps only the two named fields. Deferring rather than deleting is the point: neither site is reduced to a bare cross-reference, and neither now claims to be complete. The reference row itself is unchanged.
2026-08-16 — v0.73.14 (the icon legend’s opening paragraph stops stating the frequency-only rule the page itself retired, and the privacy page’s summary list catches up with the exposure list above it)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump, v0.73.13’s class one day past its date. What moves is prose the book had already outgrown, brought back into step with the pages carrying it.
- The icon legend’s lead-in caught up with its own admission bar. v0.73.13 replaced the page’s frequency-only rule with two routes — a term recurs widely enough across the book to be worth marking, or admitting it was a deliberate editorial call — but everything it touched sat under the Why these terms heading. The opening paragraph, which is the sentence a reader scanning the page meets first, still said the set “covers only the handful of terms that recur across the whole book”, so the page shipped the retired rule and its replacement at once. The lead-in now names both routes and points down at the section holding the frequency ranking and the record of the calls; that section, its heading and the table are untouched.
- The privacy page’s summary list caught up with the exposure list above it. v0.73.13 added marketplace opt-in to On-chain: public and permanent and stopped there, so the page’s closing digest — the three-item list a reader who skims takes away — named the social graph, public ciphertext and marketplace purchases while staying silent about the one exposure a reader deliberately opts into. It now carries a fourth item: publishing a beacon puts your wallet, its topic tags, and the times you published or changed it in the on-chain campaign pool, while the attached profile stays sealed. The claim is the one the list above already makes, shortened to the digest’s one-line-claim voice rather than restated differently. A fourth item rather than a widening of the purchases item, because opting in is not a purchase and the two sit as separate bullets in the list above; the paragraph beneath the list still reads correctly, since a public campaign pool is as much a cost of settling on a public chain as the other three.
2026-08-15 — v0.73.13 (opting in to campaigns joins the list of what is public, the icon legend stops asserting a rule its own table breaks, and a term icon’s four artefacts are checked against each other)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump, v0.73.12’s class and v0.73.12’s date. Three changes of that one class landed together, so they share this section instead of taking three of their own: one book page gains a bullet it was missing, one stops stating a rule it does not follow, and one docs-gate checker learns to notice a term icon that shipped incomplete.
- Opting in to campaigns belongs on the list of what is public.
On-chain: public and permanent
named wallets and their settings, send metadata, money, and marketplace
purchases — but not marketplace opt-in, which is the one item in that list
a reader takes a deliberate decision to do. Publishing a
beacon, the page v0.73.12 wrote for
exactly that reader, puts your wallet in the campaign pool on-chain together
with the topic tags you picked, the CID of any encrypted profile you attach,
and when you published or last changed it. The profile’s contents stay
sealed; the fact that you have one does not. Each of those claims is read off
the
ParticipantBeaconrow of the privacy field reference — the account’s own field list — rather than paraphrased from the bullets around it. - The icon legend stops asserting an admission rule its own table breaks. “Terms that recur on a third or more of the pages carry an icon”, with the client icons as “the one deliberate exception”, was false against the table printed six lines beneath it: a third of 66 pages is 22, and the table’s own bottom rows are POP and IMAP at 19, SOL at 17 and daemon at 12 — all carrying icons. No adjusted threshold rescues it either: campaign and beacon post-date the table entirely, and re-measured against the book’s current 106 pages they appear on 13 and 10 of them. The bar is now a route rather than a number: a term carries an icon because it recurs widely enough to be worth marking, or because admitting it was a deliberate editorial call, with the paragraphs beneath as the record of those calls — the four client icons becoming the largest of them rather than the sole exception, and campaign and beacon joining as calls and not counts. No rationale is invented for the two new ones: the book holds no frequency argument for either, so none is made. The table itself is unchanged and still the ranking that fixed the core of the set; what moved is the claim about what it proves.
- The IPFS note’s page count stopped mixing two conventions. It read “48 of
those 106 pages (45%)”, and the two numbers were counted differently — the 48
included
SUMMARY.md, which is a table of contents rather than a content page and which the 106 excludes. Measured both ways on the same convention it is 47 of 106 (44%), which is the figure the note now carries. The conclusion it supports — IPFS clearing the one-third mark on frequency alone — is unaffected; a percentage assembled from two denominators just states nothing, whichever way it rounds. - A term icon is four artefacts, and nothing checked that a term had all
four. Shipping one means a
.ticon-<term>rule incss/icons.css, aTARGETentry injs/topic-icons.jsfor the chapter the glyph links to, a row in the legend, and thesrc/images/icon-<term>.svgthe CSS mask points at — and a term holding three of the four rendered as an inert or unexplained glyph with the whole gate green.check_topic_icons.pygained a third pass comparing those four sets for agreement; all four measure twenty-two today with every pairwise difference empty, so the fence landed green rather than with a backlog to work off. Building its fixtures found a hole rather than confirming one: a.ticon-rule commented out with a CSS block comment — what an edit in progress actually looks like — was counted by the raw scan as a defined term, a false member of the set, which is the worse direction for a checker to be wrong in. Block comments are now blanked first, the stylesheet twin of the line-comment blanking the JS side already did. Each of the three sets read off disk also gained a floor: one that parses to nothing is the scan reported broken (exit 2), not twenty-two findings pointing at the wrong file. The fixture trees go from seven to twelve, the gate README’s description of the checker moved with the behavior, and the gate stays at nineteen legs.
2026-08-15 — v0.73.12 (beacons are defined in plain language before the docs use the word, and the term gets a glyph)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump, v0.73.11’s class and date: the participant beacon itself shipped at v0.9.0 and the browser pane that publishes, disables and closes one at v0.49.0, so nothing about the product moved here. One new book page, the links that reach it, and one term icon.
- Beacons, written for the person deciding whether to opt in. The Marketplace topic explained campaigns but never the thing you publish to join one: “beacon” arrived mid-sentence with nothing having defined it, and the fuller tellings were the CLI reference’s flags and the design note’s rationale — neither written for a reader weighing the decision. The new page answers what that reader actually asks: what a beacon says (coarse labels off a shared list, and optionally the address of an encrypted profile — there is no free-text box to over-share in), that a wallet has at most one and publishing replaces it wholesale rather than amending it, how an advertiser’s search matches (every topic named, so naming more narrows the result and never widens it), what is permanently public versus what stays sealed, and the difference between disable — a browser-side republish with no topics, which keeps the account and its deposit — and close, which refunds the deposit and leaves nothing behind. No CLI flags and no account layout: it links out for both.
- The price sentence is read off the account, not paraphrased from the
neighbouring prose.
ParticipantBeaconcarries tags, a detail CID, its two timestamps and its owner, and no price field at all — so the page can say outright that opting in adds no second price to keep in step: what an advertiser pays is the mailbox’s own default postage. - Reached from where the word is first read, not only from the sidebar. The
SUMMARY.mdbullet sits between Trading names and Campaigns so the concept precedes its use; the first “beacon” in both Campaigns and the marketplace pane’s Participants tab now links it; and the Marketplace hub’s “Where to go next” gained its row — a new page its own hub does not list is the defect the page exists to fix. - A
beaconterm icon joins the set, taking it to twenty-two terms. The glyph is a lighthouse rather than a second megaphone besidecampaign: a beacon is a standing sign left lit to be found by, not an outbound blast. It ships the whole four artefacts the system needs — the legend row, the CSS mask rule, the SVG the mask points at, and the link target the in-text glyph resolves to, which is the concept page rather than the CLI reference.
2026-08-15 — v0.73.11 (the Welcome page rewritten around the fight for your attention)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump. One page of book prose moved; no screenshots or reference tables changed.
- Welcome hero and feature cards rewritten. The hero tagline is now “Your inbox has never known peace. Now the fight pays you.”, and the seven cards were reframed as a single progression — the quiet inbox was always a lie; price your attention; get paid for your inbox; end spam at the source; an address nobody can take from you; a protocol, not a product; no conversion required — with parallel “Through X, Y” openings carrying the argument from recipient-set pricing through to a network nobody owns. The features described are unchanged; only the framing and copy moved.
2026-08-15 — v0.73.10 (the settings frame carried the same clipped-heading sliver)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump. One committed screenshot and the rig README’s geometry tables moved; no book prose changed.
webmail-settings.pnghad v0.73.9’s defect too, found by scanning every committed frame’s bottom row for ink. The uncropped 1280x900 frame ended on the top half of the same stray “Marketplace” heading, visible above Sealed rows and your reading key. Now cropped1280x856+0+0: the teased Mailbox pane heading’s last inked row is raw 841 and the sliver’s first is raw 885, so the crop ends at raw 855 — 14 blank rows below the Mailbox heading, 29 clear of the sliver. The two frames that also touch their bottom row (webmail-inbox,marketplace-listings) are not this defect — their fold cuts a continuing list/form mid-row, the intended “more below” look — and the rig README now records that distinction beside the crop recipes.
2026-08-15 — v0.73.9 (the balances frame’s clipped “Marketplace” sliver is cropped away)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump. One committed screenshot and the rig README’s geometry tables moved; no book prose changed.
webmail-balances.pngno longer ends mid-heading. The frame is shot at the rig’s one deliberate 1280x1000 viewport exception and was committed uncropped, but the viewport’s bottom “air” held the top half of the app’s next section heading (“Marketplace”) — which read as half-cut letters at the image’s bottom edge on the webmail Balances section. The committed frame is now cropped1280x971+0+0, measured the way the two password frames’ heights were: the Set price button’s last inked row is raw 956 and the stray heading’s first is raw 985, so the crop ends at raw 970 — 14 blank rows below the button, 14 clear of the heading. The raw capture is unchanged (same viewport, same driver); re-shoots reproduce the crop per the geometry table inscreenshot-tools/README.md, whose both tables and content-derived-heights note now carry this frame.
2026-08-15 — v0.73.8 (the marketplace pane rewire is pixel-neutral; two hashes re-pinned)
No on-chain ABI, instruction, error-code, economic or behavioral change, and no
book page or screenshot changed a byte — a PATCH bump, and the docs’ fifth
movement of the day. Only screenshots.manifest.json moved.
- A webclients-only checkpoint reddened the docs gate without touching
mail_docs/.check_screenshots.pypins each client to a hash of its UI source, and the my-beacon pane’s move onto themail_wasmbeacon exports editedshared/marketplace-panes.js— a file inside both the webmail and the marketplace entry-point closures. The screenshots had been captured and pinned earlier the same day, before that edit landed, so twosource_hashvalues went stale with no rendered pixel involved. - All eight affected frames were measured, not assumed. Against a fresh
capture at each frame’s documented crop:
webmail-first-run,-settings,-remove-password,-rotate-password,-balancesandmarketplace-sign-inreproduce at AE 0, andwebmail-inboxat 12, its documented bistability floor. A same-rig A/B — one bundle built from the current tree, one with the pre-changemarketplace-panes.jsswapped into all three app bundles, captured on one Chrome — returns AE 0 on every frame (inbox 12). The pane change is visually neutral, so nothing was re-shot and every committed PNG keeps its bytes. marketplace-listings’s large number is the rasterizer, and the A/B says so. It reads AE 113441 against a fresh capture, but 0 across the A/B. Its committed frame carries 25194 colour-fringed pixels of 840960 against a fresh capture’s 2352 — the LCD-subpixel era it was last shot in (2026-07-23), which the screenshot rig’s README documents and which is deliberately not a reason to re-shoot. The differing pixels span every text-bearing band of the page rather than any one pane, and the content is identical listing for listing.- Hash-only re-pin.
webmailandmarketplacewere re-pinned withcheck_screenshots.py --update;onboarding’s hash is unmoved, since itssourceslist does not reachmarketplace-panes.js. Nosourcesorscreenshotsvalue moved, and no PNG was re-baselined.
2026-08-15 — v0.73.7 (the stored-password removal control gets its screenshot)
No on-chain ABI, instruction, error-code, economic or behavioral change, and no client or server code changed a byte — a PATCH bump, v0.73.6’s class and v0.73.6’s date: the docs’ fourth movement of the day, so it takes a section of its own. One new book screenshot, the prose reference that places it, and the capture-rig changes that make it reproducible.
- Removing the stored mail password
now shows the armed control. The section has described the two-step gesture
in prose since v0.59.0 gave the control to the Settings pane, while its
sibling rotation
section has carried a picture of exactly the equivalent moment since v0.58.0.
The new
webmail-remove-password.pngcloses that asymmetry with the state a reader has to recognise before pressing anything: the heading, the what-you-are-trading paragraph, the armed warning, and the Remove it now / Keep my stored password pair. The removal is armed but never clicked — the resulting frame would document the outcome, and hide the very block it is about, instead of the decision. - Making the frame reproducible needed a fixture flip, and that flip is a
capture input rather than cosmetics.
screenshot-tools/fixtures.mjsnow answersmail_password_set: true, because the whole removal block renders only for an account that actually has a stored password: atfalsethe block does not exist and the frame cannot be shot. In its resting state the flip inserts a heading, a paragraph and a button into the settings pane above the wallet-derived section, so every frame shot on that route can move with it — which is why the fixture file now carries a comment saying exactly that. - Exactly one neighbouring frame moved, and it was measured rather than
assumed.
webmail-rotate-password.pngsits below the insertion and came back rasterized one device pixel higher — content byte-identical, every differing band reaching an exact zero difference at a 1px shift, because the driver’s scroll now lands on a fractional offset that rounds the other way. It was re-baselined; its geometry is unchanged. Every other frame (webmail-settings,-first-run,-inbox,-balances) reproduces unchanged, andmarketplace-listingsstays excluded for the pre-existing Chrome grayscale-AA drift the screenshot rig’s own README documents — an appearance change no content change caused, which is not a reason to re-shoot. - The new crop is content-derived, so it is written down.
1280x300+0+22out of the pinned 1280x900 raw frame: the+22drops the clipped sticky topbar and keeps its bottom border as the frame’s top rule, and the height ends 14 blank rows below the confirm buttons and 9 clear of the next heading. The screenshot-tools README’s geometry table carries the row and the measurement recipe, since a height that follows the text has to be re-measured on any re-shoot rather than copied.
2026-08-15 — v0.73.6 (the citations checker learns to watch its own source, and an accepted gap goes on record)
No on-chain ABI, instruction, error-code, economic or behavioral change, and no
book page changed a byte — a PATCH bump, v0.73.5’s class and v0.73.5’s date:
the docs’ third movement of the day, so it takes a section of its own. Docs
tooling only, v0.73.3’s shape: two new self-test guards in
check_config_citations.py and one paragraph of record in the gate README.
- The capitalized-fix branch gets a structural guard. v0.73.5 taught a drifted count word’s suggested fix to arrive capitalized when its claim opens a sentence — a branch reached only through a template that opens with its count word, and exactly one does (the discovery set’s). The count-fence cases derive their expectations from the templates themselves, so rewording that last sentence-initial template would have retired the branch’s only exerciser with every case still green. A guard in the self-test now reds instead, naming the branch that would be left running unexercised.
- No module-level
defmay be declared twice, in either checker. Python keeps the last column-zerodefof a name and silently shadows the rest, with every call site still green — and in files this long (~160 module-level defs in the citations checker, ~180 in the keys one) a fixture set pasted in under an existing name is a real hazard, not a hypothetical: one landed in wave #99 and worked only by accident of declaration order. The resolution is code rather than a convention, the discovery guard’s own technique — a line-anchored scan over the source text — swept over both checkers’ sources, because this checker imports the other’s functions, so a def shadowed over there runs (or silently stops running) in this very process. A source in which the scan finds nodefat all is refused as the pattern rotting, never reported clean. - The coverage fences’ missing root hook is now a taken decision, not a
gap. Neither counted-versus-run fence — v0.73.4’s citations side nor
v0.73.5’s keys side — has a mirror-tree hook of its own, and the gate README
and a comment at the fence now say why together: the run side of the
equality can only come from
inspect.getsourceon the imported module, which no mirror tree can perturb, so a hook could redirect nothing but the declaration texts and the leg would be answering for a chimera — a mirror’s roster against the real interpreter’s suite. The in-process cases prove the arms; a counted table genuinely left unrun is a real-tree finding. - Every counted figure stays still. Both guards are failure-list functions
wired into the self-test beside the sets they protect, deliberately not case
tables — the coverage tally reads
…_fixtures(accumulation lines, and a guard driving no table must not register as one — so the ninety-nine citations cases, one hundred and fifty keys cases, twenty-three counted tables, eighteen fenced README claims and nineteen gate legs are all unchanged. - The wave then re-read its own prose, and found itself once. The self-test’s docstring, amended by both guard phases, still promised that its in-process block “need[s] no tree” — a predicate the duplicate-def sweep, enumerated inside that very clause, falsifies by reading the two real checker sources off the tree. It now says what is true: the block builds no fixture tree, and the sweep alone reads the real sources rather than a stand-in.
2026-08-15 — v0.73.5 (the static-hosting walkthrough becomes a pointer, and the counted-versus-run fence reaches the second checker)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump, v0.73.4’s class and v0.73.4’s date: the docs moved twice in one day, so they take a section each. Two book pages changed, one in what it says and one in how it renders.
- The
[[static]]walkthrough stops keeping its own copy of the rules. Static hosting for browser clients had become a second telling of what v0.73.4 wrote into[[static]]— same-origin static mounts: the same motivation, then the same five rules in the same order, maintained in two places. It is now the lead-in, the worked three-entry TOML, and the four refusal messages an operator meets at startup, with the rules named once and pointed at — 112 lines to 82. What stayed is what the reference does not carry: the API’s ownwwwroot/test/as the thing the harness fence exists for, the change-history link for the release that stopped serving a mount below a nest, and an example you can paste without uncommenting it. The heading, the TOML and all four quoted refusals are byte-identical, so every inbound deep link still lands where it did. One correction rode along: those refusals abort the process while the router is assembled, but a missingrootnever did — that mount is built and answers 404, which makes it not a refusal at all. - The autoconfiguration route table stops wrapping its Client labels. The
route table under client autoconfiguration (no
plugin) put
“Thunderbird” on a second line under its own term icon: the book-wide floor
under every prose table’s second column — there so the configuration
reference’s Default column is not crushed by a long Meaning — was spending
that width on a Method column holding nothing but
GETandPOST. The floor is right for the tables it was written for, so it is scoped off that one table through a marker span rather than relaxed book-wide, and Method now falls back to its content width. - The gate’s case counts now answer for both config checkers. v0.73.4
closed counted-versus-run for
check_config_citations.py’s own case tables and left thecheck_config_keys.pytables it imports joined to nothing: one of those could be summed into the totals and iterated by no fixture at all, with every leg green — v0.73.4’s own defect, one file over. The same tally is now pushed over that checker’s entry point as well (imported for its source; none of its cases run here), with two seams of its own — the alias hop, since every loop over there writesCASESwhere the counter here sumsKEYS_CASES, and the import block, since a fixture function that entry point calls and this file never imported would otherwise read as tables nothing runs. Exit 2 like its neighbours, and it runs between them and the count fence. The keys checker’s one remaining inline case loop moved into a fixture function of its own so the tally can see it run at all — behaviour-preserving, and what let the join be one more equality rather than a special case. - A drifted count word’s suggested fix now arrives in the letter case it has to be pasted in. That finding always printed the word to write, and always printed it lowercase — wrong for a claim whose sentence opens with its count. The wave’s totals, for the record it is worth keeping: the citations checker’s self-test runs ninety-nine cases against ninety-three (sixty-five fixture shapes, thirty-four in-process), twenty-three case tables are declared and counted across the two checkers, one hundred and fifty keys cases run, and the gate README’s fenced count claims go from seventeen to eighteen. The gate’s nineteen legs are unchanged.
- The wave then re-read its own prose, and again that is where the last edits came from. Passages this wave wrote were made untrue by later phases of the same wave: the count fence’s case list still introduced six cases with a list of five, the clean control unnamed — v0.73.4’s defect exactly, one table over; the sentence saying which findings name the suite being measured named the two that do not; and the keys-side set’s description undersold two of its own cases. On the doc side, the new pointer named four of the reference’s five rules, dropping the one about nesting and order. Two older sentences went with them, both the same family: a note that “nothing anywhere notices” a dropped fixture call, which outlived by a version the fence that notices it, and a description of the discovery guard’s import finding that named the wrong checker’s import block. Nothing in either gate reads prose for truth, which is why the pass that finds this is a phase of the wave rather than a courtesy at the end of one.
2026-08-15 — v0.73.4 (the case counts learn which cases actually run, and [[static]] gains its background)
No on-chain ABI, instruction, error-code, economic or behavioral change — a
PATCH bump, v0.73.3’s class. One thing differs from that entry: a book page
did change this time. [[static]] — same-origin static
mounts explained
every rule the mount list obeys and never said what static hosting is; it
now says so first. The tooling half finishes the join v0.73.3 opened.
- A counted case table and a run case table were still two different
things. v0.73.3’s discovery guard proves that every case table either
config checker declares is summed into
live_case_counts(). What no fence could see was the other direction:live_case_countsandself_testare two hand-kept rosters over one set of tables, and afailures += …_fixtures()line dropped from the entry point leaves every count word in the gate README true — of a suite that no longer runs those cases, with the whole fence family green about it. A roster proved complete is still only a roster; what a suite executes is a separate claim. - The tally reads the entry point rather than trusting it. It starts at
self_test’s own source asinspect.getsourcehands it over, names the fixture functions it accumulates from by that one line shape, and counts a table as run only where a fixture body drives it through afor … in NAME_CASES.items():loop — a name mentioned in prose is not a run, and several of those functions discuss their neighbour sets by name. Anything reached some other way tallies as zero rather than raising, and the asymmetry is deliberate: the total can then only come out short, so an idiom the tally cannot read reds the leg instead of blessing a suite it could not see. Three findings come out of it — a counted table no fixture iterates; a totals gap where every counted table is iterated, blamed onlive_case_counts()’s own arithmetic rather than on any table; and a counted total of zero, refused rather than compared, because a match over nothing proves nothing. - It is exit 2, and it runs between the discovery guard and the count
fence. The line is v0.73.3’s unchanged: a table counted but not run is not
drift in a tree this gate reads, it is section 5’s own state being
inconsistent, so it reds ahead of the words rather than beside them — the
roster of tables first, then the cases actually executed, then the README’s
claims about them. It leans on the guard in front of it (the tables this
file declares are, by then, exactly the tables counted here), and the
imported
check_config_keys.pytables stay outside the diff by design: they are counted underkeys-totaland driven by that checker’s own entry point. Like its neighbour it has no root hook — its subject isself_test, which no mirror tree can perturb — so it was red-proved on the real file instead: aPROBE_CASEStable planted at column zero and summed into the counter, exit 2 naming both figures and the table, the discovery guard beside it staying correctly green, revert sha256-identical. - The gate’s leg count is unchanged; its case count moves twice. Seven
cases drive the coverage fence, so
check_config_citations.py --self-testgrows from eighty-six cases to ninety-three — sixty-five fixture shapes and twenty-eight in-process. Both new in-process sets then got a count claim of their own,citations-discoveryandcitations-coverage, taking the README’s fenced claims from fifteen to seventeen. They sit beside the in-process aggregate rather than inside a split of it: their cases are already summed there, and a per-set claim and a total over the same cases are both true — what the per-set claim buys is a finding that names the table a stale word is about. Leaving a number this same wave wrote unfenced until a later one would have been precisely the hole this work exists to close. [[static]]now says what static hosting is before it says what the rules are. Four paragraphs ahead of the existing prose: the browser clients SithBit ships are static bundles whose only job is to call this API; the browser’s same-origin rule is why serving them from the API’s own listener beats a second web server and a CORS allowlist; these are directories the API finds rather than files it ships —sithbit-outlook.zipis four entries whose manifest points Outlook at{{BASE_URL}}/addin/taskpane.html, which is why an Office add-in needs a mount at all and whyroutedefaults to/addin, while the Thunderbird.xpiis 61 entries and needs no server; and it is deliberately not a general-purpose web server. Recorded there because no doc stated it: mounts carry no authentication — they sit alongside the API’s routes, not behind its JWT layer — so a mounted directory is public.- The wave then re-read its own prose, which is where the remaining edits came from. The “what each checker owns” entry for section 5 still told the reader that the discovery guard “is what keeps those counts from being true about the wrong suite” — a sentence this wave falsified by building the fence that closes the other half of that claim; the coverage fence’s own new paragraph overstated its reach (it diffs the tables this file declares, not everything the discovery guard proves counted); and its seven cases were introduced by a list of six, the clean control unnamed. On the doc side, the mount-list paragraph’s closing clause re-explained the CORS rationale the new background paragraphs now give in full, and lost it. A wave routinely falsifies sentences it wrote itself three phases earlier; the pass that catches them is a phase, not a courtesy.
2026-08-14 — v0.73.3 (the case-count fence finds the case tables nobody told it about)
Docs tooling only: no on-chain ABI, instruction, error-code, economic or
behavioral change, and no book page changed a byte — a PATCH bump, v0.73.2’s
class exactly. Section 5 of check_config_citations.py reads the gate
README’s self-test case counts back against the live case tables; what it
could not do was notice a table it had never been told existed.
- The counting seam is that the counter is hand-kept.
live_case_counts()sumslen()calls written out one table at a time, so a new*_CASEStable nobody adds to it is not miscounted — it is invisible, and every count word in the gate README stays true about a smaller suite than the one that runs. That is not a hypothetical: it happened twice in wave #96, once per checker, each time a table landing in one commit and its registration in the next, with nothing red in between. A fence whose answer is only as good as its own roster has to report on the roster first. - The guard reads both checkers’ source and diffs it against the counter’s
body. Every column-zero
NAME_CASES = {declaration incheck_config_citations.pyandcheck_config_keys.pyis collected and looked for inlive_case_counts(), whose text comes straight from the interpreter (inspect.getsource), so what is read is the function that will run rather than a second copy of it. Two subtleties carry it: thefrom check_config_keys importblock is parsed for the original → local mapping, because that file declaresCASESand this one counts it asKEYS_CASES; and every name is matched as a whole identifier, since a substring test would let the registeredSTEP_UP_CASESwave through aUP_CASESit has nothing to do with. A table declared incheck_config_keys.pythat the citations checker imports nowhere is a finding of its own, blamed on the import block — the shapeDEFAULT_VALUE_CASESarrived in, and the one no amount of counting can reach, since the name is not in scope to sum. Six in-process cases drive it, one per class plus the lookalike and a clean control. - Both classes are exit 2, and the guard runs ahead of the count fence itself. The line is the one the roster-pairing guards already draw: whose state is wrong. Section 5’s exit-1 findings are drift it can describe — a count word a grown table left behind — while an uncounted table is section 5’s own state being inconsistent, and while one sits outside the sum neither the green nor any exit-1 finding beside it is worth acting on. The fix is always an edit to the checker, never to the docs. Its anti-vacuity case is the membership guards’: a declaration pattern matching nothing at all in either checker is exit 2, never a clean report over no tables.
- Its one limitation is stated here rather than left to be discovered: the
guard has no root hook. It reads the counter through
inspect.getsource, so no fixture tree can be pointed at it and--self-teststays green under a planted unregistered table — only the real-tree run bites. That is why it was red-proved in the tree from both directions instead: an unregistered table added (exit 2), and an existing registration removed (exit 2, correctly pre-empting the count fence’s exit 1), each reverted sha256-identical. It also puts one constraint on the files it scans — fixture source text must stay indented, since a column-zero stand-in declaration would be discovered as a real table. - The gate’s leg count is unchanged; its case count is not. The guard
rides the
check_config_citations.pyinvocation the gate already runs, and its--self-testgrows from eighty cases to eighty-six — sixty-five fixture shapes and twenty-one in-process — with the gate README’s section 5 moving with it. That account, and the guard’s own comments, were then re-read against the wired code rather than merely appended to, which is the discipline wave #96 paid for: the checker’s import comment still told the reader there was no discovery pass to fall back on, and it now names the guard that closed it.
2026-08-14 — v0.73.2 (section 6’s fences finish their proof: a real-tree probe and branch coverage)
Docs tooling only: no on-chain ABI, instruction, error-code, economic or
behavioral change, and no book page changed a byte — a PATCH bump, v0.73.1’s
class exactly. That entry landed check_config_citations.py’s sixth section,
holding the account API’s [[static]] startup refusals — quoted on
[[static]] — same-origin static
mounts and
static hosting for browser
clients — to the
Rust source that prints them. It landed on fixture proof alone. This is that
proof finished.
- The value fence has now been probed in the real tree, the strong form
sections 2 and 3 were each held to: one of the five refusal literals
reworded in
account_api/src/lib.rs, left compiling, this leg red alone among the gate’s legs naming both sides — the source line and the roster row that has to follow it — and the source reverted sha256-identical. Recorded with it because it changes how the finding is read: the source-side line’s excerpt names which refusal drifted but can show none of the drift, when the reworded words sit past the excerpt’s cut, so the roster-side line is the half that pairs the two for a reader. - The render guard is parameterized, and every branch it reports is now covered. It read its three rosters out of the checker and took no arguments, so it could only ever run against the live tree — and no fixture tree can reach it either, because the quote fence’s fixture pages are built from the very rosters it compares, leaving its branches unreachable from that root hook by construction. It now takes those rosters as arguments, the two older pairing guards’ shape, and an in-process case set drives all three branches beside a live control: a rendered row naming no message, a rostered key no row renders, and a row left behind by a reworded format string. Each was mutation-checked — disabling one branch reds that branch’s case and no other.
- The self-test grows from seventy-six cases to eighty, and the count fence follows it. The new set is registered in the live figures section 5 reads this gate’s README back against, and it gets a claim of its own rather than joining the existing one: that claim asserts a single count against both older pairing sets, which works only while they are equal, and this third set is a different size. One claim per differing size keeps every finding able to name the table it is about.
2026-08-14 — v0.73.1 (the [[static]] refusals the book quotes are fenced to the code that prints them)
Docs tooling only: no on-chain ABI, instruction, error-code, economic or
behavioral change, and not a single book page changed a byte — a PATCH bump.
v0.72.6 and v0.73.0 below put the account API’s [[static]] startup refusals
into the two operator pages verbatim; nothing then tied those quotes to the
Rust source that prints them, so an edit in account_api/src/lib.rs would
have left the book quoting refusals the API never prints — the drift class
the step-up nonce prefix closed at v0.66.0, with a startup abort in place of
a nonce prefix. check_config_citations.py gains a sixth section closing it.
- The five messages themselves are the anchor. A hand-kept call-site →
verbatim-text roster is diffed against the live string literals in
account_api/src/lib.rsboth ways, byte for byte — a refusal edited, added, or removed at a call site reds the gate until the roster, and so the book, moves with it. The extraction reads each literal across the backslash-continuations rustfmt wraps long strings with, so a reflow of the Rust source changes nothing the fence compares. - The two pages’ quotes are held to that roster through the worked
example.
[[static]]— same-origin static mounts and static hosting for browser clients each quote four of the five refusals, rendered with the walkthrough’s/addinvalues; a render guard holds those rendered texts to the live format strings, and each is diffed against its page byte for byte — section 3’s pair of guards, one section over. - A whole-book scan fences the roster’s membership. A refusal-shaped quote on a page no row rosters, and a rostered page quoting none, are both drift — a quote pasted onto a third page is a red, not silent unrostered coverage. This page is the one excluded from that scan, by name and with its reason: dated prose-of-record is never retro-edited, which is also why this entry retells the refusals rather than quoting one.
- The root-mount refusal stays prose-only, and the fence knows that is a
decision. Both pages tell the
route = "/"refusal in prose without quoting it, deliberately: its worked example would hand the reader the origin root, the one line no reader should be given to copy. The asymmetry is encoded rather than merely omitted — the root-mount message has no quote row, and a rendered copy of it anywhere in the book reds with a diagnostic naming the decision (drop the quote, or take the decision back and roster it) instead of passing as extra coverage. - The gate’s leg count is unchanged; its case count is not. The new
section rides the
check_config_citations.pyinvocation the gate already runs, and its--self-testgrows from fifty-eight cases to seventy-six; the gate README’s retelling of the checker — the fixture shapes, the flag list, the counts its own count-fence reads back — moved with it.
2026-08-14 — v0.73.0 (no [[static]] mount may stand on or below another mount’s harness nest — BREAKING)
A breaking configuration change: a [[static]] list that puts one mount
below another mount’s harness nest — route = "/addin" beside route = "/addin/test/sub" — used to build and serve, and GET /addin/test/sub/x.html returned 200. The account API now refuses that pair
while the router is built, before the listener binds, so a deployment
carrying it stops starting on upgrade instead of starting and serving. The
rule in one sentence: a [[static]] mount may not stand on or below
another mount’s harness nest — test, tests, __tests__, spec — at any
depth. Per the versioning preamble MAJOR stays 0 pre-launch, so a break
rides the MINOR digit and the heading carries the word.
- Exactly which configurations stopped working. A pair of
[[static]]entries where one entry’sroutelies strictly below another entry’srouteplus one of the four harness names — at any depth beneath it, and in either configuration order, since the check does not care which entry was written first. Nothing else changed shape: every list that started before and is not that pair still starts. - Why it was refused rather than left alone. Every mount 404s
test,tests,__tests__andspecbeneath itself, unconditionally. A mount standing below one of those names wins the match — nested prefixes resolve most-specific-first — and so served content straight through a fence the rest of the docs describe as absolute. The choice was to keep the fence and refuse the config, or keep the config and let the fence have a hole nothing reported. - What is not affected. An entry standing exactly on a nest
(
/addin/testbeside/addin) already aborted, as an axum route conflict; it now aborts with a message of our own instead, which is not a behavior change. Ordinary nesting is untouched —/addin/help/submounts as it always did — and so is a prefix that merely starts with a harness name:/addin/testingis not inside/addin/testand still mounts. - The remedy is in the message the process aborts with. It names the
deeper route as the unmountable one, quotes the nest it breaches, and
gives the two ways out: drop one of the two entries, or move the deeper
mount to a route that is not inside a harness directory of the other. No
data migration, no rebuild — one edited
routeline, or one deleted entry. - A
routemissing its leading slash is refused too, and never normalized.route = "addin"previously died on a bareassertion failed: path.starts_with('/'); it now says what to write instead. It is refused rather than silently read as/addin, so the prefix that answers requests is always the prefix in the file — a correction the process makes for you is one the config file no longer describes. - Both pages describing the list now teach the nests before an operator
trips over them. Neither
[[static]]— same-origin static mounts nor static hosting for browser clients had ever said that four directory names 404 under every mount — an operator could only discover it by shipping a bundle with aspec/directory in it. Both now state the nests, print the two refusals verbatim, and mark which of the two is the break; the reference table’sstatic.routerow lists all four ways the process aborts at startup rather than the two it named before.
2026-08-14 — v0.72.6 (the [[static]] mount abort is documented in the operator’s own vocabulary)
Docs catching up to a message that changed under them: no on-chain ABI,
instruction, error-code, economic or deployment-default change, and —
one pathological spelling aside — no [[static]] list that was accepted
before is refused now. A pair of entries sharing a prefix already
aborted, because axum registers nest_service("/addin/") as
/addin/{*tail}, the same route as /addin, so what moved is which
message an operator gets rather than which lists are accepted: a PATCH
bump. (The aside is route = "//", refused now where axum may have
tolerated it.) Both pages describing the list, and the annotated
example file beside them, said the abort names an axum-internal synthetic
route and never mentions [[static]] — false since the validation
landed, and the reason this entry exists.
- The abort quotes the operator’s own
routeback at them.[[static]]— same-origin static mounts and the walkthrough in static hosting for browser clients now print the message itself — it names the section, quotes the duplicated value, and says what to do about it — and the next step they give is to grep the config file for that prefix. The old advice, that the docs were the only place a copy-pastedroutewas ever named as the cause, goes with the message it described. - A trailing slash buys no second mount, and both pages now say so.
/addinand/addin/are one prefix and collide; where two entries disagree about the spelling the message carries both, so whichever line an operator greps for, they find one of the two. - The grep has one hole, stated rather than glossed over. An entry
that omits
routeinherits the/addindefault, so the message can quote a prefix that appears nowhere in the operator’s file. The accusation stays correct — the entry it names is the one with norouteof its own — only the search fails, and both pages say that in place of promising a grep that always lands. - The root mount reads as our refusal, not axum’s.
route = "/"gets a message of its own: the API’s own routes live at the origin, so a directory is served under a prefix instead.account_api.toml’s commented[[static]]block, which explained the same failure as “(androute = "/", which axum will not nest)”, is rewritten to match, as is thestatic_routerdoc comment carrying that phrasing in the source. - The v0.53.0 entry below keeps its wording. It records what the pages said when that capability shipped, which was true then; this log is not rewritten after the fact.
2026-08-14 — v0.72.5 (the Default-column fence spells an enum variant and a listener constructor, and stops accepting a cell that lists alternatives instead of a default)
Tooling, plus the two doc rows that tooling caught: no on-chain ABI,
instruction, error-code, economic or behavioral change —
check_config_keys.py’s value fence learns the two remaining shapes the
tree writes a default in, and grows one refusal on the doc side, so this
is a PATCH bump. Two pages moved, both because a fence that could now
read the code side found the page saying something else: the mode row
of the two SMTP listeners,
and a sentence on
the account API’s search over sealed rows.
- An enum variant is rendered in its serde spelling. The bare
variant path an
impl Defaultwrites (StoreKind::Sqlite) and theEnum::default()a bare#[serde(default)]over an enum-typed field becomes both resolve to the string the row actually states ("sqlite"), by reading the enum’s own declaration — itsrename_allrule, a variant’s#[serde(rename)], and the#[default]marker (or animpl Default forthe enum) naming which variantDefaultpicks. That is reading a declaration, not evaluating anything, which is why it stays inside this boundary; and it is derived only from the six rulesRENAME_ALL_RULESholds. An enum deserialized through a conversion type —KeySource’stry_from— or carrying a rule that table does not name states no spelling the fence can reach and stays unsupported, and a struct-variant literal (KeySource::File { … }) states structure rather than a spelling, so it stays the section it always was. ServerConfig::plaintext(<addr>)reduces to its bind address. It is the one constructor the fence follows, because the argument is the whole of what those[server]rows state and every part of it is a literal in front of the reader — the same argument the multiplied-out product rides on. Both spellings the tree writes are read: the([127, 0, 0, 1], 2525)octet tuple under its.into(), rendered the way the page writes it, and the"127.0.0.1:1430"string under its parse wrapper. It is held exactly there — a named constant or a computed port in either position, a tuple of another arity, and any other constructor (anipv6one, one that also takes a certificate) each state something the address alone does not, and stay refused.- A Default cell that lists several literals is now refused. A cell
claiming
`"mx"` / `"submission"`over a field the fence resolved to one value enumerates what the setting takes instead of stating what it defaults to, so there is nothing to diff. Passing it on whichever token matched first is how a Default column stops meaning anything, so it is exit 2 with a sentence of its own — state the default alone and move the alternatives to the Meaning column, or allow-list the field by name. - That refusal caught the sithbitd
[smtp]moderow. It now states"mx"alone, the value the code defaults to, with the"submission"half — and what each role means for AUTH, sender policy and relaying — moved into the Meaning column, where it also says plainly that the[submission]table does not flip the mode by name: setmode = "submission"on it explicitly. DEFAULT_ALLOWLISTwent twelve entries to six, and the six became comparisons.StoreConfig.kind,SmtpConfig.modeandSmtpConfig.sender_authwere excused for naming an enum variant;SmtpConfig.server,ImapConfig.serverandPopConfig.serverfor the constructor. Compared default values rose 90 → 96 across the same 31 tables — one per retired entry, exactly. The six that remain excuse shapes this fence still does not read: avec![…]macro, aKeySource::File { … }literal, a helper the cell describes in prose, andHealthConfig::on_port(8193), whose port the health-listener arm fences separately.- One sentence on the account API page was factually wrong. Its
sealed-row search section said the per-session summary cache is keyed
on a digest of the session’s bearer token. It is keyed on a digest
of the session id — the token’s
jticlaim — andaccount_api::session_summary_cachenever sees the token at all. The distinction is what makes re-keying a live session evict what the replaced secret opened, so it is worth stating correctly. The same wording in the v0.51.0 entry below is left exactly as written: that entry records what the page said at the time, and this log is not rewritten after the fact. - The gate’s leg roster is unchanged. All of it rides the existing
check_config_keys.pyleg, whose self-test went 138 → 150 cases (its Default-column table 32 → 44): the two new green cases carry theplaintextcall three ways and a#[default]marker on a variant with a#[serde(rename)]of its own, so a fence assuming the first variant — or spelling the renamed one by the enum’srename_allrule — reds on a tree in perfect sync; the drift cases add a page naming another variant’s serde spelling and a port moved behind the listener constructor three times over, once per parse path and once per argument spelling; and the unreadable ones add the four near misses of that constructor, an enum behind a conversion type, and the enumerating cell itself.
2026-08-14 — v0.72.4 (the Default-column fence multiplies out a byte size, and the standalone servers’ own tables come under it)
Tooling-only: no on-chain ABI, instruction, error-code, economic or
behavioral change — check_config_keys.py‘s value fence learns the one
arithmetic the tree writes its byte sizes with, and the three standalone
protocol servers’ own doc regions come under it, so this is a PATCH
bump. Nothing documented needed correcting; every newly fenced value was
already in sync with the tree. No book page moved — neither
the configuration reference nor any other
page was edited for this.
- The value fence learned the one arithmetic it needed. A chain of
bare integer literals multiplied together —
25 * 1024 * 1024, this tree’s spelling for a byte size — reduces to its product now, through a newint_product()behind anINT_PRODUCTfullmatch called fromliteral_value(), so both parse paths reach it through the one shared boundary rather than one of them learning a shape the other would exit 2 on. It is held to*over bare integer literals alone: a+, a shift, a named constant, a call or a float among the operands, or a parenthesis, all fail the fullmatch and stay unsupported exactly as before — multiplying literals asserts nothing a reader has to check, because the operands are in front of them, wherePAGE * pages()would. That retired the threeDEFAULT_ALLOWLISTentries excusing nothing but a unit conversion (SmtpConfig.max_message_size,ImapConfig.max_message_sizeand sithbit-ipfsd’sIpfsdConfig.max_pin_bytes), the rationale being that an allow-list carrying arithmetic a reader can do at a glance is how an allow-list stops being read at all. A finding over a product states what the factors come to, the way a finding over a named helper already states what that helper resolved to: both are shapes that state something other than their own value, and both would otherwise leave the reader a second lookup — or a second sum — before they can tell which side moved. - The standalone binaries’ own H2 regions are fenced for the first
time.
SmtpConfig,ImapConfigandPopConfigeach gained a second rostered table under the empty prefix, because the standalone protocol servers’ regions tabulate those same structs at the top level rather than under[smtp]/[imap]/[pop]— the keys sit at the top level ofsmtp_server.tomland friends, so the doc path is the bare field name and the row label a finding prints carries noprefix.either. That is what tells the two rows apart:[imap] hostnamecame from sithbitd’s table, the barehostnamefrom imap-server’s. Tables went 28 → 31 and compared values 84 → 90, and the six break in half. Three are the fields the product rule above stopped excusing. The other three are the genuinely new comparisons, and the rise is only that becausetable_cellskeys by cell text, so a field two tables document identically merges into one comparison rather than two: pop-server’s and imap-server’s ownrequire_tls, and smtp-server’shostname— which sithbitd’s table writes*(discovered)*, a cell claiming no literal — were under no fence at all before this. - Three new allow-list entries were the price of those tables.
SmtpConfig.server,ImapConfig.serverandPopConfig.server: each region’s[server]row states a bind address, while the code side isdefault_server()’sServerConfig::plaintext(…)— a constructor call the one-hop resolver refuses by design, the same refusalHealthConfig::on_port(…)already carries an entry for. So the allow-list total has not moved from twelve while its composition turned over, and the total is the less interesting half of that sentence: three entries excusing a unit conversion left, three excusing a constructor arrived. - The remaining gap is written down rather than left to be inferred
from the fence’s silence. Each standalone binary wraps its server
config in a private
FileConfigin its ownmain.rs— the#[serde(flatten)]host of[[accounts]]and, for SMTP,[[mailboxes]]— and no roster entry holds it. That is deliberate and buys little to close: both array rows state*(no entries)*, an italic cell claiming no literal and so nothing to diff, and the[health]port the same struct owns is already read by the health-listener arm. It is still a gap rather than a covered edge, so it is named in the roster’s own comment beside the prose-documented structs, the way the allow-list is printed in the clean run’s summary. - One rule set, two authoritative copies — and the third was
retired. The module docstring’s value-fence paragraph used to
restate which expressions the boundary evaluates, how a refusal is
excused, and what an italic cell is counted as; it is now a pointer at
the
--- the Default-column value fence ---section comment standing overDEFAULT_SOURCES, where those rules live beside the code they govern. One rule set in two places is one that drifts, and the two copies worth keeping are that section comment andmail_docs/README.md’s checker row, which describes the same fence for a reader who is not in the file. - The gate’s leg roster is unchanged. All of the above rides the
existing
check_config_keys.pyleg, whose self-test went 132 → 138 cases (its Default-column table 26 → 32): the product read both ways through both parse paths, since a product taught to theimpl Defaultside alone would exit 2 on the serde one; the near miss that must go on being refused, a*chain with a call among its operands; and the empty-prefix table from both sides — the doc side, where only the top-level row moves and the finding has to name the bare label a prefixed read never produces, and the code side in the shape the real page wears, where the prefixed row states no literal at all and the top-level table is the only one asserting anything about the field. The clean-tree case carrying a25 * 1024 * 1024default is an assertion by itself: a resolver that had not learned the shape would exit 2 on that same tree, and one multiplying wrongly would exit 1.
2026-08-13 — v0.72.3 (the Default-column fence learns the two other ways the tree writes a default, and the health-listener ports get a fence of their own)
Tooling-only: no on-chain ABI, instruction, error-code, or economic
change — check_config_keys.py’s value fence widens on both of its
sides, the checker grows a seventh arm, and check_screenshots.py’s
--update learns to report the one input gate mode already reports, so
this is a PATCH bump. Nothing documented needed correcting; every newly
fenced value was already in sync. No book page moved.
- A default written as a call to a named helper is compared by value
now. The serde-default-fn idiom (
default_report_window_hours()) used to sit outside the expression boundary, so every field spelled that way was excused by name instead of diffed. The resolver follows such a call exactly one hop, and only when the helper takes no arguments, is declared in the same source, and has a body that is a single bare literal — wrapped in the conversion calls the fence already normalizes, or not. Anything else (a further call, a struct or variant literal, arithmetic, a second statement) stops the hop and the field stays unsupported: the name is indirection rather than computation, so following it asserts nothing the literal rules did not already. Compared values went 60 → 65 andDEFAULT_ALLOWLIST13 → 8, and the three excuses that stayed now name the real refusal rather than the idiom. - The three protocol-server configs’ Default cells are fenced for the
first time.
SmtpConfig,PopConfigandImapConfigwrite noimpl Defaultat all — theirdefault()istoml::from_str("")— so the parser that reads aSelf { … }literal read nothing for them, which is why the[smtp],[imap],[pop]and[smtp.quota]tables of the Configuration reference went value-unfenced for as long as they did. A roster entry now picks its parse path with adefaultskey: absent reads theimpl Defaultblock,"serde"reads the#[serde(default …)]attributes of the struct declaration and turns each field back into an expression the one shared boundary already evaluates —default = "f"becomesf(), a bare#[serde(default)]becomes the field type’s ownDefault, and anOption<T>becomesNonewhether it carries the attribute or not. A field of such a struct with neither an attribute nor anOptiontype has no default at all and is exit 2 rather than a skip: the struct parses the empty document, so a field serde cannot fill would panic at startup. The two paths are fenced against each other in both directions, each naming the roster key to change. Compared values went 65 → 84 across 28 tables, the allow-list 8 → 12 (two enum variants documented by their serde spelling, two25 * 1024 * 1024products — honest entries rather than a wider resolver), and a finding over a named helper now states what that helper resolved to, so the reader can tell which side moved without a second lookup. - The nine health-listener ports were restated on two pages and
fenced by nothing. Each binary picks its default with one
HealthConfig::on_port(N)call; the Liveness table tabulates all nine and the configuration reference repeats four of them in per-binary rows. That is a constructor call, outside the value fence’s expression boundary by design, and three of the nine are set in amain.rshelper no roster entry could name — so the ports get a dedicated arm rather than a looser resolver. It scans every*.rsfor call sites, attributes each to a binary by its crate directory (never by port set, so two binaries with exchanged ports cannot pass), and diffs three surfaces: the Liveness table both ways, the reference’s four per-binary literals one way — which four restate a port is itself a roster fenced in both directions, so the asymmetry stays a decision rather than a hole — and that page’s8190–8198range claim against the tree’s own lowest and highest, since the range is everything it says about the five ports it does not restate. The documented127.0.0.1host is read out of the constructor rather than assumed. Port 0 is the ephemeral test spelling and is skipped and counted; a mention that is no call, a non-literal argument, or a site in a crate no service row names is exit 2. --updateno longer crashes on the one input gate mode reports. Since v0.71.4 asourcesentry naming a missing path has been a named finding in gate mode, but the re-pin path still walked into a rawFileNotFoundError. It now prints that same report — verbatim, through helpers both modes share, so their wording cannot drift — skips that client’s re-pin, re-pins every sibling anyway, and exits non-zero. Skipping is the only implementable answer rather than merely the tidy one: a hash over a partial source set would look pinned while covering less than it claims. Two rules ride along:--updaterewrites the manifest only when something was actually re-pinned, and the self-test redirects the manifest path as well as the repo root, so no section can reach the committed manifest.- The gate’s leg roster is unchanged. All of the above rides the
existing
check_config_keys.pyandcheck_screenshots.pylegs; the former’s self-test went 99 → 132 cases. Each widening was mutation-proved on the real tree in the strong form — for the new arm, moving one binary’s health port in the code reds that leg alone among the gate’s legs, naming the tabulated row, the source line and both addresses — andmail_docs/README.mdnow describes the seventh arm and the widened--updatecontract.
2026-08-13 — v0.72.2 (screenshot checker: the walked closure now fences the offline shell — and its first run caught real drift)
Tooling-only: no on-chain ABI, instruction, error-code, or economic
change — check_screenshots.py gains three hardenings and the new
fence’s first catch fixes webmail’s service worker, so this is a PATCH
bump. No book page moved.
- A
sourcesentry naming a missing path is a report now, not a traceback. The hash walk used to escape with a rawFileNotFoundErroron a manifest entry no longer on disk, aborting the run and swallowing every closure finding queued behind it. Each missing entry is now a named finding carrying its two fixes (restore it, or drop it and re-pin), the closure findings from the same run still surface beside it, and only the hash comparison is skipped — a hash over a list that is not all on disk cannot be computed to disagree with anything. - The walker learned the PWA manifest’s one reference form.
.webmanifestis a walked extension now and its JSON"src"members are followed, so the icons the manifest names are part of the computed closure instead of structurally invisible. The form is scoped to.webmanifestfiles alone — a JSON-shaped"src":literal in JS is data, not a reference, and must never widen a closure — and the reached assets stay out of the closure diff and the hash (they are not source), so no client’s pinned hash moved for this. - The offline app shell is fenced against the closure — the guard
those assets were walked for. A client whose root ships a
service-worker.jskeeps a second hand-kept copy of its closure, the worker’sPRECACHE_URLSarray, and it drifted exactly the way thesourceslist did before v0.71.4.check_precachediffs the array against the walked closure both ways in URL space: a reached file the precache omits is red (a first offline open of the installed PWA would lack a file its pages reach), an entry nothing reaches is red,"."and the worker itself are allowed unreached, and the generated wasm pair is existence-exempt — the walker can never reach a build output — but required present. - The fence’s first real run disproved “harmless today”. Eight
reached
shared/modules were missing from webmail’s precache — four directapp.jsimports (chain.js,chain-send.js,recipient.js,trustless-inbox.js) plusbase58.js,keystore.js,store.jsandtag-vocabulary.jstransitively — so the installed PWA’s offline shell lacked files its pages reach. Fixed in the worker; webmail’ssource_hashre-pinned, hash only, no re-shoot — no rendered pane changed. - Mutation-proven live, and the hand-kept third copy retired. Both
drift directions were probed on the real tree — a planted dead entry
and a removed reached entry each red the checker naming the url — and
webmail’s
service-worker.test.jsdrops its hand-hardcoded shell list, a third copy of the same closure, for the assertions the Python fence is structurally blind to: a non-empty list, the"."scope root present, no duplicate entries. Three new self-test sections take the checker’s own suite from two to five, the gate’s leg roster is unchanged, andmail_docs/README.md’s checker table now describes all three guards. - v0.72.1’s queued follow-up landed alongside. The Rust unit test
that entry deferred — pinning
server_common’sidle_timeout_secsdefault at 1800 as an RFC 3501 conformance matter — now exists and is itself mutation-proven, so the docs fence over the reference’s Default column is no longer the only thing in either gate reading that figure.
2026-08-13 — v0.72.1 (the configuration reference’s Default column is now checked by value, not just by key)
Tooling-only: no on-chain ABI, instruction, error-code, or economic
change — check_config_keys.py gains a fence over the reference page’s
Default columns, so this is a PATCH bump. No documented default needed
correcting; the tree was already in sync.
- Every leg of the docs gate could agree a row existed while none of
them read what it said. The checker diffed key sets — the nine
example TOMLs against
Configuration reference, the service
roster against the tree, appconfig-gen’s roster and its artifacts,
keys and all — and a value stated in a Default cell was fenced by
nothing. That is exactly how a
600sat in the[*.server]table’s Default column long after the in-code default had moved to 1800. - The fence reads the
impl Defaultblock behind each table.DEFAULT_SOURCESpairs each rostered config struct with the doc region its fields are tabulated in and the path prefix they are written under — thirteen sources, twenty-five tables and sixty compared values today — and each field’s default expression is diffed against the Default cell of the row that resolves to its path, through the same cell resolver the key diff already uses. Region-scoping is what gives it teeth:bind_addris a row in six regions carrying six different values, so a map keyed on the path alone would report drift on its first row. A struct two regions tabulate is diffed against both of its tables. - The expression boundary is deliberately narrow. A literal is the
value; a literal under a conversion wrapper (
.into(),.parse().expect(…),PathBuf::from(…)) is normalized to the literal it wraps, since those spellings change a value’s type and never its value; a nestedType::default()resolves one level, to the empty rendering for the std containers and otherwise to a struct, whose row states structure rather than a value; andNoneis fenced as saying nothing is set, so a row claiming a literal for an unset field is drift. Integers compare as numbers, so a code-side10_000and the page’s10,000agree. Anything else is an assertion the fence cannot make — exit 2, not a pass — because a fence that guesses what a Rust expression evaluates to is worse than no fence. DEFAULT_ALLOWLISTis the one seam, and it cannot go stale. Thirteen fields are excused by name with a written reason (serde default helpers, a constructor, arithmetic over literals, an enum variant’s serde spelling), each printed in the clean run’s summary so nothing is skipped invisibly, and an entry the run never needed is itself a finding — the same contractEXCLUDED_PREFIXEShas carried since v0.66.1. Cells that state no literal at all (the page’s italic*(unset)*/*(discovered)*spelling) claim no value to diff and are counted rather than skipped, so “the fence read nothing here” is a number in the summary rather than a silence.- Eighteen self-test cases, and the gate’s own count fence had to
learn about them in the same breath. The new cases — two green,
eight seeding drift, eight seeding an input the fence cannot read —
take the two config checkers’ suite from seventy-nine to ninety-seven.
check_config_citations.py’s section 5, which fences the case counts written inmail_docs/README.md, sums only the case tables it imports by name, so the new table was invisible to it: the README understated the suite by eighteen and nothing red. The import list is now part of the fence’s contract, stated as such in its own comment, and the README’s checker table gains a row describing the value fence. - Probed on the real tree. Flipping
server_common’sidle_timeout_secsfrom 1800 to 600 — the drift this fence was built over — redscheck_config_keys.pyalone among the gate’s legs, naming the row, the source’s block and both values in one line. The workspace cargo gate does not catch that mutation at all (no Rust test pins that field’s default), which is the argument for the fence; a unit test pinning the value as an RFC 3501 conformance matter is queued separately. No gate leg was added — the fence ridescheck_config_keys.py’s existing run.
2026-08-13 — v0.72.0 (pinning is defined in plain language before the docs use it)
Documentation-only; the version is unchanged. The terms pin / pinning / unpin were used throughout the mail chapters with only a storage-jargon glossary line (“retain its blocks against garbage collection”) defining them — nothing a non-technical reader could lean on before first meeting the words. Following the precedent of What is a CID?, the IPFS-benefits appendix gains a plain-language What does pinning mean? section (a pin is one keeper’s promise, anyone can add their own, unpinning is not deletion everywhere), and the definition now precedes the term on each reading path: the Email chapter opens with a plain-terms note before its first “pinning”, its Pinning to IPFS section links the definition at its first “pinned”, the Pinning mail command reference points new readers at it before the concept link, and the glossary’s Pin / unpin entry cross-links it the same way the CID entry cross-links its own plain explanation.
2026-08-13 — v0.72.0 (the shared idle deadline rises to 30 minutes: IMAP autologout stops breaking RFC 3501 §5.4)
A new enforcement default that changes how every deployment behaves, so this is the MINOR class widened at v0.10.0 — no on-chain ABI, instruction, error-code or economic change.
limits.idle_timeout_secsnow defaults to1800, not600. RFC 3501 §5.4 requires an IMAP server’s inactivity autologout timer to be at least 30 minutes, and the shared listener deadline every protocol’s reads run under was ten — so the reference server broke that MUST every time an IMAP session simply fell quiet, and had been breaking it since the knob existed. 1800 s satisfies it. POP3 (RFC 1939 §3) and SMTP (RFC 5321 §4.5.3.2) state only smaller minimums of their own, and a deadline clearing the largest minimum clears the rest, which is why one shared knob stays conformant on all three listeners rather than needing to be split per protocol.- What an operator will notice: a silent session holds its connection
slot three times longer. Ten minutes of quiet no longer closes a
connection on SMTP, IMAP or POP — thirty does. The exposure that
bounds is
max_connectionsandmax_per_peer, not this deadline, so a listener sized for the old churn should be re-checked against those two rather than tuned back down here: loweringlimits.idle_timeout_secsunder 1800 on a listener serving IMAP re-opens the §5.4 violation. Every commented example value in the shipped TOMLs and.envsamples now shows 1800. - The IDLE seam of v0.71.0 is inert at shipped defaults, and both
pages that framed it as a raise were rewritten.
imap.idle_command_timeout_secsalready defaulted to 1800, so with the shared deadline there too the substitution raises 1800 to 1800 and changes nothing out of the box — §5.4 conformance at defaults is now bought by the base default, not by the IDLE-specific one. The seam is still real, because the two knobs move independently: an operator who lowers the shared deadline for every other command does not thereby hang up on a conformant idler. The configuration reference’s twoidle_command_timeout_secsrows (sithbitd’s and the standalone server’s) and the conformance appendix’s RFC 2177 section all said the default “allows the full half hour instead of” a stingy 600 s shared one; that contrast is false now, and each says the two coincide instead.
2026-08-13 — v0.71.4 (screenshot manifest: the closure rule is now walked mechanically, not kept by hand)
Tooling-only: no on-chain ABI, instruction, error-code, or economic
change — check_screenshots.py gains a guard over its own manifest, so
this is a PATCH bump.
- The rule v0.71.3 wrote down was still hand-kept. That entry stated
the curation rule correctly — a client’s
sourceslist names the shared files reached by the closure of every one of its HTML entry points — but nothing checked it, so the next shared import a page gained would sit unpinned exactly the way onboarding’sfund.js/dnd.jsimports did, silently narrowing what that client’s screenshot hash covers. check_screenshots.pynow walks the closure and diffs it against the list both ways. A shared file the pages reach that no entry covers is “reached but not in ‘sources’”; an entry no entry point reaches is “in ‘sources’ but unreachable”. Both are exit 1 with the path named, and both fixes end in--updateto re-pin the hash. The walk starts at every HTML page in the client’s tree and is transitive across the two hops that matter, HTML → JS → JS by import and HTML → HTML bymount.- Seven reference forms, four of them load-bearing today.
<script src>,<link href>,<img src>, ESimport … from, side-effectimport "…", a literal-pathfetch("…")andmount(id, "…")are all followed. On the three manifested clients the work is done by<script src>andimport … from, plusmount(the only path to the threeshared/*-panes.htmlfragments) and<link href>(the only path tobrand.css);<img src>reachesmark.svg, which<link rel="icon">reaches as well. The side-effect andfetchforms are covered by the self-test but unexercised — the literal fetches in the tree belong to thechromeandthunderbirdshells, which carry no screenshots. - The bundle-versus-source path skew has a remap. The shells spell
shared imports
./shared/api.js, which exists only afterbuild.shcopieswebclients/shared/into a client’s staging root, so a reference landing on a missing<client>/shared/…is retried against the siblingshared/directory it is staged from. - An unresolvable reference is dropped silently, deliberately. The
generated
wasm/mail_wasm.jsimport exists only afterwasm-pack, and a gate that reds on an unbuilt bundle would be unusable. The cost is that a shared file reached only through something the walker cannot see would read as an unreachable extra; the fix then is to widen the reference forms, not to trim the manifest. - No new gate leg, and no manifest correction needed. The guard is
folded into
check_screenshots.py’s existing verify pass, so the gate’s nineteen legs stand as documented inmail_docs/README.md, whose checker table now describes the closure guard; the threeclientsentries were already exactly their closures, so nosources,screenshotsorsource_hashvalue moved.
2026-08-13 — v0.71.3 (screenshot manifest: the curation rule is every HTML entry point’s closure, not index.html’s)
Tooling-only: no on-chain ABI, instruction, error-code, or economic
change — a correction to the prose rule inside
check_screenshots.py’s manifest, so this is a PATCH bump.
mail_docs/screenshots.manifest.json’s_formatnote stated a rule that was only accidentally right. Since v0.8.10 it had said a client’ssourceslist names the shared files “that client’sapp.js+index.htmlactually reach” — but a client is not necessarily one page.onboardingalso servesfund.htmlanddnd.html, whosefund.js/dnd.jsclosures reach shared filesindex.html’sapp.jsnever touches, which is why those imports sat unpinned as long as they did.webmailandmarketplacehave a single HTML entry point each, so the old wording happened to be complete for them.- The rule is now stated as the closure of every HTML entry
point.
_formatsays to walk each page’s<script>imports transitively when curating a list, and names onboarding’s second and third pages as the worked example — so the next client to gain a page is covered by the rule rather than by luck. - Prose only. The three
clientsentries were verified correct when the true closures were re-traced, and nosources,screenshots, orsource_hashvalue moved here;_formatis documentation for humans, ignored by the checker, which reads only theclientsobject.
2026-08-13 — v0.71.2 (the conformance appendix gains the RFC 2177 section its IDLE deadline had been missing)
- The appendix now covers IDLE, where it had covered every other IMAP
extension the server advertises. The new RFC 2177: IDLE — the push
watch, and its own
deadline
section sits with CONDSTORE and APPENDLIMIT, and records the same
three things they do: what is implemented (the capability, offered
with the other post-credential extensions;
+ idling,DONE, and untaggedEXISTSpushes fed by both the in-process and cross-process change seams), what a custom server should copy, and where the numbers come from. v0.71.0 shipped the behaviour and the configuration rows; the conformance page had never mentioned the RFC at all. - The 1800 s default is now explained as a relationship, not a
number. RFC 2177 requires nothing of a server here: it permits one
to treat an idling client as inactive and log it off at its inactivity
timeout, and on that basis advises clients to re-issue
IDLEat least every 29 minutes. The default is that interval plus a minute, so a client taking the advice re-issues before the deadline it would otherwise meet — and the section says plainly that 29 minutes is client-side guidance rather than a server-side floor, so an operator lowering the knob under it knows what they are choosing. - The two expiries are contrasted where a server author will look for
them. The IDLE allowance ends with an untagged
* BYE IDLE timed outand a clean close; the shared read deadline’s expiry stays a silent EOF. The section notes both are pinned to the tick by paused-clock driver tests, liveness included, so the shapes are behaviour rather than intent. - Documentation-only, so PATCH: v0.71.2. Nothing moved in the server; the appendix caught up to v0.71.0’s work.
2026-08-13 — v0.71.1 (two logins in one second are two sessions: the logout section stops denying the jti it now has)
- The logout section’s “same-second logins are one session” exception is
gone, because the claim behind it is. Ending a
session told
readers that two logins by one wallet inside a single second minted a
byte-identical JWT —
iat/expbeing second-resolution, with nojtito tell them apart — so logging out of either ended both. Every issued token now carries a randomjtisession id, minted fresh per issuance, and that id (not the token text) is what the reading-secret and decrypted-summary stashes are keyed by: a same-second pair is two sessions with separate stashes, and ending one leaves the other whole. The “per token, not per wallet” bullet now says so without the exception. - What logout still does not do is revoke the token, and that stays in
bold. The JWT remains a stateless bearer credential valid to its
expon every route; the section’s closing paragraph keeps that warning and only corrects its reason — there is no denylist, and thejtiis the key a session’s server-side state is filed under rather than a revocation list a presented token is checked against. The narrower guarantee is unchanged: after logout the server holds no key material for that session, so the same still-valid token reads exactly what an unkeyed session reads. - Documentation-only, so PATCH: v0.71.1. Nothing about the API’s behaviour moved here — the docs caught up to behaviour that had already shipped.
2026-08-13 — v0.71.0 (a client inside IMAP IDLE outlives the shared read deadline, and is told when its own expires)
- IDLE now runs under a deadline of its own. While a client sits inside
an accepted
IDLE, the connection’s read deadline is the newimap.idle_command_timeout_secs— 1800 s by default — instead of the listener’s sharedlimits.idle_timeout_secs(600 s by default), and the shared deadline is restored the moment the exchange ends. RFC 2177 tells clients to re-issue IDLE at least every 29 minutes; the previous arrangement hard-closed exactly such a conformant idler at the ten-minute mark, so a mailbox left open in a standards-following client kept dying mid-watch. The default allows the full half hour. - The IDLE deadline expiring is a goodbye, not a vanishing. A client
that idles past the raised deadline without
DONEor a re-issue is sent an untagged* BYE IDLE timed outbefore a clean close — where the shared deadline’s expiry was, and for every other command remains, a silent EOF. A client that sees the BYE knows its session ended rather than wondering what the connection died of. - Where the knob lives, and where it deliberately does not. It scopes
one IMAP command, not the listener, so it sits beside
max_login_attemptsrather than under the shared limits: top level in the standalone server’simap_server.toml(envIMAP_SERVER_IDLE_COMMAND_TIMEOUT_SECS), and under[imap]insithbitd.toml(envSITHBITD_IMAP__IDLE_COMMAND_TIMEOUT_SECS). Both configuration-reference tables — sithbitd’s[imap],[pop]and the standalone server’s — carry the new row, and the shared listener section’sidle_timeout_secsrow now names its IMAP exemption. - The version rolls to v0.71.0. Observable IMAP wire behaviour changed — who survives the read deadline, and what an expiring IDLE is told — plus a new config default: the additive, deployment-affecting class that took MINOR at v0.68.0 (APPENDLIMIT advertised) and v0.69.0 (the oversize refusal explained); PATCH stays reserved for docs-only changes.
2026-08-12 — v0.70.0 (the v1 wallet-signature window closes: no epoch accepts an epoch-less mail password — BREAKING)
A breaking credential change: the epoch-less v1 wallet mail password is
refused everywhere, including at auth epoch 0, which was the one state that
still honoured it. A client holding a v1 password no longer logs in over SMTP,
IMAP or POP until it re-derives; every shipping client already derives
v2, so the remedy is one
re-derivation and there is no migration to run. Per the versioning preamble
MAJOR stays 0 pre-launch, so a break rides the MINOR digit and the heading
carries the word.
- The window is gone rather than deprecated. The v1 prefix, the function
that built its bytes, and
verify_at_epoch’s epoch-0 fallback arm are all deleted; verification is now one message build for the account’s current epoch and one signature check, with nothing to fall back to. The wallet mail password and auth epoch glossary entries stop describing two live constructions, and the account API’sauth_epochfield stops calling0the state in which the older signature is still accepted —0is now just “never rotated”, refusing a wrong-epoch signature as firmly as any other value does. - Abrupt was safe because the two shapes were never confusable. v1 and v2 were domain-separated from the start, at the version digit in a prefix of the same length and by total message length (53 bytes against 61), so no v1 signature could ever be reinterpreted as a v2 one and no account could be mid-way between them. That is what allowed a clean removal rather than a deprecation period: there was no ambiguous byte string to keep supporting.
- Closure is proven by refusal, not by absence. Rather than resting on
“the code is gone”, the server credential layer, the account API and the
wasm signer each build v1-shaped bytes in-test and assert they are rejected
— the server layer across a spread of epochs with
0leading, since0is the epoch that used to accept them. A future re-introduction therefore has to delete a test that says so. - Docs-side scope. Two pages asserted the live window and both were rewritten; the versioning preamble gained the pre-launch rule that decides which digit a break like this moves. No configuration key, gate leg or checker behaviour changed — the docs gate stays at nineteen legs, and its self-test case counts stay at 79 / 58 / 44 / 7.
2026-08-12 — v0.69.1 (three open questions get answers: the upgrade authority’s end-state, the money unit each surface speaks, and the line numbers the gate leaves unfenced on purpose)
-
SithBit’s canonical network launches behind a multisig, and freezes on a stated trigger. How this qualifies “nothing to rug” used to close on an open question — freeze, multisig or DAO — and told the reader that a governance decision was not a protocol fact this repository could settle. For this project’s own network it is settled now: the authority sits in an
N-of-Mmultisig at launch (option (a)), and freeze (option (c)) is a later step gated on a written trigger — that the rules stop moving, meaning no planned protocol work still needs an instruction-set change and RFC coverage is no longer growing the programs’ surface. Freezing before then forecloses the on-chain bug-fix path while that surface is still being extended, and a program that cannot be fixed is a weaker promise than one whose fixers you can count and name. A DAO (option (b)) is ruled out: governance that is more than a multisig wants tokenomics to weigh votes, and the “minimal rake, no token” stance excludes the token that would take. What did not change is the page’s framing. Every network still sets its own upgrade authority at deploy time and can transfer it, so this settles one deployment rather than imposing a protocol rule, and the page still sends you tosolana program show— including for this deployment, after launch — rather than to its own statement of intent. -
The CLI takes lamports, the human-facing clients show SOL, and that split is now written down as a rule instead of read as an inconsistency. Which unit each surface speaks is a new section on the economics page, at the seam between the lamport-denominated narrative above it and the constants table below. Every money argument the
sithbitbinary reads —alias sell --price,alias bid --amount,mail send --bounty,mailbox create --default-postage, the delegate’s fee setters — is an integer count of lamports, because the CLI is a scripting surface and an exact integer in the chain’s own unit composes: it survives shell substitution and generated command lines with no float rounding, no locale decimal separator and no ambiguity about which unit a bare number is in, and it is the unit an RPC response or a program error quotes back. Webmail, the marketplace and the Outlook / Thunderbird / Chrome panes quote SOL, because SOL is what a person holds in a wallet and compares against a price, and they convert at their own edge. The auction flags are framed as an instance of the rule rather than an exception to it:--reserveand--amountare lamports because their siblings--priceand--feeare, and a marketplace that read a reserve in SOL and a fixed price in lamports would be a foot-gun for anyone scripting both. Moving the whole project to SOL was the alternative, and it was weighed and rejected. The rule is scoped to what the CLI accepts: its reports may still annotate a figure with a SOL and best-effort USD tail (sithbit earnings,frombox get,mailbox get --usd), which is readability rather than a second input unit. -
The gate’s page-less line numbers are recorded as deliberately unfenced, with the reason, and the one volatile instance is gone.
check_config_citations.pyrefuses the bare:Npointer shape outright andcheck_timelock.pybinds every pinned<page>.md:<line>to a pin or a written allowance, but a line number written as ordinary prose or as aScope’slines=argument — naming no page and carrying no colon — sits outside both.check_timelock.py’s module docstring now says why that shape gets no fence of its own, and the argument is what its instances are rather than what a fence would cost to build. There are three classes and a fence would help with none: one is guarded by the live code beside it (a comment restating the bounds of aScopea few lines below is re-checked every run, because moving the prose those ranges bracket makes two scopes overlap andrequire_disjoint_scopesrefuses the table at exit 2), one deliberately describes a range that no longer exists (the close row’s retired range is quoted to record why that scope became asection=, and a fence demanding it match today’s file would force the record to be falsified to stay green), and one is illustrative prose pointing at nothing (a measurement of magnitudes, taken when the choice was made, that no reader is meant to follow to a line). The standing call is unchanged either way: prefer asection=, a heading name or a quotation to a number, and where a number is the only thing that says it, write it where live code checks it. The one instance that quoted live line numbers “today” — the glossary split’s parenthetical — was deleted rather than kept and pinned, the same call the checker’s own duplicated citation got. -
The version rolls to v0.69.1. Everything here is prose: a decision written down, a rule stated, a reason recorded. No instruction, wire format, default or configuration key moved, no gate leg was added (the docs gate stays at nineteen), and no exit code changed for anything already green — the checker’s self-test stays at 44 cases and every timelock mention count is unchanged. That is the documentation-only class, so PATCH.
2026-08-11 — v0.69.0 (An oversize IMAP APPEND is now told why it was refused)
- A refused
APPENDliteral now names the reason and the ceiling. An oversize synchronizing literal draws exactly one line — a taggedBADreadingTOOBIG: message exceeds the APPENDLIMIT of <n> bytes, sent in place of the continuation request, so nothing of the payload is buffered first.<n>isimap.max_message_size, the same setting advertised asAPPENDLIMITa version earlier, so the promise and the refusal cannot drift. Previously the refusal carried the flow layer’s default text — the three characters...— and told the client nothing at all. - The
TOOBIGname is unbracketed, and that is upstream’s ceiling rather than a shortcut. RFC 7889 §4 wants the machine-readable[TOOBIG]resp-text-code; the adoptedimap-nextpre-builds this rejection with its response code hardcoded toNone, and its one seam — the reject text — is validated as continuation-request text, whose constructor refuses a leading[. A trueNO [TOOBIG]is unbuildable on the pinned version without forking the dependency, which was weighed and declined for the same reason the CONDSTORE deviation is left alone. So a client matching on the code still reads a genericBAD; only a human reads the reason. - The
LITERAL-gap is unchanged and now stated in full. A non-synchronizing oversize literal still gets one untagged* BAD could not parse command— noTOOBIG, no ceiling — and, the part worth knowing before you rely on it, theAPPEND’s own tag is never completed, which a client waiting on its tagged reply experiences as a hang. The connection survives and the next command is answered normally. Both gaps are recorded as upstream’s in the conformance appendix’s RFC 7889 section, where the previous entry had claimed the single gap was ours to fix, and narrowed in the standards page’s IMAP table. imap.max_message_sizeis documented as three things, not one. Both configuration reference rows for it —sithbitd’s and the standalone IMAP server’s — now say it is enforced, advertised asAPPENDLIMIT, and named in the refusal text.- The version rolls to v0.69.0. Observable IMAP wire behaviour changed — what a refused upload is told — so this is not the doc-only case that keeps the previous version, and MINOR rather than PATCH because PATCH is reserved for docs-only changes.
2026-08-11 — v0.68.0 (IMAP publishes its APPEND ceiling: RFC 7889 APPENDLIMIT)
- The IMAP server now advertises
APPENDLIMIT=<n>(RFC 7889), so a client can size an upload instead of discovering the ceiling by having a finishedAPPENDrefused. The server has always enforced a maximumAPPENDsize and simply never said what it was. The advertised value isimap.max_message_size— 25 MiB by default — which is the same setting the driver caps incoming literals with, so the advertised limit and the enforced one cannot drift apart, and the extension needs no config knob of its own. It rides §2’s form (a) (one ceiling for every mailbox, carried in the capability name), and is advertised both before and after authentication, which §2 permits and which is what lets a client size its very first upload. See the new row in the standards page’s IMAP table and the new conformance appendix section. - Two parts of RFC 7889 are deliberately absent, and the appendix says so
rather than claiming the RFC whole. Limits do not vary per mailbox here,
so the extension’s bare-atom form and the
STATUS (APPENDLIMIT)item it depends on are not implemented. And §4’s[TOOBIG]response code is a known gap: an oversizeAPPENDis refused, but the refusal is generated by the adoptedimap-nextflow layer below the session, as a taggedBADwith no response code (a non-synchronizing literal is discarded with no tagged response at all). That one is ours to fix rather than upstream’s — unlike the CONDSTORE wire deviation recorded a section earlier — and is queued as a follow-up in the server’s driver. - The version rolls to v0.68.0. A newly advertised capability is the significant additive, deployment-affecting class that took MINOR for SASL-IR (v0.33.0), SPECIAL-USE (v0.34.0) and CONDSTORE (v0.45.0) — even though no enforcement changed here, only what the server says about it.
2026-08-11 — v0.67.1 (Getting started opens the Using SithBit chapter)
- Getting started is now the first page of Using
SithBit, ahead of GUI clients, rather than a
sibling sitting after the marketplace. It is the wizard every GUI client
embeds, so a reader opening the chapter to set themselves up now meets the
setup page first instead of finding it below the clients that depend on it.
The page’s own path is unchanged, so every deep link into it — the several
#web-onboarding-the-browser-wizardand#self-service-pages-for-refused-senderscitations across the book — still resolves.
2026-08-10 — v0.67.1 (the example TOMLs show what production actually sets, the beacon rows link the campaign commands they name, and the recon page’s audit cites carry both halves again)
-
The example TOMLs now show every setting the production AppConfig artifacts set, and a new cross-check holds them there. The six
iac/appconfig/aws/documents are the config a real deployment runs on, and 33 of their settings appeared in no annotated example — most of two entire storage flavors: the S3 blob store, which no example TOML in the workspace showed at all, and the Azure store and blob flavor, which onlysithbitd’s example showed. Nothing red, because nothing compared the two: the artifact tree was fenced for existence only. It is now fenced for content, on both clouds — each AWS document’s key set, and the merged Azure key-value set, diffed against the example TOML its service row names, so a setting cannot reach production while the example that is supposed to teach it stays silent. The gap itself is closed insithbitd, account-api, sithbit-ipfsd and sithbit-gateway: the S3 and Azure flavors now sit beside the local one in each[blobs]/[store.blobs]table, doubly commented because each replaces that table rather than adding to it, withsithbitd‘s example — which already showed the Azure flavor — as the template the others follow by name. Two settings had never been documented anywhere: sithbitd’simap.hostnameandpop.hostname, which unlike the SMTP listeners’hostnameare never discovered from the chain, and they now have reference rows. Among the new keys onlyregion(S3) and the Azureaccount/table/queue_prefixhave defaults; the rest are required, and the examples say which is which. -
The RFC-updates recon page’s audit cites carry both halves again, and the gate now holds them there. The page’s own method note promises
file:linecites, but 18 of them had dropped the file half — a line number alone against a file named a phrase or a paragraph earlier, exactly the form whose line moves while nothing reds. Every cite now names its file, and the page joins the page-less-pointer fence’s roster as its first book page, so the 19th cannot land. The assessed-files record that had deferred it (“a rewording job first, a roster edit second”) now points at the roster entry instead of naming a candidate. See RFC-updates recon — 2026-08. -
The three participant-beacon rows stop naming CLI commands they don’t link. In the
MailInstructionvariants table, theCreateParticipantBeacon/UpdateParticipantBeacon/CloseParticipantBeaconrows citedcampaign create,campaign update, andcampaign closeas bare code spans, even though every sibling row whose command has a reference page links it — and Campaigns has carried all six subcommands as anchored sections since it landed. Each row now links its own section in the same linked-command form thefrombox reclaimanddomain attest-senderrows use. Thecreaterow keeps its item 43 concept link alongside the new one: the concept appendix and the CLI reference answer different questions. Documentation-only, so PATCH: v0.67.1.
2026-08-10 — v0.67.0 (the compose 429 becomes alertable on its own, the gate fences the artifacts its roster implies, and the CLI’s own help stops teaching short flags)
-
A refused compose now has a series an operator can alert on directly. Until now the only way to see the account API’s outbound-quota refusals was to filter
sithbit.api.refusalsbyroute="/v1/mail/send"— and that filtered series is not even quota-only, since any other 429 answered on that route lands in it too. There is now a dedicatedsithbit.api.compose_quota.refusals, incremented at the quota gate itself, carrying no labels at all: a counter you must filter to read has not solved the problem it exists for. The blanket counter is untouched — same name, same labels — so one refused compose increments both instruments and the two must never be summed. See Outbound quotas and suspension. A flat zero on the dedicated series means enforcement is off ([quota] enabled = falseshort-circuits before the counter), not that nobody is over quota. -
The docs gate fences the
iac/appconfig/artifacts against the roster that generates them. Theappconfig-genservice roster was already diffed against the configuration reference’s, but nothing checked that a row’s artifacts exist. Both are now required for every row — the AWS base and the Azure override — becauseappconfig-genitself requires both at generate time, so a row carrying one is a build that cannot succeed; an artifact no row names is drift in the other direction. A missing artifact directory is a distinct, louder failure than a missing file, because absent it the fence would report “no orphans” while blaming every roster row for a defect none of them has. -
The gate now records the widening decisions it has already refused. Its page-less-pointer fence reads a hand-kept roster of files rather than sweeping the tree, because one look-alike — a port written with no host in front of it — cannot be told from a real finding by shape. That roster now carries an assessed-files record: which files were read, which were refused and why, and one refusal that is permanent rather than pending a rewording (a page that quotes the banned shape in order to document it cannot be fenced against writing it). The roster is also held to its own stand-ins, so a half-added entry names itself instead of failing nine fixtures with a message about a missing file.
-
The CLI’s
--helpoutput stops teaching short flags. Eleven examples insithbit --helpspelled a flag short — ten-kand one-x. The standing rule that documentation uses long-form flags now covers any example that surfaces in help output, not just the prose in this book. Two of the eleven sit behind non-default features, so a default build never compiled them. -
Two participant-beacon rows name the CLI commands that ship.
UpdateParticipantBeaconandCloseParticipantBeacondescribed what they do but named no command, thoughcampaign updateandcampaign closeare both in a default build. See the instruction reference.
2026-08-09 — v0.66.2 (the gate stops writing the pointer shape it forbids, a second service roster joins the fence, and three pages stop promising work that shipped)
-
The docs gate no longer writes the citation shape it refuses.
check_config_citations.pyhas long refused a page-less line pointer — a bare:412with no page in front of it — because the line moves and nothing reds.check_timelock.pywas the largest single pocket of that shape in the tree and was not one of the files the fence scanned: fifty-two of them, and five had already rotted, naming lines that no longer carry the figure they claimed. They now name sections, the file has joined the roster, and the two checkers hold each other’s prose to one standard. Rewording forced a re-measurement in four separate phases — a pointer spelled as a number lets you describe a place without ever looking at it. -
The pointer fence also stops half-reporting. Writers chain these pointers (
:52/:54/:56), and the fence’s head member always matched, so a chained run did red — but the message names one token per finding, so a fixer who edited the token it named left the tail members live and the next run went green over them. The anchor class now admits/and-, measured at zero new findings across all four rostered sources. What keeps the precision is the character kept out of the class: a slice, a port, a clock time and a ratio are every one of them digit-preceded, and a digit never precedes a real pointer. -
The gate’s own count words are fenced. The docs README states how many citations it writes, and that number had gone stale twice — once inside a single session, when a correction was invalidated by its own rewording.
check_timelock.pynow holds it, and holds the unit with it: occurrences, distinct page-and-line pairs and allowance-table rows are nine, six and five here, three figures for one idea, which is exactly how the word rotted. A number word with no stated unit can no longer be written in that slot. -
appconfig-gen’s service roster is fenced against the reference’s. Two independent lists of the config-taking binaries have coexisted — the configuration reference’s nine, and the sixappconfig-genwrites the Azure kvset artifact from — with nothing checking they agree. They are now diffed by service name, which is the only correlation that survives their disagreements: the two schemas use the same field names for different things, and they list their rows in different orders.sithbit-gatewayneeded a recorded exception on both halves, its env prefix and its file stem, and the exception table is itself fenced against going stale. The exclusion list beside it gained the same treatment: an entry excusing a binary the tree no longer has is now reported rather than left reading as a standing decision. -
Three pages stopped promising work that has shipped. The program reference’s
CreateParticipantBeaconrow said CLI authoring was still to come; it namescampaign createnow. The account API page’s two refusal sections named no metric and now cross-referencesithbit.api.refusals— precisely, because the compose-quota429lands in that counter under its own route series rather than joining the three gated ones. And the IMAP server crate’s docs, alone among the three protocol crates, still described its production storage as forthcoming. -
The SMTP and IMAP crate READMEs name the daemon that runs them. Only
pop_servermentionedsithbitd; a reader arriving at either of the others learned about the standalone binary and never learned the combined daemon is the usual way to run it. The SMTP note deliberately departs from the POP model’s “same handler, different backend” shape, because it is not true there — the daemon spawns both SMTP roles from one config and wires DMARC reporting seams the standalone binary leaves unset. -
One last short flag. The compute-unit appendix’s
mail send -frow reads--from. The v0.66.1 entry below claims everysithbitexample in the book uses long flags; that cell was believed to be pinned short by a checker reading it, which turned out not to be true of that checker. The claim is now literally correct rather than nearly so.
2026-08-09 — v0.66.1 (the CLI examples all speak long-form, two more gated commands say they are gated, and the gate’s three hand-kept rosters are fenced against the tree)
-
Every
sithbitexample in the book uses long flags. The convention has always been long-form in examples with the short spelling named in the argument list, but eleven pages had drifted from it across thirty sites —-k,-y,-x,-oand-s. Every long form was verified against the clap definition inmail_clientrather than assumed. The house style is now uniform and worth stating once: examples and synopsis blocks are long-form only, argument lists name the short form parenthetically, and no page carries a short-first-x, --longentry any more.solana address -kin the devnet vanity-ID appendix is the Solana CLI’s own flag and deliberately stays short.mailbox create-certalso had its synopsis and argument list normalized, and itsclient_cert_authsentence harmonized with the phrasing the two client pages already used. -
The two
rand-gated command pages say so.mailbox reading-secretandmailbox key create/key setridemail_client’srandfeature and never mentioned it. Both now carry a build note, written in the direction that helps:randis on by default, so a stock build already has these commands and there is nothing to enable — only a--no-default-featuresbuild that leaves it out drops them. The neighbours that are not gated (credentials,sign-text,key get,key close) are named too, so the note is not read as covering a whole page. -
The rate-limit section points at the counter that observes it. The per-wallet mutation budget explains the 428/429 charge order but named no metric, so a reader deciding how to size the budget had nothing to measure with. It now cross-references
sithbit.api.refusalsand the monitoring page, worded “per route template — not per method” so it cannot be read as claiming the counter separates a refusedPUTfrom a refusedDELETEon one route. It does not, by the deliberate design v0.66.0 recorded. -
The docs gate’s three hand-kept rosters are fenced against the tree.
check_config_keys.py’sSERVICESandcheck_config_citations.py’sSITESandSTEP_UP_PAGESwere each a list nothing checked for membership: a new config-carrying binary, a fifth listener readingclient_cert_auth, or a fourth page quoting the step-up nonce prefix could all appear and no leg would notice. Each roster is now diffed against the tree both ways. The per-page copy counts stay hand-kept on purpose — deriving them from the very scan they fence would dissolve the anti-vacuity guard they exist to be. A fourth fence refuses page-less:Ndoc pointers in the gate’s own sources, the rotcheck_timelock.py‘s pinned-citation guard structurally cannot see. All four ride invocations the gate already runs, so it stays at nineteen legs; the two checkers’ self-tests grew to twenty-nine and forty-five cases.mail_docs/README.mddescribes the widened ownership, and its claim that renaming a real source reds one leg alone now covers the step-up fence too, having finally been run that way. Docs-tooling and prose only, no behavioral change — so PATCH: v0.66.1.
2026-08-09 — v0.66.0 (POP3 stops freezing its SASL list at connect, the account API’s refusals become countable, and the step-up nonce prefix is fenced past the Rust boundary)
-
POP3 offers SASL EXTERNAL after an STLS upgrade. The mechanism was frozen at connection start, so a plaintext POP listener with
[pop.tls]andclient_cert_auth = truenever advertised or accepted EXTERNAL no matter what happened on the socket afterwards — it reached POP3S (995) only. POP now gates the offer the way IMAP and submission always have, on the listener’s client-auth mode and the live TLS channel, re-read at everyCAPAandAUTHrather than computed once. The same connection that was refusedAUTH EXTERNALin the clear is offered it, and logs in with it, the moment STLS completes. Client-certificate auth (SASL EXTERNAL) loses its POP caveat and gains the reason the offer can appear mid-connection; thepop.client_cert_authrow now differs fromimap.client_cert_authonly in protocol spelling. POP3S behaviour is unchanged. An additive listener capability that changes what an existing deployment offers on the wire, so MINOR. -
The account API’s refusals are countable now. The monitoring page told operators to count 428s and 429s off the fronting reverse proxy’s or ingress’ access log, because no
sithbit.metric covered the account API’s HTTP surface at all — v0.65.1 below says so out loud. One counter joins the metrics table:sithbit.api.refusals, labeledstatus(the integer code, per the OpenTelemetryhttp.response.status_codeconvention) androute. It is a single layer over the fully merged router rather than a branch inside the error type, which buys two things — the route label, which needs the request that the response conversion never sees, and coverage of the refusals no handler produces: an extractor rejection on malformed JSON, the 404 an unrouted path earns, the 405 a wrong method earns. All of them land in the one counter deliberately — each under its ownstatus/routeseries, so a panel can keep them apart or sum them — because an operator alerting on refusals wants the client sending malformed JSON beside the one sending none.routeis the matched route template, never the request path, so a wallet or message id in a segment cannot explode cardinality, and anything matching no route collapses into the single<unmatched>series — a[[static]]mount’s 404 included, since a nested service carries a private matched-path type the public extractor cannot see. A new operational surface deployments will want to scrape and alert on, so MINOR. -
The refusal guidance names the metric, and warns off two wrong dashboards. Outbound quotas and suspension now points at the counter instead of an access log, and states the two traps a panel built on it falls into. First, the
routelabel carries no HTTP method, so the five step-up-gated method/path pairs collapse onto three series per status —/v1/account/password,/v1/account/auth-epoch,/v1/account/pin-provider— and a refusedPUTof the password is indistinguishable from a refusedDELETEof it. Second, the budget is charged in front of the handler holding the step-up gate, so a spent window answers 429 before a 428 can be reached: under sustained abuse of a gated route the two statuses replace each other rather than rising together, which makes the sum of both the honest “sensitive-mutation refusals” signal and the split the diagnostic one. The alerting split itself is unchanged from v0.65.1 — a 429 spike is the control working, a 428 spike means clients cannot sign. -
The prefix the book tells you to sign is checked against the one the API mints. Step-up challenges hand you a nonce beginning with a fixed prefix, and you sign that string verbatim — so every place the book writes the prefix out is a string a reader pastes into a signer, not a description of one. Seven such copies were spread over three pages (the account API reference, the recipient PIN provider guide, and the CLI’s mailbox-credentials page) with nothing tying them to the constant in
account_api. Renaming it would have left all seven telling people to sign bytes the API never issues, silently, with every checker green.check_config_citations.pygained a third section comparing each copy against the constant byte for byte — the trailing space included, since the nonce is a bare concatenation — and holding each page to the number of copies its roster claims, so a copy quietly reworded away fails as loudly as a copy quoting a prefix that was never minted. Nothing on the three pages needed an edit: they already agreed, so the fence locks in a truth rather than correcting a lie, and renaming the constant now reds the docs gate until the copies move with it. Two design points, because the obvious implementation misses both: it matches step-up-shaped prefixes and then compares, since a search for today’s exact string would go quiet on a rename at precisely the moment it should report the copies that rename stranded; and each page is flattened to one line before matching, so a copy an editor’s reflow left straddling a line break still reads as one literal — a fence that reddened on rewrapping is a fence people delete. A constant renamed away or declared twice, or a value the fence’s shape no longer recognises, is exit 2: a fence that cannot read its own anchor must not blame the docs. This page’s own quotations of the prefix are deliberately not fenced, because dated prose-of-record is never retro-edited — which is why the roster is an explicit page list rather than a tree-wide search. It rides thecheck_config_citations.pyinvocation the gate already runs, so the docs gate stays at nineteen legs while the checker’s--self-testgrows from fifteen cases to twenty-four. Docs-tooling only — no page prose changed and no leg was added — so this part is PATCH; the section’s version comes from the two entries above it.
2026-08-08 — v0.65.1 (the step-up 428 reaches every page that meets it: the two extensions, the glossary, the operator’s refusal table and the budget’s own section)
-
The extension pages say a rotation can be refused, and say it GUI-first. Thunderbird and Outlook walked through rotating the wallet mail password without mentioning that the epoch bump is one of the account API’s step-up gated calls, shipped at v0.63.0 — so a user who met
428 Precondition Requiredhad nowhere in the book to read what it meant. Both pages now say it from the side a user actually stands on: the pane fetches the challenge and has the wallet sign it, which is the Phantom or Ledger approval prompt that appears at that moment, so the case that surfaces in the extension is only the one where no signature could be produced — a locked or disconnected wallet, a declined prompt, a challenge gone stale — and the pages quote the pane’s one sentence verbatim, so searching it lands here. The remedy is written as three acts rather than a wait: unlock or reconnect, arm the control again, approve. Waiting is explicitly not the remedy, because each attempt spends its own challenge and a retry needs a fresh signature, never the previous one. And a refused rotation changes nothing — the epoch stays where it was and the password the client already holds keeps working. -
The glossary carries the same fact, definition-shaped. Auth epoch is where the term glyphs land a reader, and it described what a bump does without saying what a bump takes; it now closes on the 428 and the nothing-changes guarantee in three sentences. That insert pushed every heading below it down the page, which moved
check_timelock.py’s two hardcoded glossary scopes and the pinned citationmail_docs/README.mdcarries — docs tooling, not page prose, and both halves had to move in the same commit, since either one alone exits 2. -
The operator’s refusal table can tell the two refusals apart. The wire-refusal table carried only the 429 +
Retry-Aftershape for the five gated mutations. A second row names the step-up refusal — HTTP 428 plus{"error":"step_up_required"}, and deliberately noRetry-After, because the remedy is a signature rather than a wait — with two paragraphs of alerting guidance beside it, since a 428 spike and a 429 spike demand opposite responses and the table has no “what to do” column. Recon result stated plainly on the page because it surprises: neither refusal is metered. The account API installs no request-tracing layer and exports no metric over its HTTP surface, so both are counted off the fronting proxy’s or ingress’ access log, by status against the five gated paths. The page also rules out the wrong cause by name — a 428 is not client clock skew. A challenge’s expiry is stamped at issue time from the issuing replica’s clock, so what eats into its 300-second life is skew between API replicas; the client’s clock never enters the judgement. -
The per-wallet budget bounds attempts, not changes.
[rate_limit]read as though only a success or a 429 charged the budget. The budget is applied as a tower layer while step-up is a per-handler extractor, so the charge lands in front of the gate: the 428 that asks for a signature costs exactly what a success costs (why). A fifth property spells out what that buys the person choosing the number —max_per_window = 30is 30 real changes for a client that fetches a challenge and signs its very first send, but 15 for one that tries the mutation bare and steps up on the cue, since that client spends two slots per change. Only the client re-sending the bare request in a loop is punished as intended: it never succeeds and finishes on a 429. -
And the configuration reference’s nine example-TOML bullets stop being hand-maintained. The page’s intro names each canonical annotated TOML by path, a roster nothing checked — the sibling of the count word fenced at v0.63.1.
check_config_keys.pynow diffs those bullets againstSERVICES[*]["file"]both ways: a service whose file the page never names, and a bullet naming a file no service carries, are each drift. It is narrowed twice, like the count — the page preamble only, and only the first bullet run after the “canonical per-key documentation” sentence — because the page carries a dozen further bullet lists, several of them backticked paths, that a looser matcher would sweep in; and an intro the checker cannot read (that sentence missing or doubled, no bullets after it, a bullet that is not exactly one backticked path, the same path bulleted twice) is exit 2 rather than a quiet pass, the convention the roster cases already follow. The page itself needed no edit: its list already matched, so the fence locks in a truth instead of correcting a lie, and adding a TOML-taking binary now reds two intro assertions until theSERVICESentry, the bullet and the count word all move together. The checker’s--self-testgrew from 13 cases to 22, the fence rides the invocation the gate already runs, and the docs gate stays at nineteen legs. Every change in this entry is documentation or docs-tooling describing behavior that already shipped — no instruction, route, wire contract or default moved — so PATCH. The protocol state is still the one v0.65.0 left, and by the preamble’s rule that a run of documentation-only edits keeps one version across several dated sections, this section repeats the stamp the section below it already carries rather than inventing a second: v0.65.1.
2026-08-08 — v0.65.1 (the client-certificate section’s read-site claim is fenced against the four sources it names)
-
A sentence about code stops being maintained by hand. Client-certificate auth (SASL EXTERNAL) states, as of v0.65.0, that each binary reads
client_cert_authin exactly one function —listener_tlsinsithbitd,load_tlsin the standalonepop-server/imap-server/smtp-server— and each of those four functions carries a doc comment asserting it is that one place. Nothing checked either side, so a rename, a moved marker, or a second branch on the flag falsified the page in silence while every checker stayed green.check_config_citations.pygained a second section that diffs the sentence against those four Rust sources both ways: a function the page names that no longer exists, a marked function the page never names, a marker sitting on a function that does not branch on the flag, and a second decision site all fail the docs gate. Passing the flag along —sithbitd’s three call sites — is deliberately not a read; a secondiformatchon it is. -
It is a second section in an existing leg, not a new gate step. The fence rides the
check_config_citations.pyinvocation the gate already runs, so the docs gate stays at nineteen legs; the checker’s--self-testgrew from five cases to fifteen, and the read-site half is proved against generated stand-ins in the two real shapes rather than copies, so reformattingsithbitd.rscannot red it. A claim sentence that is missing, doubled, or names no readable function in binary pair is exit 2 — an anchor the checker cannot read is a defect in the fence rather than a clean bill of health — the same conventioncheck_config_keys.py’s intro-roster cases follow.mail_docs/README.md’s owns-table row, which still described this checker as “RFC numbers only, nothing else … one way”, now describes both sections. Docs-tooling only: no page prose changed, no exit code moved for anything already green, and no leg was added — so PATCH: v0.65.1.
2026-08-08 — v0.65.0 (SASL EXTERNAL is advertised if and only if the listener really requests a client certificate)
MINOR — an enforcement-default change that affects deployments (the class widened at v0.10.0). No instruction, route, or account layout moves; what changes is which mechanism a listener puts on the wire.
- A listener can no longer offer an EXTERNAL that no client could complete.
Each of the four binaries reads
client_cert_authin exactly one function —listener_tlsinsithbitd,load_tlsinpop-server/imap-server/smtp-server— and that function returns the TLS acceptor paired with the client-auth mode it was built with. Every handler gates the offer on that recorded mode, never on the config boolean. The pairing is necessary rather than stylistic: rustls keeps a finishedServerConfig’s client-certificate verifier private, so a built acceptor cannot be asked after the fact whether it requests certificates — the fact has to be recorded where the acceptor is chosen. Previously the flag and the acceptor could disagree, and clients would see the offer, attempt it, fail, and on IMAP burn a login attempt each time. That state is no longer reachable by configuration. - Client-certificate auth (SASL
EXTERNAL)
states the invariant instead of the advice it replaces — the page used to tell
operators to read an offer as “the operator turned this on”, not as proof the
handshake asked for a certificate, which is now exactly backwards. It also
says plainly what the toggle does not do: it defaults to
false, and turning it off leaves the password mechanisms (PLAIN, LOGIN, CRAM-MD5, APOP) untouched — only the one mechanism the transport cannot back is withheld. - The guarantee is bounded, and the page says so three ways. It is per
listener —
sithbitd’s copies of the flag are read independently and nothing cross-checks them, so on-for-IMAP-off-for-POP is valid and silently accepted. It prevents a false offer, not a silently absent one:client_cert_auth = truewith no[*.tls]section is still inert and warns nothing. And it governs advertisement and completability, not authorization — whether the wallet a certificate proves is one this daemon will serve is the verify step’s business, unchanged here.
2026-08-08 — v0.64.0 (the CLI can answer a step-up challenge: sithbit mailbox sign-text)
MINOR — an additive CLI surface over the existing step-up contract (precedent v0.9.0). No new instruction, route, or wire change: the v0.63.0 step-up gate is unmoved. What is new is that a shell can now mint the proof it asks for.
sithbit mailbox sign-textjoinsmailbox credentialsandmailbox reading-secreton the Mailbox credentials page as their third neighbour: the raw signer, base58 of the wallet’s ed25519 signature over the exact UTF-8 bytes it is handed, with no prefix, hash, or trim of its own. It ships in the default feature set and is fully offline — no RPC endpoint, no chain read, no HTTP call — so it runs on an air-gapped machine holding the wallet. The reference documents the stdout/stderr split that makes it scriptable (the signature alone on stdout, theSigned as <address>:line on stderr, so a shell capture is exactly the header value), and states the rule the raw-signer design implies: sign only text a server just handed you, verbatim. Domain separation is the challenge’s job, which is why a signature collected at login can never be spent as a step-up proof, or the reverse.- The per-recipient pin-provider walkthrough is scriptable end to end.
Configuring a
provider said of
its step 2 that any ed25519 signer would do and that no
sithbitsubcommand minted one — true when it was written, and now false. Step 2 is the real command, between the twocurlcalls it always had, with the verbatim-nonce trap called out (one stray trailing space is a different message) and the reminder that a failed attempt still spends the challenge, so a retry re-runs step 2 against a fresh nonce. - Not a CLI-only capability, and the docs say so. The browser clients sign
the identical bytes in the page with the wasm wallet — the two signers are
pinned to the same test vectors on both sides, so neither can drift from the
other unnoticed — which is how the webmail settings pane’s buttons already
answer their own challenges.
sign-textis for the surfaces that have no button: a shell, a script, a provisioning job.
2026-08-08 — v0.63.1 (the configuration reference’s “Nine binaries” is fenced against the checker’s own service roster)
- The count word in the configuration reference stops being
hand-maintained. How a setting
resolves opens by counting
the binaries that take an annotated TOML file of their own, and that English
number had already been wrong once — v0.62.1 corrected it from four to nine
after v0.61.1 added the three standalone protocol servers, a drift that went
unnoticed for eight sessions.
check_config_keys.pynow fences it: the count word must equal the service roster the checker already derives every other count from, so adding or dropping a binary fails the docs gate instead of leaving the sentence behind. The match is narrowed to that one section and that one phrasing, because the page also says “Nine behaviours decide whether the defaults suit your deployment” and a looser fence would read it as a second roster. A roster the checker cannot read at all — unsupported number word, sentence missing or doubled, heading renamed — exits 2 rather than passing quietly, on the principle that an assertion the checker cannot make is a defect in the fence, not a clean bill of health. The checker’s self-test grew six cases to cover that contract, andmail_docs/README.mddescribes the widened ownership. Docs-tooling only — no doc prose changed and no exit code moved for anything already green — so PATCH: v0.63.1.
2026-08-08 — v0.63.0 (a valid session stops being enough to change a mail credential: the five sensitive account mutations demand a fresh wallet signature)
-
Step-up authentication, documented as the wire contract it is. The account API’s five sensitive mutations — both
/v1/account/passwordwrites, both/v1/account/pin-providerwrites, and the auth-epoch bump — now want proof of present control of the wallet key on top of the JWT, and Step-up: proving present control of the wallet is the new reference section for it.POST /v1/auth/step-up(bearer only, no body) mints a challenge —"SithBit step-up nonce: <uuid>", living 300 seconds, a constant in the code with no configuration knob — the wallet signs that string’s raw UTF-8 bytes, and the base58 signature rides anx-sithbit-step-upheader on the mutation itself. A gated route reached without a usable proof answers428 Precondition Requiredwith the byte-exact{"error":"step_up_required"}and noRetry-After. The four statuses a client branches on are set out as a table:401the session is over, log in again;428the session is fine, re-sign with the token you hold;400the header is not a base58 ed25519 signature, and the challenge survives an honest retry;429the budget is gone, honourRetry-After. -
Three properties a client has to be built around, each stated where a client author will meet it. The challenge is consumed whatever the outcome and a proof spent on one route cannot be replayed onto another, so it is one round trip per mutation — a UI applying three gated changes needs three challenges. Login and step-up challenges share the account’s single nonce slot and are told apart by a kind prefix checked where each is consumed, so neither can ever be spent as the other, in either clobber order. And a 428 still spends the per-wallet budget, because the limiter is charged by a layer in front of the gate — which makes retrying a bare 428 the one thing a client must not do.
-
The refusal table counts three. What was “Two 429s, and only one carries
Retry-After” is now the table under Which refusals carryRetry-After: the 428 joins the mutation budget’s 429 and the outbound quota’s, and it omits the header for a different reason than the quota does. The quota cannot state an honest delay; the step-up gate has no delay to state at all, because no amount of waiting turns a 428 into a success. BothDELETE /v1/account/passwordand the epoch bump gained the new status in their own response lists, and the bump’s guard bullet — which used to say the JWT was “the whole gate — no separate re-challenge, and no rate limit of its own” — is corrected on both counts. -
In the browser clients nobody sees any of this. The webmail settings pane fetches the challenge and has the wallet sign it as part of the action, so rotating the wallet mail password and removing the stored password now say what the pane says — your wallet signs a one-time confirmation as you continue — including that the signature is asked for at the confirming press rather than at arming, that declining it leaves the account untouched, and that an in-app wallet signs silently while a Phantom or Ledger one shows its approval prompt. The settings overview says the same for Set password, and the rotate screenshot’s alt text carries the pane’s new closing line. A reader never meets the wire’s
step_up_required. -
Except for pin providers, which have no GUI to hide it. Configuring a provider is the one gated surface a recipient drives from their own tooling, so that page now shows the three-step dance in full — challenge, signature, then the write carrying the header — and every
curlexample on it grew thex-sithbit-step-upline it would otherwise be refused without. TheGETis deliberately not gated: it reveals nothing a token holder cannot already learn, and gating it would cost a wallet signature per poll.A new enforcement default on an existing public surface, which every client of the account API has to be built against, so MINOR by the v0.10.0 widening rather than PATCH: v0.63.0.
2026-08-08 — v0.62.1 (the configuration reference counts to nine binaries; the citation checker’s prose stops saying six)
-
The configuration reference names every configurable binary now. The page’s opening file list and its How a setting resolves section both named four binaries —
sithbitd,account-api,domain-sithbit,mail-grpc— while the layering they describe has applied to nine since v0.61.1 added the three standalone protocol servers. Both carry the full nine, and the prose that used to enumerate file names,*_CONFIGvariables and env prefixes inline — three parallel lists a reader had to zip together — is replaced by one Binary / config file / config-path variable / env prefix table. Beside it, a note for the two binaries that layer their configuration identically but ship no annotated example file because their whole surface is a short table:sithbit-consoleandsithbit-migrate. -
And the
.envsection stops under-counting. Which services get a.envlisted five crates shipping a sample;pop_server,imap_serverandsmtp_servership one too, and now say so. The exceptions bullet beneath it gainssithbit-console,sithbit-migrateand the repo-side generatorappconfig-gen, which is what makes its closing line — every remaining workspace crate is a library with nothing to configure at runtime — true rather than an overclaim that quietly swept up three binaries. -
The docs gate’s own prose caught up.
check_config_citations.pydescribed itself, in four docstrings, as fencing “the six canonical example TOMLs”, and its RFC 2595 allowlist comment namedsithbitd.example.tomlas the sole citer. Both had been wrong since v0.61.1: every count in the checker derives fromSERVICES, so it was already reading nine files and reporting nine while its prose said six, andpop_server.tomlcites 2595 for the same POP3require_tlsdefault. Comment-only — no logic, no allowlist data, no exit code moved — andmail_docs/README.md’s two matching “six“s went with it. Documentation and docs-tooling prose only, correcting how already-shipped behavior is described, so PATCH: v0.62.1.
2026-08-08 — v0.62.0 (the cross-connection login budget reports itself; the account-mutation 429 reaches the refusal reference)
-
The cross-connection login budget is observable now. The
[auth_rate_limit]table introduced at v0.60.0 enforced in silence — nothing told an operator how many (client address, account) pairs it was holding, or how often it was refusing. Two instruments join the metrics table:sithbit.auth.rate_limiter.tracked_pairs, an unlabeled gauge of the pairs currently tracked — registered once per limiter as it is built, and exactly one live limiter reports per process (sithbitdbuilds each protocol handler’s default limiter and then replaces it with the single shared table; a replaced instance goes silent rather than reporting beside its successor) — andsithbit.auth.rate_limiter.refusals, a counter labeledprotocol(pop3/imap/smtp), counted at the drivers’ refuse sites. The two SMTP roles deliberately share the onesmtpseries: MX and submission answer a refusal byte-for-byte the same (v0.60.0 below), so separate series would draw a distinction the wire itself refuses to make. What to watch: a rising refusal count is the control working, and the gauge approachingmax_tracked(default ten thousand) warns that the fail-open path — new pairs going untracked — is near. A new operational surface deployments will want to scrape and alert on, so MINOR. -
The account-mutation refusal is on the monitoring page now. The wire-refusal table — the debugging reference for what a refused account sees, per surface — gains the credential-mutation row: over the per-wallet
[rate_limit]budget is HTTP 429 plusRetry-Afterin delta-seconds, floored at1, sitting one row under the compose 429 that deliberately carries no such header — the confusable pair v0.60.0 explains, now told apart at a glance in one table. -
Internal: the account API reads the auth epoch off the account row it already fetched instead of making a second point-read — one read answers for the whole response, so there is no between-reads window in which a vanished row could 404 a live account — and the outbound suite now pins that a suspended account’s IMAP wallet-signature login (the self-proving path that skips the stored-secret lookup) is refused with the same distinguishable
[CONTACTADMIN] account disabledshape as the password path. Neither changes a wire contract.
2026-08-08 — v0.61.1 (the standalone servers’ config files reach the reference, and the gate holds them there)
-
The three standalone servers’ TOML files are documented now.
pop_server.toml,imap_server.tomlandsmtp_server.toml— the files the dev/pilot protocol servers read — were the last configuration surface with no page in the book: every key lived only in the shipped files’ comments. They get one combined Standalone protocol servers section in the configuration reference — a shared intro for what the three have in common (the same layering as the other binaries, the keyssithbitdnests under[pop]/[imap]/[smtp]sitting at top level here, each binary reading its own[auth_rate_limit]), then a key table per binary, with the shipped files’ deliberate dev exceptions (require_tls = false, SMTP’ssender_auth = "none") called out where they diverge from the documented defaults. -
The config-key docs gate covers them.
check_config_keys.pynow reconciles nine services and 356 keys — the six canonical example files it has fenced since v0.16.1 plus the three standalone TOMLs. Because the three share one H2, the checker learned H3-aware sub-regions: an H3 naming a service starts that binary’s own region, while every other H3 stays part of its enclosing section. The new fence was adversarially probed in both directions before being trusted — a key deleted from a TOML, a bogus doc row, and an undocumented top-level key each turned the leg red naming the offender, and each probe was reverted byte-identical. Documentation and docs-tooling only, no protocol surface moved, so PATCH: v0.61.1.
2026-08-08 — v0.61.0 (a config typo now stops the dev servers; four term glyphs that were never links)
-
A misspelled key in a dev server’s config now stops it starting, instead of being ignored. The three dev/pilot protocol servers —
pop-server,imap-server,smtp-server— read their TOML through a wrapper that quietly discarded any top-level key it did not recognise, sohostnmae = "mx.example.com"parsed clean and the server ran onlocalhostwhile the operator read their own file and believed otherwise. All three wrappers now reject unknown top-level keys — the samedeny_unknown_fieldscontractsithbitd’s[*.server]tables have carried all along: startup fails with an “unknown field” error naming the key and listing the ones it accepts. This is deployment-observable in the direction that matters — a file that started a server yesterday can refuse to start it today, and it refuses on exactly the files whose typo was doing nothing the operator wanted. Read the error, fix the key. One gap is deliberately left: the[[accounts]]/[[mailboxes]]dev-fixture tables do not deny unknown fields, so a typo inside one of those is still swallowed. A new enforcement default that refuses a startup which succeeded before, so MINOR. -
The dev servers’ example files now spell out the cross-connection login budget.
pop_server.toml,imap_server.tomlandsmtp_server.tomleach carry[auth_rate_limit]commented out with its real defaults — ten failures per fifteen minutes, ten thousand tracked pairs — plus a note on what changes when the block is carried intosithbitd.toml, where one table serves every listener. Nothing about the limiter itself changed: it was already in force on those binaries, with no sign of it in the file you edit. The configuration reference’s account of that same bullet is corrected on two points in the same pass — those files are no longer silent about the table, andsithbitd’s warning about a per-protocol section it is ignoring fires only when that section was tuned away from the defaults, so a block carried across unchanged is dropped without a word. A quiet boot means no tuning was lost, not that no per-protocol section was there. -
Four client term glyphs are links now, and the gate can see the next one that isn’t. The webmail, Chrome, Outlook and Thunderbird marks in the prose — the four GUI-client icons — rendered and did nothing, because the term→chapter map carried no entry for them; each now opens its own chapter under Mail clients. The docs gate had only ever checked that the entries it found resolved, so a term with no entry at all was invisible to it: deleting one of the four left the leg green. It reads the prose too now and fails on a term the map does not carry, which is what stops the next inert glyph lasting months.
-
The glossary defines the credential a mail app actually stores. Wallet mail password — base58 of a signature over a fixed challenge, presented with the wallet address as the username — was the one term in the login story with no entry of its own, so a reader looking it up found only the auth epoch that retires it. The new entry says what it is derived from, why the servers keep no secret for it, how it differs from the operator-held stored mail password, and that a login may append the session reading secret after a
.separator.
2026-08-08 — v0.60.0 (the sensitive account mutations get a per-wallet budget; the mail protocols get a cross-connection one)
- Changing an account’s mail credentials now costs allowance. The three
account-API routes that can change or revoke the credentials a mail app logs
in with — setting or removing the stored mail password, the pin-provider
writes, and auth-epoch rotation — sit behind a per-wallet budget, on by
default at 30 attempts per 300 seconds, and answer
429 Too Many Requestsonce it is spent. It is one count per wallet shared by all five method/path pairs, not one per route, so a caller cannot spread a burst across password / pin-provider / auth-epoch and earn three allowances, and every attempt is charged including the refused ones — the hammering is the thing being blunted, not just its successes. Nothing else is limited: the reads, the wallet-challenge login, the timezone and do-not-disturb routes and compose are untouched. The refusal carries aRetry-Afterheader in RFC 9110 delta-seconds form, floored at1(aRetry-After: 0would invite the immediate retry the budget exists to prevent), and its body stays the API’s ordinary{"error": …}prose with no machine-readable field — the status code is the branch, and the delay already has a standard channel in the header. Two consequences an operator should plan for rather than discover: the counters are in-process and per-replica, so two replicas behind a load balancer grant a wallet two budgets, and an unauthenticated request is never charged — it names no wallet, gets the usual401, and is therefore not slowed by this control, which leaves anonymous hammering of those paths to the network layer in front of the API. The full contract is under Rate limits on the sensitive mutations, the knobs under[rate_limit]. The same page now also explains why this API’s other429— the compose route’s outbound quota — deliberately carries noRetry-After: that one counts a rolling hour/day total over hour-bucketed counters, so when allowance returns depends on which past bucket ages out, and any delay it printed would be a guess a conforming client would honour at exactly the wrong moment. A new enforcement default that refuses requests which succeeded before, and which deployments must size per replica, so MINOR. - Guessing a mail password now costs allowance that outlives the
connection. POP and IMAP have carried a per-connection login budget
(
max_login_attempts, spent and forgotten when the socket closes) — so a guesser who reconnected after every third try kept a steady rate forever, and on SMTP there was no budget of any kind. A new[auth_rate_limit]table closes that: the (client address, account) pair is remembered, and once it has spent its failures every further attempt is refused unchecked — no store lookup, no password comparison. On by default at ten failures per fifteen minutes, and the recovery story needs no operator at all: the ban lapses by itselfwindow_secsafter the pair’s last counted failure (refusals in between do not push it out), and a successful login forgets the pair immediately. It keys on the pair rather than the address alone, so one hostile login cannot lock out a co-located neighbour, and on the effective client address, so a listener behind an L4 balancer must haveproxy_protocolon or every user of that balancer shares one bucket.sithbitdruns one table for the whole daemon — a per-protocol section still parses but is ignored, with a startup warning naming it, because private budgets would let a guesser rotate POP → IMAP → MX → submission for four times the allowance; the standalonepop-server/imap-server/smtp-serverbinaries each read their own. Three consequences worth planning for rather than discovering: SMTP has no per-connection sibling at all, so on the submission listener this is the only authentication budget in the stack andwindow_secs = 0costs more there than elsewhere; the two SMTP roles answer out of one budget with one indistinguishable refusal —454 4.7.0then421 4.7.0, byte for byte the same on MX and submission, and sharing the454 4.7.0code with the store-outage reply on purpose so a locked-out client cannot tell a rate limit from an outage (an operator reading logs must go by the message text, since the code alone cannot tell them apart); and the counters are in-process and per-replica, like the account API’s, so two replicas over one store grant two budgets. A new enforcement default that refuses logins which succeeded before, so MINOR — the same reasoning as the entry above.
2026-08-07 — v0.59.0 (a stored mail password can be removed; the idle POP session keeps its deletions)
-
A stored mail password can be removed. Until now a password you had set for your mail client could be replaced but never taken away — the docs said so in four places. The webmail Settings pane now carries an armed, two-step Remove the stored mail password control, shown only while a password is actually stored, and the wire contract behind it is
DELETE /v1/account/password. The control warns before it commits, because the server keeps only a hash: the removed value cannot be shown to you again, and every mail app set up with it stops connecting until you give it a new one. The new prose is explicit about the three things removal does not do — it does not rotate the wallet-derived password, does not sign you out, and does not revoke a client-certificate login. Afterwards the account simply sits on wallet-signature mail login, and a new stored password can be set at any time. -
A POP session that idled too long no longer loses its deletions on a quiet mailbox. The maildrop lease has a 15-minute lifetime, and a session that spent longer than that reading before deleting used to fail every deletion at sign-off — even on a single-instance server where nobody else wanted the mailbox. It now takes the lapsed lease back, provided the key was genuinely free and the mailbox still lists exactly what the session opened on; Scaling out describes both gates. A stolen maildrop, one whose contents moved — new mail arriving counts — and any store that cannot answer are all still refused, so the guarantee that matters is unchanged: no client is ever told the deletion succeeded when it did not.
-
Clicking a term glyph no longer 404s. The small icons that mark defined terms in the prose — mailbox, frombox, alias, domain, pin, POP, IMAP and the rest — are links, and eleven of the seventeen had been pointing at page paths that stopped existing when the reference was reorganised into basic concepts and technical reference. A
pinglyph went to a page that was never rebuilt under that name; amailboxglyph went to a CLI page’s old location. All seventeen now resolve, and the policy behind them is written down rather than left to taste: a term glyph points at the chapter that explains the term, not at a CLI how-to that uses it. Two glyphs that had shared one destination now separate — POP3 and IMAP each land on their own section — andpingoes to Pinning leases. A gate check now fences this, so the next time a chapter moves the build fails instead of the link quietly rotting. -
The glossary defines auth epoch. The counter behind last release’s rotatable wallet mail password now has its own entry, sitting with the other keys and the levers that retire them. It states what a bump actually does — retires every outstanding wallet-derived password at once, on every listener — and, just as importantly, the three things it does not do: it does not clear a stored mail password, does not sign the session out, and does not revoke a SASL EXTERNAL client-certificate login, which proves identity from the certificate rather than from a signature over the epoch.
2026-08-07 — v0.58.0 (the wallet mail password can be rotated)
-
The wallet mail password can be rotated. Until now the credential a mail app derives from a wallet signature was permanent: the signature is deterministic, so a copy of it worked forever and its owner had no way to take it back. Accounts now carry an auth epoch — a counter mixed into the challenge the wallet signs — and bumping it changes the bytes every valid password must sign over, so one bump retires every outstanding wallet-derived password at once, across every SMTP, IMAP and POP listener. The user-facing lever is a deliberate two-step control in the shared Settings pane: an arming button, then a warning naming the cost — every mail app already set up with the wallet-derived password will stop connecting until you paste the new value into it — then Rotate it now or Keep my current password. It is written up, with a screenshot of the armed control, under Rotating the wallet mail password, and the Thunderbird and Outlook extensions and the operator’s enrollment page carry the same pane. The wire contract is
POST /v1/account/auth-epoch— JWT, no body and no path parameter (it always acts on the token’s own wallet), answering200 {"auth_epoch": <new>}— and the current value now rides everyGET /v1/account. Offline, the CLI takes the epoch as an argument:sithbit mailbox credentials --epoch <N>, a page whose whole premise used to be that this credential could not be revoked. The scope is documented as carefully as the capability, because three plausible readings of “rotate” are all wrong: a bump retires the wallet-derived password only, so a stored mail password survives it untouched (and, as of this release, had no delete path of its own — see v0.59.0 above, which gave it one); the caller’s session survives it, the JWT being as unrevoked as it is after logout; and a SASL EXTERNAL client-certificate login is not epoch-revocable at all, since it proves identity from the presented certificate rather than from a signature over the epoch — only the operator’sclient_cert_authcloses that door. A password derived before epochs existed still authenticates an account that has never rotated, which is why--epochdefaults to0; the first rotation ends that grace for that account permanently. New user-facing capability plus an additive API, so MINOR. (The screenshot rig grew the frame that shows the control — and lost a long-standing double-render on the way: re-initialising the settings panes cloned Alpine’s already-rendered output and then rendered it again, so every templated block came out twice, the seven-day do-not-disturb list included.) -
A long POP session no longer loses its maildrop to a peer daemon — and is told plainly when it does. The store’s keyed leases gained renewal: a live lease may be extended, with its expiry set absolutely to a full TTL from now, by one atomic conditional write on all six backends. The POP maildrop session uses it to re-prove ownership before every mutation, so two daemons over one shared store can serve a wallet’s POP without either expunging the other’s messages, and a session that outlives its original 15-minute TTL keeps its maildrop instead of silently losing it. A renew that comes back lost — stolen after expiry, lapsed unstolen, or never held — fails that deletion rather than expunging mail the instance no longer owns, and because POP3 has no untagged channel to warn on, the QUIT reply is the whole signal:
-ERR [SYS/TEMP] some deleted messages were not removedinstead of+OK POP3 server signing off, so a client that would have dropped its local copies on+OKkeeps them. Written up under POP maildrop exclusivity, with the renewal contract in the glossary’s Keyed lease entry. Stated as a known seam rather than glossed: renewal extends only a live lease, so a session that idles past the TTL without mutating anything gets that same-ERRat QUIT even on a quiet single-instance server where nothing else ever touched the maildrop — the safe direction to fail, never a false+OK, but user-visible. Observable new behaviour in the POP surface, so MINOR. -
A misspelled listener setting now fails startup instead of being ignored. The
[*.server]table and itslimitssub-table reject key names they do not recognise, naming the offender and listing the accepted keys, soidle_timeout_seccan no longer leave the 600-second default quietly in force while the operator believes they changed it. The same section also spells out a long-standing surprise:bind_addrhas no default of its own, so uncommenting a single limit and nothing else fails withmissing field bind_addr— the listen addresses documented there are defaults for the whole absent section. Both under[*.server]— the shared listener section. A config that booted before can now refuse to boot, which deployments observe, so MINOR. -
IPFS has a glyph, and the autoconfig table now says which client each route serves. The term-icon set gains an IPFS mark — the hexagon of an isometric cube, traced in the same stroked house style as the rest of the set rather than lifted from the real logo, whose several tones would flatten to one solid shape under the CSS mask these icons are painted with (the logo’s inner ring of cubes is left out for the same reason: it closes into a smudge at inline size). It enters on the ordinary frequency rule and not as an exception — IPFS is named on 48 of the book’s 106 pages — and is swept into 34 of them at each page’s first substantive mention. Both the glyph and the measurement that admitted it are in the Icon legend. Separately, the Client column of the three autoconfig/autodiscover routes now carries the Thunderbird and Outlook glyphs, so the table answers “which client is this route for?” at a glance. Documentation only, so it rides the version this section already carries.
2026-08-07 — v0.57.0 (deadlines on stalled connections, a POP login budget, client icons)
- A mail login opens the account it authenticated. POP and IMAP resolve a login name to a wallet exactly once per session, on the way to the stored password, and open the mailbox that one resolution named — so an alias re-pointed in between (a transfer, a sale, or an auction settlement, which anyone may crank) cannot redirect a session to another wallet’s mail. Stated as a guarantee in the threat model under A re-pointed alias cannot redirect a mail login, and where a reader meets the login pair, in Mailbox credentials. A name that resolves to nobody is now refused exactly as a wrong password is, with no distinguishing reply, so the login prompt is not an alias-existence oracle. Wallet-signature and client-certificate logins are unaffected: their username is the wallet, so nothing is resolved. A change in authentication behaviour that deployments can observe, so MINOR.
- Two new listener deadlines bound connections that stop making progress —
the
[*.server]limits table, and the replica-sizing note in Scaling out.limits.handshake_timeout_secs(default 30) bounds the TLS handshake on both paths — the implicit-TLS accept and the STARTTLS/STLS upgrade — so a peer that connects and then never sends a ClientHello no longer holds its connection slot until the kernel gives up on the socket.limits.write_timeout_secs(default 60) bounds one write’s progress: reads were already bounded by the idle timeout, writes were not, so a peer that stopped draining its socket could wedge the session task behind TCP backpressure. The write clock restarts on any byte the peer accepts, so it expires only on a reader taking none at all — a dead or hostile peer, not a slow one.0disables either. Both are additive settings with new default enforcement behaviour, so MINOR. - POP3 now has a login-attempt budget and tarpit, matching IMAP —
pop.max_login_attempts. A POP connection previously allowed unlimited password guesses at full speed; it now tolerates 3 by default and tarpits each failure (2 s, then 4 s, doubling) before hanging up. Bothmax_login_attemptsrows also now state what neither said before:0is not unlimited — the session counts the failure before comparing, so0behaves exactly like1. The budget is per-connection; a reconnect resets it. client_cert_authnow works in the combinedsithbitddaemon — Client-certificate auth (SASL EXTERNAL). The standalonesmtp-server/imap-server/pop-serverbinaries always honoured the toggle, butsithbitdbuilt a plain TLS acceptor at all three listeners, so it never asked the client for a certificate and SASL EXTERNAL could not complete there — while all three listeners advertised it anyway. Client-certificate login therefore only ever worked on the standalone binaries; it now works in the daemon too, with each listener’s acceptor fenced by a test that boots the real daemon and walks EXTERNAL to completion. The same section’s claim that EXTERNAL is advertised only under client-auth TLS was wrong and is corrected: advertisement follows the toggle, acceptance additionally requires a presented certificate whose key matches the wallet — which is the gap that hid this. A fix restoring documented behaviour rather than a new capability, so it does not move the version on its own.- The icon set gains the four GUI clients — Icon
legend, applied across the client
pages and the GUI clients overview.
webmail,Chrome,OutlookandThunderbirdjoin the inline term icons so a reader scanning a page can see which clients a passage applies to. They are the one deliberate exception to the legend’s frequency rule and enter as a closed set of four: the icons answer “which clients does this apply to?”, so an unmarked client would read as excluded rather than merely rarer. Drawn as monochrome line glyphs rather than vendor logos because the icons render as CSS masks in the surrounding text colour — a gradient-filled brand asset collapses to a solid blob. Documentation-only, so it does not move the version on its own.
2026-08-07 — v0.56.0 (the reclaim tool leaves launch builds for real)
AdminCloseAccountis now compiled out of launch program builds — Why the launch build doesn’t have this command and the program reference instruction rows and error table. That page has always described this lifecycle, but only the CLI half was ever enforced: all three programs shipped the handler unconditionally, and the on-chain value guard (error 106) cannot tell leftover state from a live account — a live mailbox, alias, or domain holds exactly its rent-exempt minimum, so the standing delegate could destroy live user accounts and take their rent. Each program now carries its ownreclaimCargo feature (off by default, implied bydevnet— see the devnet build appendix) gating the handler; a default-feature build refuses the instruction outright with new custom error 108 (AdminCloseDisabled), proven by launch-build refusal tests run against the shipped bytecode in all three program suites. A new error code is an additive public-ABI change, so MINOR.
2026-08-07 — v0.55.0 (signing out of a mail server, and mid-session key changes)
- Locking a wallet now ends its server session — Locking and signing
out on the webmail page, and
Ending a session
for the wire contract. The new
POST /v1/auth/logoutdrops the session’s reading secret and its decrypted-summary cache; every client’s Lock button calls it, so signing out is one click and needs no CLI. Documented with its limits rather than its happy path: the token is a stateless bearer credential and is not revoked — it stays valid until it expires — and a sign-out that could not be delivered passes without a word to the user. A new endpoint plus a new default client behaviour, so MINOR per the versioning rules above. - Publishing or closing a delegated encryption key now takes effect immediately — same webmail section. Previously the change reached the server only at the next unlock, so mail sealed to the new key read as locked until you locked and unlocked again. The flip side is now stated too: because a session reads with exactly one key, mail delivered before a publish stays listed as locked for the rest of that session.
- libsodium is defined, and the record corrected — a glossary
entry (which gives every mention a hover
definition) and Where libsodium
fits. SithBit links no
libsodium: it implements libsodium’s
crypto_box_sealwire format via the pure-Rustcrypto_boxcrate, which is why the same sealing code compiles to WebAssembly for the browser clients, and why a libsodium.js or tweetnacl client can open SithBit mail. - Seven more configuration keys documented —
greeting,max_message_size,max_messages,max_recipientsandmax_recipient_errorsfor the two SMTP listeners, plusmax_message_size/max_login_attemptsfor[imap]andenable_apopfor[pop]. All were live settings that the reference had never listed.
2026-08-06 — v0.54.0 (expunge releases an offloaded attachment’s pin)
- Offloaded attachment pins are reclaimed on expunge, by refcount —
When an offloaded pin is
released replaces the
old “offloaded pins are never released” section on the
sithbitdpage. One attachment is pinned once per submission and referenced by every copy that carries its link — each local recipient, the sender’s Sent copy, every IMAPCOPY— so each expunge drops one reference and only the last reference standing unpins, exactly once. Deleting one copy therefore does not make the attachment unfetchable for the other readers, which is the sentence the page now says out loud. A message that offloaded nothing pays one empty lookup at expunge and enqueues nothing, so a deployment that never arms the threshold cannot tell the machinery exists. A new default behavior for deployments that run offload, with no ABI or wire change — MINOR per the v0.10.0 widening above. - No queue to provision, and the recorded coordinates do the unpinning —
the same section records the two operational facts an operator needs before
turning offload on: the release job rides the existing
chain_deletequeue (same worker, same provider client, same “already gone is fine” posture as the message-body teardown), so enabling offload needs no queue provisioning changes on any store backend, cloud ones included; and the pin is released by the coordinates the spooler recorded at delivery — theoffload/<uuid>object name and the provider’s cid, both riding the job — never by a derived name, because Filebase unpins by name and Pinata by cid. - Relayed submissions are exempt, permanently — and the threat model says so
plainly — Offloaded attachments: the link is the
credential
loses its “nothing ever unpins an offloaded attachment” gap and gains the
bounded version: expunge reclaims a local-only attachment, and never a
relayed one. If any recipient was remote the pins are flagged at delivery and
no local expunge ever releases them, not even the last local copy’s, because
the link is already on servers this deployment cannot see. For those there is
no automatic cleanup: retention is the pinning provider’s policy or an
operator sweep’s job, against the
offload/object-name prefix. The section also notes what no release path can do — unpinning removes this deployment’s copy of the ciphertext, it revokes nothing, and a link already forwarded, logged, or pasted is beyond it. - An armed threshold with no pipeline now warns at boot — the prerequisites
bullets record that a non-zero
threshold_byteswithout a configured[grpc]+[ipfs]pipeline makes the daemon warn that attachment offload is inert, naming the threshold that was set. It warns rather than refusing the boot: the sink is never armed, every over-threshold attachment delivers inline, and nothing is pinned — so read the startup log after arming the threshold instead of assuming the setting took.
2026-08-06 — v0.53.0 (one account API hosts several browser bundles at once)
- The account API’s static hosting is a list of mounts, not a single
directory — Static hosting for browser
clients is
rewritten around that shape (and renamed: it stopped being about the Outlook
add-in alone). Each directory you want served is one
[[static]]entry, a route prefix plus a root, so one instance can carry the add-in bundle, the onboarding and self-service pages and a webmail shell on the API’s own origin — which is the point of serving them here at all: those pages call/v1/…with no CORS configuration anywhere. No entries, the default, still means no static routes and a pure JSON API. A deployment change that adds capability without touching any ABI — MINOR per the v0.10.0 widening above. - What the config file cannot say for itself — the configuration reference
gains
[[static]]— same-origin static mounts beside the account-api key table, and both pages now state the three behaviors an operator otherwise meets the hard way. Route prefixes must be unique: a repeated prefix (orroute = "/") aborts the process while the router is built — before the listener binds, so it fails fast rather than half-serving — and the message names an axum-internal synthetic route rather than the offending[[static]]entry, so the docs are where you learn to suspect a duplicatedroute. Nested prefixes (/addinand/addin/help) are legal and resolve most-specific-first in either config order, so entry order is cosmetic. And arootdirectory that does not exist is not a startup error: that mount answers 404 per request, which is what an unbuilt bundle or a relative path read against the wrong working directory looks like. - Migration: the old single
[static]table is now refused at load — it is a loud startup error, not an ignored section, so no deployment can quietly stop serving its bundle. The fix is the second pair of brackets; the two keys are unchanged. The reference carries the before/after block, and — because__-nested environment names cannot address array entries — the one environment variable that sets the whole list,ACCOUNT_API_STATIC, taking the array as a TOML value (inline tables,key = value; JSON is rejected).account_api.toml’s commented example now shows two mounts with distinct prefixes. - Every remaining copy of the old spelling is gone from the client pages and
the repo’s web-client docs — the migration is only half-done if a page an
operator copy-pastes from still hands them a single
[static]table, and four of them did, uncommented: the building-and-serving blocks on webmail, Outlook and marketplace, plus the scratch-server recipe inwebclients/README.md— which is a config that is actually executed by the end-to-end recipe, so it would have taken that run down rather than merely misinforming a reader. Each doc fence now also says why the second bracket pair is there and points at the canonical[[static]]list. The prose mentions on onboarding and in the enrollment-page hosting note follow; the latter now names which mount servesenroll.htmlinstead of implying there is only one.
2026-08-06 — v0.52.0 (a browser-made wallet reads its own sealed mail)
- The default webmail account no longer lists its mail as locked —
Sealed rows and your reading key
is rewritten around what changed. A wallet the in-page wizard generated,
with no encryption key published, has its stored mail wrapped to the
wallet’s own X25519 twin; the page now derives that twin secret when you
unlock and sends it on the login exchange, so every sealed row opens —
including mail delivered long before, since the twin is the key it was
always wrapped to. Nothing to publish, nothing to save, no button to press.
The sign-in is also key-aware: it reads what your mailbox has published
on-chain and offers the matching secret, so the section’s cases are
re-stated as four. The browser-generated default (no key published) and the
connect-wallet (Phantom/Ledger) account read everything, and so now does an
account that published the recoverable wallet-derived key
(
sithbit mailbox set-key --derive) — that key is reproduced from the wallet at unlock and used instead of the twin once the mailbox is seen to carry it, so those accounts, which read nothing sealed here at all before, open every row on any device holding the wallet. Only a fresh random key published from the Encryption key pane still costs the old trade: nothing about your wallet reproduces it, so the app holds it while the tab lives and a reload signs in with the wallet twin again, leaving rows sealed to the published key locked. Handing that exported secret back to the app at sign-in remains filed follow-up work; the pane is documented as being about key separation, not about unlocking reading. The page also now says plainly what the reading key is: not a password that proves who you are but a key that opens mail, so anyone holding it reads every message ever sealed to that wallet. No ABI, wire or on-chain change, but a significant additive capability that changes what end users can read — MINOR per the v0.10.0 widening above. - The same secret, for a mail app you configure by hand — the CLI gains
sithbit mailbox reading-secret, documented besidemailbox credentialson a page now framed as the two offline derivations from your keypair: who you are, and what opens your mail. The reference states the secret-versus-authenticator distinction as a warning, the address-to-stderr/secret-to-stdout split, and — documented for the first time — how the value is used: appended to the wallet-signature password after a single.(never part of base58, so the split is unambiguous), on the incoming IMAP/POP server only, because the submission server refuses a password carrying a reading secret rather than accept a key sending mail never needs. - Screenshots re-pinned, not re-shot — the four webmail, five onboarding
and two marketplace captures were re-pinned in
screenshots.manifest.jsonafter the wave’s web-client edits. Those edits are session-logic only (wallet-session.js‘s key-aware sign-in, the keys pane’s publish path, and four shells’ pane contexts); no.html,.cssor.svgunder any hashed source changed, so no rendered pane can differ and the committed captures still show the current UI.
2026-08-06 — v0.51.0 (a keyed session reads sealed mail everywhere, not only on open)
- A session that logged in with its reading key now gets decrypted
summaries and search hits, not just decrypted message opens — the
account-api reference gains
Sealed bodies and keyed sessions.
It documents, for the first time, the listing’s
sealedboolean and its narrow meaning — “this response did not open the body”, not “encrypted at rest” — with the four cases written out exhaustively: plaintext or a sealed body a keyed session unwrapped both list assealed: falsebeside a real parsed summary, while a sealed body in an unkeyed session, or one whose key does not match, has no wrapped key for this reader, or hits a store error, lists assealed: truebeside an all-default summary. The row’s own metadata (uid, size, flags,internaldate) comes off the store row and is always real; a client that predates the field sees no field, which reads asfalse. Search keeps its exact response shape — what changed is which rows can match: a keyed session decrypts each sealed candidate inside the same capped candidate window and matches headers, and the body whenbody=true, at parity with a plaintext row, while a row it cannot open is skipped rather than matched as ciphertext, so an unkeyed search returns precisely what it always did. Every hit therefore carriessealed: false. Cost is bounded by that same window (at worst one blob fetch, one wrapped-key lookup and one decrypt per candidate), unkeyed sessions pay nothing extra because the sealed path short-circuits before any store round-trip, and decrypted summaries are cached per session — keyed on a digest of the bearer token, never in the shared plaintext cache, dying on token expiry, logout, or eviction of that session’s reading secret. Nothing to configure. No ABI, wire or on-chain change and no default-behavior change, but a significant additive capability that changes how end users read their mail — MINOR per the v0.10.0 widening above. - What a locked message looks like in webmail, and which accounts can
unlock one — the webmail page gains
Sealed rows and your reading key:
a row the app could not open is drawn as locked rather than blank —
(sealed)where the sender goes,(sealed — sign in with your reading key)where the subject goes, no snippet, a real date (dates come off the mailbox row, not the body), and a readable sign in again with your wallet’s reading key message on open instead of a broken reader; search skips such rows rather than matching their ciphertext. Signed in with the reading key, the same rows list, search and open with nothing marking them out. The section is honest about which accounts hold that key today: a connect-wallet (Phantom/Ledger) account onboarded through the web wizard does, because the wizard published a delegated reading key whose secret the browser holds; a wallet whose published key is the recoverable, wallet-derived one does, reproduced on any device holding the wallet (sithbit mailbox set-key --derive— there is no settings-pane button for it yet); and a browser-generated account that has published no key does not yet, because its mail is wrapped to the wallet’s own encryption twin and the in-page wasm module does not hand that secret to the app. Publishing the derived key fixes it for mail delivered from then on — mail already delivered stays wrapped to the earlier reader — and exposing the twin secret to the page is a filed follow-up. The Encryption key pane’s Generate & publish a delegated key button is explicitly not the workaround: it publishes a fresh random key while the browser session derives the wallet’s own reading key, so the two never meet. - The privacy page says so too — What your operator holds previously described only the sealed-body refusal a keyless session gets on open. It now also states that a keyed session reads normally throughout (list summaries and search hits decrypted, not only the message you open), while an unkeyed one still gets its list and its search with unopenable bodies shown as locked rather than blank or silently missing.
2026-08-06 — v0.50.0 (large attachments can ride an encrypted IPFS link)
sithbitdcan now offload large attachments to IPFS — the new[spooler.offload]section documents it. An attachment larger thanthreshold_bytesdecoded bytes is sealed under its own freshly generated key, pinned through the same[ipfs]provider the chain workers use, and replaced in the delivered message by a placeholder part linking to<gateway_url>/ipfs/<cid>with the key in the URL’s#fragment— so the gateway serves ciphertext it cannot read, and no access log on the fetch path can carry a key. The feature is off by default and opt-in: with the section absent, or present but leavingthreshold_bytesat0, no message is ever rewritten and delivered bytes are byte-identical to a daemon without the feature. Naming agateway_urlalone does not arm it, and the chain pipeline ([grpc]+[ipfs]) is required, because without a pinning provider there is nothing to build a fetchable link from. A seal or pin failure tempfails the whole submission (451) rather than quietly delivering the attachment inline. 5 MiB is the suggested production threshold. No ABI, wire, or on-chain change and no default-behavior change, but a significant new deployment-affecting capability — MINOR per the v0.10.0 widening above.- The operator’s picture of that offload, and its threat model — the
sithbitdpage gains Large-attachment IPFS offload: when to turn it on, the two hard prerequisites (a configured chain pipeline, and agateway_urlrecipients can actually reach — it is baked into delivered mail and cannot be corrected afterwards), the decoded-versus-wire size trap (threshold_bytesis a decoded size, so a 5 MiB threshold is roughly 6.8 MiB on the wire and an operator setting it from SMTP log figures sets the bar a third too high), what is never offloaded at any size (body parts,multipart/message/rfc822containers, and any part carrying aContent-ID— which is how inlinecid:images survive), the placeholder’sX-SithBit-Offload-*headers, the451tempfail on a pin failure, and the fact that offloaded pins are never released. The threat model gains Offloaded attachments: the link is the credential, which states plainly that the link is a bearer credential — no wallet binding, no expiry, no revocation, and explicitly weaker than the sealed-box path the message body takes — enumerates where it leaks (forwarding, theX-SithBit-Offload-Urlheader riding every relay and archive, browser history, link-previewing and URL-rewriting middleboxes, paste), records the compensating facts (the#fragmentnever reaches the gateway or any access log, the gateway cannot decrypt, a fresh key per attachment, ciphertext is what is pinned), and records the known gap that nothing ever unpins an offloaded attachment — deleting the message does not remove the pin. The sithbit-gateway page notes the one deployment where that read-only gateway becomes load-bearing for mail delivery. Documentation only; no behavior change, so the version stays at v0.50.0.
2026-08-06 — v0.49.0 (the marketplace Participants tab authors your beacon)
- The Participants tab
now authors the connected wallet’s own beacon — the new
Managing your own beacon
section documents the flow. Below the pool list, a
Manage my beacon button connects the external wallet (the same
Phantom/Ledger path buys and listings sign with) and opens a
three-state surface: publish when not opted in (grouped tag pickers
by name over the CLI-identical 24-tag vocabulary, an optional
paste-only detail CID, at least one tag required),
update/disable/close when opted in, and re-enable when disabled.
Update is a wholesale replacement mirroring
sithbit campaign update(an empty CID input clears a published one); disable is a pane-side convention — a zero-bitmap update that leaves the account and its rent on chain while dropping the beacon out of every tag search, with the prior tags remembered per wallet in browser storage so re-enable can restore them; close refunds the rent. The design-note appendix retires its “browse-and-discover surface, not an authoring one” limitation accordingly; the group-offer (quote/send) flow of decision 4 stays CLI-only. No ABI, wire, or on-chain change — the pane drives the existing beacon instructions — but a significant new client capability, so this is a MINOR bump to v0.49.0 (the new-client-capability precedent, per the versioning note above).
2026-08-06 — v0.48.2 (the marketplace Participants tab speaks tag names)
- The Participants tab
now shows tag names, not raw bit positions. The browser clients grew
a shared copy of the participant-tag vocabulary
(
webclients/shared/tag-vocabulary.js) — a hand-maintained mirror of themail_modelTAG_*constants, fenced by drift-guard tests, whose names are identical to thesithbit campaign --tagnames by construction. Rows render names (a bit newer than the page’s vocabulary falls back to its numeric position and stays visible), and the tag filter accepts names and bit positions mixed in one comma-separated list, case-insensitively, rejecting an unknown name in the pane before any request is sent. The shipped web surface note’s known-limitation paragraph (tags render as raw bit positions) is retired accordingly, and that page now records the hand-maintained mirror + drift guards as the accepted mechanism. The participants API wire contract is unchanged — the route still takes bit positions and names resolve client-side — so no ABI, wire, or economic behavior moved: a PATCH bump to v0.48.2.
2026-08-06 — v0.48.1 (the MODSEQ-parentheses wait now names its upstream issue)
- The conformance appendix’s CONDSTORE
section
now cites the upstream tracking issue the previous entry said was
being filed:
imap-codec#722,
opened 2026-08-06. That issue is the unblock signal for the deferred
QRESYNC work — when it is fixed and released, the pins move and the
wave starts.
imap_session’s scope doc carries the same link. Documentation-only and the protocol state has not moved, so this keeps v0.48.1 rather than bumping it, per the versioning note above.
2026-08-05 — v0.48.1 (the MODSEQ-parentheses deviation is recorded as a wait on upstream)
-
The conformance appendix’s CONDSTORE section now carries the dated probe behind the known
MODSEQ 4vsMODSEQ (4)wire deviation. Probed 2026-08-05: the threeimappins are already at the newest published releases (imap-codec2.0.0-alpha.9,imap-types2.0.0-alpha.7,imap-next0.3.4, checked against the crates.io sparse index,cargo searchand the upstream default branch), the encoder arm that drops the parentheses is byte-identical on that branch, and no upstream issue or pull request mentions the defect — so one is being filed. The record states the consequence explicitly: the QRESYNC wave stays queued behind the upstream fix rather than being worked around with a local patch or fork, which would put the servers on a private wire encoder for one FETCH item. The section also now notes that the codec’s own parser requires the parentheses it does not write. Same record inimap_session’s scope docs, whose deferred-QRESYNC bullet had the probe still pending. Documentation-only — no code, pins or behavior changed, so this is a PATCH bump to v0.48.1. -
Running as an OS service now covers all nine server binaries: the three standalone dev/pilot protocol servers (
pop-server,smtp-server,imap-server) gained the sameservice install|uninstallsubcommand the fleet six got at v0.47.0, each declaring itsServiceDefinitionover the sharedos-servicecrate. The generated units’ commented unprivileged-run hints scope to each server’s own ports (110/995, 25/465/587, 143/993) rather than the daemon’s full mail-port list. MINOR: an additive deployment capability, per the v0.47.0 precedent. Also behavioral, same change: each binary’s config env var now follows the fleet-wide{PREFIX}_CONFIGconvention —POP_SERVER_CONFIG,SMTP_SERVER_CONFIG,IMAP_SERVER_CONFIGreplace the oldPOP_CONFIG/SMTP_CONFIG/IMAP_CONFIGnames (the rendered unit’sEnvironment=line and the loader must agree; anyone exporting the old names must switch).
2026-08-05 — v0.47.0 (every server binary installs as an OS service)
- Running as an OS service
now covers every server binary, not just
sithbitd:mail-grpc,domain-sithbit,account-api,sithbit-ipfsd, andsithbit-gatewaygained the sameservice install|uninstallsubcommand (systemd unit on unix, SCM registration plus the internalservice runverb on Windows), each registering under its binary name with its own config env var. The machinery moved from the daemon into the sharedos-servicecrate; each binary declares only its service definition. The generated units’ commented unprivileged-run hints are per-binary: low mail ports forsithbitd, low web ports (80/443) for the internet-facing three, a bareUser=line for the fleet-internal two. MINOR: an additive deployment capability across the fleet. Also behavioral, same change:mail-grpcnow shuts down gracefully on ctrl-c (draining telemetry) instead of dying on the default signal handler. The five operate pages cross-link the shared section.
2026-08-05 — v0.46.2 (module files migrate from mod.rs to the Rust 2018 layout)
- Every
foo/mod.rsin the workspace’ssrc/trees renamed to the Rust 2018foo.rs+foo/layout — 14 files across nine crates, pure renames with no code changes. The two integration-test helper modules (mail_program/tests/support/mod.rs,domain_program/tests/support/mod.rs) deliberately keepmod.rs: under Cargo, a top-leveltests/*.rsfile compiles as its own test binary, andmod.rsis the prescribed pattern for shared test helpers. Doc impact is one path reference: the Scaling out chapter’s add-a-backend checklist now namestests.rs(formerlytests/mod.rs) as the conformance-registration step. Documentation-only — no code or behavior changed, so the version stays v0.46.2.
2026-08-05 — v0.46.2 (a beginner’s tour of the components joins the book after the Introduction)
- New page: The components at a glance — a
non-technical orientation tour of the four groups that make up SithBit
(the on-chain programs, the operator-run servers, the graphical
clients, and the terminal tools), each introduced in plain terms with
its own diagram, closing with a full three-tier map of how the groups
interrelate (clients → servers → chain + IPFS, including the CLI’s
direct-to-chain path and the trustless-reading path). Five new
diagrams under
images/components-*.svg. Wired into the book’s front matter between the Introduction and Standards and RFC coverage; links throughout point into the existing concept, client, operator, and reference chapters for depth. Documentation-only — no code or behavior changed, so the version stays v0.46.2.
2026-08-05 — v0.46.2 (a non-PEEK FETCH’s implicit \Seen is echoed back as an untagged FETCH)
- The IMAP server now sends RFC 7162 §3.1.4.1’s follow-up untagged
FETCHafter the implicit-\Seenpersist (wave-set #52 lane B), closing the question that had stood open since the CONDSTORE landing (v0.45.0): when a non-PEEK body fetch implicitly sets\Seenand the persist succeeds, one untaggedFETCHper changed message precedes the taggedOK, sharing theSTOREflag echo’s exact shape —FLAGSalways,MODSEQwhen the session is CONDSTORE-enabled,UIDforUID FETCH. On a failed persist there is no echo and the taggedOKstill follows. Described in the conformance appendix’s CONDSTORE section, including the client-visible consequence (an unseen message’s flags are reported twice on a body fetch) and the echo’s exposure to the knownMODSEQ-parentheses codec deviation. The version rolls to v0.46.2: a wire-visible behavior addition with no new capability is the small-server-behavior PATCH class of v0.44.2’s source-port entry and v0.46.1’s reply-code change. - The standards table’s
RFC 7162 row now credits the echo too: alongside its existing
conformance-appendix link (which previously it cited only for the one
known wire deviation), the row states that the §3.1.4.1 follow-up
untagged
FETCHis sent, pointing at the same appendix section for the echo’s shape. Same tidy-up inmail_store’s rustdoc:MailRepo’s counter-allocation paragraph no longer conflates “bumps the IDLE change sequence” with “allocates UIDs” —set_flags/expungebump without allocating andmove_messagesbumps both mailboxes, matching the adjacent mod-sequence paragraph that already listed all five bumpers. Documentation-only — no code or behavior changed, so the version stays v0.46.2.
2026-08-05 — v0.46.1 (the DMARC bounce takes RFC 7372’s precise code)
- The DMARC rejection is re-coded from
550 5.7.1to554 5.7.26(wave-set #51 lane A), taking the deferred candidate v0.46.0 recorded:5.7.26is the “multiple authentication checks failed” code RFC 7372 §3.3 registers for a DMARC failure, following the in-repo precise-code idiom the5.7.23SPF-hardfail reply set. The reply text still names the offending From domain, both thedmarc-liteand fulldmarcpolicies share the one helper, and quarantine/none handling is untouched. Described in the conformance appendix and the configuration reference; the recon page’s cluster 5 records the take. The version rolls to v0.46.1: a wire-visible reply-code change with no new capability is the small-server-behavior PATCH class of v0.44.2’s source-port entry.
2026-08-05 — v0.46.0 (the EAI question is decided: deferred with criteria)
- The recon page’s EAI family moves from recommendation to record (wave-set #51 lane C — decision-recording only, zero code): the internationalized-email program (RFC 6530–6533 and its per-cluster satellites) is deferred with criteria — parked, unopened, until a concrete demand signal arrives (an operator or user needing non-ASCII addresses, or interop with an EAI sender); no EAI implementation now. Per the recon page’s family rule the one verdict covers all five defer (EAI) rows — RFC 6533 (DSN, cluster 4), RFC 8616 (SPF cluster 5 and DKIM cluster 6), RFC 5738 and 6858 (IMAP, cluster 8), and RFC 6532 (MIME, cluster 10) — each cluster now carrying the recorded verdict, with decision-inventory entry 7 as the governing record. Documentation only: PATCH, and the protocol state is unchanged since the entry below, so the version holds.
2026-08-05 — v0.46.0 (outbound DKIM can dual-sign with Ed25519, and the SPF/DKIM update RFCs land)
- Outbound DKIM gains opt-in RFC 8463 Ed25519 dual-signing. Each
[spooler.dkim]entry accepts a newed25519_selector/ed25519_key_filepair (see the configuration reference): configured, every signed message carries a second, ed25519-sha256DKIM-Signatureheader alongside the rsa-sha256 one, each signing the same headers and body, so verifiers honor whichever algorithm they support. The pair is validated both-or-neither at load; absent (the default) signing is rsa-only, unchanged. The key is a PKCS#8 PEM (openssl genpkey -algorithm ed25519) from the same file-or-secret-manager key source askey_file, and the second selector’s DNS record isv=DKIM1; k=ed25519; p=<raw-32-byte-key-base64>— not a DER SubjectPublicKeyInfo. Inbound,ed25519-sha256signatures verify to pass, fenced with the RFC’s own appendix-A test key. The version rolls to v0.46.0: a new operator-facing signing capability with its own config and DNS surface is the significant additive deployment-affecting class that took MINOR for SASL-IR (v0.33.0) and SPECIAL-USE (v0.34.0), not the small-behavior PATCH class of v0.44.1/v0.44.2. - The RFC 8301 algorithm floor now holds on the verify side too. The
signer was already rsa-sha256-only by construction (now cited as
such); new is the repo-side post-filter that downgrades a verified
rsa-sha1signature to failure before it reachesAuthentication-Results, reporting, or DMARC alignment input — fenced in both directions, with a deliberate assert that the adoptedmail-authstill verifiesrsa-sha1so a future dependency version closing the gap upstream signals the filter can retire. - RFC 7372 is credited and fenced. The published-SPF-hardfail
rejection already answered
554with enhanced status5.7.23; it is now cited as the code §3.2 registers for exactly that outcome and fenced end-to-end over the offline resolver, softfail pass-through included. - DMARCbis is RFC 9989, and the From-extraction disposition is
explicit. The number is settled (RFC 9989 obsoletes 7489/9091;
RFC 9990 is aggregate reporting, RFC 9991 failure reporting — the
recon page’s one stray “9990” is
corrected). Per §5.3.1, an evaluation that extracts zero author
domains (absent or group
From:) or several differing ones now terminates without a verdict as an explicit, fenced disposition — no reject or quarantine even under a publishedp=reject, the §5.3.1 MAY for the multi-domain case deliberately not taken — rather than an incidental bypass. - The coverage assertions move together: the standards page’s RFC 7208, 6376, and 9989 rows gain the update cites, a new conformance appendix section spells out all four postures, and the recon page’s clusters 5 and 6 move from recommendation to record (wave-set #50 lane B), answering decision-inventory entries 3 and 4.
2026-08-05 — v0.45.0 (the DSN/MDN update verdicts are recorded: every row closes as declined, deferred, or done)
- The recon page’s cluster 4 moves from recommendation to record (wave-set #50 lane C — decision-recording only, zero code, so no adopting wave will ever own this cluster). RFC 8098 (MDN) is declined at the server layer — an MDN is generated by the recipient’s mail client on display, never by the MTA — reinforced by the workspace’s standing “inbound DSN/MDN processing — dropped” record in HANDOFF.md; RFC 3885 (MTRK) is declined with its 3886 companion; RFC 6533 (internationalized DSN) is deferred behind the EAI program, to be decided with the whole family and never alone; RFC 4865’s DSN-side wording falls with cluster 2’s FUTURERELEASE decline; and RFC 6522 stands done. Per the recording’s convention call, declined RFCs get no standards page rows — the recon page is the record. Documentation only: PATCH, and the protocol state is unchanged since the entry below, so the version holds.
2026-08-05 — v0.45.0 (the IMAP server speaks CONDSTORE: mod-sequences land on every store backend)
- The IMAP server now advertises
CONDSTORE(RFC 7162) once a session authenticates, and implements the extension in full:ENABLE CONDSTORE,SELECT/EXAMINE (CONDSTORE)with the unconditionalHIGHESTMODSEQ/NOMODSEQresponse code,STATUS (HIGHESTMODSEQ),SEARCH MODSEQ, theMODSEQfetch item andCHANGEDSINCEmodifier, andSTORE UNCHANGEDSINCEansweringOK [MODIFIED …]with the messages it left untouched. Returning clients can now resynchronize flags by asking only for what changed. The QRESYNC half of RFC 7162 is queued as its own follow-up wave; its parameters are refused. See the new row in the standards page’s IMAP table and the new conformance appendix section, which also records the one known wire deviation (the adopted codec omits the parentheses around the FETCHMODSEQvalue; an upstream fix is queued). - Every store backend now tracks mod-sequences: a per-mailbox highest
mod-sequence and a per-message mod-sequence, bumped once per
flag-changing operation, across all six
mail_storebackends (sqlite migration 0013, postgres 0011). Production mailboxes are always tracked — the counter is born at 1 — soNOMODSEQnever appears in production. The version rolls to v0.45.0: a newly advertised capability with a storage-schema addition is the significant additive deployment-affecting class that took MINOR for SASL-IR (v0.33.0) and SPECIAL-USE (v0.34.0), not the small-behavior PATCH class of v0.44.1/v0.44.2. - The recon page’s cluster 8 records the adoption: the RFC 4551 row moves from answered-piecemeal to adopted at its RFC 7162 target, and decision-inventory entry 1 notes the landing while QRESYNC stays queued.
2026-08-05 — v0.44.2 (forensic reports name the peer’s source port, and the ARF applicability statement is credited)
-
The standards page’s SMTP table gains RFC 6692 (source ports in ARF reports), now implemented. Every DMARC failure/forensic report attributes the failing connection with the SMTP peer’s TCP source port alongside its
Source-IP: the port is captured at accept, threaded through the policy seam into the forensic datum, and emitted as theSource-Portfield — omitted entirely when the port was never learned, so a fakeSource-Port: 0is never sent. There is deliberately no config switch:Source-IPis already unconditionally disclosed in the same report, so the port adds no new disclosure class. The version rolls to v0.44.2 for this small additive report field, following the v0.44.1 precedent of a patch roll for a small server-behavior change. -
RFC 6650, the ARF applicability statement, is credited on the same table’s RFC 6591 row and argued in the conformance appendix: the stack’s only report-generation path is solicited-by-publication —
ruf=targets the policy domain itself published, external targets gated by the RFC 9991 §5 authorization check — which satisfies 6650’s consent requirement by construction. The forensic pipeline’s module doc now names 6650 as the governing applicability statement; no behavior moved for this credit. -
The recon page’s cluster 7 records both adoptions, and decision-inventory entry 5 is answered: implement, always emit when known, no config switch. The cluster-10 residual also closes to “fence pinned”: the group-syntax
From:DMARC bypass recorded by the RFC 6854 audit now has its explicit test, pinning degrade-not-fail as current behavior while hardening stays a deferred candidate for a future policy wave.
2026-08-05 — v0.44.1 (the IMAP cluster’s decisions are recorded: an RFC 7162 wave is queued, four declines go final, and RFC 6186 is answered for both halves)
-
The recon page’s cluster 8 records its decisions — nothing is implemented by any of them. CONDSTORE/QRESYNC moves from decision-required to answered as piecemeal: a dedicated RFC 7162 implementation wave (the
imap-typesfeature flip,imap_sessionsemantics, andimap_server/backend MODSEQ state, independent of any IMAP4rev2 adoption) has joined the queue, and the row records that decision, not an adoption. The four fork-blocked extensions — RFC 4469 (CATENATE), 5032 (WITHIN), 8437 (UNAUTHENTICATE), and 8474 (OBJECTID) — move to declined-final now that the strategy answer makes the earlier decision not to forkimap-typespermanent. And the IMAP half of RFC 6186 client-side SRV discovery is declined as superseded — the signed DHT service records are wallet-anchored, which plain SRV can never be — mirroring the POP3 half cluster 9 recorded in this same wave-set, while the operator-side SRV documentation stands. -
Both shared entries in the user decision inventory close. Entry 1 (the IMAP 3501-vs-9051 strategy) is answered as option (a), piecemeal — narrowed first, since the 9051/fork path was already declined in session #10 — and entry 2 (RFC 6186 discovery) is answered as option (b), declined as superseded, with both protocol halves now recorded. The options stay on the page as history, per the inventory’s marking convention.
-
The in-crate records landed in this wave’s earlier phases:
imap_server’s crate docs gained the driver-layer “RFCs implemented” block its two sibling drivers already had (closing the gap the recon page recorded), andimap_session’s scope docs now name the queued RFC 7162 wave in the deferred list and carry the new “Declined (final)” section for the four fork-blocked extensions. -
No coverage table moves. Because these are recorded decisions rather than implementations, the standards page’s IMAP table and the conformance appendix are deliberately untouched — and the transport-hardening table’s RFC 7817/8996/8997 rows already speak for the shared acceptors IMAP rides, so the per-protocol table gains no duplicate rows.
2026-08-05 — v0.44.1 (the TLS 1.2 floor is asserted in code, and RFC 7817/8996/8997 join the standards tables)
-
The standards page’s transport-hardening table gains the email TLS trio. RFC 8996 (TLS 1.0/1.1 deprecated), RFC 8997 (the TLS ≥ 1.2 floor for email, updating RFC 8314), and RFC 7817 (the updated TLS server-identity check — under strict verification the outbound relay verifies the connected MX host or smarthost, never the recipient domain). All three behaviors were already present, since every acceptor and connector rides rustls; what’s new are the credits and the fences — tests now assert the version floor at the shared
server_commonacceptors and at the outbound relay connector rather than inheriting it from library defaults. The version rolls to v0.44.1 for the one (tiny) behavioral hardening in the set: the spooler’s two HTTPS report fetchers (MTA-STS policy fetch, TLS-RPT submission) now pin the rustls backend in code instead of riding reqwest’s feature defaults. -
The conformance appendix’s RFC 8314 section states the version floor those credential gates sit on, so a custom server matching the conformance claim knows to refuse the deprecated versions too.
-
Two audited one-line credits fold in from the recon work: RFC 5248 on the enhanced-status-codes row (every emitted enhanced code is listed in its IANA registry) and RFC 6854 on the Internet Message Format row (group syntax in
From:/Sender:parses; generated messages always carry a singleton mailbox). -
The recon page’s cluster 3 records the adoption, its cluster 2 and cluster 10 rows record the two folded credits, and decision-inventory entry 6 is answered as option (a) — the reqwest backend pin. The page’s top note no longer claims it is unwired from the book nav (it has been under Appendix: Reference since wave-set #47).
2026-08-05 — v0.44.0 (RFC 1957 credited in the POP3 standards table, and the recon page’s cluster 9 records the wave)
-
The standards page’s POP3 table gains RFC 1957 (observations on POP3 implementations). Its one server-side recommendation — real clients depend on the optional
UIDLcommand, so provide it — was long implemented but uncredited; the credit now rides the table,pop3_proto’s own RFC block, and a new test fencing the per-messageUIDLreply against RFC 1939’s own example. No behavior moved. -
The RFC-updates recon page’s cluster 9 now records the adoption. RFC 1957’s verdict moves from recommendation to adopted, and the POP3 half of the shared RFC 6186 discovery decision is answered: client-side SRV lookup is declined as superseded — SithBit tooling trusts only the signed DHT service records — while the operator-side SRV documentation stands.
2026-08-05 — v0.44.0 (the RFC-updates recon page opens the adoption program; the dependency audit joins the book nav)
-
The RFC-updates recon page opens the RFC-updates adoption program. A new appendix-track reference page maps every published update RFC onto the repo surfaces it touches — ten clusters (SMTP core, enhanced status codes, email TLS, DSN/MDN, SPF, DKIM, ARF, IMAP, POP3, MIME), each update carrying an audited status (method + cites) and an adopt/decline recommendation — closing with a cross-cluster overlap map and the inventory of decisions each future adopting wave must surface. Recommendations only — no behavior changes ride this page.
-
The manifest-majors dependency audit is now reachable from the book nav. The page existed but was never wired into the summary; both it and the recon page now sit under Appendix: Reference.
2026-08-05 — v0.44.0 (RFC 7504 and RFC 7505 credited in the SMTP standards table)
- The standards page’s SMTP table gains RFC 7504 (521/556 “server does not accept mail” reply codes) and backfills RFC 7505 (null MX). Both behaviors were long implemented but uncredited: the relay detects a null MX and refuses such recipients outright, bouncing with reply code 556 and enhanced status 5.1.10 exactly as RFC 7504 §2.2 prescribes, and the client send machine classifies both codes as permanent. This change is credit and test fences only — no behavior moved.
2026-08-05 — v0.44.0 (the lifecycle figure matches its list, client pages are reordered, and the account API page is sectioned)
-
The account API page is now linkable by fragment. It previously had only its title heading, so nothing on it could be cited by anchor — the structural cause of a wave-45 dead-anchor miscite. It gains ten
##sections at the existing seams of its prose — for example the DMARC aggregate-report reader — with the content itself unchanged. -
GUI clients’ subtopics are reordered around what they are. The mail clients now lead, webmail first and the extensions following by reach — webmail, trustless webmail, Chrome, Outlook, Thunderbird — with the parent page’s prose reordered to match. Lockbox is no longer the section opener: it is a capability of the Outlook and Thunderbird plugins, not a client, so it now closes the section after the extensions that carry it. And the name marketplace — a browse/buy/sell page for aliases and domains, not a mail client — moves out of GUI clients to its own top-level entry under Using SithBit. Page URLs are unchanged; only sidebar order and the parent page’s prose moved.
-
The lifecycle of a message’s figure and the operation list below it now number the same four operations. The figure previously counted five mechanism steps (1–5) while the list counted the four CLI operations (Send/Get/Pin/Delete), so the two numberings never lined up. The figure’s compose-seal / store / envelope boxes are now sub-steps 1a–1c of Send, Get & open is step 2, Delete is step 4, and the figure gains the previously missing optional step 3 — Pin, re-pinning the verified body to a provider the recipient controls. Each list item cites its figure step. No semantics changed — the flow itself is as before.
2026-08-04 — v0.44.0 (DMARC is RFC 9989/9990/9991, and pct= is inert)
- DMARC is now specified by RFC 9989, which
obsoletes RFC 7489. Standards and RFC coverage
retires the 7489 row for three: 9989 for evaluation and disposition, 9990 for
aggregate (
rua) reporting, 9991 for failure (ruf) reporting. The conformance appendix and the glossary follow, and the appendix’s ingestion section is retitled RFC 9990: ingesting DMARC aggregate reports. Relaxed alignment now folds to the organizational domain through 9989’s DNS tree walk instead of a public suffix list, which is why subdomain-signed mail aligns without one. - A published
pct=no longer does anything, and the change is strictly stricter. Whypct=no longer does anything is new: 9989 retired the tag, so a domain publishingp=reject; pct=0— which used to have its unsampled mail downgraded to quarantine — now has it rejected. The page names the migration 9989 intends,t=ytest mode, which drops enforcement one level and needs nothing from an operator’s configuration. Every page that claimed the fulldmarcmode “honorspct” — the standards table, the conformance appendix, the glossary, the threat model andsmtp_server.toml’s shipped example — now says enforcement is all-or-nothing instead. - The subdomain policies
sp=/np=are documented, with the resolver trap that can disablenp=outright. Subdomain policies and thenp=existence probe explains which ofp=/sp=/np=applies and states that “does not exist” is decided by a live single-A-record probe. Behind a resolver that answers NODATA instead of NXDOMAIN — systemd-resolved among them — that probe always says “exists”, every subdomain takessp=, and a domain publishingp=none; sp=none; np=rejectgets no enforcement at all. - The reporting sections are re-cited, and one stale claim about emitted
reports is corrected.
[spooler.dmarc_report]and[spooler.dmarc_ruf]now cite RFC 9990 §4 and RFC 9991 §5 for the external-destination checks and RFC 9991 §7.1 for the headers-only default. Two facts an operator can see on the wire are stated for the first time: emitted aggregate reports carry the RFC 9990dmarc-2.0XML namespace, and theirpolicy_publishedblock does carryp/sp/adkim/aspf(the page still said it did not) while deliberately omittingdiscovery_method, because this MX folds a report’s grouping domain with the suffix list and its alignment verdicts with the tree walk — naming either method would be false rather than merely absent. - The dependency audit records that its
mail-authrecommendation was declined. That page advised pinning=0.11.0and taking 0.11.1 “only when the RFC 9989/9990/9991 features are actually wanted”; they were wanted, so the manifest kept a caret0.11and the adaptation was run as its own wave. The residual exposure the caret leaves — a futurecargo updatetaking another SemVer-illegal patch from an upstream that has already shipped one — is written down as accepted, not solved. Its prioritized list is annotated with what has since been done. - The RFC 7489-era compatibility anchor is gone. When the ingestion section
was retitled RFC 9990: ingesting DMARC aggregate
reports,
a raw
<a id>was left behind so that DNS setup and the account API kept resolving under the old §7.2 fragment. Both links now point at the real heading and the shim is deleted —mail_docs/srccarries no hand-written HTML anchors at all again. - The
dmarc-2.0XML namespace on emitted aggregate reports is now justified rather than merely noted — and pinned by a test.[spooler.dmarc_report]no longer presents the namespace as an unhandled consequence of a library bump. RFC 9990 is Standards Track and obsoletes RFC 7489, so emitting the current schema is precisely what running a DMARCbis receiver means, andnpexists only indmarc-2.0, so downgrading the namespace would foreclose ever reporting it. The tradeoff is stated rather than hidden: a consumer that still validates strictly against the RFC 7489dmarc-1.0schema will refuse our reports, and there is no switch to emit the older form. Report ingestion stays lenient and accepts both schemas. A new test gunzips the aggregate report that actually crossed the wire and asserts its namespace, so a future silent library bump that moves it again fails loudly instead. - Three surfaces outside the earlier sweep’s reach now name the right RFC
too. The account API’s stored aggregate reports are the parsed RFC 9990
form, not 7489 — see the account API; footnote 6 of
Trusting a “from”
address cites RFC 9989
for the specification a receiver evaluates against, and names RFC 9990 and RFC
9991 as the reporting halves DMARCbis split out; and
sithbitd.example.toml’s commented[spooler.dmarc_report]and[spooler.dmarc_ruf]blocks cite RFC 9990 for aggregate emission and RFC 9991 §7.1 — not 7489 §7.3 — for the headers-only default. Comment text only in the example file: no setting, default or value changed. The remaining mentions of 7489 across the book are all historical (“obsoletes RFC 7489”, “RFC 7489-era report bodies”), and are correct as written. - The dependency audit’s summary table now
shows the manifest as it is, not as it was. Seventeen of its twenty-six rows
named a version requirement the root
Cargo.tomlno longer carries: the eleven bare*entries the audit asked to pin have since taken caret floors, the five OpenTelemetry entries moved to the 0.32/0.33 train, and GAazure_corewent to1.1. A new note under the table says which column is live (Manifest) and which stay frozen as the July 2026 evidence (Resolved, Latest (index), Verdict), so a row whose requirement now runs ahead of its resolved version reads as intended rather than as an error. One correction beyond the sweep:num-deriveis on0.5, a major above the0.4the audit’s own pin-floor block proposed. No manifest, code or documented behavior changed. - Emitted aggregate reports now carry
np, and only when the domain published it.[spooler.dmarc_report]previously said the element was not emitted because the store did not retain it; it does now, end to end from the DMARC evaluation to the gzipped XML on the wire. The subtlety is stated rather than hidden: the DMARC library materializes an absentnp=as a copy ofsp=while parsing — the same fill-in that givessp=its value for a record publishing onlyp=— so echoing it unconditionally would report a policy the domain never stated. A report therefore carries<np>only where the tag was really published and differs fromsp=, and omits it otherwise, which RFC 9091 makes the identical statement. - Documentation only in this repository: the behavior described here landed with the DMARCbis adaptation itself. It changes what an operator’s MX does to inbound mail and what its reports look like on the wire, so it is tagged MINOR under the v0.10.0 widening for default-behavior changes that affect deployments.
2026-08-04 — v0.43.0 (the auction’s default duration gets a heading of its own)
- The default
duration
is now a subsection of its own. What an auction runs for when neither
--ends-innor--ends-atis given was the third bullet in the list of the two flags that do name an end instant, where it read as a third way of setting one. It now sits under its own heading alongside Anti-snipe extension, and states what the bullet left implicit: the default and the ceiling those two flags clamp to are separate on-chain settings that hold the same length today, so an auction naming no end instant gets the default, not the maximum. No documented figure and no behavior changed. - The timelock gate now follows that heading instead of a pinned line. The gate fences the two auction constants — the hard cap on an auction’s length, and the length one runs when no end instant is given — against separate slices of that one page. The default’s slice was the single line its bullet sat on, and inserting a line anywhere above it slid the bullet into the cap’s slice: the cap claimed the figure, the cap’s own total did not move, and the only complaint was the default row’s deletion guard reporting a mention as removed when it had merely moved. The slice is now the new subsection, tracked by its heading, with the cap’s two ranges stopping one line either side of it — so that same edit is refused outright, naming both rows and the exact window, the way the glossary split has behaved since v0.42.1. Every fenced-mention count is unchanged. Documentation and gate tooling only: PATCH, and the protocol state is unchanged since the entry below, so the version holds.
2026-08-04 — v0.43.0 (what an authenticated submission session pins at AUTH, written down)
- Outbound quotas and
suspension now
explains the session-pinning window. The refusal table already carried the
symptom — a suspension landing after AUTH is refused at MAIL with
550 5.7.1— without saying what an authenticated submission session keeps from AUTH time and what it re-asks. It keeps the identity and only the identity: the wallet the login resolved to, which for an alias login is whichever wallet the alias pointed at in that moment. Both verdicts are asked afresh (suspension at AUTH and at every MAIL, rolling usage at every external RCPT), so neither the suspend flag nor the allowance is frozen for the session. The operator-visible consequence is the flip side of the pin, and is the reason the paragraph exists: re-point an alias mid-session and the open session keeps being charged to — and keeps being stopped by the suspend flag of — the old wallet, so suspending the new one leaves that session sending. The window closes at the session’s next authentication, which is the next connection in practice, since a secondAUTHon an authenticated session is refused (503, RFC 4954 §4). Compose is contrasted as having no window: it checks both on every request. No code and no behavior changed — the prose was catching up to what the servers already do. Documentation only: PATCH, and the protocol state is unchanged since the entry below, so the version holds.
2026-07-30 — v0.43.0 (the keyserver stops answering <wallet>@domain with a stranger’s key)
MINOR — a corrected enforcement default on a public endpoint; no protocol change. Recipient resolution is one decision with two arms, and the two had drifted apart at one site.
- The
certkeyserver returned the wrong recipient for a documented address form. Discovering a recipient’s encryption key promises you may give it “a wallet address or an alias, with or without a domain suffix”. The bare form was handled correctly; the<wallet>@domainform was not. Its wallet-literal guard was conditioned on the query having no@, so a suffixed address skipped it, was lowercased, and went to the chain gateway — which echoes back anything decoding to 32 bytes. The endpoint then answered with a different, equally real wallet and that wallet’s published key, which a sender would seal to.<wallet>@domainis not an exotic spelling: it is what the SMTP wallet-literal recipient fallback accepts. The guard is now on the local part alone, so both spellings resolve to themselves, case-exact. The prose was already right — only the code was wrong — so no page changes; this entry records the behavior fix. - Why case matters here at all. Base58 text is the encoding of a key, so a
case variant of a valid 32-byte address is a different wallet, not another
spelling of the same one. This is measured rather than argued: the fixture
address
6EhtMhq…su2Rlowercases into another valid address. Only addresses containingLescape, becauselfalls outside the base58 alphabet and those merely 404. Aliases are the opposite — stored lowercased and globally unique, so they must fold. Every caller now makes both decisions in one step, and a source-sweep test fails the build if a new site builds the resolution request by hand and skips it. Same class as the wallet-envelope fix in v0.25.0 below. - The gateway’s alias cache no longer shadows a freshly registered alias.
mail-grpc cached resolutions under the raw local
part, so each case spelling of one alias held its own entry — including its
own independently-aging negative entry, letting a
NOT_FOUNDcached under one spelling hide a just-registered alias for a fullalias_cache_seconds. Alias keys now fold. Wallet-literal keys deliberately do not: folding those would file one wallet’s answer under another’s key, which is the same defect one layer down. No configuration changes.
2026-07-29 — v0.42.1 (the Balances pane gets its screenshot; the timelock gate closes its known gap, then the figures still outside it)
- Balances: quotes before you buy
is now illustrated. The pane that carries the outbound price quote, the
ceiling that quote pins a purchase to, and the Reclaim unspent button was
the last webmail surface documented in prose alone. The new
webmail-balances.pngrides the same capture driver — and the same dark-theme/timezone pin — as the rest of the committed set, but is shot against a populated fixture, so the frame shows a returned quote and a frombox still holding stamps rather than an empty form. The capture tooling andscreenshots.manifest.jsonmoved with it; no web-client source changed, so every client’s pinnedsource_hashis unmoved. Documentation only: PATCH, and the protocol state is unchanged since the entry below, so the version holds. - The timelock gate now fences the pages it used to skip. Its rows claimed whole files, so any page mixing two constants’ figures had to be left out altogether — the known gap recorded on 2026-07-27 below, covering closing accounts, the economics page, the threat model and Proving behavior. A row can now take a named section or an explicit line range instead of a file, so two constants may share a page as long as their line windows do not overlap, and the fenced-mention count goes from 19 to 30 (14 mailbox-close, 9 domain-deactivation, 4 domain-reclaim, 3 reply-bounty). Two figures stay deliberately unfenced, argued in the checker’s own docstring: one diagram alt-text line that states two different constants’ figures at once, and the glossary’s Mailbox close timelock entry, whose figure is fenced on the close pages instead. One line of prose moved with the tooling — the finalize error under Deactivate a domain now says “the 7-day deactivation timelock”, naming the timelock it means so the fence can see it. No constant and no documented figure changed: tooling, gate and wording only, PATCH, and the protocol state is unchanged since the entry below, so the version holds.
- The auction page joins the fence, and the last figures sitting outside it are now inside. The gate held one auction row for two deliberately separate constants — the hard cap on an auction’s length and the length one runs when no end instant is given — so Auction an alias could not be fenced at all: it states the cap on six lines and the default on one, and a single row over the page would have read correctly today only to fail at the wrong line naming the wrong constant the day the two diverge. There are now two rows at disjoint scopes, one per constant, and the page is fenced including the alt-text of its auction diagram — the last diagram figure in the doc set that no row could see. Two sentences moved with the tooling, each so the gate can reach a figure it had been scanning past: the mailbox close timelock now says operators can watch a mailbox “announce its own close” where it said “its own exit”, and the threat model’s close section now says a timelocked key revocation would leave MX servers sealing to a key the attacker holds “for another seven days” where it said “for seven more days”. Fenced mentions go from 40 across five rows to 50 across six (25 mailbox-close, 10 domain-deactivation, 4 domain-reclaim, 3 reply-bounty, 7 alias-auction cap, 1 alias-auction default). No constant and no documented figure changed: wording, tooling and gate only, PATCH, and the protocol state is unchanged since the entry below, so the version holds.
2026-07-27 — v0.42.1 (GUI parity for the money paths: sponsored close, quote-pinned purchases, sender reclaim)
Client-side only — no Rust, no ABI, no protocol change. Three on-chain capabilities that already shipped were unreachable, or reachable only wrongly, from the browser clients. Tagged PATCH on the same reasoning as v0.27.0 (“External wallets can now buy stamps and claim a mailbox… client and docs only”): the protocol state is unmoved and each item is a client adopting an option the program already offered.
- A sponsored mailbox can now finish closing. The webmail close pane built its finalize transaction without the funder account the program requires whenever someone else paid the mailbox’s rent, so the close failed on-chain. Mailboxes users created themselves were never affected. The pane now reads the recorded funder and also says where each rent went, which it previously got wrong for the sponsored case. See closing a mailbox and the webmail dashboard.
- GUI stamp purchases are pinned to the price they quote. The slippage ceiling the CLI has applied by default now also covers the webmail Balances pane, the compose card’s inline prepay, and the self-service funding page. A recipient repricing between the quote and the purchase makes the program refuse rather than charge the new rate. The ceiling is read fresh at buy time and covers the per-stamp postage only. See the price you are quoted is the price you pay.
- Senders can reclaim unspent postage from the GUI.
ReclaimFromboxStampswas CLI-only. The Balances pane now offers Reclaim unspent once a quote shows a frombox of yours holding stamps; it is hidden rather than disabled when the sender is an alias or email string, which the instruction cannot address. See getting unspent postage back and reclaiming unspent stamps. - Balances also gained an outbound price quote, which it never had — the pane previously showed only the inbound (someone → you) price. See Balances: quotes before you buy.
2026-07-27 — v0.42.0 (domain-scoped aliases removed: one holder-controlled alias namespace)
BREAKING on-chain ABI — AliasInstruction discriminants 13–15
(RegisterDomainAlias, RemoveDomainAlias, UpdateDomainAlias) are deleted,
and the gRPC AliasRequest.domain field is removed with its tag reserved.
Clients that emit those instructions or set that field must stop. They were
the tail of the enum, so nothing renumbered; per the versioning preamble the
protocol is pre-launch, so MAJOR stays 0 and this lands as a MINOR bump with
the break stated plainly.
- Domain-scoped aliases are gone. A verified domain’s authority could map any local part under its own suffix to a wallet it chose, and that mapping took precedence over the global namespace — so an authority could silently capture mail for a global alias holder resident on its domain, with no consent and no write to any account of theirs. Resolution is now global and domain-blind everywhere (gateway, GUI clients, inbound SMTP): the suffix is parsed off and discarded, and only a name’s holder can repoint it. See Aliases.
- Lockbox’s end-to-end claim is now unqualified. Sealing follows resolution in the sender’s browser, so the domain-scoped namespace was the one operator power that reached inside a sealed body — redefining which wallet an address named chose which key a sender sealed to. Removing it closes that, and Lockbox no longer carries the caveat.
- The verified-sender mark has two independent inputs again. The badge
resolves the From through alias resolution, then checks a
SenderAttestationthat is DNSSEC-gated on the From domain’s own DNS. While one party could both define a local part and vouch for it, that independence was nominal; it is now structural. - Organizations still issue addresses to staff — by reserving global aliases in bulk and transferring each to its holder. That hand-off needs two-party consent and leaves the employee holding the name outright, which the removed namespace did not.
- Error codes 81–83 stay (
DomainAliasAccountInfo,DomainAuthorityMismatch,NoDomainAlias). Unlike the instruction variants they sit mid-enum, where the numeric position is the on-chain code, so deleting them would renumber every error below. A trap note for whoever appends the next alias instruction — it inherits discriminant 13 — is recorded with the removed discriminants. - Docs. The two domain-alias pages are deleted; the
certkeyserver section they hosted moved intact to Get an alias. The old concept page’s claim that a domain-scoped alias “can never shadow or hijack a global alias” was false as written — it described account separation while denying the resolution shadowing that actually occurred; the threat model now states the guarantee correctly. No rendered client surface changed, so the existing client screenshots remain accurate.
2026-07-27 — v0.41.0 (money-path hardening: sender stamp reclaim, purchase slippage guard, admin-close value guard)
-
Senders can now withdraw unspent prepaid postage. A new
ReclaimFromboxStampsinstruction (discriminant 52) returns the balance above rent to a sender who prepaid against their own wallet address, zeroing the stamp count and leaving the frombox alive on its rent so the recipient keeps the price it set. The frombox derives from the hash of the signer’s address bytes, so reproducing that derivation is the authorization — no stranger can reach someone else’s frombox, and a frombox keyed on an email string stays recipient-managed by design. Documented at Reclaiming unspent stamps, with the concept-level story under Prepaying with stamps. This corrects the threat model, which previously stated that prepaid stamps had no refund path at all. New instruction: MINOR. -
Stamp purchases carry a slippage ceiling.
CreateFromboxandAddStampsgained an additivemax_price_lamportsfield; the recipient controls the per-stamp price and can raise it between the moment a buyer is quoted and the moment their transaction lands, so a purchase above the ceiling now reverts with custom error 107 rather than silently overpaying.frombox stamppins the ceiling to the price it just quoted by default, with--max-priceto pre-authorize a rise and--no-max-priceto opt out. Additive ABI field affecting how end users buy postage: MINOR. -
The admin reclaim tool can no longer reach accounts holding value.
AdminCloseAccount, in all three programs, now refuses any target whose balance sits above its rent-exempt minimum (custom error 106), sopostmaster reclaimreaps only rent-empty leftover state — never a sender’s escrowed postage, a reply bounty, or a live auction bid. New error code: MINOR. -
A permissionless crank can no longer capture the requester’s deposit.
PendingReclaimrecords the wallet that funded the request, anddomain reclaim --finalizepins the pending account’s rent refund to it. Finalize stays permissionless to crank; only the refund target changed. The new field is noted in the privacy reference. Additive account field: MINOR. -
Error codes 105
PinLeaseAccountInfo, 106AdminCloseEscrowPresentand 107PriceExceedsMaxare now listed in the program reference; 105 predates this entry and had simply never been written down.
2026-07-27 — v0.40.7 (timelock docs gate widened to all four constants)
- The “7 days” figure is now fenced for every timelock, not just
mailbox close.
check_timelock.pyguarded exactly one ofmail_model’s four same-valued timelock constants, so the prose describing domain deactivation, reclaim-by-proof and reply bounties could drift from its constant unnoticed — the three constants are deliberately independent literals, so retuning any one of them would have silently falsified those pages. The checker is now table-driven with one row per constant (19 fenced mentions in total, up from 9) and each row is drift-proved independently. Rows own file-exclusive allowlists, enforced at startup, because the context filters really do overlap inside a shared page. Scope stays.md-only: the three diagrams that also say “7 days” are excluded, since the screenshot gate already hashes them. The checker also gained a--self-testproving its exit-code contract against fixture trees, joining the link and anchor checkers. Known gap: a handful of close-family figures remain unfenced —closing-accounts.md,economics.md,threat-model.mdandproving-behavior.mdeach mix figures from two or more constants in one file, which the file-exclusive model cannot express; fencing them needs per-section scoping. Tooling and gate only, no documented behavior changed: PATCH.
2026-07-23 — v0.40.6 (first-run screenshot re-baseline)
- Webmail first-run screenshot re-baselined. The
committed
webmail-first-run.pngwas the last frame still shot on the old capture rig: its standalone capture path had no CDP session, so the frame rendered in the capture host’s own color scheme and could not be reproduced on a box whose desktop theme differed. The shot now rides the same capture driver — and the same dark-theme/timezone pin — as every other committed screenshot, so the full set is byte-reproducible on any box (run-to-run AE=0 across all four driver-captured frames). Same subject, same dark theme; only capture tooling and pixels moved. Documentation only: PATCH.
2026-07-23 — v0.40.5 (API-mode on-chain reply reveal + mailbox-credentials reference)
- Webmail: “Reply on-chain” now works in API mode. The on-chain compose card was CSS-hidden for the whole page lifetime whenever an account API was configured, so a trustless-viewer Reply click seeded the draft into an invisible card — a silent no-op. The card now reveals itself when the pane opens (it stays hidden until then, so nothing changes in the always-rendered UI), floating in the corner like the server compose pane. This narrows the v0.40.4 entry’s “it was never silent” note: that held for trustless mode only. The webmail screenshot set was proven pixel-neutral (same-rig A/B, AE=0 on all three shots) and re-pinned hash-only. Client-shell UX only, no protocol change: PATCH.
- CLI reference: new
Mailbox credentials
page documents
sithbit mailbox credentials— the offline, no-RPC derivation of the deterministic mail login (username = the wallet public key, password = the base58 wallet signature over the fixed auth challenge) — and wires it into the Mailboxes command tree beside the client-certificate alternative; the client walk-through pages already showed the invocation and are now cross-linked from the reference. Documentation only: PATCH.
2026-07-23 — v0.40.4 (lease-open acknowledgement + reference and screenshot upkeep)
- Lease-open acknowledgement — the trustless viewer’s “Lease this
message” button now switches the two view-routed shells to the settings
view with the lease form prefilled: webmail routes
via the
#/settingshash (so the browser’s Back button returns to the mail view), while the Outlook taskpane switches its plain view state (no history entry). No reply listener was added — the on-chain Reply compose card already opens in place in the mail view, so it was never silent. The webmail screenshot set was proven pixel-neutral (same-rig A/B, AE=0 on all three shots) and re-pinned hash-only. Client-shell UX only, no protocol change: PATCH. - Docs screenshots:
webmail-inbox.pngandmarketplace-listings.pngre-baselined on the current capture rig. The webmail capture driver now pins the topbar wallet line to a fixed display base58 (the public key of the checked-inmail-key1test keypair — the same wallet the marketplace capture signs in with) before the inbox shot; previously a fresh keypair minted per run made that line the frame’s one nondeterministic element. Both shots are re-shot on the chrome-headless-shell 151 rig and proven run-to-run byte-identical (AE=0 across consecutive full captures), so committed-vs-fresh comparisons are directly meaningful again. Capture tooling and images only: PATCH. - CLI reference: new
Create a client certificate
page documents
sithbit mailbox create-cert— the offline SASL EXTERNAL certificate mint (<prefix>.crt/.keyPEMs plus the deterministic password-less.p12, including--out’s extension-replacement behavior) — and wires it into the Mailboxes command tree; installation stays on the client walk-through pages, now cross-linked. Documentation only: PATCH.
2026-07-23 — v0.40.3 (the gRPC RPC rosters now gate-fenced against the proto)
-
Docs-tooling: the two hand-maintained
SolanaMailRPC rosters are now gate-fenced. A new docs-gate leg,mail_docs/check_rpc_rosters.py, diffsmail_api/README.md‘s flat RPC list and the gateway topology appendix’s three role buckets against the service definition inmail_api/protos/sithbit.proto: name-set equality both ways, the buckets’ union covering the proto set with no RPC claimed by two roles, and — where a bucket is introduced by an English number word (“Five RPCs”, “Thirteen RPCs”) — that word matching the bucket’s own list length. Both rosters had silently gone stale twice before (most recently the chain-read bucket omitting v0.40.0’sGetPinLease, caught only by hand at v0.40.1) — that drift class now fails the gate instead of waiting for a manual sweep. Docs tooling only, no protocol or server change: PATCH. -
Docs-tooling: the committed capture tooling now reproduces the webmail settings screenshot on its own.
capture-populated.mjs’s webmail pass now scrolls the settings page to the Pinning-leases pane — the shot’s subject, which sits below the fold — before shootingwebmail-settings.png; previously the committed image was reproducible only with an uncommitted modification to the capture driver. The upstreamed step’s output was verified byte-identical to the committed PNG, so no screenshot changed. Docs tooling only, no protocol or server change: PATCH. -
The trustless viewer now hands the open message to the Pinning-leases pane in all four GUI shells (webmail, Thunderbird, Outlook, Chrome): a Lease this message button beside Reply — shown only once a body has rendered, since a local-only message has no CID to lease — dispatches a
sithbit-lease-open {cid, messageId}event that prefills the pane’s create fields with the message’s on-chain CID and id. In webmail and Outlook the prefill waits in the settings view where the pane lives; in Chrome and Thunderbird the pane sits above the viewer on the same page. Prefill only — the user still reviews the deposit and submits — and manual CID entry is unchanged. No committed screenshot changes appearance (the new button is unreachable in every committed capture). Client-side UI only, no protocol or on-chain ABI change: PATCH.
2026-07-23 — v0.40.2 (pinning leases reach the web clients)
- A Pinning leases pane in all four GUI shells (webmail, Thunderbird, Outlook, Chrome): the v0.40.0 pinning-lease surface — until now CLI-only — is now a shared dashboard pane. Create a lease by a message’s CID and id (the recipient defaults to your own mailbox, the deposit prefills to the protocol minimum straight from the on-chain constant, and the one-time creation fee splits to the recipient’s operator exactly as the CLI resolves it), check whether your wallet holds a lease on a CID, and close a lease anytime to reclaim the deposit — including after the message itself has settled, since the lease is addressed by the CID. The transactions are built and signed in the shared wasm module, byte-parity-fenced against the CLI’s own builders, and the pane is documented on each client page. Client-side UI only, no protocol or server change: PATCH.
2026-07-22 — v0.40.1 (the sender-reputation figures reach the gRPC gateway)
- New
GetSenderReputationRPC on theSolanaMailservice: wallet in, the recorded cumulative postage spend and the effective first-contact rate in bps out — the same two figures as the CLI’ssithbit postoffice reputation, computed through the same fenced on-chain rule (tuned floor included), so MX operators and other servers can weigh a sender’s on-chain track record without shelling out to the CLI. Absent reputation account = the ordinary zero-spend/full-price answer; a failed chain read surfaces asUNAVAILABLErather than masquerading as zero spend. The gateway topology appendix’s chain-read role now counts all thirteen read RPCs (it had also omitted v0.40.0’sGetPinLease). Additive gRPC surface, no on-chain ABI change: PATCH. - The settings pane’s wallet-derived mail password is now real markup in all four shells (webmail, Thunderbird, Outlook, Chrome — the pane logic existed but no shell rendered it): a Derive mail password button, gated on the wallet being unlocked, with the same one-time reveal pattern as the encryption-key pane — username and derived password computed entirely client-side (see The mail password). A reactivity fix rides along: unlocking the wallet now re-renders the derive gate immediately (it previously stayed on the “unlock your wallet” hint until a reload). The webmail settings screenshot was re-shot to show the new section. Client-side UI only, no protocol or server change: PATCH.
sithbit mailbox create-certand the extensions’ Certificate sign-in now emit a combined.p12: the CLI writes<name>.p12— a password-less PKCS#12 bundle (certificate + unencrypted key, deterministic per wallet) — beside the PEM pair whenever--outis given, the wasm module derives the byte-identical bundle client-side, and both the Thunderbird and Outlook extensions’ Download client certificate button now saves<pubkey>.p12first, ahead of the PEM pair. The client pages’ certificate-login walkthroughs drop the manualopenssl pkcs12 -exportconversion step — the bundle imports in one step (leave the password prompt blank) — and note that the.p12, like the.key, embeds the wallet secret. Client surface only (CLI output + extension download), no protocol or server change: PATCH.
2026-07-22 — v0.40.0 (pinning leases: paid extended retention for mail bodies)
- New: pinning leases —
a per-(CID, holder) mail-program account
(
sithbit mail lease create/show/close) escrowing a reclaimable deposit (minimum 0.01 SOL, returned in full at close) that asks operators to keep a message body pinned past the default retention. Deliberately no expiry and no renewal fee; the only spend is a one-time creation fee (default 0.001 SOL, cap 10×, tunable viaSetPinLeaseFee/ read-onlypostoffice fee pin-lease) split with the recipient’s domain authority at the operator share. New instructionsCreatePinLease(49) /ClosePinLease(50) /SetPinLeaseFee(51), errors 103–105, Postoffice 192→200; see the economics rationale and the program reference. - The auto-settle sweeper
enforces leases with no new configuration: before releasing a pin it
asks the gateway’s new
GetPinLeaseRPC whether the CID is leased — a leased copy still settles (the stamp reclaim is unaffected) but keeps its pin; an unanswerable lookup fails closed and the copy retries next sweep. - The privacy reference on-chain account table gains the PinLease row (a lease publicly binds its holder wallet to a message CID), and the compute-units table the three new fences.
2026-07-22 — v0.39.2 (small-item cleanup: cheaper first-contact purchases, fee visibility, reference completeness)
- CreateFrombox on the default reputation tail costs ~27% less compute: the pricing pass’s postoffice and reputation reads now thread through to the fee-collection leg instead of being re-derived (the second postoffice PDA grind was the bulk of the cost). Measured CU dropped 48,625 → 35,256 and the fenced ceiling 72,000 → 58,000 — see Compute-unit budgets. No account-list, fee, or pricing change: PATCH.
sithbit postoffice fee attestationjoins the public read-only fee getters: it prints the effective one-time verified-sender attestation fee and its cap without a signature, in every CLI build. The read-surface list also now names thefee settlementgetter it had omitted.- Reference de-staling: the
mail-grpc topology appendix’s
chain-read role now counts all eleven read RPCs (it omitted
ListParticipantsandGetSenderAttestation), and the privacy field reference’s on-chain account table grew from nine rows to the full eighteen account types — adding the marketplace escrow/bid accounts, the participant beacon, the sender attestation/reputation records, and the three pending-timelock markers.
2026-07-22 — v0.39.1 (the verified-sender trust mark reaches the web clients)
- The readers now show a “✓ Verified” trust mark beside the From line when the sender holds an on-chain verified-sender attestation from its domain — the deferred client half of the v0.38.0 trust-mark decision, across all four shells (webmail, Thunderbird, Outlook, Chrome). The api-backed reader resolves the From address to a wallet on-chain and checks that wallet’s attestation; the trustless viewer binds the program-verified envelope signer instead. Absence renders nothing — no negative indicator. See Using it (the inbox screenshot now shows the mark).
- The web compose/prepay paths now pass the payer’s attestation to the first-contact frombox purchase when it exists on-chain (one existence check, the CLI’s default since v0.39.0) — so an attested org’s webmail first contact prices at the floor without any CLI step. Top-ups are unchanged (attestation affects first-contact pricing only). No ABI or fee-rule change: PATCH.
2026-07-22 — v0.39.0 (reputation-scaled sender friction: proven senders pay less at first contact)
- The default price a stranger pays at first contact now scales with the
sender wallet’s on-chain track record. New
Reputation-scaled first-contact pricing
section: a per-wallet
SenderReputationaccount (new mail-sidesender_reputationPDA seed — see the PDA seeds table) records the wallet’s cumulative distinct-recipient postage spend atCreateFromboxtime, and that spend steps the default first-contact rate: 10,000 bps (full default postage) below 0.1 SOL of spend, 7,500 from 0.1 SOL, 5,000 from 1 SOL, 2,500 from 10 SOL. A verified-sender attestation prices first contact at the floor immediately. Recipient-set prices are never touched — only the default a stranger inherits — and a nonzero price never rounds to zero: first contact is never free. Owner (self) purchases stay on the legacy path, unaffected. - The discount floor is delegate-tunable: new mail-side
SetReputationFloorinstruction (discriminant 48,sithbit postmaster fee reputation-floor <BPS>) tunesreputation_floor_bps(defaultDEFAULT_REPUTATION_FLOOR_BPS= 1,000 bps = 10% of the recipient’s default postage, capped atMAX_REPUTATION_FLOOR_BPS= 10,000; over-cap refuses with new custom error 102ReputationFloorAboveCap, and a zero rate stores the “unset” sentinel and resolves to the default). The postoffice account grew 184→192 bytes (versioned reads default older accounts; the setter upgrades in place). See the tunable-constants table and the error-codes tail. - Third-party stamp purchases now carry a reputation tail by default:
CreateFromboxaccepts 6/8/9/10-account forms — the CLI and wasm builders emit the 9-account form (operator pair + the payer’s sender-reputation PDA, lazily created rent-exempt) on every third-party create, and append the payer’s attestation as a tenth account when one exists on-chain. A present-but-invalid attestation fails the purchase (error 19 / error 17) instead of silently repricing. See Reputation-scaled first contact on the stamps page, which also documents the new read-onlysithbit postoffice reputation <WALLET>lookup (cumulative spend + effective first-contact rate in bps, floor included) and the floor setter. - The wasm frombox builders (
create_frombox_tx/create_frombox_unsigned) gained a trailing optionalattestationparameter and emit the 9-account reputation tail on third-party creates; existing JS callers are unaffected. - The compute-unit table’s
CreateFromboxrow now measures the 9-account default-tail path — 48,625 CU measured / 72,000 fenced (the old 12,251 / 35,000 row measured the owner-legacy list) — andSetReputationFloorlands at 6,756 / 30,000.
2026-07-22 — v0.38.0 (verified-sender attestation: a domain vouches for its sending wallet)
- A domain can now attest its sending wallets on-chain. New
Verified-sender attestation
concept page and
Attest a verified sender
CLI reference: a sending organization proves control of its domain’s
DNS — the same staged DNSSEC proof
domain authorizerides — and mints aSenderAttestationrecord binding the domain to a wallet, the protocol’s trust mark for organizational senders. Attesting requires noMailDomainaccount and confers no serving rights; a domain may attest any number of wallets, one revocable record per (domain, wallet) pair. - Two new domain-program instructions:
AttestSender(discriminant 17, permissionless — the attested wallet rides the payload) andRevokeSenderAttestation(18, holder-signed close with rent refund; the PDA re-derives from the signer, so no other key reaches the record), plus the newsender_attestationPDA seed — see the program reference and the new blake3 table row. - The attestation fee is delegate-tunable: new mail-side
SetSenderAttestationFeeinstruction (discriminant 47,sithbit postmaster fee attestation <LAMPORTS>) tunes the one-time feeAttestSenderpays the postoffice (defaultDEFAULT_SENDER_ATTESTATION_FEE_LAMPORTS= 0.01 SOL, capped atMAX_SENDER_ATTESTATION_FEE_LAMPORTS= 0.1 SOL; over-cap refuses with new custom error 101SenderAttestationFeeAboveCap, and a zero fee stores the “unset” sentinel and resolves to the default). The postoffice account grew 176→184 bytes (versioned reads default older accounts; the setter upgrades in place). See the tunable-constants table. - Both query surfaces ship: the read-only
sithbit domain attestation <MAIL_DOMAIN> <WALLET>lookup (every build), and the gRPC gateway’s newGetSenderAttestationcall —{domain, wallet}→{attested, attested_at}, where a clean absence answersattested = falseand a failed chain read isUNAVAILABLE, never afalse. A client badge over these reads is planned but not yet shipped. See Looking up an attestation. - The compute-unit table’s three attestation
rows (landed with the measurement suite) are part of this release:
AttestSender322,474 CU measured / 345,000 fenced,RevokeSenderAttestation11,224 / 34,000,SetSenderAttestationFee6,546 / 30,000.
2026-07-22 — v0.37.0 (client-certificate download from the extensions)
- The Thunderbird
and Outlook
client pages’ certificate-login sections now document the extension
path: each extension’s settings surface gained a “Certificate
sign-in” section whose Download client certificate button derives the
<pubkey>.crt/<pubkey>.keypair in-extension — byte-identical tosithbit mailbox create-cert’s output, and deterministic per wallet (re-downloading anywhere yields the identical certificate). Import into the mail client or OS store stays manual; locked and external (Phantom/Ledger) wallets cannot derive and the CLI path remains the canonical route.
2026-07-22 — v0.37.0 (privacy concept diagrams)
- The What’s public and private page gained two concept diagrams: one under What your operator holds showing where a password-less account’s mail sits and what at-rest sealing does and does not protect against, and one under What you control laying out the five account-holder privacy settings and their defaults.
2026-07-22 — v0.37.0 (do-not-disturb vs. autoresponder diagram)
- The Do not disturb
concept page gained a side-by-side diagram contrasting the classic
accept-and-autoreply flow (mail piles up with postage to settle; the
“I’m away” reply may never reach the sender) with SithBit’s
refuse-at-the-door
450(the sender’s own mail server queues and retries; nothing piles up and no stamp is burned).
2026-07-21 — v0.37.0 (stamp-fee operator split + honest gRPC fee fields)
- The per-stamp protocol fee now splits with the recipient’s MX
operator.
CreateFrombox/AddStampsaccept an optional trailing “operator tail” — the recipient’s mailbox, its named domain, and the domain authority. When present (the CLI, wasm builders, and web prepay all build it automatically), the authority receivesoperator_share_bps(default 10%) of the hybrid fee and the postoffice the remainder; the buyer’s total is unchanged. Lapse and filler rules mirror the settlement share, and legacy account lists keep the whole fee with the postoffice — the tail is optional, so no client breaks and no instruction payload changed. The owner waiver still precedes the split. See The per-stamp protocol fee. FromboxResponsecan now say “fee unknown”. Newbool stamp_fee_known(field 6) on the gRPC response:falsemeans the gateway’s postoffice read failed and the two fee arms are 0 — unknown, not free — which no value convention could express since a stored flat fee of 0 legitimately charges nothing. The failed read stays non-fatal and uncached.- The wasm frombox builders (
create_frombox_tx/add_stamps_txand their unsigned twins) gained trailing optionaloperator_domain/operator_authorityparameters (both-or-neither); existing callers are unaffected.
2026-07-21 — v0.36.0 (Core Concepts go GUI-first, with concept graphics)
- Every Core Concepts page now points at the GUI clients first. The
Addresses, Mailboxes,
Fromboxes, Aliases,
Domains, Email,
and marketplace pages describe each
user action via the getting-started wizard, the client
panes, and the marketplace web page; the
sithbitCLI equivalents moved into footnotes (or stay inline only where no GUI exists — domain registration, auctions, escrowed transfers, and the advertiser campaign flow). - Four new concept diagrams: Mailboxes gained the address–mailbox–aliases relationship, Fromboxes the sender–recipient–postage triangle, The Marketplace a three-stall market-square map of names, domains, and attention, and Privacy’s one-line summary a public-envelope vs sealed-letter split view. (Campaigns already carries its lifecycle diagram.)
2026-07-21 — v0.36.0 (glossary: sans-io)
- The glossary gained a sans-io entry, and the term’s mentions on Standards and RFC coverage now link to it (hover for the definition tooltip).
2026-07-21 — v0.36.0 (settlement basis-point rates: hybrid stamp fee + tunable operator share)
- The per-stamp protocol fee is now a hybrid: third-party stamp
purchases at
AddStamps/CreateFromboxpay the greater of the flat per-stamp fee and a bps share of the escrowed postage (stamp_fee_bps, defaultDEFAULT_STAMP_FEE_BPS= 100 = 1%, capped atMAX_STAMP_FEE_BPS= 1 000). At the defaults the arms cross at 0.01 SOL of postage per stamp — cheap friend-tier stamps still pay the flat fee, while a default-priced 1-SOL stranger stamp now pays 0.01 SOL instead of 0.0001. The recipient-self-funding waiver covers the whole hybrid unchanged (“friends mail you free” is untouched), and the refundable signature surcharge is never in the bps base. See The per-stamp protocol fee. - The operator share is now delegate-tunable: the 10%
OPERATOR_SHARE_BPSsplit atDeleteMailsettlements, reply-bounty claims, escrowed alias transfers, marketplace sales, and auction settlements now reads the postoffice’soperator_share_bpsfield (default 1 000 = today’s behavior, capped atMAX_OPERATOR_SHARE_BPS= 2 000). Behavior at the default is byte-identical; an unreadable postoffice charges the protocol defaults (the rate read never blocks a settlement). - New
SetSettlementBpsinstruction (discriminant 46, delegate-only) sets both rates in one instruction; over-cap rates refuse with new custom errors 99OperatorShareBpsAboveCap/ 100StampFeeBpsAboveCap. A zero rate stores the “unset” sentinel and resolves to its protocol default — the bps rates cannot be tuned to literal zero. The postoffice account grew 160→176 bytes (versioned reads default older accounts; writers upgrade in place). See the program reference and the tunable-constants table. - Every quote surface knows the hybrid:
sithbit postoffice fee stampprints both arms, the newsithbit postoffice fee settlement/sithbit postmaster fee settlement <OPERATOR_SHARE_BPS> <STAMP_FEE_BPS>read and tune the rates (Postmaster administration), the CLI stamp-purchase preview quotes the hybrid against the actual postage, the gRPCFromboxResponsegainedstamp_fee_bps, and the webmail prepay card and onboarding funding page price quotes through a new wasmstamp_purchase_feeexport. - New section: Modeling the postoffice’s revenue base — the honest segmentation (waived owner purchases, flat-dominant friend tiers, priced-out strangers) that motivates the settlement rates as the scalable, capped levers.
2026-07-21 — v0.35.0 (length-tiered premium pricing for short alias names)
- Registering a 1–4 character alias now pays a per-length premium claim
fee instead of the flat fee; names of 5 or more characters are
unchanged. Defaults: 10 SOL (1 char), 1 SOL (2), 0.1 SOL (3), 0.05 SOL
(4) — short names are scarce assets (36 one-character combinations) and
are priced accordingly, on the registrant’s side per the positioning
principle. The schedule lives on the postoffice
(
ALIAS_TIER_FEES_LAMPORTS, account grown 128→160 bytes, versioned reads default older accounts) and is delegate-tunable via the newSetAliasTierFeesinstruction (discriminant 45), each slot capped at 10× its default (MAX_ALIAS_TIER_FEES_LAMPORTS; over-cap refuses with new custom error 98AliasTierFeeAboveCap). See Economics — Alias holders and the tunable-constants table. - Delegate reservations stay fee-free at every length — the postmaster reserves premium short names for rent alone and resells them on the marketplace at seller-set prices; see Reserve aliases in bulk.
- The price always shows before you pay.
sithbit alias createprints a fee preview (per premium name + run total, or the delegate waiver notice),sithbit postoffice fee aliasprints the effective per-length schedule with its caps, and the newsithbit postmaster fee alias-tiers <1> <2> <3> <4>tunes it — see Create an alias and Postmaster administration. The webmail aliases pane quotes “Registration fee: N SOL” live as you type (from the fetched postoffice account, protocol defaults when unreadable), and the onboarding wizard’s funding check prices a premium handle by its length.
2026-07-21 — v0.34.0 (IMAP SPECIAL-USE mailbox attributes, Tier 1)
- The IMAP server now advertises
SPECIAL-USE(RFC 6154) and marks the well-known top-level mailbox names —Sent,Trash,Drafts,Junk(alsoSpam),Archive— with their\Sent-style attributes in LIST responses, case-insensitively, so clients file sent/deleted/draft mail into the same folders everywhere. INBOX and nested names carry no role; theLIST (SPECIAL-USE)selection filter andCREATE-SPECIAL-USEare not supported. See the Standards support IMAP table.
2026-07-21 — v0.33.0 (IMAP advertises SASL-IR)
- The IMAP server now advertises
SASL-IR(RFC 4959) in the greeting and CAPABILITY responses, wherever theAUTH=mechanisms are offered. The initial-response form of AUTHENTICATE was already accepted; the advertisement lets clients discover it instead of probing. See the Standards support IMAP table.
2026-07-21 — v0.32.0 (docs: setup and earnings join the SithBit CLI; onboarding leads with the web wizard)
Documentation-only: the version tags the unchanged protocol state.
- The two CLI walkthroughs move into the SithBit CLI reference tree:
First-run setup (
sithbit setup, now the tree’s first subtopic) and Revenue snapshot (sithbit earnings, between Campaigns and Closing accounts). Cross-links follow (Fromboxes’ USD-annotation pointer, the CLI Quickstart’s walkthrough link, and Solana clusters’ faucet note). - “Setup and earnings” becomes Getting started — the page now opens with the browser wizard the four web clients share (the audience most users belong to), keeps the standalone get-started and refused-sender pages, and points terminal-comfortable readers at the two relocated CLI topics. The page’s URL and section anchors are unchanged.
2026-07-21 — v0.32.0 (docs: tables wrap in place instead of scrolling)
Documentation-only: the version tags the unchanged protocol state.
- Prose tables no longer cut off their last column behind mdBook’s
horizontal scrollbar — felt hardest in the Configuration
reference’s key/default/meaning tables.
Book-wide CSS (
css/brand.css) now spans tables across the text column, slims the cell padding, left-aligns headers, and lets long tokens in every column but the first break at the overflow point (config keys never break mid-token; a width floor keeps “Default” readable beside a long “Meaning”).
2026-07-21 — v0.32.0 (docs: deploy-page service table and provider links)
Documentation-only: the version tags the unchanged protocol state.
- Running a mail server’s service table now lists
the optionally-embedded IPFS node among
sithbitd’s roles, with its “needed when” column noting that role applies only under[ipfs] kind = "embedded"— a fleet delegates tosithbit-ipfsdor a pinning service instead. - Choosing a commodity provider — each provider name now links to that provider’s developer sign-up page (or product home page where sign-up URLs are region-specific).
2026-07-21 — v0.32.0 (docs: Icon legend joins the Glossary)
Documentation-only: the version tags the unchanged protocol state.
- The Icon legend now sits in the Glossary
sidebar section, right after Terms and
definitions, instead of under Appendix: Reference —
the two term-lookup pages now live side by side. The source file (and its
deployed URL) is unchanged; only the
SUMMARY.mdplacement moved.
2026-07-21 — v0.32.0 (docs: landing-page hero leads with earned postage)
Documentation-only: the version tags the unchanged protocol state.
- Welcome landing tweaks — the hero paragraph now says the sender-paid postage is earned by you, not just that it prices out spam, and the “No gatekeeper, no single company” card drops its featured accent border to sit as a regular card; the spam-pricing card is the page’s only featured one.
2026-07-21 — v0.32.0 (sithbitd installs as a systemd or Windows service)
MINOR — an additive deployment capability.
- Running as an OS service —
the new
sithbitd service install/sithbitd service uninstallsubcommands install the daemon under systemd (a generated unit file with restart-on-failure, network ordering, and commented unprivileged-user/low-port-capability lines;--printrenders it without writing) or the Windows service control manager (an auto-start registration whose internalservice runverb re-anchors the recorded working directory and config before the daemon boots). Neither install activates anything behind the operator’s back — thesystemctl/Start-Servicestep is printed, not run.
2026-07-21 — v0.31.0 (Closing accounts joins the SithBit CLI reference)
PATCH — documentation-only.
- The closing-accounts reference now lives in the SithBit CLI tree as
its Closing accounts
subtopic, right after Campaigns — retitled from “Closing accounts and
reclaiming rent”, following the campaign reference out of the appendix.
All cross-links follow, and the old deployed URL
(
appendix/closing-accounts.html) redirects to the new page so external bookmarks keep working.
2026-07-21 — v0.31.0 (Campaign CLI reference moved under the SithBit CLI)
PATCH — documentation-only.
- The
sithbit campaignreference now lives in the SithBit CLI tree as its Campaigns subtopic, beside the other command references, instead of in the appendix. All cross-links follow, and the old deployed URL (appendix/campaign-cli.html) redirects to the new page so external bookmarks keep working.
2026-07-21 — v0.31.0 (Sponsored mailbox creation: a domain authority provisions for its users)
MINOR — additive public-ABI change (a tail field on CreateMailbox and
on the Mailbox account, plus three appended error codes).
- A domain’s on-chain authority can now create a mailbox for a different
owner. Sponsored mailboxes
explains the concept and its three guards: only the named domain’s
authority may pay, the default postage is forced to the 1-SOL spam floor,
and no self-alias is bundled. The CLI surface is
mailbox create --for <address>(requires--domain). - The mailbox account records its funder, and closing refunds the funder.
The
Mailboxaccount gains a tailfunderfield (shown bymailbox getand the wasm account decoder);mailbox close --finalizenow routes the mailbox’s rent to the recorded funder — the owner itself on a self-created mailbox (unchanged), the sponsor on a sponsored one — with the CLI passing the funder account automatically. The pending-close account’s rent still refunds to the owner who funded the request. - Program & PDA reference
gains error codes 95–97 (
SponsoredMailboxRequiresDomain,UnauthorizedDomainSponsor,FunderAccountInfo) and updates theCreateMailbox/FinalizeCloseMailboxrows. This retires the lastTODOin the workspace’s Rust tree (the payer/owner split in the mail program’s create processor).
2026-07-21 — v0.30.1 (Navigation reorder: standards up front, clients first under Using SithBit)
PATCH — documentation-only.
- Standards and RFC coverage moved to the front matter, directly after the Introduction — the wire-compatibility story now greets a reader before the concept chapters instead of trailing them.
- “Using SithBit” reordered around the reader’s journey: GUI clients leads the section, followed by Setup and earnings (moved here from Core Concepts), with Economics after them.
- “RFC” is now a glossary term. The glossary’s Mail protocols section defines it, so RFC references linked to it get the standard hover tooltip; the standards page links its first prose mention.
2026-07-21 — v0.30.1 (Full sithbit-console tutorial and reference in the appendix)
PATCH — documentation-only.
- The
sithbit-consoleadmin TUI now has a full appendix page. The sithbit-console admin TUI documents the operator console end-to-end: prerequisites (a reachable account API and theadmin_walletsallowlist), running and configuring it, the wallet-challenge login, a tutorial through both tabs (Accounts → Mailboxes → Messages with chain states, the on-chain balances pane, queue depths and the confirmed dead-letter requeue/discard workflow with the cloud-store claim window), a complete key reference, the console’s deliberate scope limits (API-only, no store access, no chain writes), and a troubleshooting table. The job-queues section and the configuration reference now link to it; previously the console was documented only in fragments across those two pages.
2026-07-21 — v0.30.1 (Client pages note on-chain domain ownership for wallet submission)
PATCH — documentation-only.
- The Outlook and Thunderbird client pages now carry the on-chain-ownership
half of the wallet-submission envelope rule. Both
Outlook and
Thunderbird previously phrased
the sender rule as “your own wallet address at a domain the server serves /
is authoritative for”, which omitted the v0.29.0 enforcement: on a
chain-connected submission server the wallet must also own that domain
on-chain (its recorded
GetMailDomainauthority), not merely have the server serve it. The pages now state that nuance at end-user altitude and add the matching553 5.7.1refusal case; the full rule (including the chain-less-dev-stack fallback to server-served domains only) still lives in the configuration reference.
2026-07-21 — v0.30.0 (Onboarding wizard warns on an unfunded wallet before the mailbox create)
MINOR — a new client capability and default onboarding behavior.
- The web onboarding wizard now checks the wallet balance before it claims a mailbox. On the Review and Finish steps, a wallet that can’t cover the create cost (the account rents, plus the flat alias fee when a handle is claimed — about 0.0013 SOL bare, 0.0124 SOL with a handle) gets a plain-language warning naming the wallet, its balance, and how much more to transfer. The warning does not block the flow — you can fund the wallet out of band and continue.
- A funded-then-failed create no longer shows the raw chain error. If the create is attempted with too little SOL, the node’s “Attempt to debit an account but found no record of a prior credit” preflight rejection is rewritten into the same funding guidance, across the create, import, and connect-wallet paths.
- Single source of truth for the figure. The required-funding amount the
wizard quotes is computed by the same core routine the CLI
sithbit setupwizard uses, so the web and CLI figures can never drift.
2026-07-20 — v0.29.0 (Wallet submission envelope now checks on-chain domain ownership)
MINOR — a new enforcement default that affects deployments.
- A gateway-backed submission listener now requires the authenticated
wallet to own the envelope domain on-chain. For a wallet-literal
Wallet submission envelope,
a listener with a chain gateway (
[grpc]configured) no longer accepts<wallet>@<domain>merely because the domain is inlocal_domains; the domain must also be one the wallet is the recorded on-chain authority for, checked via the gateway’sGetMailDomainlookup (exact base58 match).local_domainsstill scopes which domains the listener serves; the authority check scopes which of those the authenticated wallet may send as. - Chain-disabled listeners are unchanged. A listener with no chain
gateway (an empty
[grpc]/ dev MX) has no per-wallet lookup available and falls back tolocal_domainsalone, so empty-config dev stacks keep sending.
2026-07-20 — v0.28.0 (Chrome extension gains trustless compose/reply parity)
MINOR — a new client capability and a new shipped default that affect deployments.
- The Chrome extension can now send trustlessly, at parity with webmail,
Thunderbird, and Outlook. The
Trustless viewer’s header
gains a Reply on-chain button, and the popup mounts the same floating
on-chain compose card the other GUI clients carry — a Compose on-chain
button opens it blank, Reply seeds it with the decrypted sender and the
parent message’s account address, and the seal → pin →
SendMaillifecycle is signed in the extension’s wasm module with no mail server in the path. - The extension ships a default IPFS pin origin. Connection settings
gains
ipfsPinUrl(defaulthttp://127.0.0.1:8182— an unauthenticated loopback sithbit-ipfsd) and its optionalipfsPinToken(default empty), where outbound sealed bodies are pinned; saving a non-loopback pin origin prompts for that host’s permission.
2026-07-20 — v0.27.1 (Second CID pointer linked on the mailbox page)
PATCH — documentation-only, no protocol change.
- The “Opting out of IPFS storage” section now links “CID”. The Opting out of IPFS storage prose said the on-chain message carries a “fetchable CID” as bare text; it now points to What is a CID?, matching the same page’s No-IPFS bullet, which already linked the term.
2026-07-20 — v0.27.0 (All nine dashboard panes documented on every GUI client; CID explained for non-technical readers)
PATCH — documentation and docs-tooling only, no protocol change.
- The Domains, Reply bounties, and Mailbox panes are now documented on all four GUI client pages. The Thunderbird, Outlook, Chrome and webmail pages previously described only six of the nine shared dashboard panes; the domain-marketplace pane (list or buy a domain), the reply-bounty settlement pane (claim a bounty on a message you replied to, or refund an expired one you placed), and the mailbox-config pane (claim the mailbox and set its handle, sending domain, default stamp price, and opt-out-of-IPFS flag) are now described on each, in that page’s own form.
- “CID” is now explained for non-technical readers. The
IPFS storage: benefits page opens
with a new “What is a CID?” section that explains a content identifier as a
fingerprint computed from a message’s exact bytes — the same content always
yields the same CID (so it is the address you fetch by) and any change yields
a different one (so it doubles as a tamper check) — with a two-row
illustration and a note that SithBit produces CIDv1 byte-for-byte identically
to Kubo. The glossary’s terse
CIDentry now links to it. - Docs-tooling: the mailbox-close timelock figure is now fenced. A new
mail_docs/check_timelock.pygate leg parsesMAILBOX_CLOSE_TIMELOCK_SECSfrommail_model/src/constants.rsand asserts, both ways, that the “7 days” quoted in the mailbox-close docs matches it — so retuning the constant or drifting the prose fails the docs gate. It is scoped by an explicit allowlist plus a mailbox-close context filter, so the identical “7 days” literal used for the domain-deactivation, reclaim, and bounty-window constants is not swept in. - The dashboard chain panes now load over a direct RPC connection, not only the account API. Balances, the encryption key, mailbox settings and the mailbox-close request now populate for a wallet-unlocked client with no account API configured — previously they re-rendered but stayed empty until an API token existed. The Aliases pane still needs the API, since there is no on-chain alias index to read directly. (The web-client screenshots were re-pinned to the updated source: this change is confined to the API-less load path, which the documentation screenshots — captured in API-backed mode — do not exercise, verified by re-capturing the inbox, settings and marketplace shots.)
- Docs-tooling:
check_anchors.pygains a--self-testleg. A checked-in, build-free fixture tree undermail_docs/tests/anchor_fixtures/(aclean/root that must exit 0 and abroken/root that must exit 1) proves the checker’s exit-code contract, mirroringcheck_links.py --self-test. Unlike the link fixtures, each anchor-fixture root ships both asrc/and a hand-authoredbook/HTML tree, because the checker validates#fragmentlinks against built-book anchors. It is a standalone dev command, not wired into the docs-gate chain; the defaultcheck_anchors.pyrun is unchanged.
See The Chrome extension, The webmail app and IPFS storage: benefits.
2026-07-20 — v0.27.0 (External wallets can buy stamps and claim a mailbox; Glossary promoted)
PATCH — client and docs only, no protocol change.
- External wallets (Phantom/Ledger) can now buy stamps and claim a mailbox. The dashboard gated those buttons on holding an unlocked in-app wallet key, even though the unsigned-transaction paths behind them were already wired and working for external wallets. Setting a stamp price, publishing an encryption key and settling reply bounties still require the in-app key — those have no unsigned equivalent — and the panes now say so specifically instead of telling every user to “unlock your wallet”.
- The mailbox-close pane is documented on the Thunderbird, Chrome, Outlook and webmail client pages, including the 7-day wait, that it is cancellable throughout, and that the encryption-key close stays instant. Outlook gained a full pane list, which it previously lacked entirely.
- The account-closing figure no longer shows
CloseMailboxas an instant one-step close; the mailbox and key legs now carry their own timings. local_domainson the submission listener is shown as a real configuration example rather than described in prose. Each SMTP role carries its own list — the MX section’s copy does not carry over — which is the domain half of the wallet-envelope rule.- The Glossary is now a top-level section in the navigation, immediately before Appendix: Reference. Its page keeps its existing address, so every existing link to it still works.
- The web-client screenshots were regenerated, which replaced a marketplace listings image that had been shipping as a blank page.
- The Core Concepts pages no longer assume you can read the source. References to internal file, function and type names, and to configuration keys and their file sections, have been rewritten as plain statements of what the system does — the concepts pages now explain the protocol without requiring a copy of the code beside them. Every fact those references carried is retained; only the way of stating it changed.
See GUI clients, Close a mailbox, Configuration and Terms and definitions.
2026-07-20 — v0.26.0 (Closing a mailbox is timelocked; the one-step close is disabled)
MINOR — BREAKING for clients that emit CloseMailbox. Closing a mailbox is
now a two-step, 7-day flow. RequestCloseMailbox (42) starts the clock and
refunds nothing — the mailbox stays open and keeps receiving mail;
FinalizeCloseMailbox (44), legal only after MAILBOX_CLOSE_TIMELOCK_SECS
(604,800 s), closes it and refunds the mailbox’s rent and the transient
pending record’s together; CancelCloseMailbox (43) aborts the request
meanwhile. The CLI spells these mailbox close, mailbox close --finalize
and mailbox close --cancel, and the flow is reachable in webmail, Outlook,
Thunderbird and Chrome, including trustless mode.
- The one-step
CloseMailbox(discriminant 16) is refused with error 94,InstantCloseDisabled. The discriminant still decodes, so indexers replaying history resolve old transactions — the same shape item 30 used forTransferAlias/UnilateralTransferDisabled(85). MAJOR stays0: the protocol is pre-launch. - New errors 91–94:
MailboxCloseAlreadyPending,NoPendingMailboxClose,MailboxCloseTimelockNotElapsed,InstantCloseDisabled. New PDA seedPENDING_MAILBOX_CLOSE_SEED(pending_mailbox_close) — the prefix is load-bearing twice over: the Mailbox PDA is bare-seeded on the address, andPendingMailboxClose,PendingDeactivationandPendingReclaimall serialize to the same eight bytes, so only the derivation distinguishes them. - Why a delay and not a fee. Instant rent reclamation made a burned sending identity free to discard. The owner of a mailbox is a recipient, and the positioning principle puts the cost burden on senders — so the lever is time, not money: the rent still comes back in full. Spammers get capital stuck for a week per burned identity and operators get a flagging window; honest owners see a delay on an action they take approximately never.
CloseKeydeliberately stays instant. It revokes a compromised delegated encryption key; a seven-day window there would leave MX servers sealing to a key the attacker holds, protecting the attacker rather than the owner.- Compute units: three new fenced rows — RequestCloseMailbox 13,334 / 36,000, CancelCloseMailbox 12,156 / 35,000, FinalizeCloseMailbox 12,858 / 36,000.
See Close a mailbox, Closing accounts, the threat model and Economics.
2026-07-20 — v0.25.0 (Wallet submission envelopes are pinned to the wallet’s own address)
MINOR — a new enforcement default on the submission path; no protocol
change. A wallet-authenticated submission session may now present exactly one
envelope sender: its own wallet base58, at a domain the listener is
authoritative for (local_domains). Another wallet’s address, its own address
at a domain the server does not serve, and the null sender MAIL FROM:<> are
all refused 553 5.7.1.
- The case-sensitivity fix is the security-relevant part. The previous rule
compared the envelope local part case-insensitively, which for base58 is
wrong:
Aliceandalicedecode to different keys, so a wallet session could send as a neighbouring valid wallet. The comparison is now exact. - Scope, stated honestly. The domain leg checks the domains this server serves, not the domains this wallet’s mailbox holds on-chain — the per-wallet reverse lookup is not reachable from the SMTP driver without new gateway surface. On a multi-domain instance a wallet may still send as itself at any domain that instance serves.
- Alias and password submission are byte-for-byte unchanged; the new rule is consulted only for wallet-literal identities.
- Dev-stack trap. With
local_domainsempty the check falls back tohostname, which the empty-config dev stack leaves aslocalhost, and the chain-disabled dev stack never discovers domains — so sending as<wallet>@sithbit.netthere now returns553where it previously worked. The refusal text names the sender, not the domain, so both the symptom and the one-line fix are written down. Note each SMTP role reads its ownlocal_domains: setting it under[smtp]does not affect the[submission]listener.
See Wallet submission envelopes, Thunderbird and Outlook.
2026-07-19 — v0.24.0 (Onboarding: passphrase confirmation, reachable connection settings)
PATCH — client/docs only, no protocol change. Four fixes found smoke-testing the Chrome extension loaded unpacked, all in shared client code, so every client gets them.
- Passphrase reveal and confirmation. The wallet passphrase set during onboarding could not be seen and was typed only once — and a typo there is unrecoverable: the wallet seals fine and only fails later, at unlock, with no way back. Every passphrase field now has a reveal (“eyeball”) toggle, and every field that sets a passphrase (the onboarding wizard’s step 1, the wallet manager’s import, the marketplace sign-in) now requires a matching confirmation before it will seal anything. See Web onboarding: the browser wizard.
- Connection settings are reachable during onboarding. In the Chrome extension they had been gated behind the signed-in view, which requires a registered on-chain mailbox — which in turn requires a reachable account API, the very thing those settings configure. With no API running, a new user was pinned in the onboarding wizard with no way to correct the URL or switch to trustless mode. They now sit in a collapsed disclosure at the foot of every view. See The Chrome extension.
- A meaningful message when the account API is unreachable. A refused
connection surfaced the browser’s bare
Failed to fetch. Clients now name the endpoint and the remedy, in language matched to the reader: a loopback URL means the reader runs the stack themselves, any other host means they are somebody’s mail customer. - A degraded popup no longer reads as broken. An unreachable account API is reported as a warning with the fix, and onboarding continues, instead of dumping a raw error and blocking.
2026-07-19 — v0.24.0 (Chrome extension: in-popup mail reader + side panel)
PATCH — client/docs only, no protocol change. The Chrome extension gains
a real in-popup mail reader, at parity with webmail: the shared three-pane
reader (folder rail, message list, message view, compose, and search) over the
account API’s /v1/mail surface when an account API
is reachable, and a trustless on-chain inbox (the mailbox’s messages listed
straight from Solana, bodies unsealed in wasm, no server) as the server-down
fallback and the only reader when the API url is left blank. The same surface
now also opens in Chrome’s persistent side panel via an Open in side
panel button (the toolbar-icon click still opens the transient popup). See
The Chrome extension.
2026-07-19 — v0.24.0 (Core Concepts section rename)
PATCH — docs only. The first documentation part, previously titled
Basic Concepts, is now Core Concepts in the navigation. Only the
displayed part title changed; the page URLs under basic-concepts/ are
unchanged, so existing links and bookmarks still resolve. (Earlier
change-history entries that name the old title are left as-is — they record
what the section was called at the time.)
2026-07-19 — v0.24.0 (Cloudflare backend: true multi-daemon writes)
MINOR — deployment-affecting default-behavior change. The cloudflare
store’s single-writer delivery caveat is removed: IMAP-uid allocation is now
a server-side atomic UPDATE … RETURNING on D1, and keyed leases moved from
Workers KV (best-effort, no CAS) to the same strict single-statement CAS the
SQLite/Turso stores run, on D1’s leases table — so any role may run
N-wide on Cloudflare, exactly as on postgres/aws/azure (see Scaling
out’s checklist and Which stores support
which split). The DMARC
drain also became one atomic DELETE … RETURNING, so concurrent daemons
partition report rows instead of double-reporting. The
[store.cloudflare]
kv_namespace_id key is retired: still accepted so existing TOMLs keep
parsing, but ignored, and no longer a required id — the KV namespace itself
is no longer a provisioning prerequisite. The Durable-Object route the
glossary recorded for this work was
superseded by these plain atomic D1 statements (no Worker-side code).
2026-07-18 — v0.23.0 (Addresses/Fromboxes/Email/Marketplace move into Basic Concepts + Technical Reference)
PATCH — docs only. Wave 1 of the docs audience reorg (item 42): Addresses, Fromboxes, Email, and Marketplace now split cleanly between a new Basic Concepts section (pure conceptual/explanatory prose, no CLI examples) and the CLI reference under Technical Reference → SithBit CLI. Every command-reference page now opens with a short referral link back to its concept page. Mailboxes, Aliases, Domains, and the GUI-clients pages are untouched this wave — the deferred remainder of the reorg. See Basic Concepts → Fromboxes and Basic Concepts → Email for the split’s shape.
2026-07-18 — v0.23.0 (CLI Quickstart relocated ahead of the docs audience reorg)
PATCH — docs only. The developer quickstart page moved from
getting-started.md to CLI Quickstart
under a new Technical Reference section, retitled to avoid colliding
with a future end-user “Getting Started” tutorial section (item 42’s
audience reorg, in progress). Operating a SithBit Server re-nested
under Technical Reference alongside it; no operator pages moved, only
the SUMMARY.md heading structure changed. Every inbound link across the
book was repointed to the new path.
2026-07-18 — v0.23.0 (DNS rows for the autoconfig/autodiscover hostnames)
PATCH — docs only. The DNS guide’s client-access
section now
spells out the two hostname records the native-wizard fallback path
needs — autoconfig.<domain> and autodiscover.<domain> pointed at the
domain-sithbit host — with the TLS-certificate SAN caveat. The routes
themselves were already documented on the
domain-sithbit page;
the zone-side half was missing.
2026-07-18 — v0.23.0 (compute-unit table now gate-fenced against the suite)
PATCH — docs tooling. A new docs-gate leg, mail_docs/check_cu_rows.py,
diffs the compute-unit table’s
measured/ceiling values against the integration suite’s fenced constants
(mail_client/tests/api/cu.rs), both ways, and checks the methodology
prose still quotes the suite’s grind allowance. The rounded-auction-rows
drift the previous entry corrects had sat silent since the auction wave —
this class of drift now fails the gate instead of waiting for a manual
sweep.
2026-07-18 — v0.23.0 (auction rows now quote exact measured CU)
PATCH — docs only. In the compute-unit table, the three auction rows had rounded “Measured CU” values while every other row quotes the integration suite’s exact fenced measurement. Aligned to the suite’s constants: SellAlias (open auction) 26,800 → 26,798, BidAlias 19,700 → 19,715, SettleAuction 26,700 → 26,674. Ceilings unchanged; all 25 rows now match the suite exactly.
2026-07-18 — v0.23.0 (cancel-instruction CU ceilings documented)
PATCH — docs only. The compute-unit table
now covers the three marketplace cancel flows fenced by the integration
suite: CancelTransferAlias (alias transfer cancel, 24,828 measured /
48,000 ceiling), CancelAliasListing (alias sell --cancel, 19,183 /
42,000), and CancelDomainListing (domain sell --cancel, 16,001 /
39,000). The methodology’s bump-grind variance note gains
alias transfer cancel as the widest swing recorded (9,816–24,828 CU).
2026-07-18 — v0.23.0 (threat-model lockbox metadata retitle)
PATCH — docs only. In the threat model, the lockbox scope bullet formerly led “Body only, in v1.” — stale now that the v2 sealed-envelope engine has landed. Retitled “Metadata stays visible.”: the SMTP envelope and headers travel unsealed regardless of lockbox version; the bullet’s substance is unchanged.
2026-07-18 — v0.23.0 (opt-in wallet-literal recipients on a chain-less MX)
MINOR — new configuration setting (default preserves existing behavior everywhere):
[smtp] accept_wallet_literals(defaultfalse). A chain-less MX (no[grpc]configured) refuses every recipient today; with this switch on, it accepts syntactically valid 32-byte base58 wallet-literal local parts — mirroring the account API’s chain-less compose route, where a literal wallet resolves to itself. No postage check applies on that path (no chain to consult), which is why the default stays off: leaving it unset keeps the postage gate intact and behavior byte-identical. Inert when[grpc]is configured. Applies tosithbitdand the standalonesmtp-serveralike. See Configuration.
2026-07-18 — v0.22.0 (marketplace guards: no deactivation while listed, no sale of an inactive domain)
MINOR — additive on-chain behavior change (new refusals using existing error codes; one instruction gains a required account):
RequestDeactivateDomainrefuses while a listing is open. The instruction now takes the domain-listing PDA as a required read-only account and refuses withDomainHasPendingListing(code 64) when a marketplace listing stands — a deactivation can no longer be staged under a live listing. Thesithbit domain deactivatebuilder passes the new account. See Program reference → Marketplace.BuyDomainrefuses an inactive domain. Settlement now re-checksis_activeat purchase time and refuses withInactiveDomain(code 25) — a deactivation finalized after listing can no longer sell a dead name. Inactive domains stay listable by design; the sale completes once the domain is reactivated. (An in-flight pending deactivation was already refused at buy time, code 29.)- Reference corrections riding the change:
DeactivationAlreadyPendingis code 29 (the page said 28), andBuyDomain’s account list is 9 slots (the row predated the pending-reclaim slot).
2026-07-18 — v0.21.1 (devnet keypair locations reconciled)
PATCH — repository layout and documentation only (no code behavior, on-chain ABI, instruction, error-code, or configuration change):
- Keypair homes reconciled.
keypair/again holds the mainnet-track vanity keypairs for all three programs; the live devnet mail/alias program keypairs moved beside the domain one atmail_client/tests/*-dev-keypair.json(the retired first-generation devnet pair is now git-history-only). The devnet vanity-ID appendix’s keypair-location prose and bothcpstaging workflows now reflect the layout, and the per-program README deploy snippets are correct as written again.
2026-07-18 — v0.21.0 (lockbox envelope: rich HTML + attachments in the engine)
MINOR — additive capability in the shared lockbox engine (no on-chain ABI, instruction, error-code, or configuration change; the shipped plugins’ user-facing behavior is unchanged today):
- The sealed payload is now a structured envelope. The shared compose/read engine seals a JSON envelope carrying the text body plus, when the sending client supplies them, rich HTML and attachments — as one sealed unit, with no wasm or on-chain change. Messages sealed by earlier versions remain readable (bare-body fallback). A 12 MiB pre-seal ceiling refuses oversized payloads with a clear error (sized so the double-base64 result clears the SMTP server’s default 25 MiB message-size limit). The Thunderbird and Outlook plugins still hand the engine only the plaintext body — host-side compose glue for HTML/attachments is a planned addition. See How it works and What v1 does — and does not — do.
2026-07-18 — v0.20.7 (smoke script rebuilds images)
PATCH — tooling and documentation only (no code behavior, on-chain ABI, instruction, error-code, or economic change; no configuration key, value, or default moves):
docker/smoke.shrebuilds before probing — the script now brings the compose stack up withdocker compose up -d --build, so a standalone smoke run rebuilds the images instead of silently probing stale local ones (the already-exportedchainprofile covers themail-grpcbuild too). Documented in The compose dev stack.
2026-07-18 — v0.20.6 (web-client terminology sweep)
PATCH — code comments only (no rendered UI, logic, or configuration change):
- “knob” retired from the web clients — the eight remaining
occurrences in
webclients/source comments (the shared panes and onboarding-wizard modules, and the fund/DND standalone pages’ operator-endpoint headers) now read “setting”, completing the v0.20.1/v0.20.4/v0.20.5 terminology sweeps. The screenshot manifest was re-pinned hash-only — no pixel changed, so the recorded captures remain valid.
2026-07-18 — v0.20.5 (build-features heading & terminology polish)
PATCH — documentation and comments only (no code behavior, on-chain ABI, instruction, error-code, or economic change; no configuration key, value, or default moves):
- Deploy’s build-features section renamed — the heading is now
Slim-build features
(formerly “Storage-backend build features”), reflecting everything
the section grew to cover: the storage backends and the key-source
(
akv/asm/gsm) and app-config (awsconf/azconf) cloud features. Inbound links in Deploy, Scaling out, and this page’s earlier entries follow the new anchor (URL only — the old entries keep their wording). - Configuration-reference cross-links — the key-sources and cloud-app-config passages in the configuration reference each point at Slim-build features for the per-binary slim build commands.
- Terminology residuals — the retired “knob” leaves its last
holdouts (the
ipfs_daemonandipfs_gatewaycrate READMEs and acheck_config_keys.pycomment; now “setting”), completing the v0.20.1/v0.20.4 sweeps.
2026-07-17 — v0.20.4 (terminology sweep completed in source)
PATCH — source comments and example-config prose only (no code behavior, on-chain ABI, instruction, error-code, or economic change; no configuration key, value, or default moves):
- Terminology sweep, source side — the informal “knob” is now retired
from the Rust source comments and test names (
mail_spooler,smtp_server,account_api,mail_store,mail_client,mail_grpc,ipfs_swarm,pop3_proto,app_config,key_source) and from the prose comments of the two canonical example configs (sithbitd.example.toml,sithbit_ipfsd.example.toml), completing the v0.20.1 book sweep. Same standing rule, same replacements: “switch” for boolean enable/disable entries, “setting”/“option” for tunable values, and case-appropriate rewrites (e.g. “deliberately not configurable”) elsewhere. Meaning is unchanged everywhere; earlier entries on this page keep their wording as the record of the retired term.
2026-07-17 — v0.20.3 (slim per-binary builds)
PATCH — build features and documentation only (no on-chain ABI, instruction, error-code, or economic change; a default build compiles the exact same feature set as before):
- Slim per-binary builds are now real — every binary crate forwards
its dependencies’ cloud features under the same names, so
--no-default-features --features <what you need>works at the binary you actually build. Forwarded alongside the storage backends:akv/asm/gsm(the credential-sealing key source’s cloud secret managers) andawsconf/azconf(the cloud app-config sources). See the build-features section for concrete slim build commands. Defaults still compile every cloud; a config naming a compiled-out cloud parses in every build and fails at load with a purposefulNotCompilederror naming the cargo feature to rebuild with. mail_store’s key-source edge is feature-forwarded — its formerly unconditional dependency on all ofkey-source’s clouds now follows the same per-cloud features, so store-consuming binaries can strip clouds too.
2026-07-17 — v0.20.2 (compose smoke runs mail-grpc live)
PATCH — dev/CI tooling and documentation only (no on-chain ABI, instruction, error-code, or economic change; nothing a deployment configures moves):
docker/smoke.shnow boots thechainprofile live — it exportsCOMPOSE_PROFILES=chainfor every compose call it makes (teardown included), somail-grpcstarts with the rest of the compose dev stack and must pass the healthy-wait. No validator is required: the gateway’s readiness gates on its own gRPC listener, never on chain connectivity. The previous smoke only statically parsed the profile (docker compose --profile chain config -q), which let a stale broken image sit undetected on a dev host — the live boot caught exactly such an image on landing.- CI’s smoke job lifts the profile too
(
.github/workflows/docker-publish.yml): the job-levelCOMPOSE_PROFILESbuilds the mail-grpc image alongside the other five before the smoke runs, so the gRPC gateway is exercised live on every push and pull request, not merely compiled.
2026-07-17 — v0.20.1 (terminology sweep: “knob”)
PATCH — documentation only (no code change):
- Terminology sweep across the book, per style direction: the informal “knob” gives way to industry-standard terms — “switch” for boolean enable/disable entries, “setting”/“option” for tunable values, and “settings” for collections. Meaning is unchanged everywhere, including in earlier entries on this page, which keep their versions, dates, and facts.
2026-07-17 — v0.20.0 (smarthost implicit-TLS dial)
MINOR — an additive capability affecting deployments (no on-chain ABI, instruction, error-code, or economic change):
- New
[spooler.smarthost] implicit_tlsswitch — defaultfalse; when set, the relay dials the smarthost with TLS from the first byte (the port-465 “SMTPS” style, named after the inbound listeners’ switch) instead of the default in-band STARTTLS, so STARTTLS never happens on the wire. The port is not auto-switched to 465 — it stays whatever the operator set — the handshake keeps the smarthost path’s strict webpki verification, and the direct-to-MX path is unchanged. This is the first consumer of the SMTP client machine’s implicit-TLS dial mode (SendMachine::new_tls) — see the[spooler]reference.
2026-07-17 — v0.19.0 (truthful TLS-RPT rows; report retention setting)
MINOR — a default-behavior change and a new deployment setting (no on-chain ABI, instruction, error-code, or economic change):
- TLS-RPT success rows are now flag-truthful — a §4.1 success row is recorded only when the completed conversation actually ended on TLS (the send outcome carries the negotiated flag), so a success row can no longer describe a plaintext session; a completed plaintext opportunistic delivery — including a declined STARTTLS offer that continued in the clear — records no row at all (neither a TLS session nor a failed attempt). This closes v0.18.0’s recorded gap; the honest limit that remains (“never offered” vs “offered but declined” — both unrecorded) is in the rewritten RFC 8460 conformance section.
- Pre-dial policy exclusions now record TLS-RPT rows — hosts a policy
excludes before dialing land never-dialed failure rows instead of
vanishing (the other v0.18.0 gap): a DANE-unusable host records
dnssec-invalidwith a baretlsapolicy block, an MX target outside an enforce-mode MTA-STS policy recordssts-policy-invalidrendering the enforce policy body, with the planner’s diagnostic infailure-reason-code. Unreachable/timed-out hosts and STS testing-mode mismatches still record nothing — see the configuration reference. - New
[spooler] report_retention_dayssetting — default0= keep forever; when set, an hourly worker prunes ingested DMARC aggregate reports (dmarc_rua/) and pending TLS-RPT rows (tlsrpt/pending/) older than the window. Off by default deliberately:dmarc_rua/is the dataGET /v1/admin/dmarc-reportsserves, and retention removes reports from that surface — see the[spooler]table.
2026-07-17 — v0.18.2 (Outlook + Thunderbird gain the trustless reply/compose card)
PATCH — additive client UI over existing chain capability (no on-chain
ABI, instruction, error-code, or economic change; the compose threads
SendMail’s existing bounty/expiry/reply parameters through the already
parity-fenced builders — the same call as item 39’s webmail card, v0.8.8):
- Outlook: Trustless reply and compose —
the taskpane’s trustless reader gains webmail’s Reply on-chain
action, and the mail view mounts the floating on-chain compose card
(reply-chip threading, bounty SOL + claim-window-days fields, inline
stamp prepay). Replies are on-chain sends — never a host SMTP
compose. The connection-settings pane gains the IPFS pin service
URL/token (
config.ipfsPinUrl/config.ipfsPinToken, default the loopback sithbit-ipfsd, unauthenticated). - Thunderbird: Trustless reply and compose — the same seam on the extension’s dashboard; the options page surfaces the pin URL/token, and a non-loopback pin origin joins the Save-click host-permission grant automatically.
2026-07-17 — v0.18.1 (postmaster page cross-link)
PATCH — documentation only (no code change):
- Initializing the postoffice —
the “held by anyone else” recovery path’s mention of the holder closing
the alias (
sithbit alias close) now links to Closing accounts, matching the transfer link beside it.
2026-07-17 — v0.18.0 (TLS-RPT reporting; DANE for MX-less domains)
MINOR — an additive capability and a default-behavior change affecting deployments (no on-chain ABI change):
- DANE now covers MX-less domains — the outbound relay’s default-on
daneenforcement previously skipped domains with no MX record (the documented RFC 7672 §2.2.1 subset). A DNSSEC-proven MX denial (Secure proof on the negative answer’s SOA) now marks the implicit-A fallback secure, so TLSA at_25._tcp.<domain>is consulted and enforced for signed MX-less recipients — see the rewritten DANE conformance caveat and the dns.md publishing note. The narrower remaining subset: denials without a validatable SOA stay insecure. Unsigned zones behave exactly as before. - TLS-RPT (RFC 8460) sending — new default-off
[spooler.tlsrpt]switch: dialed relay attempts record per-host TLS results, and a drain worker folds them into per-domain aggregate reports delivered to recipients publishing_smtp._tls.<domain>rua targets — over bothmailto:(DKIM-signed via the outbound relay) andhttps:(application/tlsrpt+gzipPOST). The new RFC 8460 conformance section carries the honest gaps (certificate-* taxonomy collapses into validation-failure with detail preserved; pre-dial exclusions record nothing; duplicate-on-crash tolerated via deterministic report ids). This closes the “no TLS-RPT” limitation recorded by the MTA-STS and DANE landings.
2026-07-17 — v0.17.0 (DMARC aggregate-report ingestion; postmaster alias claim)
MINOR — additive capabilities affecting deployments (all off by default or init-time only; no on-chain ABI change):
- DMARC RUA ingestion — SithBit deployments can now receive the
aggregate reports other operators send about their domains. Three pieces,
documented across dns.md, the
configuration reference, and a new
RFC 7489 §7.2 conformance section:
[smtp] postmaster_wallet(RFC 5321 §4.5.1 — bare/domained postmaster bypasses alias resolution and the frombox/postage gate so external reporters can deliver at all; unset keeps refusals byte-identical),[spooler.dmarc_rua_ingest](matched delivered recipients get their reports parsed with the vendored RFC 7489 parser and stored as JSON under the fixeddmarc_rua/blob prefix — additive to delivery, never a diversion), and the account API’sGET /v1/admin/dmarc-reports[/{id}]admin reader. Scope is deliberately ingest + store + surface only: auto-disabling accounts from RUA data is rejected on record (aggregate rows carry no join key to a local wallet; failing rows are almost always third-party spoofers). Norufingestion, no blob pruning yet. postmaster initclaims thepostmasteralias — the init transaction now atomically registers the globalpostmasteralias to the delegate, fee-waived by construction (the alias program’s delegate waiver reads the postoffice state written one instruction earlier; rent-only). A squatted name refuses loudly with nothing submitted — the postoffice is deliberately never created without its name; the alias program still has no reserved words, so the squat window is narrowed to deploy→init, not closed.
2026-07-17 — v0.16.1 (config-key docs gate; example-config drift fixes)
PATCH — documentation/tooling only (no behavioral change):
- New docs-gate leg —
mail_docs/check_config_keys.pydiffs the six canonical example configs’ keys (commented-out entries included) against the configuration reference in both directions, killing the drift class where a key ships in an example TOML but never reaches the docs (or vice versa). - Drift repaired by the new gate —
sithbitd.example.tomlgained the documented-but-missing[store.cloudflare]backend block,client_cert_authon the three authenticated listeners,[smtp.server]limits.*, and the[swarm]service-record freshness settings (the last also added tosithbit_ipfsd.example.toml); both IPFS binaries’ examples gainedobservability.otlp.metrics_interval_seconds; the reference gainedstore.aws.sqs_wait_time_secondsand the gateway’spublic_host, and the[store.blobs]s3/azure key lists are now machine-checkable code spans. - Example-value fix —
domain_sithbit.example.toml’s[mail_hosts.smtp]showed the retired 587/STARTTLS pair while claiming to show defaults; the in-code default (and the documented RFC 8314 posture) is 465/SSL. - RefundBounty CU fence — the expiry-gated sender reclaim was the one bounty-family instruction never CU-measured; the compute-units table gains its row (7,579 max measured, 31,000 ceiling — the cheapest fenced instruction: no reply-linkage check and no operator share on the refund path).
2026-07-17 — v0.16.0 (chain-account proxy read; accepting_at on the DND check)
MINOR — additive public-API changes (a new route and a new response field; no on-chain ABI or economic-model change):
- New authenticated chain read —
GET /v1/chain/account/{address}on the account API returns any raw account verbatim (ownerbase58,database64; 404 when absent), the generic escape hatch API-mode web shells use to decode accounts client-side — e.g. the bounty-claim resolver’s domain-account read, so a domained claim pays the domain authority instead of falling back to the filler pair. accepting_aton the anonymous DND check —GET /v1/dnd/{wallet}now carries the RFC 3339 UTC instant the wallet accepts mail again, gated three ways: currently excluded, owner opted in viaexpose_dnd_schedule, and the schedule ever reopens (a full-week recurring schedule omits it). The schedule page localizes it to the sender’s own clock (“accepting mail again at …”); the not-opted-in default stays yes/no only.
2026-07-17 — v0.15.2 (docs: introduction reordered around the no-token pitch)
Documentation-only: the introduction’s “Priced in SOL — No New Token to Trust” subtopic moved up to lead the protocol sections (ahead of “Decentralized Email”), and the former “Only on Solana” subtopic became a note box directly beneath it.
2026-07-17 — v0.15.2 (docs: privacy cross-links for the DND-exposure opt-in)
Documentation-only: What you control and the
privacy reference now name the
expose_dnd_schedule opt-in — anonymous schedule checks return only the
yes/no “away right now” answer unless the owner opts in — linking to
What the refused sender sees
for the semantics. The deploy guide’s GCP mail-tier
bullet now points at the PROXY-protocol container recipe as the exception
to “VMs, not serverless”.
2026-07-17 — v0.15.2 (ChainSender: pin service lazily required by send)
PATCH — web-client behavior change, no public-ABI change: ChainSender
no longer demands an IPFS pin URL at construction — only send() pins, so
the pin service is lazily required at pin time (a send without one refuses
at the pin stage before anything is pinned or submitted; prepay() never
pins). The onboarding fund page drops its dummy-pinUrl workaround.
2026-07-17 — v0.15.1 (mail-migrate replays the DND-exposure opt-in)
PATCH — behavioral defect fix, no public-ABI change: sithbit-migrate
now replays each account’s expose_dnd_schedule opt-in onto the target
store; previously a migration silently reset the flag to hidden
(fail-safe, but lossy for owners who had opted in). The
migration guide’s field list updated to match.
2026-07-17 — v0.15.0 (docs: diagrams catch up to the three-leg bounty split)
Documentation-only (the settlement change itself shipped at v0.14.0): the reply-bounty flow and campaign lifecycle diagrams — titles, box labels, and the campaigns page’s alt text and “paid twice” bullet — still described the retired 90/10 recipient-to-postoffice split. All now state the three legs: 90% to the claimant, 10% to the claimant’s active domain authority, with the postoffice collecting the share only when no active domain resolves.
2026-07-17 — v0.15.0 (self-service refusal links; DND schedule privacy)
MINOR — new default-off capability plus a public-API default-behavior
change (no on-chain ABI or economic-model change; the widened-at-v0.10.0
rule): a single new [smtp]/[submission] setting, self_service_base_url
(default unset = every refusal stays byte-identical to the legacy text),
makes the RCPT-time refusals link self-service pages — the postage
refusals (450 4.7.0 out of stamps, 550 5.7.0 no frombox) append
{base}/fund.html?to=…&from=…, and sithbitd’s do-not-disturb refusal
(450 4.2.1) appends {base}/dnd.html?to=… (the standalone
smtp-server carries only the funding link — it has no DND gate). The
two pages ship in the onboarding web bundle: fund.html quotes the
stamp price trustlessly off the chain (postage + settlement surcharge +
live protocol fee) and takes a Phantom/Ledger prepay; dnd.html shows
the recipient’s away state. Alongside them, the anonymous
GET /v1/dnd/{wallet} route’s default behavior changed —
privacy-tightening: it used to return the full exclusion list to any
caller, and now always answers excluded_now but includes the
exclusions array only when the owner opted in via the new
expose_dnd_schedule account flag (PATCH /v1/account, default
false; a wallet with no account answers excluded_now: false).
- New Do not disturb page: the case for reject-at-RCPT DND over accept-and-autoreply (the sender’s MTA queues and retries; no unread pile-up; the sender learns at send time; a refusal burns no stamp), what a refused sender finds on each linked page, and the schedule-sharing opt-in.
- Configuration reference:
the
self_service_base_urlrow in the[smtp]/[submission]table and a new one-setting-two-pages subsection, including the standalone-smtp-serverscope note. - account-api: the DND schedule surface —
the authenticated
GET/PUT /v1/account/dndroutes, theexpose_dnd_scheduleflag onGET/PATCH /v1/account, and the changed publicGET /v1/dnd/{wallet}contract, called out as a behavior change. - Self-service pages for refused
senders: the
two pages in the onboarding bundle and their
<meta>endpoint settings (sithbit-rpc-url/sithbit-api-url, localStorage fallbacks, same-origin default under the account API’s[static]root).
2026-07-16 — v0.14.0 (ClaimBounty operator share to the domain authority)
MINOR — economic-model change (no wire-ABI change: the instruction’s
discriminant and payload are untouched): ClaimBounty’s 10% operator
share (OPERATOR_SHARE_BPS) now pays the claimant’s domain authority
when their mailbox names an active domain — the DeleteMail settlement’s
domain-resolution rules, filler-account guards included — and falls to
the postoffice when the chain legitimately doesn’t resolve (no mailbox,
no domain named, domain closed or inactive; the prior behavior, preserved
for domainless claimants). The claimant keeps the remainder including the
rounding dust, exactly as before, and RefundBounty is unchanged. The
instruction grew from 4 to 7 accounts (mailbox, domain, and authority
appended; the mailbox PDA stands in as a filler when no domain is named).
Re-measured: 24 847 CU (was 21 624), fenced at 48 000 (was 45 000).
- Reply bounties: the claim-split
bullet is rewritten for the three destinations — claimant, domain
authority when active, postoffice fallback — and ties the operator leg
to the
DeleteMailsettlement’s resolution rules. - Claiming the bounty: the user-facing payout callout now names the domain authority as the operator leg’s destination, with the postoffice fallback.
- Compute-unit consumption: the ClaimBounty row carries the new measurement and ceiling, noting it is measured on the domained 7-account path.
2026-07-16 — v0.13.0 (domain-sithbit mail_hosts table correction)
Documentation defect fix (no version bump — docs-only): the
domain-sithbit [mail_hosts]
table still claimed
the advertised SMTP default was 587/STARTTLS; the code default has
been 465/SSL since the RFC 8314 cutover (v0.9.0, fenced in
domain_sithbit/src/config.rs tests), and configuration.md already said
so. Same drift class as the v0.11.6 sithbitd port corrections.
2026-07-16 — v0.13.0 (MTA-STS policy publication)
MINOR — new default-off capability (no on-chain ABI or economic-model
change; the widened-at-v0.10.0 significant-additive-capability rule):
domain-sithbit now publishes a domain’s MTA-STS policy (RFC 8461) at
GET /.well-known/mta-sts.txt, rendered from a new optional [mta_sts]
config section (mode default "testing", mx patterns, max_age
default one week). The section is validated fail-fast at boot — an
unknown mode, an enforce/testing policy without an mx pattern, or a
max_age above the RFC’s one-year ceiling (31557600, fenced equal to
the value the sending relay clamps fetched policies to) refuses to start —
and with no section the endpoint replies 404, so existing deployments are
untouched. TLS stays the fronting proxy’s job (a certificate for
mta-sts.<domain>), and the _mta-sts.<domain> discovery TXT record
stays operator-managed DNS.
- New domain-sithbit: publishing the MTA-STS
policy
subsection: the route, the opt-in 404 contract, the startup failure
modes, and the
mta-sts.<domain>A/CNAME + TLS-proxy fronting. - New
[mta_sts]— MTA-STS policy publication reference section: the key/default table and the boot-time failure modes. - RFC 8461 conformance section: the “sending side only” scope bullet is replaced by a publish-side bullet — what is implemented (§3.2 serializer, well-known route, startup validation, the shared one-year ceiling) and what is not (no per-domain policy map, no TLSRPT, TXT record stays operator DNS).
- Sending mail: SPF, DKIM, DMARC,
PTR: the MTA-STS
inbound guidance now points at the built-in endpoint instead of “host
the policy file yourself”, and ties the TXT
idbump to editing the[mta_sts]section.
2026-07-16 — v0.12.0 (DANE outbound enforcement)
MINOR — additive behavioral change (no on-chain ABI or economic-model
change; the widened-at-v0.10.0 new-enforcement-default rule): the outbound
relay now looks up and enforces recipient MX hosts’ DNSSEC-validated DANE
TLSA records (RFC 7672) by default on direct-to-MX delivery — a validated
usable TLSA set pins the STARTTLS handshake to the published certificate
data (preferred over MTA-STS wherever both apply), and any failure defers
rather than downgrading — behind a new [spooler] dane switch (default on).
Send-side only: publishing TLSA records stays operator DNS work. Only
tightens delivery to domains that sign their zones and publish TLSA;
everything else keeps the MTA-STS/opportunistic posture.
- New RFC 7672 conformance section: TLSA discovery over a DNSSEC-validating resolver and the per-host outcome matrix, DANE-EE/DANE-TA verifier semantics (EE skips name/expiry/chain; TA path-validates anchored at the matched chain cert), the DANE-over-MTA-STS composition rules, the documented CNAME and implicit-A subsets, and the send-side-only scope.
[spooler]— outbound workers: thedanekey and a paragraph on the pinned handshake, the stricter-of-both rule under an MTA-STS enforce policy, never-dialed bogus hosts, and the debugging escape hatch.- Sending mail: SPF, DKIM, DMARC,
PTR: a DANE note —
outbound needs no configuration; publishing
_25._tcp.<mx-host>TLSA records in a DNSSEC-signed zone protects your own inbound mail, with the recommended3 1 1form, the openssl digest recipe, and key-rollover guidance. - Threat-model subsection updated: DANE is now implemented and closes both MTA-STS residuals (trust-on-first-use and cache lifetime) for recipient domains that deploy DNSSEC + TLSA.
2026-07-16 — v0.11.6 (sithbitd default-port corrections)
PATCH — documentation/example corrections only (no code change;
the binds themselves never moved): the sithbitd docs and example
config claimed default ports the code never had. The in-code defaults
are SMTP 2525, IMAP 1430, POP 1100 (imap_server/src/config.rs,
pop_server/src/config.rs), and the submission listener has no
distinct default — disabled by default, it would inherit SMTP’s
2525, so its bind_addr must be set explicitly when enabled. The
2143/2110/2587 numbers are the docker-compose files’ explicit rebind
convention, not defaults. Corrected in the sithbitd
page, the listener-section
table,
the deploy quick-start, and
mail_spooler/sithbitd.example.toml (whose [store] prose also now
lists the cloudflare kind alongside the other backends).
2026-07-16 — v0.11.5 (production import documents for the cloud config stores)
PATCH — deployment content and repo-side tooling only (nothing a
deployed binary does changes: the cloud config tier itself shipped at
v0.11.0, and importing these documents is an operator opt-in): the
repository now ships ready-to-import production configuration for a
complete six-service sithbit.com deployment — sithbitd, account-api,
mail-grpc, domain-sithbit, sithbit-ipfsd, sithbit-gateway — for both
cloud config stores, under iac/appconfig/.
- AWS AppConfig: six commented per-service TOML documents
(
iac/appconfig/aws/), imported verbatim as freeform hosted configuration profiles — AWS stores the document opaquely, so the TOML comments are the in-store field documentation. These documents are the single source of truth for both clouds. - Azure App Configuration: a generated kvset import file
(
iac/appconfig/azure/sithbit.kvset.json) — per-service-prefixed:-separated keys on the NULL label, each TOML comment carried as the key’sdescriptiontag (the kvset profile is the only import path that preserves per-key metadata). Sparse per-service overrides (iac/appconfig/azure/overrides/) swap the store kind, blob shape, and key sources to their Azure forms; theappconfig-genworkspace tool merges and emits, and the test suite fails on a stale kvset, a document that no longer parses into its service’s real config struct, or a broken cross-service invariant. - Secrets stay out of the store by construction: the documents carry
key-source coordinates (Secrets Manager / Key Vault) and obvious
CHANGEdummies, machine-enforced by pinned placeholder tests. - The cloud app-config
section
points at the import documents (and now lists all six bootstrap
prefixes — the two IPFS binaries were missing); the
Production deployment bullet gains
the same pointer; the store-creation and import runbooks live in
iac/README.md.
2026-07-16 — v0.11.4 (store-name refresh on the extension pages)
PATCH — documentation only (no code change): the store-install sections now link the stores themselves, and Microsoft’s rebrand is reflected.
- Chrome: Installing from the store — “Chrome Web Store” now links to the store.
- Outlook: Installing from the store — Microsoft AppSource has been renamed Microsoft Marketplace; the section says so (linking the store) and uses the new name throughout. The v0.11.2 entry below keeps its historical “AppSource” wording. The in-client Apps → Get Add-ins flow is Outlook UI and is unchanged.
- External links open in a new window via the site-wide
external-links.jshook, as usual — no per-link markup.
2026-07-16 — v0.11.3 (PROXY protocol trusted-proxies allowlist)
PATCH — additive hardening setting, default off-path (the defaults
preserve prior behavior exactly; no on-chain ABI or economic-model
change): listeners running with proxy_protocol = true can now
restrict which socket peers are permitted to speak the preamble,
instead of trusting whoever reaches the port.
- New
proxy_trustedkey in the shared[*.server]section: a CIDR allowlist (e.g.["10.0.0.0/8", "2001:db8::/32"]; bare addresses count as /32 or /128, and v4 entries match v4-mapped peers on dual-stack listeners) of the peers allowed to send a PROXY preamble. Untrusted peers are refused before a single header byte is read, closing the address-spoofing hole a directly reachable client would otherwise have. Empty (the default) trusts any peer — the pre-allowlist behavior, suitable when only the balancer can reach the port. Ignored unlessproxy_protocolis on; a malformed entry fails serve startup with an error naming it. - The four annotated example configs (
sithbitd.example.toml,smtp_server.toml,imap_server.toml,pop_server.toml) show the default (proxy_trusted = []) commented out besideproxy_protocol, house style.
2026-07-16 — v0.11.2 (extension store-install instructions)
PATCH — documentation only (no code change; the extensions are not
yet published to any store): the three extension-client pages each gain
an Installing from the store section ahead of the build-and-sideload
path, with obviously-placeholder listing tokens
(_todo_store_listing_name_ / _todo_store_listing_url_) that resolve
when the real store listings go live, plus an honest note on what each
store offers for pre-release distribution.
- Thunderbird: Installing from the
store —
addons.thunderbird.net search/listing install; MailExtensions need no
signing, so the self-distributed
.xpistays a fully supported permanent channel (ATN listings are public-only). - Chrome: Installing from the store — Chrome Web Store “Add to Chrome”; the Web Store’s Unlisted/Private visibilities and trusted-tester draft sharing allow a non-public pre-GA listing.
- Outlook: Installing from the store — AppSource / in-client Get Add-ins search; AppSource is public-only, so the private paths are sideloading and the Microsoft 365 admin center’s Upload custom app tenant-wide deployment.
- The GUI clients overview points at the three new sections with the placeholder caveat.
2026-07-16 — v0.11.1 (commodity hosting: generic VM + Postgres + S3)
PATCH — documentation only (no code change; the recipe rides existing backends): the vendor-independence claim gets its commodity chapter — any provider with a VM, Postgres, and S3-compatible object storage runs the full stack — plus a ranked six-provider comparison.
- New Hosting on a generic VM: Postgres + any S3-compatible
storage
section: the two-edit recipe (
[store] kind = "postgres"+[store.blobs] kind = "s3", with[ipfs.blobs]riding the same trait), the four operational caveats a big cloud would otherwise absorb (build features, certbot with a restart--deploy-hookfor the load-once TLS acceptor, file-based key sources, outbound port 25), and a six-provider ordered list — Hetzner, OVHcloud, Scaleway, Linode, Vultr, DigitalOcean — ranked on port-25 posture and PTR/rDNS control first, managed-Postgres availability second. - Containers behind a PROXY-protocol balancer are documented as
viable for the mail tier (decision 2026-07-16), superseding the
older VM-only guidance: the listeners’ existing
proxy_protocol = truesupport recovers the real client IP for DNSBL/limits/SPF, so LB-fronted containers qualify when the balancer injects the preamble; proxies without it stay ruled out. (iac/README.md’s client-IP constraint note was revised to match — outside this book.)
2026-07-16 — v0.11.0 (cloud app-config sources)
MINOR — additive capability (no on-chain ABI or economic-model
change): every TOML-config binary can now pull its settings from AWS
AppConfig or Azure App Configuration — a new resolution tier
directly above the config file, opted into per binary by a single
bootstrap env var ({PREFIX}_AWSAPPCONFIG / {PREFIX}_AZAPPCONFIG) and
skipped entirely when neither is set, so the zero-config contract is
untouched. Settings only, never secrets — key material keeps going
through key sources.
- New Cloud app-config sources: AWS AppConfig or Azure App
Configuration
section: the bootstrap variables, the per-provider payload idiom
(AWS: one whole TOML document, deep-merged; Azure: per-key values
nested on
:, case-sensitive), the neither/both/compiled-out rules (awsconf/azconffeatures), ambient authentication, and the env-gated live probes. - How a setting
resolves: the
chain gains the cloud tier between the TOML file and
./.env—{PREFIX}_{PATH}env overrides still win over cloud values. - Container images and Production: config can arrive from a cloud app-config source instead of a mounted TOML file; the mail-grpc keypair callout notes only key-source coordinates travel through it.
- The three annotated example files (
sithbitd.example.toml,mail_grpc.example.toml,domain_sithbit.example.toml) spell the cloud tier into their layer-chain headers; the loader’s crate-level reference isapp_config/README.md(outside this book — the configuration section is the operator-facing description).
2026-07-16 — v0.10.0 (MTA-STS outbound enforcement)
MINOR — additive behavioral change (no on-chain ABI or economic-model
change): the outbound relay now discovers and enforces recipient domains’
published MTA-STS policies (RFC 8461) by default on direct-to-MX delivery —
an enforce-mode policy means verified TLS to a policy-matching MX or a
deferral, never a plaintext fallback — behind a new [spooler] mta_sts
switch (default on). Send-side only: SithBit publishes no policy endpoint of
its own.
- New RFC 8461 conformance section: policy discovery/parsing, the enforce branch and its defer semantics, the §5.1 policy cache, testing-mode logging (no TLS-RPT), and the send-side-only scope.
[spooler]— outbound workers: themta_stskey and a paragraph on the three policy modes, the defer semantics, and the debugging escape hatch.- Outbound mail and port 25: direct-to-MX delivery honors recipient policies by default; smarthost deployments are unaffected.
- Sending mail: SPF, DKIM, DMARC,
PTR: an MTA-STS note —
outbound needs no configuration; publishing the
_mta-stsTXT record and policy file protects your own inbound mail. - New threat-model subsection: the STARTTLS-downgrade threat on MX-to-MX delivery, what opportunistic TLS does not protect against, and the trust-on-first-use / cache-lifetime residuals.
2026-07-16 — v0.9.3 (hosting on Google Cloud)
PATCH — no ABI or economic-model change (docs + infrastructure templates only; zero application code). SithBit’s third hosting cloud, proving the vendor-independence seams end to end:
- New Hosting on Google Cloud
section in the deployment chapter: blobs = a GCS bucket over its
S3-interop endpoint (HMAC credentials,
region = "auto"— the existings3blob kind, no new backend); tables/leases/queue =kind = "postgres"against Cloud SQL; secrets = the v0.9.3gsmkey source; outbound port 25 is unconditionally blocked on GCE (unlike AWS/Azure, no lift on request) so the smarthost is the supported outbound shape (inbound MX unaffected); the mail-port tier belongs on a GCE managed instance group behind an external passthrough Network LB (source-IP preservation — the GCP analog of the VMSS-not-ACI rule), while the private mail-grpc gateway fits Cloud Run. - New
iac/gcpTerraform module: the GCS bucket + dedicated service account + HMAC key always; an optional customer-managed Cloud KMS key; opt-in Cloud SQL Postgres; an opt-in internal-only Cloud Run v2 mail-grpc unit with thegsmkeypair selector.iac/awsgains the matchingmail_grpc_keypair_asmselector (task-roleGetSecretValue) as the volume-free alternative to the EFS mount. - Scaling out, the glossary (new GCS entry), migration, monitoring, and the production compose example now name the GCS/Cloud-SQL shapes where they list backends.
2026-07-16 — v0.9.3 (multi-cloud secret managers)
PATCH — no ABI or economic-model change (a config/deployment
capability; every existing config parses byte-identically). Key
sources
now fetch from AWS Secrets Manager (kind = "asm") and Google
Secret Manager (kind = "gsm") alongside the existing Azure Key Vault
(kind = "akv") and local files, everywhere a key source is accepted —
JWT/DKIM/credential-sealing keys, every server’s TLS pair, the
domain-sithbit delegate key, and mail-grpc’s signing keypair:
- Configuration reference rewritten: the key-sources section (heading
renamed — old deep links to
#key-sources-files-or-azure-key-vaultnow target the cloud-secret-managers anchor) documents all three clouds’ TOML shapes, auth chains (managed identity / AWS credential chain / ADC), and the live-probe env vars; per-field tables and the account-api, domain-sithbit, and mail-grpc pages generalize their file-or-Key-Vault phrasing. - Per-cloud cargo features (
akv/asm/gsm, all default-on): the build-features caveat in Deploy now describes the real mechanism — compile out the clouds you don’t use; a compiled-out kind still parses and fails at load naming the feature. - Cloudflare deliberately absent: its secrets products are write-only over the API (no fetch path), noted in Deploy and the configuration reference.
- Every example TOML’s commented
akvline gains an(or kind = "asm" / "gsm")pointer.
2026-07-16 — v0.9.2 (the Marketplace topic & campaigns)
PATCH — documentation only, no ABI or economic-model change (the participant beacon, campaign CLI, and their money flows all shipped in v0.9.0; this surfaces them in the user-facing guide). A new top-level Marketplace topic under “Using SithBit” introduces the three things that trade on SithBit — alias names, domains, and attention — with two subtopics: Trading names (the alias/domain resale market, linking the existing per-name how-tos) and Campaigns, an audience-facing introduction to opt-in inbox monetization written for both advertisers and non-technical participants, with a hand-drawn campaign-lifecycle diagram. Supporting changes:
- Campaigns highlighted as a headline feature. A new campaign feature
card on the Welcome page and a new
campaignterm icon (megaphone) across the icon system (legend). - Economics gains a Campaigns section tracing the campaign-wallet-funded per-recipient flow (rent + postage + bounty + fees) as a batch of existing flows, and Economics moved ahead of “GUI clients” in the reading order.
- The marketplace pane doc now documents its Participants tab, reconciling a gap with the shipped web surface.
- Running a mail server opens with a vendor-independence pitch (the storage, blob, IPFS, and secret seams are trait-abstracted with multiple backends), and its subtopics are reorganized into a motivated arc — Go-live essentials, The services, Day-2 operations.
- Welcome page card grid rebalanced. The feature cards no longer strand “Works with the inbox you already use” alone on its own row: the spam-pricing hero card keeps its full-width row, and the remaining six cards now flow three across in two even rows (“No gatekeeper” keeps its accent border but joins the grid).
2026-07-16 — v0.9.1 (mail-grpc honors JSON_RPC_URL)
PATCH — config surface, no ABI or economic change. The mail-grpc
gateway now honors a bare JSON_RPC_URL environment variable
(precedence: env > the configured json_rpc_url > the Solana CLI
config), the one legacy env name kept from the clean break, for parity
with the sithbit CLI and the standard Solana convention. See
the mail-grpc chapter migration note and the
json_rpc_url row in the
configuration reference. This also fixed an integration-suite singleton
race (the in-process gateway’s config::get() no longer force-initializes
the process-wide config, so a read-only gateway boot can’t pre-empt the
binary’s one config::install).
2026-07-16 — v0.9.0 (the sithbit campaign CLI)
MINOR — additive CLI surface over the existing beacon ABI (item 44, the
group-offer authoring flow
of the participant-pool marketplace). No new on-chain instruction: the
command tree drives the create/update/close beacon instructions that
shipped with item 43 and the existing SendMail, so nothing about the ABI
moves. What is new is the chain-direct authoring surface a participant and a
campaign wallet use:
- A new
sithbit campaignCLI reference documents the whole tree — a participant’screate/update/close(opt in, rewrite, opt out; the advertised price is the mailboxdefault_postage, so a beacon needs a mailbox first) and a campaign wallet’ssearch/quote/send.searchruns the trustlessgetProgramAccountsscan and recovers each match’s sendable wallet from the beacon’s on-chainownerfield (the D-P1 append this session);quoteprices the matched set term-for-term against the on-chainSendMailfunding math;sendexecutes N direct-signed bountied sends, continuing past a per-recipient failure so one bad address can’t strand a paid campaign. - The participant-marketplace note
and its item-44 line
are the design record; each
campaignbounty rides the ordinary reply-bounty escrow, refundable to the campaign wallet if a recipient never replies.
2026-07-16 — v0.9.0 (participant-pool web surface)
MINOR — additive web/API surface (item 45, the
browser read path of the
participant-pool marketplace
design). The browser read path adds no on-chain ABI itself; the beacon’s
on-chain layout shipped with item 43 under this same v0.9.0 and was
extended this session with an appended 32-byte owner field
(PARTICIPANT_BEACON_LEN 113→145; tags stay first at offset 0) so the
trustless scan recovers each participant’s sendable wallet from the PDA.
The opted-in participant pool is now browsable end to end:
a new ListParticipants gRPC RPC on mail-grpc runs the trustless
on-chain beacon scan (getProgramAccounts + a memcmp filter over the
fixed-offset tag bitmap), account-api proxies it as the authenticated
GET /v1/chain/participants?tags=… index route
(account-api), and the
name marketplace grows a Participants tab
beside its For sale / Expired / Sold tabs — a lazy-loaded, JWT-authenticated
browse-and-filter surface over the pool. A beacon must carry every filtered
tag bit to match. Known limitation: the browser clients have no shared
tag-name vocabulary yet, so both the filter input and each row render tags
as raw on-chain bit positions (the mail_model TAG_* constants),
not human labels; group-offer authoring (quote/send) stays CLI-first
(item 44).
2026-07-16 — v0.9.0 (RFC 8314: production implicit-TLS mail posture)
PATCH — docs and config defaults only; no on-chain ABI, instruction, error-code, or economic change (item 47). The reference stack’s transport posture is hardened to RFC 8314 (“Cleartext Considered Obsolete”) — TLS on connect for submission and access, credentials refused before the connection is protected — and documented end to end. Nothing about what the servers can be configured to do changed at the wire level (STARTTLS listeners and the plaintext loopback dev stack still work); what moved is the advertised production default and the prose describing it.
- Protocol conformance
gains an RFC 8314 subsection and checklist item citing the three
on-by-default
require_tlsenforcement guards — SMTP submission (530withAUTHhidden from EHLO), IMAP (LOGINDISABLED+NO [PRIVACYREQUIRED]), and POP3 (-ERR Must issue STLS command first). mail_spooler/sithbitd.example.tomlgrows the commented production implicit-TLS stack —[submission.server]/[imap.server]/[pop.server]on 465/993/995 withimplicit_tls = trueand matching[*.tls]— with the STARTTLS 587/143/110 listeners demoted to opt-in secondaries, anddocker-compose.prod.example.ymlpublishes those same 465/993/995 (plus 25 MX) as the primary host ports.- Running a mail server: Production and a
new configuration production-posture
subsection
document the implicit-TLS primaries, the MX-on-25 exception, and the
require_tls-on-by-default rule. domain-sithbit’s advertised submission default flips 587/STARTTLS → 465/implicit-TLS (SSL) acrossMailHostsConfig::default, the Mozilla autoconfig (socketType=SSL) and Outlook autodiscover (Encryption=SSL) documents it serves, the[mail_hosts]reference default, and the DNS setup submission caveat. STARTTLS on 587 remains a supported opt-in; the ports now match the implicit-TLSSRVrecords that same page recommends.
2026-07-16 — v0.9.0 (threat-model prune)
PATCH — docs only. Removed the threat model subsection “History before the cutover shows the old postmaster key” — it advised rotating away from a pre-cutover single-postmaster key at adoption, but there has never been a production deployment, so no such historical key or pre-adoption chain history exists to rotate away from. The custody model itself (ceremony seeds + rotate-on-schedule delegate) is unchanged.
2026-07-16 — v0.9.0 (client-access SRV records)
PATCH — docs only. DNS setup
gains a section on RFC 6186 / RFC 8314 SRV records for client
autoconfiguration: the implicit-TLS labels SithBit’s production posture
prefers (_imaps 993, _pop3s 995, _submissions 465) and the STARTTLS
secondaries (_imap 143, _pop3 110, _submission 587), the RFC 2782
priority/weight/port/target fields, the .-target convention for
disabling a protocol, and how these relate to the autoconfig/autodiscover
documents and to the DHT-based service
discovery that avoids DNS altogether.
Ports match the
[mail_hosts]
defaults.
2026-07-16 — v0.9.0 (on-chain participant beacon)
MINOR — additive public ABI (item 43, the first build-out of the
participant-pool marketplace
design): three MailInstruction variants appended —
CreateParticipantBeacon (39), UpdateParticipantBeacon (40),
CloseParticipantBeacon (41) — plus the ParticipantBeacon account
(fixed 113-byte layout; the coarse tag bitmap sits at account offset 0 so
getProgramAccounts memcmp search works without deserializing), the
append-only 24-tag starter vocabulary in mail_model constants (32-byte
bitmap = 256 slots; bits are deliberately NOT validated on-chain, so
vocabulary appends never need a redeploy), the participant_beacon PDA
seed, and error codes 86–90. Creating a beacon requires the wallet’s
mailbox to exist, because the advertised participation price is the
mailbox’s default_postage
(decision 5);
closing it refunds the rent — opting out is free and complete. See the
program reference
for the instruction/seed/error tables. CLI authoring (sithbit campaign,
item 44) and the web surface (item 45) build on this next.
2026-07-16 — v0.8.11 (trustless webmail: external-wallet flavor on the Balances pane)
Client-side only: no on-chain ABI, instruction, error-code, or economic
change — balancesPane.buyStamps() now supports the external
Phantom/Ledger signing flavor (the unsigned wasm builders plus
sendUnsigned), mirroring mailboxConfigPane.commit()’s existing
split. Item 40 (v0.8.9) deliberately scoped this to the compose card
only; this fills in the Balances pane, so PATCH per the v0.8.7
precedent.
- No frombox yet? Prepay inline notes the Balances pane’s stamp purchases now ride both signing flavors too, matching the compose card’s inline prepay.
- Creating the frombox on first purchase cross-references the Balances pane alongside the compose card as riding the same create-or-top-up decision, now in both flavors.
2026-07-16 — v0.8.10 (screenshot manifest: curated shared-file lists replace wholesale hash)
Tooling-only: no on-chain ABI, instruction, error-code, or economic
change — a fix to check_screenshots.py’s own drift detection, so
this is a PATCH bump.
mail_docs/screenshots.manifest.jsonno longer hasheswebclients/sharedwholesale for every client. Each ofwebmail,onboarding, andmarketplacepreviously carried a blanketwebclients/sharedsource entry, so editing a shared file only one client actually reaches (e.g.connection-settings.js, webmail-only) falsely flagged the other two as needing a re-shoot — the same false-positive class the item-40 docs commit (ee30da5) had to re-pin around after an unrelatedwebclients/sharededit landed. Each client’ssourceslist now names only the shared files itsapp.js/index.htmlactually reach (traced via the import/fetch/ mount closure), while the client’s own directory stays wholesale (rglob’d) as before.check_screenshots.py’siter_source_fileshashes an individual file directly when asourcesentry names one rather than a directory, alongside the unchanged wholesale rglob. A new--selftestmode fixture-proves both directions: a one-client-only file edit leaves the other two clients untouched, and a shared-by-all file edit (e.g.panes.js) still flags all three.
2026-07-16 — v0.8.9 (docs: participant-pool marketplace design note)
Documentation-only: the version tags the unchanged protocol state.
- New design note:
The participant-pool marketplace
records the settled design for advertiser/survey campaigns over reply
bounties — the on-chain participant beacon (coarse tag bitmap + sealed
detail CID, coarse-by-construction privacy), mail-native key handout
for the detail blob, the two search paths over one fixed-offset
layout, the CLI-first
sithbit campaignauthoring surface, the advertised-price-is-default_postageidentity, the rejected alternatives, and the three implementation items it spawns. Nothing in it is implemented yet.
2026-07-16 — v0.8.9 (trustless webmail: inline prepay on the compose card)
Client-side only: the trustless compose’s no-frombox refusal becomes an
inline Prepay & send — quote, purchase, and automatic re-send of the
held draft, in both signing flavors. The new wasm exports
(create_frombox_unsigned/add_stamps_unsigned,
postoffice_account_address/decode_postoffice_account) are client
surface over unchanged instructions — no on-chain ABI, instruction,
error-code, or economic change (the prepayment economics shipped long
ago; this is an affordance over them) — so this is a PATCH bump per the
v0.8.7/v0.8.8 precedent.
- No frombox yet? Prepay inline documents the card — the stamp-count input (the ≥1-stamp non-owner floor), the exact per-stamp funding quote (postage + settlement surcharge + the postoffice’s live protocol fee, owner-waived), the fresh create-vs-top-up read at click time, and the deliberate no-auto-retry on a still-pending purchase.
- Creating the frombox on first purchase
cross-references the webmail surface riding the same
create-or-top-up decision as
frombox stamp.
2026-07-16 — v0.8.8 (trustless webmail: reply + bounty authoring)
Client-side only: the trustless webmail now authors what it could
already display — the reader gains a Reply on-chain action and the
compose card gains reply-bounty fields. No on-chain ABI, instruction,
error-code, or economic change (the compose threads SendMail’s
existing bounty/expiry/reply parameters through the already
parity-fenced builders, both signing flavors), so this is a PATCH bump
per the v0.8.7 precedent. This closes the v0.8.7 entry’s “future work”
note for this surface.
- Replying, and attaching a bounty
documents the new compose surface — Reply pre-fills the decrypted
sender and carries the parent message’s account address (the
blake3-hashed
--reply-tolinkage, no bounty required); the bounty fields mirror--bounty/--bounty-windowwith the same 7-day default window, and a born-expired window is refused in the page before anything is pinned — the same rule the chain enforces. - Attaching a bounty names the trustless compose as an authoring surface — and restates the standing decision that bounty authoring is direct-signed only: sends composed through a mail server stay bounty-less.
2026-07-15 — v0.8.8 (trustless webmail: send-lifecycle and pin-caveat diagrams)
Documentation-only: no ABI, instruction, error-code, or economic change — two hand-authored diagrams illustrating already-documented behavior.
- Sending without a server gains a lifecycle diagram covering the four client-side steps (resolve, check mailbox, check frombox, seal) through the pin step and the in-page-wallet/external-wallet signing branch to submit-and-poll.
- The pin lifecycle caveat gains a comparison diagram showing the operator’s server-delivery pin lifecycle and a trustless client’s pin lifecycle as independent paths converging on the same CID — why GC never reclaims a client-made pin on the operator’s behalf.
2026-07-15 — v0.8.7 (trustless webmail: external-wallet send)
Client-side only: the trustless webmail compose can now send through a
connected external wallet (Phantom/Ledger) — previously it required
an in-page wallet. No on-chain ABI, instruction, error-code, or
economic change (the external flavor emits the byte-identical SendMail
wire transaction through the already-parity-fenced unsigned builder), so
this is a PATCH bump per the kit-migration precedent.
- Sending without a server documents the two signing flavors — an unlocked in-page wallet signs in the page; a connected external wallet approves the same transaction built unsigned with it as fee payer, with sealing always happening in the page before anything leaves it. Bounty and reply fields remain future work on this compose surface (they exist in the CLI and the builders today).
2026-07-15 — v0.8.6 (domain-sithbit compose parity: mounted TOML, dead env var retired)
Deployment-surface only: the compose files move the last
env-configured service onto the mounted-TOML pattern — no on-chain
ABI, instruction, error-code, or economic change, and no server
behavior change (no binary is touched) — so per this file’s own rules
this is a PATCH bump, following the v0.8.4 compose-migration
precedent. It is also a correctness fix: the
production example still set
DOMAIN_SITHBIT_POSTMASTER_KEY_FILE, a config field removed by
v0.8.2’s hard break (the field is now delegate_key_file), and
because unknown DOMAIN_SITHBIT_* variables fail startup loudly,
copying the example verbatim crash-looped the domain-sithbit
container.
- The production example gains
sithbitd/account-api/mail-grpc parity —
domain-sithbitwas the last service configured through a wall of env vars:docker-compose.prod.example.ymlnow mounts a realdomain_sithbit.toml(viaDOMAIN_SITHBIT_CONFIG; start fromdomain_sithbit/domain_sithbit.example.toml) plus a separate read-only delegate-keypair mount the TOML’sdelegate_key_filenames, with a minimal-TOML sketch and the Azure Key Vault table-form alternative inline — mirroring the mail-grpc block v0.8.4 shipped. - The dead
DOMAIN_SITHBIT_POSTMASTER_KEY_FILEvar is retired from everything runnable: the production example’s crash-looping setting is gone, anddocker-compose.yml’s stale comment telling operators to set it now points at the current contract (delegate_key_filein the mounted TOML). The surrounding prose also stops calling it the “postmaster key” — the signer is the postoffice’s standing delegate key.
Deployment-surface only: the iac/ templates gain opt-in units that run
the unchanged published image — no on-chain ABI, instruction,
error-code, or economic change, and no server behavior change (no
binary is touched) — so per this file’s own rules this is a PATCH bump,
following the v0.8.4 deployment-surface precedent. It lands item 34,
the last of the three hardening items queued with the
gateway topology decision.
- Both IaC templates gain an opt-in
mail-grpcunit (deploy_mail_grpcin Terraform,deployMailGrpcin Bicep; default off — a plain apply/deploy keeps producing the store footprint with zero diff): ECS Fargate on AWS (security group + cluster/task/service), a VNet-integrated ACI container group on Azure. Both are BYO network (an existing VPC + private subnets, or an existing delegated subnet — the templates never create one) and private-only by construction: no public IP or load balancer, ingress on the gRPC/health ports only from caller CIDRs or the VPC’s own CIDR — the private-network posture as infrastructure rather than convention. See Provisioning with IaC andiac/README.mdfor the full parameter ↔ config mapping. - Keypair delivery splits per cloud, because the gateway’s
keypairis a key source (a file path or a Key Vault secret — never key content in an env var): AWS mounts an optional EFS volume read-only and pointsMAIL_GRPC_KEYPAIRat the file; Azure wires the AKV source through the nested env overlay (MAIL_GRPC_KEYPAIR__KIND=akv+__VAULT_URI/__SECRET_NAME) authenticated by the container group’s system-assigned managed identity — the operator grants that identity secret read on the vault (themailGrpcPrincipalIdoutput exists for exactly that role assignment). - Durable constraint recorded on the Azure unit: ACI cannot pass through the client source IP — acceptable for mail-grpc (private gRPC; callers are our own servers), but a future SMTP-server unit must be a VM scale set, because SPF/DNSBL need the real peer IP.
2026-07-15 — v0.8.4 (compose files onto the mail-grpc TOML/env config)
Deployment-surface only: the compose files, smoke script, and dev
scripts move onto the configuration surface v0.8.3 shipped — no
on-chain ABI, instruction, error-code, or economic change, and no
server behavior change (the binaries are untouched) — so per this
file’s own rules this is a PATCH bump. It lands item 35, queued by the
v0.8.3 hard break, and closes that break’s last loose end: nothing
runnable in the repo drives mail-grpc with the retired
GRPC_SERVER_ADDRESS/DEFAULT_KEYPAIR names any more.
- The dev
chainprofile is operative again:docker-compose.ymlwiresmail-grpcthroughMAIL_GRPC_*overrides (bind, RPC URL, alias-index path), and the signing keypair is a file mounted read-only —SITHBIT_CHAIN_KEYPAIRnames the host path (default: the checked-in devnet test key the retired.envflow held as JSON content) — keypair content in an env var is gone for good.SITHBIT_CHAIN_RPCstill retargets the cluster; the OTLP overlay now setsMAIL_GRPC_OBSERVABILITY__OTLP__ENDPOINT. - The production example gains
sithbitd/account-api parity:
docker-compose.prod.example.ymlmounts a realmail_grpc.toml(viaMAIL_GRPC_CONFIG) plus a separate read-only keypair mount, with a minimal-TOML sketch and the Azure Key Vault alternative inline. docker/smoke.shnow statically parses the chain profile (docker compose --profile chain config -q), so a compose regression there fails the smoke run;mail_docs/screenshot-tools/serve-stack.shandwebclients/README.md— the last live dead-name consumers — swept onto theMAIL_GRPC_*overrides.- Trap for compose authors: compose (v2.29.7) interpolates
${VAR:?}even in inactive profiles, so the profile’s variables take defaults (${VAR:-…}) rather than being required.
2026-07-15 — v0.8.3 (docs: sithbit CLI glossary entry)
Documentation-only: the version tags the unchanged protocol state.
- New glossary entry:
sithbitCLI (Operations & infrastructure section), disambiguated from the external Solana CLI it’s a substitute for in thesithbit config/sithbit wallet createworkflow. CLI Quickstart’s first mention of “CLI” now links into it, picking up the sitewide hover/focus definition previewjs/glossary-tooltip.jsalready gives every glossary link — no new JS or CSS needed.
2026-07-15 — v0.8.3 (docs: marketing landing page)
Documentation-only: the version tags the unchanged protocol state.
- New landing page: Welcome, now the book’s
index.html— mdBook always renders a source tree’sREADME.mdtoindex.htmlregardless ofSUMMARY.mdorder, so the new landing content took over theREADME.mdfilename and the Prelude moved to its ownprelude.mdfile (rendering asprelude.html) to make room;SUMMARY.mdstill lists Welcome ahead of the Prelude for the sidebar reading order. The landing page itself is a hero with the SithBit lockup and tagline, a feature-card grid ordered by end-user value proposition (spam priced out at the source, getting paid for your own inbox, an address tied to your wallet, sealed end-to-end mail, drop-in SMTP/IMAP/POP compatibility, no gatekeeper operator model), and closing links onward into the Prelude and Introduction. Styled by the newcss/landing.css, scoped under.sb-hero/.sb-grid/.sb-cardso it never affects the rest of the book; reuses the existing term-icon set and mdBook theme variables rather than introducing new artwork or colors.
2026-07-15 — v0.8.3 (mail-grpc onto TOML config; [grpc]-only verification for sithbitd)
Server-behavior changes, config surface only: no on-chain ABI,
instruction, error-code, or economic change, so per this file’s own
rules this is a PATCH bump, following the v0.8.2 precedent
(config-surface breaks and server behavior outside the protocol ABI
move PATCH). It is not docs-only — previously-required environment
variables are now ignored, a boot that previously refused without them
now comes up on in-code defaults, and a [grpc]-only sithbitd now
verifies recipients it previously could not.
- HARD BREAK —
mail-grpcmoved onto the layered TOML/env configuration every other SithBit binary uses (item 32(a); a clean break, user decision 2026-07-15). The legacy env-only surface is gone:GRPC_SERVER_ADDRESS,JSON_RPC_URL,DEFAULT_KEYPAIR(and its_VAULT_URI/_SECRET_NAMEcompanions),ALIAS_INDEX_DB,ALIAS_INDEX_POLL_SECONDS,ALIAS_CACHE_SECONDS, andHEALTH_BINDare no longer read. Config now comes frommail_grpc.toml(orMAIL_GRPC_CONFIG) withMAIL_GRPC_*env overrides; every key has a dev-friendly default, so an empty file runs a loopback gateway on127.0.0.1:50051— the private-network posture is now the default, not a convention — with the chain endpoint and signing keypair falling back to the operator’s Solana CLI config, exactly like thesithbitCLI. The keypair-content-in-an-env-var shape is gone with it: the TOMLkeypairis the sixth key source (a keypair-file path, or an Azure Key Vault secret). See the rewritten mail-grpc chapter and its configuration table. sithbitd’s[grpc]section now stands alone (item 32(b)):[grpc]without[ipfs]enables RCPT-time recipient verification (alias resolution + postage checks), at-rest sealing key reads, and alias logins, with the chain pipeline off (delivered copies stayreceived; boot logs the verification-only posture at info) — the MX posture that previously demanded an[ipfs]provider the edge never used.[ipfs]without[grpc]warns and boots with chain access disabled. Both sections together remain the full pipeline, unchanged. See the chain-pipeline section and the updated role-split presets.
2026-07-15 — v0.8.2 (domain-sithbit delegate key source; postmaster_key_file removed)
Server-behavior change, config surface only: no on-chain ABI, instruction, error-code, or economic change, so per this file’s own rules this is a PATCH bump, following the distinguishable-suspend-replies precedent (server behavior outside the protocol ABI moves PATCH). It is not docs-only — a previously-accepted config key is now refused at boot (second bullet) — and pre-launch, a config-surface break does not rise to the on-chain-ABI bar MAJOR is reserved for.
domain-sithbit’sdelegate_key_fileis now a key source — the fifth of the file-loaded secrets to take one. A bare string stays a local file path, byte-for-byte compatible with existing configs;{ kind = "akv", vault_uri = …, secret_name = … }opts into fetching the keypair JSON from an Azure Key Vault secret instead (a kind-less{ path = … }table is also a file). The hot-rotation custody contract is preserved: the key is loaded fresh from the configured source on everyPOST /domain(forakv, a fresh vault fetch per request), so rotating the delegate still needs no restart. Boot validation covers whichever source is configured — a bad vault secret fails startup exactly like a bad file did; unset still meansPOST /domainreplies 503.- HARD BREAK — the deprecated
postmaster_key_filealias is removed (user decision 2026-07-15). The config struct rejects unknown keys, so a config still namingpostmaster_key_filenow fails startup loudly instead of being accepted with a warning. Operators must act: rename the key todelegate_key_filebefore upgrading. See the domain-sithbit configuration table.
2026-07-15 — v0.8.1 (docs: populated web-client screenshots)
Documentation-only. Adds the populated-state screenshots the web-client
pages were missing: a mail-in-it inbox on
the webmail app, the signed-in
settings dashboard (balances + postage),
and live marketplace listings.
Captured by driving the real client bundles against a local mock
account-API that serves the same response shapes the clients are
unit-tested against (mail_docs/screenshot-tools/, Option C) — no chain,
store, or devnet dependency, so the frames are deterministic. Registered
in screenshots.manifest.json; protocol version unchanged.
2026-07-15 — v0.8.1 (docs: onboarding wizard steps 2–4 screenshots)
Documentation-only. Completes the browser-onboarding walkthrough in
Web onboarding: the browser wizard
with screenshots of the remaining wizard steps — claim-your-handle,
set-your-price, and review. These required a running backend (the wizard
calls the account API to establish the wallet and check mailbox
ownership), so they were captured against a local account-api +
mail-grpc→devnet stack with a fresh throwaway wallet. Registered in
screenshots.manifest.json; protocol version unchanged.
2026-07-15 — v0.8.1 (docs: mail-grpc gateway topology design note)
Documentation-only: the version tags the unchanged protocol state.
- New design note:
The mail-grpc gateway topology —
records the 2026-07-15 decision that
mail-grpcstays a separate service (three roles in one process: write gateway, read gateway, alias/sales indexer; options considered; what would reopen it), the two-key custody clarification (the gateway’s fee-payer keypair is notdomain-sithbit’s standing delegate), and the now-explicit private-network-only posture — the gRPC surface has no TLS or authentication, so it must never be publicly reachable. - mail-grpc chapter gains the network-posture
callout (prefer loopback/private binds over
0.0.0.0); Scaling out notes the gateway is not a fleet member — many workers share one gateway safely because store leases serialize each wallet’s writes.
2026-07-15 — v0.8.1 (docs: onboarding wallet-generation screenshot)
Documentation-only. Adds a screenshot of the onboarding wizard’s
“Create a new wallet” step — the one-time secret-key reveal, passphrase,
and “I have saved it” gate — to
Web onboarding: the browser wizard.
Captured from the standalone (no-backend) client with the generated
secret key redacted. Deeper wizard steps (handle/price/review) are not
included: advancing past the wallet step requires the account-API
backend, so those await a stacked capture. Registered in
screenshots.manifest.json; protocol version unchanged.
2026-07-15 — v0.8.1 (distinguishable suspend replies & operational polish)
Server-behavior changes, all backward-compatible: no on-chain ABI, instruction, error-code, or economic change, and nothing previously accepted is refused — so this is a PATCH bump, not the MINOR that at-rest sealing took (which changed what the system does with mail). The version moves because reply texts on the wire are behavior, not documentation.
- Suspended accounts now hear why (user decision 2026-07-15:
distinguishable everywhere). SMTP AUTH answers
535 5.7.13 Account disabled(RFC 3463 “user account disabled”, was the deliberately indistinguishable 5.7.8), IMAP answersNO [CONTACTADMIN] account disabled; contact your administrator(RFC 5530), and POP answers-ERR [SYS/PERM] account disabled; contact your administrator(RFC 3206). Safe disclosure: every disabled reply is issued only after the credentials verified, so only the account holder ever sees it. See the per-surface refusal table. sithbit-migratecarries the abuse controls. The account pass now copies the suspend flag and replays the rolling hour/day outbound-usage totals as of the migration instant (bucket timing is not recoverable through the store surface — caveat recorded); the run summary printsaccounts: N (M suspended). See the migration page.- Undatable dead jobs are pruned, not spared. Every storage
backend now stamps the bury time into the dead message itself, and
the hourly retention prune treats an entry with no readable date as
older than any cutoff — previously such entries escaped pruning
forever. The prune (
[spooler] dead_retention_days, default 30) is now documented under The job queues. - Mail listings surface reply and bounty facts.
sithbit mail get’s per-message listing appendsReply-to-hash:,Bounty:, andBounty-expires:lines when set (absent otherwise — a plain send’s listing is byte-identical to before). See Get mail. - The trustless viewer marks replies. The web/plugin viewer panes
render a “Reply to
<hash>…” line for reply messages, mirroring the CLI listing’s reply-to-hash treatment. - Internal: the sealed-envelope decompression cap now reports a distinct, operator-diagnosable error when a payload claims to inflate past the 64 MiB cap (a corrupt stream stays opaque, anti-probing). The cap itself is unchanged.
2026-07-15 — v0.8.0 (Solana clusters & RPC-endpoint appendix)
Documentation only; no code, ABI, API, or CLI change — the protocol version is unchanged per the versioning note above.
- A new appendix: Solana clusters and RPC endpoints. One page answering the questions every “configure an RPC endpoint” step assumes away: what Solana’s public clusters (devnet, testnet, mainnet-beta) are, which ones SithBit uses today — devnet hosts the live test deployment, local work runs on surfpool, mainnet-beta awaits launch — and what the URL you configure actually points at. CLI Quickstart introduces it at the point you first configure an endpoint.
- Book-wide cross-links. The natural “RPC endpoint” mentions across onboarding, deployment, the configuration reference, the GUI-client pages (Thunderbird, Outlook, Chrome, Lockbox), and the glossary now link to the appendix, so the term resolves to its explanation from anywhere in the book.
2026-07-15 — v0.8.0 (docs: first web-client screenshots)
Documentation-only. First screenshots of the browser clients, captured
from the standalone (no-backend) first-run states and embedded in their
pages: the webmail first-run wizard, the
web onboarding wizard,
and the marketplace sign-in screen. These are
now guarded by check_screenshots.py (registered in
screenshots.manifest.json), so a webclients/** UI change that isn’t
re-shot fails the docs gate. Protocol version unchanged.
2026-07-15 — v0.8.0 (docs: Apple Mail extensibility note)
Documentation-only. New reference appendix
Apple Mail extensibility (MailKit)
records Apple Mail’s supported extension surface (the four MailKit extension
points, the Sonoma removal of legacy mail bundles) and why SithBit ships no
Apple Mail client today — MEMessageSecurityHandler could unseal mail on
macOS, but there is no MailKit on iOS/iPadOS and no room for the shared
account pane. Protocol version unchanged.
2026-07-15 — v0.8.0 (at-rest sealing goes live in production)
The item-27 at-rest sealing machinery — shipped 2026-07-14 as a fixture-proven capability — is now wired on in production. No on-chain change; the bump is MINOR because the running system’s behavior changes for end users and one previously-accepted SMTP credential shape is now refused.
- Sealing is automatic on chain-connected deployments. Any
sithbitdor account API with a chain gateway seals password-less accounts’ delivered mail at spool time; there is no config setting (decided: the per-account rule — stored password ⇒ plaintext — is the only gate). The chain-less dev stack stays plaintext. See What your operator holds and sithbitd: At-rest sealing. - The IPFS copy is sealed at spool time too (decided: pin format
“seal-for-IPFS at spool”).
crypto_box_sealis non-deterministic, so the spool-time bytes are the canonical artifact the worker pins — a crash-window rerun re-pins identical bytes and the recorded CID never drifts. Reply-linkage ids are parsed in the same pass and persisted (a sealed body can never be re-parsed). Spool-time facts are canonical: a reading key rotated between delivery and pin takes effect from the next message. - SMTP refuses the reading-secret suffix (decided: reject). A
wallet-signature
AUTHwhose password carries the.base58(secret)login suffix is refused with a normal 535 on the submission path — the secret belongs only where reading happens (IMAP/POP/webmail login). - Wrap hygiene: deleting a message’s last copy now drops its DEK wrap rows and any not-yet-pinned sealed IPFS artifact alongside the blob bytes, on every storage backend.
2026-07-15 — v0.7.0 (alias-transfer consent — BREAKING)
A breaking protocol change: every alias transfer now requires the
recipient’s consent — the escrowed offer/accept flow is the only transfer
path, and the unilateral TransferAlias refuses. This reverses the
recorded 2026-07-05 decision (user decision, 2026-07-15): the
“never planted on a wallet that didn’t ask for
it” guarantee is now
protocol-wide instead of contradicting the old immediate path. Per the
versioning preamble MAJOR stays 0 pre-launch (the enum stayed
append-only in place), so this lands as a MINOR bump with the break
stated plainly: transactions submitting TransferAlias no longer
execute.
TransferAliasis disabled. Discriminant 1 still decodes (history replays cleanly, e.g. in the gRPC indexer) but the dispatch arm refuses with the appended error 85 (UnilateralTransferDisabled, “Unilateral TransferAlias is disabled; stage an OfferTransferAlias (fee may be 0) for the recipient to accept”). See Transfer an alias.- Zero-fee offers are legal.
OfferTransferAliasno longer requires a positive fee:alias transfer initstages a free hand-off by default (--feedefaults to 0, and--expires-inno longer requires it). The recipient still accepts — consent is structural on every path (accept/buy/bid signatures).OfferFeeZero(51) is retired in place, kept only for the frozen error-code ABI. - The flat transfer fee moved to the zero-fee accept. A free
hand-off’s
AcceptTransferAliascharges the delegate-tunableALIAS_TRANSFER_FEE_LAMPORTS(payer → postoffice), waived when the offer’s holder is the standing delegate — the waiver identity moved from the old path’s payer to the holder, keeping bulk-reservation hand-offs fee-free end to end. Priced offers keep the pure 90/10 split; no flat fee rides on top. - Clients follow. The CLI’s
transfer initis one offer-staging code path (the accept surfaces the flat fee or its waiver before signing);mail_wasmretirestransfer_alias_tx/transfer_alias_unsigned, and the web clients’ transfer pane stages a zero-fee offer with “recipient must accept” copy. The CU table swaps the TransferAlias row for OfferTransferAlias (18,894) and the zero-fee AcceptTransferAlias (13,838).
2026-07-14 — v0.6.0 (the domain-program split — BREAKING)
A breaking protocol change: the domain registry moved out of the mail
program into a new, third on-chain program. Per the versioning preamble
MAJOR stays 0 pre-launch (the instruction enums themselves stayed
append-only in place), so this lands as a MINOR bump with the break stated
plainly: transactions that submit domain instructions to the mail program
no longer execute.
- A third on-chain program owns the domain registry.
domain_program, IDDmaiNcmXsPw2juV9JoZSC47V5epAysQi3DJVk3fiBuUv(devnet twinDmaiNHGvprK2op7xqZHXp8UVXXmPUtkas96Goh5sCJQn), carries the domain lifecycle, the DNSSEC-proof authorize/reclaim flows, the domain marketplace, and its ownAdminCloseAccount(discriminant 16) asDomainInstructiondiscriminants 0–16. The domain, domain-listing, pending-deactivation, pending-reclaim, and proof-witness PDAs now derive under and are owned by the domain program. See the reworked Program & PDA reference. - The sixteen mail-side domain discriminants are retired. Sending
9–12, 22–24, 26–28, 31–33, or 35–37 to the mail program is rejected with
the appended error 84 (
InstructionMoved, “This instruction has moved to the domain program”).SetDomainFee(17) andSetRootKsk(25) stay mail-side: they mutate the postoffice, which remains a mail-program account the alias and domain programs read cross-program (fees, root KSK, delegate gate) — the split moved the registry, not the treasury. - The mail program is now unconditionally modexp-free. The
dnssec-proofCargo feature — and thesol_big_mod_expdeployability problem it gates — moved to the domain program (default-on there); the mail and alias programs’ default builds now deploy on any cluster. The modexp-free build page now describesdomain_program, the only program that still needs it. - No client-facing surface changed. The
sithbit domain …commands, the postmaster domain admin flows, the proof staging, the gRPC gateway’s domain/listing scans and sale-history walk, and domain-sithbit’sPOST /domainall follow the domain program transparently — account-meta lists are byte-identical pre/post split.sithbit postmaster reclaimnow drains all three programs’ accounts, postoffice last.
2026-07-14 — v0.5.9 (cleanup bundle: per-cause POP sealed refusals)
Server/client maintenance only; no on-chain ABI, economic, API-contract, or CLI change.
- POP3 now says why a sealed message can’t be served. Retrieving a
DEK-sealed message a session cannot read now answers with a cause-specific
-ERR [SYS/PERM] message is sealed at rest: …line — no reading key in this session, no wrapped key for this reader, or reading key does not match — using the same sealed-refusal vocabulary as the account API. Previously every such fetch collapsed to the generic-ERR [SYS/TEMP] message unavailable, which still covers genuine storage trouble (retryable). - Trustless clients decode the whole mailbox account. The web clients’
direct-RPC chain reader now surfaces
default_postage,domain, andno_ipfsalongsidemail_count, matching the wasm decoder — a strict superset of what the account-API proxy returns.
2026-07-14 — v0.5.8 (web clients: web3.js → @solana/kit codec bundle)
Client build/packaging maintenance only; no on-chain ABI, economic, API, or CLI change.
- The web clients’ vendored Solana library shrank by ~74%. The shared
client library’s vendored
@solana/web3.jsbundle (web3.esm.js, ~682 KiB) is replaced by a codec-only bundle built from@solana/kit7.0.0 (kit-codec.esm.js, ~178 KiB). The external-wallet bridge (Phantom/Ledger transaction signing) now rides kit’s wire codecs behind the same interface — no behavior change for any client. All six shells (webmail PWA, marketplace, onboarding, Outlook, Thunderbird, Chrome) rebuild and repackage against the new bundle.
2026-07-14 — v0.5.7 (trustless webmail, at-rest mail sealing)
No on-chain ABI or economic change — client, server/API, storage-schema, and infrastructure work only.
- Trustless webmail. The webmail PWA can now run with no account API at
all: leave the API URL blank in connection settings and the app unlocks
with the wallet alone, reads the chain over direct RPC (the same wasm
signing/decoding module the plugins use), enumerates the on-chain inbox,
opens sealed bodies locally, and sends — sealing to the recipient’s
published key, pinning through a configured pin service
(
sithbit-ipfsd), and submitting theSendMailtransaction itself. The recipient’sno_ipfsopt-out is honored client-side. Bodies pinned by the client sit outside the operator’s unpin/settle sweep — the client owns that pin’s lifecycle. See Trustless webmail. Both IPFS HTTP surfaces (sithbit-ipfsd,sithbit-gateway) now answer cross-origin browser requests (permissive CORS). - At-rest mail sealing (deployment capability). For accounts without a
stored mail password, a server deployment can envelope-encrypt delivered
copies at rest: each body sealed once under a fresh per-message
AES-256-GCM key, wrapped per reader with the sealed-box construction.
The client-derived reading secret rides the wallet-signature login
(IMAP/POP password suffix;
reading_secreton the account API token exchange), lives only in session memory, and unlocks decrypt-on-read over IMAP FETCH, POP RETR, and/v1/mail. Sessions without the key get a clear refusal, never ciphertext. Storage gains a per-reader wrap table (all six backends). Honest scoping — what this does and does not protect against — in What your operator holds. - Cleanups. The DNSLink config no longer prints its API token in debug
output; the AWS Terraform module’s DynamoDB GSI moved off the
provider-deprecated
hash_key/range_keyarguments; pin/unpin mentions across the book carry a pin icon (see the icon legend).
2026-07-14 — v0.5.6 (customer-managed KMS, first IaC, secret-log hygiene)
No on-chain ABI or economic change — a new optional store setting, deploy templates, and cleanups.
- Customer-managed KMS keys. The AWS store gains an optional
kms_master_key_idunder[store.aws](key ID, alias, or ARN): set, the DynamoDB table is created with KMS-backed SSE and the SQS queues switch from SSE-SQS to SSE-KMS under the same key; unset (the default) keeps today’s provider-managed encryption. See Cloud-store overlays. - First infrastructure-as-code. A new
iac/directory at the workspace root provisions what the servers otherwise create at startup: a Terraform module for the AWS store (DynamoDB + SQS, optional CMK, optional default-off S3 blob bucket) and a Bicep module for the Azure storage stack. Templates are statically validated only — the binaries remain fully zero-config-capable without them. - Secrets kept out of logs. The IPFS provider configs (Pinata JWT, Filebase secret key, remote daemon token) now redact their credentials from debug output, matching the store configs.
- Docs. A committed fragment-link validator (
mail_docs/check_anchors.py) now guards the book against silently broken anchors; the blake3 appendix’s “postoffice commitment set” icon matches its term; account API and IPFS benefits link to per-recipient pin providers.
2026-07-14 — v0.5.5 (per-recipient pin providers, docs repairs)
No on-chain ABI or economic change — a new operator-local account setting plus a docs pass. Storage gains three internal account columns for the sealed provider credentials (nullable, backward-compatible).
- Per-recipient pin providers. A mailbox owner can register their own
IPFS pinning provider (Pinata, Filebase, or a self-run
sithbit-ipfsd) with their mail server via the account API (GET/PUT/DELETE /v1/account/pin-provider, JWT wallet auth); delivery then pins their inbound sealed bodies to that provider in addition to the operator’s default pin — best-effort, never affecting delivery or chain state, and the operator pin stays authoritative. Credentials are sealed under the server credential key like mail passwords; theno_ipfsopt-out continues to suppress all pinning. See Per-recipient pin providers. - Anchor-link repairs. Seven intra-book fragment links to headings with a mid-heading icon were written with a single hyphen where mdBook’s slugifier emits a double hyphen, and silently pointed nowhere; all fragment links across the book now resolve.
- TOC nesting. The name marketplace and Lockbox pages are now nested under the GUI clients parent in the sidebar — they are features riding the clients, not top-level topics.
- Postoffice terminology. “Postmaster commitment set” is corrected to “postoffice commitment set” in the blake3 appendix and glossary, matching the code — the Merkle commitment lives on the postoffice account.
2026-07-13 — v0.5.4 (at-rest SSE, DMARC report completeness, GUI-client docs)
No on-chain ABI, economic, or public-API change — server-side hardening, DMARC report content, and a docs pass. Storage gains four internal DMARC columns (nullable, backward-compatible).
- Encryption at rest on cloud stores. The AWS backend now requests server-side encryption when it auto-creates its resources — SSE on the DynamoDB tables (AWS-owned key) and SSE-SQS on its queues — with no key configuration. Azure Storage/Tables and Cosmos are always encrypted at rest by the platform. Customer-managed KMS keys remain a deferred option. See Scaling out and Deploying.
- DMARC aggregate reports carry the published policy. RUA reports now emit
the evaluated
<policy_published>p/sp/adkim/aspf(previously left unset), stored per-record across every storage backend. See[spooler.dmarc_report]. - DMARC forensic reports carry the message envelope. RUF/ARF failure reports
now include the RFC 5965
Original-Mail-From,Original-Rcpt-To, andOriginal-Envelope-Ididentifiers. See[spooler.dmarc_ruf]. - blake3 documentation & naming. The blake3 appendix
intro and table now enumerate all five blake3 uses (adding the postoffice
Merkle commitment set); the PDA-derivation helpers’ hash parameters were
renamed
*_sha256→*_blake3to match what they actually carry (cosmetic — PDA values unchanged). - Threat model — per-authority accountability (S1). The threat model now documents the MX spoof-burn trust gap as a known, policy-mitigated assumption, with cryptographic per-authority accountability recorded as deferred work.
- GUI-client docs regrouped. Thunderbird, Outlook, webmail, and Chrome are now sibling subtopics under a new GUI clients landing page, and the browser onboarding wizard counts Chrome as its fourth shared-wizard web client.
2026-07-13 — v0.5.3 (Chrome extension, installable webmail, docs backfill)
No ABI, API, CLI, or storage change — two new client-side surfaces and a docs pass. Nothing on-chain is renumbered or relaid-out.
- Chrome extension. A fourth web-client shell (
webclients/chrome), an installable Manifest V3 popup for onboarding and wallet management — equivalent to the Thunderbird/Outlook shells, not a Gmail integration and not an in-popup mail reader (mail read/send rides any IMAP/POP/SMTP client). Reuses the shared Alpine+wasm core over a newchrome-store.js(chrome.storage) adapter;build.shproduces a loadablestaging/tree and a packagedsithbit-chrome.zip. See The Chrome extension. - Installable webmail (PWA). The webmail app now ships
a web app manifest + a static-shell service worker, so a browser can install
it (desktop “Install app” / mobile “Add to Home Screen”) and load the shell
offline. Mail content stays live — the service worker never caches the
account API (
/v1/*). - Reference/docs backfill. The Program & PDA reference
MailInstructiontable is now complete (all 39 variants); the glossary blake3 entry is corrected to its five current uses; and hand-writtentarget="_blank"was retired from the remaining pages (the global external-links hook governs them). - Docs landing page. The Prelude (
README.mdat the time; moved toprelude.md2026-07-15 when the marketing Welcome page took over theREADME.md/index.htmlslot) was the book’s root landing page; the Introduction moved to its own page.
2026-07-13 — v0.5.2 (SithBit brand identity)
Presentational only — no ABI, API, CLI, or storage change. A shared visual identity now spans the mdBook docs, the four web shells, and the two browser plugins.
- One brand, everywhere. A “dark-side” palette (near-black backgrounds, a
violet primary, a crimson accent used sparingly), an
S-monogram mark +sithbitwordmark, and a matching favicon/plugin-icon set. The docs, webmail, marketplace, onboarding, Thunderbird, and Outlook all carry it. See Brand & identity. - Single source of truth for the tokens. The palette is defined once as
--sb-*CSS custom properties inwebclients/shared/brand.css(consumed by the shells and plugins) and mirrored onto mdBook’s per-theme variables inmail_docs/css/brand.css; the plugin icons are rasterized from one canonical mark SVG.
2026-07-13 — v0.5.1 (reach: discovery keyserver, incoming delivery, key headers)
No on-chain ABI change — an additive gRPC field (AliasRequest.domain), a new
public HTTP endpoint, and a client mail-header feature build “reach” on top of
v0.5.0’s domain-scoped namespace. Nothing on-chain is renumbered or relaid-out.
- Incoming mail to
user@verified-domain. A SithBit MX now accepts inboundRCPT TO:<user@domain>for a verified domain: the recipient domain threads through the gRPC gateway’s domain-scopedDomainAliaslookup (global-alias fallback preserved; empty domain = the legacy path) to the designated wallet’s mailbox. Completes the delivery leg of the domain-scoped namespace — those addresses can now receive mail, not just be registered and resolved by a native client. See “Receiving mail at a domain-scoped address” (page removed in v0.42.0). - Public discovery keyserver —
GET /v1/chain/cert?email=. An unauthenticated account-API endpoint (HKP/WKD-style) resolves an email / alias / wallet to the recipient’s published X25519 key so any sender can discover it. Empty key ⇒ 200 (seal to the wallet itself); unknown recipient ⇒ 404. The data is already public on-chain; the endpoint is a convenience and a public enumeration surface operators may wish to rate-limit. See thecertkeyserver. - Autocrypt-style key headers on plugin mail. The lockbox
plugins now advertise the sender’s published key in an opportunistic
X-SithBit-Keyheader on outgoing mail and cache it from received mail, so a correspondent’s key is auto-discovered without a chain round-trip on replies. SithBit-specific (X25519, not OpenPGP Autocrypt); the chain stays the source of truth. See Autocrypt-style key discovery.
2026-07-13 — v0.5.0 (domain-scoped aliases)
Additive on-chain ABI: three new AliasInstruction variants
(RegisterDomainAlias, RemoveDomainAlias, UpdateDomainAlias), a new
DomainAlias account, and three appended error codes. Nothing existing is
renumbered — the protocol stays append-only and pre-launch.
- Domain-scoped alias namespace —
user@verified-domain → wallet. A verified domain’s authority can now mapalice@acme.com,bob@acme.com, … to wallets in a namespace only that authority may write to — distinct from the shared global-alias namespace, and with no registration fee (the authority already owns the domain). New CLI:sithbit alias register-domain <local@domain> --wallet <k>,alias update-domain(repoint in place),alias remove-domain(refund rent). See “Domain-scoped aliases” (page removed in v0.42.0). - Resolution precedence.
sithbit alias get user@domain— and the lockbox plugins’ recipient resolver — now resolve a domain-scoped mapping first and fall back to the global alias when none exists, so existing global aliases keep resolving unchanged. Lockbox mail touser@verified-domainseals to the domain-designated wallet. - Trust model. The domain authority alone controls its
user@domainmappings (create/repoint/remove) — the same authority already trusted to relay the domain’s mail. See the threat-model note.
2026-07-13 — v0.4.5 (lockbox mail v1)
New client capability; no on-chain instruction, account layout, or error-code
change (it reuses the existing SetMailboxKey instruction and the sealed-box
crypto). One additive CLI flag.
- Lockbox mail — client-side end-to-end encryption over ordinary email. The Thunderbird extension and Outlook add-in can now seal a message body to its recipient before it leaves your machine and unseal it after it arrives, so the mail server, relay, and stored copy all see only ciphertext. v1 seals to SithBit-native recipients (a raw wallet or a global alias); a recipient that can’t be resolved is sent ordinary plaintext, and sealing is all-or-nothing per message. Both ends need the plugin. See Lockbox: end-to-end encrypted mail.
- Recoverable reading key.
sithbit mailbox set-key --derivepublishes a reading key derived from the wallet (a deterministic wallet signature run through a KDF), so it regenerates on any device — including a hardware wallet — with nothing to back up. The trade-off (anyone who can make the wallet sign the fixed message learns the key) is documented in the threat model. - The
user@verified-domainnamespace is planned but not yet shipped — v1 aliases are domain-blind, soalice@acme.comandalice@other.comresolve to the same globalalice. A domain-scoped namespace owned by each verified domain’s authority is the next step.
New client surface; no on-chain instruction, account layout, error-code, or CLI
change (the on-chain CreateMailbox/CreateAlias/SetMailboxKey instructions
the flow uses already existed).
- Onboard with Phantom or Ledger. The web onboarding wizard — in all three mail clients and on the standalone get-started page — gains a third wallet path alongside create/import: connect an external browser wallet. Your funds-holding signing key never enters the browser (you approve the mailbox create in the extension); because a hardware wallet cannot open sealed mail, a low-value delegated reading key is generated in the browser and published in the same one-approval transaction, and mail reading routes through it. The honest trade-off (a browser-held reading key you save once, rotatable, whose loss costs only already-received mail) is documented. See Web onboarding.
- Add another wallet from inside a client. Each signed-in client dashboard now has an “Add another wallet” button that re-opens the wizard for a fresh wallet, so onboarding is no longer a first-run-only flow. See Onboarding a second wallet.
2026-07-12 — v0.4.3 (standalone onboarding page)
A new client surface; no on-chain instruction, account layout, error-code, API, or CLI change.
- A standalone “get started” page. The five-step onboarding wizard — create
or import a wallet, claim a mailbox and an optional handle, set your default
postage — is now also served on its own shareable URL (the
webclients/onboarding/bundle), so a brand-new user can be pointed straight at it with no mail client installed. It runs the identical wizard the webmail, Outlook, and Thunderbird clients show at first run, and is served same-origin byaccount_apilike the standalone marketplace page. See Web onboarding.
2026-07-12 — v0.4.3 (self-contained web clients & dependency maintenance)
Build, packaging, and dependency maintenance; no on-chain instruction, account layout, error-code, API, or CLI change.
- The web clients no longer load code from a CDN.
@solana/web3.jsis now vendored into the shared client library and served from the same origin, so the webmail, Outlook, and Thunderbird clients (and the standalone marketplace page) fetch no third-party script at runtime. This removes the Thunderbird extension’s last remote-code reference — the blocker for an add-on–store submission — and lets the clients run fully self-hosted and offline. - Dependency housekeeping. OpenTelemetry (operator telemetry) moved to the
0.32 release train; every wildcard (
*) workspace dependency was replaced with a proper version floor andmail-authpinned exactly, hardening reproducible builds. No runtime behavior change.
2026-07-12 — v0.4.2 (marketplace GUI & modexp-free deploy)
Client-surface, tooling, and operator additions; no on-chain instruction, account layout, or error-code change — the new deploy build gates code out without touching the default ABI.
- The name marketplace, in the web clients and plugins. Browse
aliases and domains listed for sale, buy them, and
list your own — as a standalone web page and as a pane inside the webmail,
Outlook, and Thunderbird clients, with For sale / Expired / Sold filters
(default: For sale). Buying and listing sign with a connected Phantom or Ledger
wallet (external signing), distinct from the in-wasm keypair used elsewhere;
browsing and sale history read the
/v1/chain/listingsand/v1/chain/salesendpoints. See The name marketplace. - A modexp-free deploy build for
AdminCloseAccount.mail_programgained adnssec-proofCargo feature (default on); a--no-default-featuresbuild drops thesol_big_mod_expsyscall so postmaster reclaim — and everything else — can deploy to devnet/mainnet-beta today, where that syscall is still inactive, at the cost of DNSSEC-by-proof coverage in that build. See The modexp-free deploy build.
2026-07-12 — v0.4.1 (marketplace read API, wallet-adapter builders & privacy docs)
Off-chain API, client tooling, and documentation additions; no on-chain ABI change.
- Browse listings and sale history. New read endpoints back the marketplace:
GET /v1/chain/listings(aBrowseListingsscan of every alias and domain listed for sale) andGET /v1/chain/sales(ListSales— full alias + domain sale history, parsed from program logs). See the account API. - Wallet-adapter (unsigned) transaction builders.
mail_wasmgained unsigned builders for the marketplace and onboarding instructions, so a Phantom or Ledger wallet can sign externally — the basis for the marketplace GUI’s external-signing path. - What’s public and private. Two new pages map SithBit’s privacy model end to end: a plain-language What’s public and private overview and the exhaustive field reference — covering the harvest-now-decrypt-later trade-off and public marketplace-purchase metadata. Cross-linked from the threat model.
2026-07-12 — v0.4.0 (postmaster reclaim)
Adds an on-chain administrative instruction; it is append-only, so no existing client breaks.
- Reclaim and reset accounts. A new
AdminCloseAccountinstruction (in both programs) lets the standing postmaster delegate close program-owned accounts and refund their rent — the basis of a newsithbit postmaster reclaimtool (behind a compile-timereclaimfeature, with a mainnet typed-confirm guardrail). See The Postmaster.
2026-07-12 — v0.3.1 (postmaster CLI regrouping & custody docs)
CLI-surface and documentation changes; the underlying on-chain instructions are unchanged.
- Postmaster commands shortened.
postmaster install-commitmentbecomespostmaster commitment, andpostmaster rotate-delegatebecomespostmaster delegate(command labels only — theInstallCommitment/RotateDelegateinstructions are unchanged). See Postmaster key custody. - New reference material. A per-server RFC-coverage table and two service-discovery diagrams.
2026-07-12 — v0.3.0 (alias auctions & web onboarding)
- Alias auctions. Aliases can now be sold by ascending-bid
auction, not only at a fixed price: new
SellAlias(auction mode),BidAlias, andSettleAuctioninstructions, with an anti-snipe extension and a 90/10 fee split. Drive it withsithbit alias sell --auction/bid/settle-auction. See Auction an alias. - Guided web onboarding. A 5-step first-run wizard (create wallet → mailbox
→ alias → keys → earnings) now greets new users across the webmail, Thunderbird,
and Outlook clients, mirroring the CLI
setupflow.
2026-07-11 — v0.2.1 (CLI tooling & ergonomics)
CLI-surface changes only; no protocol ABI or economic change.
- Build a proof witness from live DNS. New
sithbit domain gather-witness <domain>collects a domain’s signed DNSSEC chain from a recursive resolver and serializes the witnessdomain authorize/domain reclaimstage, re-walking it locally before it is ever submitted (behind the opt-ingatherbuild feature). See Building the witness withgather-witness, and the zone-setup notes — including Cloudflare-hosted domains — in Publishing the DNS records the proof needs. - Root-KSK commands regrouped under
ksk.postmaster set-root-kskbecomespostmaster ksk set,postmaster root-ksk-from-ianabecomespostmaster ksk iana, and a new read-onlypostmaster ksk getprints the fingerprint currently anchored on thePostOffice(orunset). See Prerequisite: publish the root KSK. - Derive the root-KSK fingerprint from IANA.
sithbit postmaster ksk ianaturns IANA’s published root trust anchor (root-anchors.xml) into the base58 valueksk setwants — a pure offline conversion. Its new--fetch-anchors <DIR>downloads the anchor, its detached S/MIME signature, and ICANN’s CA bundle, then prints theopensslverification command and the follow-up derive step (it does not derive until you have verified). During a root-KSK rollover (two active anchors, as with the current KSK-2024 introduction) it warns and recommends the newest byvalidFrominstead of erroring blindly, and a new--key-tag <TAG>pins a chosen anchor. See Rollovers: more than one active anchor, Downloading and verifying the anchor, and Getting ICANN’s CA independently. - Fee getters grouped.
sithbit postoffice fee stamp/fee domain/fee aliasreplace the flatfee/domain-fee/alias-fee, and the delegate setters are nowsithbit postmaster fee stamp/fee domain/fee alias(wasset-stamp-fee/set-domain-fee/set-alias-fee). See The Postmaster → Checking status. - Alias transfer grouped.
sithbit alias transfer init/transfer accept/transfer cancelreplacetransfer/accept-transfer/cancel-transfer. See Transfer an alias. - Alias listing cancel folded in.
sithbit alias sell <alias> --cancelreplacesalias cancel-sell. See List an alias for sale → Cancelling a listing. - Alias create is variadic.
sithbit alias createnow takes one or more aliases (reading stdin when none are given), absorbingalias create-bulk, which is removed. See Bulk reservation. mailbox credentials.sithbit mailbox derive-passwordis renamedsithbit mailbox credentials(it prints the mail username + password pair).
2026-07-11 — v0.2.0 (Set 7)
- Frombox prepayment. A third-party sender’s first stamp purchase must now
include at least one stamp; a zero-stamp frombox can only be created by its
owner. The standalone
frombox createcommand was removed —frombox stampnow creates the frombox on first purchase. See Add stamps → The prepayment rule and Creating the frombox on first purchase. - Set a price before the frombox exists.
frombox updatenow creates the frombox first (as a zero-stamp, owner-only account) when it does not yet exist, so a recipient can set their per-sender price up front. See Update postage → Setting a price before the frombox exists. - Escrowed alias transfer in the web clients. The escrowed offer / accept / cancel transfer flow is now available in the web and email-plugin marketplace pane, matching the CLI. See Transfer an alias → Selling an alias: escrowed transfer for a fee.