John's plan, 2026-09-19: stop the Enterprise server and the ihasmail
container, install the fork at its own path, copy the data across, bring up
INBUXA Admin and the new webmail, and every account carries on.
That is a better shape than the in-place swap this draft assumed, because
the rollback becomes a service swap rather than a restore: the old install
and its data are untouched, so going back is stopping one unit and starting
another. What it costs is whatever the fork accepted in between, since the
two stores diverge the moment the fork starts.
It buys one failure the in-place swap couldn't produce: both servers on one
set of ports, each with its own store, if a reboot brings the old unit back.
So the unit is disabled, not just stopped.
Also written down: the front ends' OAuth clients travel inside the store, so
a front end that keeps its client id and redirect URIs keeps working and one
deployed fresh needs them set up, which fails looking like an account
problem when it isn't.
/etc/ufw/user.rules is world-readable, so this needed no privilege after
all. The default input policy is DROP and 8899 is not among the allowed
ports, so pebble's connection to the suite's listener is dropped. That
matches the live run, where twelve probes from a container completed no
handshake while the same openssl reached pebble's own TLS port.
The nc readings that pointed the other way — instant refusals on closed
ports, where a DROP should hang — are still unexplained, and are left on
the page as unexplained rather than quietly dropped, since they are what
sent an earlier pass through this page in the wrong direction.
Nobody has added the allow rule and re-run the suite, so the fix is
written down as a prediction. The cutover draft says the same: the test
failing here is not evidence that renewal works on the host.
Steps 1 to 3 of SPEC §7 are met, so the remaining one is running the fork
as the mail server. The draft covers the sequence on the host, what to
check before letting mail flow, and what to watch in the first week.
Two things it refuses to gloss: rolling back stops being a snapshot restore
the moment the fork accepts a message, because nobody has tested whether
the Enterprise build reads a store the fork has written; and certificate
renewal is the failure that arrives 90 days late and quietly, on the one
path the suites couldn't settle.
Nothing in it has been rehearsed. The rehearsal on a copy is step 2 of
"Before the day", and it is what turns "the data opens" into "the server
runs on it".
Run from a copy of the stopped server's RocksDB store. Eight green lines,
which are worth reading carefully: six carry weight, and masked_email and
undelete carry none, because there are no masked addresses and retention is
off, so they iterate an empty list. tenant_compat checked the tenant, its
quotas and its members, but not what a tenant administrator can see, which
needs a --tenant-admin recording.
ai_compat is the one that might have looked vacuous and isn't: the twelve
LLM_ tags are there with the scores that were observed.
Three things had to be fixed first, each failing all eight identically and
none about the data: listener names, privileged ports, pending tasks. The
next import's copy will bring the same three, so they are written down.
SPEC §7: cutover steps 1 to 3 are met. Step 4 remains.
The run against INBUXA's store hung printing "Waiting for pending task
AcmeRenewal(...)": the copy carries that server's task queue, and a renewal
due in 2026-11 will not come due while a test watches it.
Under NO_INSERT the wait now skips tasks that aren't due and ones that have
permanently failed, which leaves the tasks the test itself caused — a
restore in undelete_compat comes due at once — and gives up after a minute
with the offending task printed. A test that was really waiting on its own
work now fails on its assertion, which says more than a spinner.
Ordinary runs are untouched: system_tests, which waits on tasks throughout,
still passes in 135s.
The first run against INBUXA's store failed all eight tests identically,
before reading a single record: the copy carries that server's listeners on
25, 443, 465, 587, 110, 143, 993 and 995, and nothing in a test run is
root, so each one failed with "Permission denied (os error 13)".
The builder now remembers the listeners it adds, and under NO_INSERT drops
build errors for any it didn't. Every other error still stands, including a
bind failing on one of its own, so this can't hide the case where the
harness's own port is taken.
The copy isn't edited for this: its listeners are simply not what a compat
run needs, and it reaches the server over the compat- ones instead. Checked
that a NO_INSERT run still boots and that scim_tests, which takes the
ordinary path, still passes.
Rehearsed the run against a real RocksDB store, and it died at startup
before checking anything: the harness inserts listeners of its own, the
registry keys them by name, and a real server already has a "jmap" and an
"imap". The message was "Primary key conflict on property name with
existing object NetworkListener", which says nothing about what to do.
Under NO_INSERT the harness now calls its listeners compat-jmap and so on,
and the same run gets through to the test's own checks.
run-compat.sh copies the store for each test and removes the copy after,
because several of these write to what they open: monitoring_compat purges
the history it reads and undelete_compat restores what it finds. The source
stays untouched, which matters when it is the only copy of a production
store anyone took that day.
INBUXA runs RocksDB, so a copy is a directory copy. The SQL backends would
need more than this: the harness builds its own container and connects to
fixed local credentials, so it cannot open a dump in place.
record-compat.py ran against the live server as a server-level
administrator: 8 accounts, which matches the dashboard, so it reached all
of them. One tenant with its quotas and members; no masked addresses, no
archived items.
The empty files are right rather than short. INBUXA has no masked
addresses and retention is off, so masked_email_compat and undelete_compat
iterate an empty list: they pass without comparing anything, which is worth
saying plainly, because a green run from either would otherwise read as
evidence of compatibility. That puts them where scim_compat and
per_domain_directory_compat already sit.
So one recording carries weight, expected.json, and it is made. SPEC §7's
cutover steps 2 and 3 come down to the tenant, the domains and the
accounts until either feature is switched on.
Three of the eight compat tests check INBUXA's data against a recording of
how the Enterprise server read it, and that recording can only be made
while that server is still up. SPEC §7 gives it 45 days from the notice, so
the capture shouldn't wait on the cutover being scheduled.
record-compat.py writes all three files: the tenants with their quotas and
members and what each tenant administrator sees, every masked address and
its state, and every archived item whole, since undelete_compat compares
every property it recorded. It only reads, and refuses to send a method
that isn't /get or /query, because it is the one tool here that runs
against the live server. Queries follow their pages, so a server that caps
one doesn't leave a short recording behind.
Exercised against the fork's own test server, which answers the same JMAP:
3 tenants with members, 8 masked addresses and 3 archived items, each in
the shape its test reads.
Probing further contradicted the previous two commits. During a live run,
when the suite is certainly listening on 8899, twelve handshakes from a
container completed nothing, with or without the ACME ALPN, while the same
openssl in the same container talks to pebble's TLS port and prints its
certificate. A listener that is up but unreachable from a container is what
a ufw DROP looks like. An instant refusal on a closed port, which nc saw
from two images, is not. Both were observed minutes apart.
So the ufw suspicion is neither confirmed nor dismissed, and the page now
says that rather than picking the reading that suits the last probe.
Settling it needs `sudo ufw status verbose` and a listener bound by hand,
neither of which this session could do.
What stands on pebble's own log, and does not depend on any of this: it
runs validations and marks the authorizations invalid, so "never validates,
stays pending" was wrong.
The previous commit called the bridge open on the strength of one nc run.
A later openssl s_client against the same closed port hung for its whole
timeout instead of reporting the refusal nc had just seen. Repeating the nc
test from a second image, with a control port and two closed ports, agreed
with the first: immediate refusal, which is not what a DROP looks like. The
openssl behavior is still unexplained, so the claim now carries what was
measured and the anomaly beside it.
The ALPN probe is recorded as proving nothing, for the same reason: it
hangs against a port with nothing behind it, so its silence during a
renewal says nothing about acme-tls/1.
Pebble's log is untouched by any of this: it validates and marks the
authorizations invalid, which is what makes the old explanation wrong.
The page blamed ufw for blocking the docker bridge, and said pebble never
validates, so the authorizations stay pending. All three are wrong.
From a container on the ACME network the host answers on both gateway
addresses: port 22 connects, and 8899 refuses at once with nothing
listening, where a DROP would hang. No firewall rule was read or changed to
establish that. Pebble's own log shows 20 validation attempts in the
regression run, five for each of the four tls.org names, each ending in
"INVALID by completed challenge". The challenges are answered and refused.
So the order goes invalid, no certificate is issued, and the test unwraps a
None. What's left to explain is the TLS-ALPN handshake. The responder is
intact and listen.rs picks it per connection from has_acme_tls_challenge,
which is computed when the network config is parsed, while the test adds
its provider after boot. That's written down as a hypothesis, not a
finding: it hasn't been tested.
None of the eight had ever executed, so all eight ran against an empty store
with synthetic inputs. The plumbing works: the documented JSON shapes parse,
and NO_INSERT stops each one before the harness touches the store, which a
sentinel file in each store directory confirmed — it survived every run,
including the one launched without NO_INSERT.
Two things the runbook got wrong, both of which would have cost a day on the
day the copy exists:
- TMPDIR is the copy's parent, not the copy. The harness opens
$TMPDIR/<test name>, so a TMPDIR pointing at the copy gets an empty store
created beside it and the test calls INBUXA's data missing.
- masked_email_compat and undelete_compat need INBUXA_COMPAT_MASKS and
INBUXA_COMPAT_ARCHIVED, which only the tests' doc comments mentioned.
Every run ended on a 401 raised as "Missing list in response", which reads
as INBUXA's data being wrong when the login is what's wrong. Each test now
authenticates once first and names the variable that failed.
Every one of the 14 was `#[cfg(not(feature = "enterprise"))]` on the arm the
fork always compiles: the Enterprise arms went with the import, and nothing
turns the feature on. Removing the attribute leaves the same code, now
unconditional, in 11 files.
Two of them looked like behavior worth checking before touching: the
`validate_tenant_quota` stub that always passes, and the refusal to cancel a
pending DestroyAccount task. The stub is vestigial — the rebuilt
multi-tenancy enforces quotas in `crates/features/src/tenancy/quota.rs` for
those objects and more — and the refusal is undelete's open question, which
this change leaves exactly as it was.
The binary builds with no new warnings, and `system_tests` and `jmap_tests`,
which cover the touched registry, task-manager and auth paths, both pass.
The feature definitions stay in the manifests, inert: taking them out would
widen every sync's diff for nothing.
The feature held the shared tests of features the fork hadn't rebuilt yet.
All nine are built and the last gate came off with per-domain directories,
so the feature was defined and documented but gated nothing.
The crate builds and holds the same tests without it: 113 by default, 118
with postgres, mysql and redis. SPEC §2.2b keeps its account of the first
import and now says when the gates came off, so a spec that still describes
a suite as gated reads as the record of its own date, which is what
monitoring.md's gate table already calls itself.
Ran it single-threaded on RocksDb: 87 passed, 3 failed, 23 ignored, all 113
tests a default build holds. lmtp_delivery passed this time, which is what
the note about queue timing under a sequential run predicted, so the four
documented failures are three. The ACME suite logged no 400 at all, so the
renewal fix holds; it still ends on the certificate that never came,
because pebble can't validate across the bridge with ufw up.
The line it replaces claimed 86 passed, 4 failed, 27 ignored from the same
command. That totals 117, and no feature set of this tree produces 117 —
113 by default, 118 with all three backends — with no test added or removed
since. Said so rather than presenting the two as a trend.
A few of upstream's dual-licensed files carry code from other projects
under MIT or BSD terms. The fork redistributes it, so their licenses
require the notices to travel with it. THIRD-PARTY.md reproduces them.
strip.py now reads the stripped tree's comments for another copyright
holder, another license, or a note that code came from somewhere else, and
names any file THIRD-PARTY.md doesn't cover. It reports, never fails: the
notice goes in with the merge that brings the release in.
On v0.16.22 it finds 14 files, all of them covered. The rest of the report
is byte-for-byte what the committed one says, so the scan disturbs nothing
it already did.
Re-ran all nine on containers removed beforehand, one suite at a time.
All nine pass. mysql_replica_position_tests failed the first time on test
18's lag assertion, with the pair half a minute old and still catching up,
and passed on a second run against the same containers; it fails before
the point where it changes the replica's settings, so it leaves nothing to
restore. Noted both, with each suite's time.
The table's STORE column said "default" for five suites, which reads as
"leave it unset". The harness has no default: it panics with "Missing or
invalid store type" before the suite starts. They run on RocksDb, as the
regression does, so the column now names it.
The renewal loop re-posted the challenge every time it polled and found the
authorization still pending. RFC 8555 section 7.5.1 has the client post a
challenge once to say it is ready and then poll; a server that has already
moved the challenge to "processing" refuses a second post, and pebble
answers 400 malformed, which failed the whole renewal.
Verified against pebble: the 400s are gone and the client polls. The suite
still can't finish on this machine, because pebble never reaches the test
server to validate the challenge; that path is the environment, and the
runbook now says so.
`cargo test -p tests` runs none of the fork's own feature suites except
what's inside `system_tests`: SCIM, per-domain directories, the sharded
stores and the four replica suites are all ignored, each needing a
container, a STORE the harness only builds on request, or both. The
runbook lists what each one needs, and the container-reuse trap.
`allowScimProvisioning` is cached with the domain as DOMAIN_FLAG_SCIM, but
it wasn't among the fields whose change drops the cached entry, so flipping
the flag changed nothing until something else evicted the domain. SCIM-60
says the change takes effect without a restart.
Found by the two acceptance checks that were written but never called from
the driver, so neither had ever run: `authority` (SCIM-58 to SCIM-60,
through `synchronize_account` itself) and `rate_limits` (SCIM-14). Both are
wired in now, and `scim_tests` passes with them.
docs/spec/compat-tests.md lists each compat test, what it needs, what it
checks and what a failure means, and says plainly that they run against a
copy only, since the monitoring one purges the history it reads. The
scale-out and per-domain statuses record the acceptance tests that now
run.
Where the routing lives and what a read scope covers, which acceptance
tests the container suite runs, what isn't exercised (two nodes, the
primary stopped, and the MySQL paths, which are built but unrun), what
was settled from the code, and the known limits.
per_domain_directory_compat, ignored, checks a copy of INBUXA's data has
no directory, no server default and no domain with its own directory, as
observed. The status names where the rules live, which suite covers each
acceptance test and how far, what was settled from the code, and the
known limits. SCIM's status notes its test 5 now passes.
Where the code lives, which suite covers each acceptance test, what isn't
exercised, what was settled from the code, and that read-replica routing
(ST-5 to ST-15) waits for per-domain directories.
Where each part lives, which suite covers each acceptance test, test 5
deferred to per-domain directories and test 31 unrun until a copy of
INBUXA's data, what was settled from the code, and the known limits.
monitoring_compat, ignored, checks the observed settings against a copy
of INBUXA's data, reads the old history without an error, and purges it.
The status names where each part lives, which suite covers each
acceptance test, the tests not exercised, and the known limits.
The classifier sends only the subject and text, between unforgeable markers
after the operator's prompt, to an OpenAI-compatible endpoint the operator
configured; nothing is preset. Its answer maps to an LLM_ tag whose score is
clamped (+5.0, -1.0 by default) and can never discard or reject on its own;
X-Spam-LLM is sanitized, encoded and folded, and a planted one is removed.
Failures, timeouts past the ceiling, a full slot or a paused model leave
mail flowing untagged. llm_prompt answers trusted scripts, and accounts
holding interactAi within an hourly limit. Redirects aren't followed and no
content or secret is logged. The limits live in inbuxa:AiLimits.
Acceptance tests 1 and 3 to 21; test 2 as the re-enabled shared llm case,
whose setup no longer waits on a rules file from a developer's own path;
test 22 written as the ignored ai_compat.
Logos resolve domain, then tenant, then server-wide, then the built-in, with
subdomains finding their domain. GET /logo serves a data-URL image, redirects
to a URL logo without fetching it, sandboxes SVG, and answers 404 when no
custom logo applies. Emails embed the first PNG, JPEG or GIF logo. Logo and
template writes are checked; stored templates are read at send time, always
escaped, and fall back to the built-in with a build warning when they don't
parse. The RSVP page is served byte for byte with a CSP and no-referrer. The
sign-in and RSVP pages load the logo through an image element. MT-22's
session logo follows the chain to the server-wide logo.
Acceptance tests 1 to 17; test 18 written as the ignored branding_compat.
With archiveDeletedAccountsFor set, a destroyed account's record is kept in
the fork subspace with its id, its DestroyAccount task is due at the end of
the period, and its shares are suspended both ways. Its addresses can't be
taken by new accounts, aliases, lists or masks. inbuxa:DeletedAccount/get
lists kept accounts to server and tenant administrators; /set restores one
with a new password (same id, task cancelled, shares reinstated) or destroys
it now. The destroy task also clears undelete's own records.
Acceptance test 14; test 16 written as the ignored undelete_compat.
Found by running system_tests, which masked email no longer stops:
- rcpt_resolve rewrites a live mask to its owner's address, so
Delivered-To names the account; delivery recognizes the mask from the
original recipient when it belongs to that account.
- x:MaskedEmail/set create responses carry the server-set email.
- x:MaskedEmail/query returns every mask to a server-level impersonate
holder, and filters on accountId.
- The refusal for an unlinked emailDomain uses upstream's wording.
- The shared delivery test checks the fork's address format (ME-13).
- The masked email test's tenant domain uses manual DKIM, so its cleanup
leaves nothing behind.