{{ message }}
Repository navigation
Conversation
…ort security Ruby 4.0.7, Rails 8.1.3.1, Rack 3.2.7, Puma 7.0.4. STORAGE Engine-specific SQL is isolated behind a dialect selected by message_db.adapter. MySQL/MariaDB is the default; PostgreSQL and SQLite are supported. The dialect contract covers connection handling, identifier quoting, value escaping, schema-qualified table operations, index and column management, and translation of the declared column types into each engine's vocabulary. Empty IN () conditions render as 1=0 rather than a syntax error. A message body is written across raw_tables rows instead of as a single statement, so a body exceeding max_allowed_packet -- and the equivalent limits on other engines -- is no longer a failure. Chunk iteration guards against cyclic next references. Retention prunes the corresponding rows, terminates instead of looping when the table list is exhausted, and skips a raw_tables name that is not a date rather than aborting the run. update() joins conditions with AND, LIMIT/OFFSET are coerced, and the connection pool never checks a failed checkout back in; a replacement obtained on retry is treated as an ordinary connection again. Bodies at or above blob_store.threshold (default 1 MiB) can be held in a blob store instead of the message database: a filesystem tree, S3-compatible object storage, or FoundationDB, addressed by URL and off by default. All three backends generate keys from one place; filesystem keys are validated against the shared pattern so a tampered database value cannot escape the store directory, and S3 object keys use "/" separators on all platforms. Optional sinks accept the daily statistics: DuckDB, ClickHouse, and a time-series sink speaking the text protocol shared by VictoriaMetrics, Prometheus, InfluxDB and vector.dev. Time-series samples carry both the server permalink and the server id, so servers in different organisations sharing a permalink do not merge. Optional live statistics can be held in Valkey or Aerospike rather than in the message database; a non-default store outage is logged and delivery continues, while the default mysql store still propagates failures because there the store is the message database. Metrics export uses the same text protocol. Every one of these is optional and inert when unconfigured. SMTP AND INBOUND TRANSPORT SECURITY The inbound server advertises the current ESMTP catalogue from one declaration, so what is announced and what is implemented cannot drift. Both sides have an explicit TLS policy, and every unauthenticated entry point is rate limited. CRAM-MD5 challenges use a CSPRNG value with timing-safe comparison; a PROXY line naming an unparsable address is rejected; a nil SMTP-IP credential no longer crashes the lookup; and the pre-newline read buffer is bounded so a client that never sends a newline cannot grow memory without bound. The outbound client refuses to send credentials when the relay advertises no mechanism, and does not transmit a message the server has already refused via SIZE. The verified-TLS context pins the system certificate store, and the MTA-STS policy fetch does the same while refusing SSRF destinations through the shared address guard; unresolvable hosts fall through to the existing fetch-failure handling. Outbound delivery honours MTA-STS policies, and can be held to them strictly. For hosted domains: DKIM key size (1024/2048/3072/4096), rotation and change reminders are configurable; DMARC is checked and displayed but not managed; an MTA-STS policy is published and served with an ACME-issued certificate for its host (key files created mode 0600, not chmodded after the fact); and TLS reports are published and ingested. Ingestion is keyed on the local part of the recipient (dns.tls_rpt_local_part, default tlsrpt, blank disables), the report is attributed to the domain it concerns, reports for domains that no longer exist are discarded, and each report contributes at most 100 policies with 100 failure details each. SECURITY The reset form no longer reveals whether an account exists, and reset requests are throttled by address and period. Rate-limit keys are bounded so arbitrary input cannot grow the counter store, with an optional shared redis backend. Session policy is declared explicitly. OIDC account linking by email requires the provider's verified claim. The API no longer echoes rejected keys. Domain verification codes use CSPRNG values with timing-safe comparison, and invitation acceptance requires the invited address. The test message composer accepts only the fields its form submits. The session secret flows through Rails' secret_key_base chain, and proxies are declared through action_dispatch.trusted_proxies. TOOLCHAIN Rails 7.1.6 to 8.1.3.1 in staged steps with the framework defaults adopted last, so the version change and the behaviour change are separable. Rack 3 arrives with 7.2. The container image is ruby:4.0.7-slim-trixie with Bundler 4. Default and bundled gems used by the code (cgi, timeout, securerandom, base64, benchmark, logger, mutex_m, ostruct) are declared and required where used; the tracking pixel reads in binary. Sprockets and Turbolinks are retained. The json gem stays pinned below 3 because Active Support 8.1 still passes quirks_mode. AUDIT Two defects would have shipped and were invisible to a suite which only ever loads db/schema.rb: seven migrations could not run under Rails 8.1, so a fresh installation could not be migrated; and the loaded schema differed from the migrated one in nine datetime columns. Both are fixed and guarded by a schema-equivalence spec. Also fixed: URL credentials were not percent-decoded in the S3 and ClickHouse backends, MySQL and PostgreSQL dialects interpolated unescaped literals into catalog queries, a TLS report could outlive its domain, and the FoundationDB constant was unreachable under autoloading. Each guard has been confirmed to fail when the fix it protects is removed. COMPATIBILITY protection.store_url is renamed protection.counter_store. PostgreSQL requires a role with access to every schema the message database uses. Toolchain requirements move to Ruby 4.0, Rails 8.1 and Rack 3. VERIFICATION bundle exec rspec: 1553 examples, 1 failure (a live-DNS spec resolving an external name, failing identically on the base), 20 pending (optional-group specs skipping without their services). bundle exec rubocop: 392 files, 20 offences, all pre-existing and autocorrectable. The message-database suite passes against MySQL/MariaDB, PostgreSQL and SQLite; the optional image target builds; the web interface was booted on Rails 8.1.3 / Rack 3 with the proxy list and session secret resolving through Rails' own configuration. LIMITATIONS FoundationDB is implemented and spec-covered but never executed against a cluster. SQLite engine attribution rests on the environment variable, not direct observation. ACME issuance is verified against a stubbed directory only. Telemetry documents only the protocols the client implements.
c41ms0n
marked this pull request as draft
September 24, 2026 13:45
YJIT ships compiled into the ruby:4.0.7 image and needs no native toolchain at runtime. It stays off unless explicitly requested: the image maps ARG YJIT=0 to RUBY_YJIT_ENABLE, so the default build and the default run both leave it untouched. Enable it with --build-arg YJIT=1 or -e RUBY_YJIT_ENABLE=1; only the value "1" enables it. The spec locks the invariant: support present, off unless enabled, stats exposed when active.
Ships libjemalloc2 5.3.0-3 as an opt-in build variant (--build-arg JEMALLOC=1, ~900KB) that preloads only when JEMALLOC_PROFILE is set at run time (balanced or aggressive). The two profiles differ only in dirty/muzzy decay cadence; thread-local caching stays on and arenas keep jemalloc defaults, matching Ruby's small short-lived cross-thread allocation shape. Two platform details are handled explicitly: the setcap-capped ruby binary ignores LD_PRELOAD, so activation goes through /etc/ld.so.preload (verified mapped); and max_background_threads is set explicitly because background_thread alone spawns no decay thread. A bad profile or a missing library aborts container start instead of running misconfigured. The spec covers the profile mapping and the architecture-independent library lookup.
The 4.0.20 pin matches BUNDLED WITH in Gemfile.lock; now a default that can be overridden instead of a hardcoded literal.
… vanilla ES2021 Migrates the client-side stack to the Rails 8 frontend defaults while keeping the rendered UI byte-identical: no view logic, markup, class names, or helper output change in this commit. The user-visible surface -- navigation, remote forms and links, search-as-you-type, the throughput graph, the remember-me banner, and the verification-code boxes -- behaves exactly as before. JavaScript runtime - Turbolinks 5 is replaced by turbo-rails 2. Layouts adopt data-turbo-track, turbo-cache-control, and data-turbo-permanent; download links opt out via data-turbo="false"; the progress-bar selector follows the new class name. - jquery-rails and jquery-ujs are removed. A fetch-based remote adapter (elements/remote.js) implements the exact UJS attribute surface Rails still emits -- data-remote, data-method, data-confirm, data-disable-with -- and the JSON contract the controllers produce (redirect_to, alert, form_errors, flash, region_html). The one non-remote method link (logout) submits a hidden form with CSRF, matching rails-ujs semantics. - All five CoffeeScript sources are rewritten as dependency-free ES2021 (querySelector/classList/fetch/template literals), verified with node --check. The vendored jquery.multibox plugin becomes a framework-free Multibox class preserving filter, auto-advance, backspace, paste-spread, initial-value, and autofocus behavior. Asset pipeline - sassc/sass-rails is replaced by dartsass-rails. The nested entry point is mapped explicitly, Sprockets glob imports (unsupported by Dart Sass) are expanded to ordered explicit imports, and compiled output is git-ignored and rebuilt during precompile. - uglifier is replaced by terser, which is required to parse the ES2021 syntax above. Compatibility notes - Server-rendered data-remote/data-method attributes are unchanged, so no controller or view logic needed modification beyond Turbo attribute names. - Chartist remains vendored and untouched; mail_graph.js only changes its mount hook from turbolinks:load to turbo:load. Verification - Production asset precompile exits 0; a live boot serves login 200 with the Turbo bundle (no jquery() calls present) and the compiled stylesheet 200. - View/request specs covering the touched templates pass; the full suite and RuboCop results are unchanged from the parent commit.
…tric stores Vendor and product names had leaked into identifiers whose contract is generic, inviting reuse against a backend the name never promised. This renames the identifiers to what they do. No behaviour changes, except where the old naming hid a real limitation, which is now stated explicitly. Process metrics - HasPrometheusMetrics becomes HasMetrics, with register_counter, increment_counter, register_histogram, and observe_histogram. The SMTP server and client, the worker, and the dequeuer follow; their setup entry points become register_metrics and setup_metrics. The registry behind the health-server /metrics endpoint is unchanged. Metric stores - LiveStats::MySQL becomes LiveStats::Database: the SQL goes through the message database's own dialect, whatever the engine. - LiveStats::Valkey becomes LiveStats::KeyValue: any store speaking the Redis protocol (Valkey, Redis, or another drop-in replacement). - RateLimiter::Redis becomes RateLimiter::Shared: the scheme names the shared topology, and the protocol is what the code depends on. - Both dispatchers accept the same schemes as before. Read-back honesty - Telemetry::Client#query now refuses non-prometheus schemes instead of posting PromQL to a backend which cannot answer it. - The configuration descriptions state that only a Prometheus-compatible store backs the dashboard read-back and streaming; the influx and json schemes are write-only. Generated docs and PULL_REQUEST.md follow.
The Rails 8.1 upgrade left three pieces of the old stack in place: view helpers removed from the framework, a time library the framework now duplicates, and a DNS resolver which repeats work the network has already answered. This removes all three. Rendered output is unchanged throughout; only the delivery-time DNS path changes observable behaviour, and only by asking the network less often. VIEWS form_for and form_tag were removed in Rails 8.1 and keep working only through a compatibility shim. All 34 calls across 29 templates become form_with, preserving the data-remote attributes the remote adapter keys on. The new_framework_defaults_7_1/7_2 initializers contain only comments under load_defaults 8.1 and cannot take effect; both files are deleted. SPECS Rails ships travel_to, so the timecop dependency, its require, and its lockfile entries go away. ActiveSupport::Testing::TimeHelpers is included globally so lib specs can use travel_to as well as request specs. DNS Every lookup opened a fresh resolver and queried the network, so a burst of deliveries to one domain repeated the same MX, TXT and A/AAAA queries per message -- the MTA-STS TXT id lookup most visibly, since it runs on every delivery by design. Answers are now remembered per (name, type), honouring the lowest record TTL clamped to 60s..1h; misses are remembered for 60s so absent records are not re-queried per message either. The cache is per-resolver-instance and mutex-guarded, a clear_cache! hook backs the specs, and a remembered answer is returned whatever the timeout mode because a cache hit is not a timeout. Five cache specs (hit, type separation, TTL floor, expiry, negative caching) join the suite. VERIFICATION Full suite 1568 examples with only the known live-DNS failure (an external name resolving in this network), 22 pending. RuboCop clean on all touched files.
The spam-range slider carried four background layers per rule: a solid colour, -moz- and -webkit- prefixed gradients for Firefox 3.6-15 and Chrome 10-25, the standard gradient, and an IE6-9 DXImageTransform filter. Autoprefixer already emits any prefix an evergreen browser still needs, and the standard linear-gradient covers the rest, so every rule keeps only its solid-colour fallback and the standard gradient. Rendered output in supported browsers is unchanged; the Dart Sass build compiles clean.
…ection Authentication brute force is already limited per client address, but nothing limits what an authenticated credential can do: a stolen API key or SMTP password sends without bound, password reset redemption can be guessed at volume, and unauthenticated intake paths accept without limit. This closes the use gap with named quotas on the existing limiter. QUOTAS Five quotas share one definition table and one check_quota entry point on Postal::RateLimiter, reusing the counter store, fixed windows, and Retry-After semantics: api_send and smtp_send per credential, smtp_ip_send per matched SMTP-IP credential, reset_redeem per client address, and unauth_intake per client address and kind. Every quota defaults to 0 (disabled) so behaviour is unchanged until configured. Limits resolve per credential options first, then the global pair: a single key can be tightened or loosened without moving the ceiling for everyone. STORAGE Quota counters live in protection.quota_store, falling back to protection.counter_store, through the existing store selector -- memory by default, shared over the Redis protocol when configured. No new tables; enforcement stays synchronous on the hot path while exceed events also emit through Telemetry and Metrics without ever raising. CALL SITES The legacy API counts each send and read against the credential after authentication, refusing with the existing RateLimited shape; the SMTP server counts each queued message against the credential (SMTP or matched SMTP-IP) and each bounce, route and TLS-report intake against the client address, refusing with a temporary failure; password reset redemption is throttled before the token is looked up, keeping the anti-oracle response. VERIFICATION Full suite 1581 examples with only the known live-DNS failure, 22 pending. RuboCop clean on all touched files. Docs regenerated from the schema.
The unit suite drives SMTPServer::Client#handle directly and mocks the outbound sender, so wire framing, EHLO capability parsing, DATA termination, and the sender's session handling against a foreign peer were never exercised. The storage backends were covered backend by backend, but the failure and recovery behaviour -- dropped connections, refused keys, full buffers, oversized bodies -- had no shared matrix. This adds three integration specs that run in the default suite with no external services beyond the standard test database and loopback sockets. SMTP A Postal-to-Postal loopback serves real SMTPServer::Client sessions on a loopback port while Net::SMTP and SMTPClient::Endpoint drive delivery, proving a message lands in the message database, unknown recipients are refused without storing, pipelined commands batch, over-long lines are rejected, and DATA ends only on a bare dot. A scripted non-Postal peer covers the interop side: delivery the peer accepts, SIZE read from EHLO with pre-transfer refusal of oversized messages, RCPT refusal mapped to HardFail, and no-listener mapped to start returning false. The harnesses mirror the real server loop's framing -- split on LF, keep the CR -- and track sockets and workers so teardown is bounded rather than hanging on blocked readers. STORAGE The matrix pins the paths the unit specs leave alone: message write and read-back through the live test database, recovery from a dropped pooled connection, oversized bodies split across chained rows, database live statistics increment and total with unknown-scheme rejection, the filesystem blob store round-trip with invalid-key refusal and builder coverage for both the filesystem and inline schemes, the in-process rate limiter counting to exceeded and back through clear, and the telemetry publisher dropping the oldest sample when full and never raising when the backend is down. VERIFICATION Storage matrix and mock-peer specs pass live against MariaDB over host networking. The Postal-to-Postal loopback is verified statically (ruby -c clean, every referenced API checked against the implementation) and awaits one live run on a Docker-capable machine. Backends needing servers beyond the test database (PostgreSQL, Valkey, S3, FoundationDB) stay with their existing URL-gated unit specs; nothing here skips. Two live-run corrections are folded in: the connection pool lives on the Database class rather than its instances, and the live_stats type column is varchar(20), so the check prefixes are shortened to fit it. CAVEATS The loopback harness disables transactional fixtures (worker threads share the pool, so truncates instead) and the publisher specs stub long flush intervals so no background thread outlives the example.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
This branch makes the message database pluggable across engines, adds a blob store for message bodies, brings the SMTP client and server up to current protocol and transport-security expectations, and moves the application to Ruby 4.0.7, Rails 8.1.3.1, Rack 3.2.7 and Puma 7.0.4.
The default installation is unchanged. The message database still defaults to MySQL/MariaDB, bodies are still stored in the database, and no transport-security feature activates without configuration. What an existing installation sees is new optional configuration, a small number of new tables, and the toolchain move.
Implementation
Message database
Engine-specific SQL is isolated behind a dialect, selected by
message_db.adapter. MySQL/MariaDB is the default; PostgreSQL and SQLite are supported. The dialect contract covers connection handling, identifier quoting, value escaping, schema-qualified table operations, index and column management, and translation of the column types the message schema declares into each engine's vocabulary.A message body is written across
raw_tablesrows instead of as a single statement, so a body exceedingmax_allowed_packet— and the equivalent limits on other engines — is no longer a failure. Chunk iteration guards against cyclicnextreferences, and emptyIN ()conditions render as1=0rather than a syntax error. Retention prunes the corresponding rows, terminates instead of looping when the table list is exhausted, and skips araw_tablesname that is not a date rather than aborting the run. The connection pool never checks a failed checkout back in, and a replacement connection obtained on retry is treated as an ordinary connection.Blob store
Bodies at or above
blob_store.threshold(default 1 MiB) can be held in a blob store instead of the message database: a filesystem tree, S3-compatible object storage, or FoundationDB. The store is addressed by URL (inline://default,filesystem://,s3://,foundationdb://), and all three backends generate keys from one place. Filesystem keys are validated against the shared key pattern, so a tampered database value cannot escape the store directory. S3 object keys use/separators on all platforms.Statistics and telemetry
Optional sinks accept the daily statistics: DuckDB, ClickHouse, and a time-series sink speaking the Prometheus text protocol (also accepted by VictoriaMetrics), InfluxDB line protocol, or line-delimited JSON for a vector.dev pipeline. Time-series samples carry both the server permalink and the server id, so servers in different organisations sharing a permalink do not merge. Optional live statistics can be held in a key-value store speaking the Redis protocol (Valkey, Redis, or another drop-in replacement) or Aerospike rather than in the message database; a non-default store outage is logged and delivery continues, while the default store still propagates failures because the store is the message database. Only a Prometheus-compatible store can additionally back the dashboard read-back and live streaming; the Influx and JSON schemes are write-only. Every one of these is optional and inert when unconfigured.
SMTP server
The advertised ESMTP extensions are derived from a single declaration rather than assembled per session, so what is announced and what is implemented cannot drift. The server has an explicit TLS version policy, and every unauthenticated entry point is rate limited. CRAM-MD5 challenges use a CSPRNG value and the response comparison is timing-safe. A PROXY line naming an unparsable address is rejected, and the unauthenticated credential lookup tolerates legacy rows without an address. The pre-newline read buffer is bounded so a client that never sends a newline cannot grow memory without bound.
SMTP client
Relay delivery supports authentication (PLAIN, LOGIN, CRAM-MD5) and refuses to send credentials when the relay advertises no mechanism. A message which a server has already refused via SIZE is not transmitted. The verified-TLS context pins the system certificate store, and the MTA-STS policy fetch does the same while refusing SSRF destinations through the shared address guard; hosts that do not resolve fall through to the existing fetch-failure handling.
Outbound delivery honours MTA-STS policies, and can be held to them strictly.
Domain transport security
For hosted domains:
Key files are created with mode 0600 rather than chmodded after the fact.
Ingestion is keyed on the local part of the recipient (
dns.tls_rpt_local_part, defaulttlsrpt). Setting it blank disables ingestion rather than accepting any address without a local part.Security
The password reset form no longer reveals whether an account exists: a request for an unknown address is indistinguishable from one for a known address. Reset requests are throttled by address and period (
protection.web_password_reset_limit/web_password_reset_period).Rate-limit keys are bounded so that arbitrary input cannot grow the counter store without limit, and counters can be shared between workers via
protection.counter_store. Session policy (sessionsgroup) is declared explicitly rather than left to the library's defaults. OIDC account linking by email requires the provider's verified claim. The legacy API no longer echoes rejected keys. Domain verification codes use a CSPRNG value with timing-safe comparison, and invitation acceptance requires the invited address. The test message composer accepts only the fields its form submits. Trusted proxies are declared throughaction_dispatch.trusted_proxies, and the session secret flows through Rails'secret_key_basechain.Toolchain
Ruby 4.0.7, Rails 8.1.3.1 (with
load_defaults 8.1), Rack 3.2.7, Puma 7.0.4. Default and bundled gems used by the code (cgi,timeout,securerandom,base64,benchmark,logger,mutex_m,ostruct) are declared and required where used.YJIT is compiled into the Ruby 4.0.7 runtime and needs no native toolchain at runtime, but ships disabled: the image sets
RUBY_YJIT_ENABLE=0via theYJITbuild arg (default0). Pass--build-arg YJIT=1to enable it for the image, or-e RUBY_YJIT_ENABLE=1at run time; only the value1enables it.spec/lib/ruby_runtime_spec.rbguards the invariant: YJIT support present, off unless explicitly enabled, stats exposed when on.jemalloc 5.3.0-3 ships as a build variant, off by default:
--build-arg JEMALLOC=1installs the library, and-e JEMALLOC_PROFILE=balanced(or=aggressive) preloads it at container start. Only two profiles exist, deliberately — Ruby allocates many small short-lived objects across migrating Puma threads, so thread-local caching stays on and arenas stay at jemalloc's default; what varies is how fast unused pages return to the OS (10s/10s vs 1s/0s dirty/muzzy decay). The preload goes through/etc/ld.so.preloadrather thanLD_PRELOADbecause the image's setcap-capped ruby binary ignores the latter (verified: mapped in/proc/self/mapsonly via the preload file).max_background_threads:1is set explicitly becausebackground_thread:truealone spawns zero threads and pages are never purged.abort_conf:truerefuses to boot on a bad profile or a missing library rather than running misconfigured.spec/lib/postal/jemalloc_spec.rbcovers the profile mapping and the library lookup. Measured on a 200MB burst workload: churn ~20% faster than glibc, steady-state RSS higher (~95MB vs ~15MB retained pool, reused rather than regrown across bursts), aggressive back to baseline within seconds of sustained allocation pressure. Combines cleanly with YJIT.Configuration and compatibility
New or changed configuration, all inert unless configured except where noted:
message_db.adaptermysql(default),postgresql,sqliteblob_store.urlinline://default)blob_store.thresholdanalytics.urllive_stats.urlmysql://default)telemetry.*dns.tls_rpt_addressdns.tls_rpt_local_parttlsrpt)dns.dkim_key_sizemta_sts.*smtp_client.mta_stssmtp_client.minimum_tls_versionprotection.counter_storememory://default,redis://shared)protection.web_password_reset_limit/web_password_reset_periodsessions.*rails.secret_keySECRET_KEY_BASEat bootCompatibility points:
ruby:4.0.7-slim-trixiewith Bundler 4. Thejsongem stays pinned below 3 because Active Support 8.1 still passesquirks_mode.Database and storage changes
Main database, all for transport security (one migration):
tls_reports— a submitted report, one row per report.tls_report_results— the per-failure detail of a report.acme_challenges— the challenge records used for certificate issuance.domainsgains DKIM rotation, DMARC status, MTA-STS policy and certificate, and TLS-RPT status columns.The message database gains the
raw_tableandraw_headers_idcolumns onmessages, theraw_tablesrelation holding the body rows, and ablobcolumn on raw tables referencing the blob store.Two corrections to the existing migrations, both needed for a fresh installation:
ActiveRecord::Migrationwithout a version, which Rails 8.1 refuses to run.One of them is the initial schema, so a new installation could not be migrated at all.
datetimecolumns were declared without the precision the migrations produce, so the schema the application loaded differed from the schema a migration produced. They are now declaredprecision: 6.The schema version and collation are unchanged. The transport-security schema is carried by the single migration that describes it.
Testing
bundle exec rspec— 1563 examples, 1 failure, 22 pending.bundle exec rubocop— 395 files, 20 offences, all pre-existing and autocorrectable.optionalimage target, which carries the optional backends, builds.The failure and the pending specs are both environmental; both are described under Known limitations. Coverage added with this work includes the schema equivalence between a migrated database and the loaded schema, the columns the SQLite dialect must normalise as booleans, credential decoding in the S3 and ClickHouse backends, escaping in the dialect queries, the OIDC verified-email requirement, and the non-echo of rejected API keys.
Known limitations