Repository navigation
v0.4.0
[0.4.0] — 2026-09-23 — Early Access
This section lists every breaking change since 0.3.2, each marked BREAKING, and summarizes the
non-breaking fixes rather than listing them all; the git history is the full record. A BACKLOG #N
or bare #N number cites the project's private planning ledger, so it has no public page.
Added
messagefoundry audit-anchor, andaudit-verify --expected-anchor/--expected-anchor-fileto
check one back. The audit hash chain links each row to its predecessor, so deleting the newest
rows leaves a shorter chain that still walks cleanly —audit-verifyon its own reports OK after a
tail-truncation, which is the shape an attacker hiding what they just did leaves behind. The store
could always compare against an external anchor; nothing exposed it, so the capability was
unreachable.audit-anchorprintsCOUNT:HEAD(a row count plus a digest — no PHI, no secret, safe
to hold in a ticket or an object store); passing it back reportstruncated or rewrittenwhen the
live chain differs. An anchor file must be UTF-8. PowerShell 5.1's>writes UTF-16, which the
engine cannot read, so pipe the line toSet-Content -Encoding utf8instead.
Know what it is before you build a job on it: an EXACT point-in-time seal, comparing the count
and the head hash. The head half is not redundant — an attacker who cuts the newest rows and forges
the same number of replacements restores the count and leaves a chain that walks cleanly, so the head
is the only thing that differs. The cost of that detection is that a chain which merely grew also
reportstruncated or rewritten. So it seals a chain at rest across a gap in custody: quiesce the
engine, anchor, hold the value off-box, re-verify while the chain is still quiesced — around a
maintenance window, a database move, a backup/restore, a hand-off. Anchoring and immediately
re-verifying compares a value to itself; re-checking a held anchor against a running engine alarms
on every ordinary boot. For a running engine the off-box log forward / tee remains the continuous
control, and the next entry adds a check at each startup. (BACKLOG #328)[integrity].audit_anchor_file— the startup audit check can now hold an anchor, so it can see a
truncated tail. On its own,[integrity].audit_verify_on_startis a bare walk, blind to a
truncated tail. With this key andaudit_verify_on_start = trueboth set, the startup check can
see one. Save theCOUNT:HEADlinemessagefoundry audit-anchorprints to a UTF-8 file, as the
entry above describes, and point the new key at it; every startup that runs the check compares
against it. The key alone arms nothing: with
audit_verify_on_startleft at its default offalse, startup logs a WARNING that the anchor is
never read. Leave the key empty (the default) and the walk is byte-identical to before.
It consumes the anchor as a PREFIX, not as the CLI's exact seal, and that is the whole reason a
startup setting can hold one. The exact seal compares the current head, so it diverges on the
next appended row — and a running engine writes audit rows, so a startup check built on it would
alarm on essentially every restart. The prefix comparison asks instead whether the recorded state was
ever true and the chain has only grown since, which survives restarts while still catching a
truncated tail and a mid-chain rewrite. A stale anchor therefore stays valid; it just witnesses less.
Alert-only in both directions. A missing, unreadable or malformed anchor logs a WARNING naming
the file, the reason and the coverage lost, then lets the bare walk run — it never blocks startup,
and it never fires the tamper alert, because a config fault that raised a tamper alarm would train
operators to ignore the real one.0:, the anchor of an empty log, is reported rather than compared:
it can witness nothing, and passing it on would have alarmed on every start of an intact chain.
A truncated tail and a broken chain fire different alert subjects (audit-chain-truncated/
audit-chain) so they route and throttle separately — but they are not independent: a chain break is
reported before the anchor comparison runs, so a break should be read as at least a break.
Scope. It fires at startup and only at startup, so it detects a cut made since the last boot and
says nothing about the window between two boots. (BACKLOG #328)- A startup preflight that reads the store principal's effective privileges, so the least-privilege
grant the runbooks prescribe stops being a claim the engine cannot check.
docs/DEPLOY-SERVER-DB.mdtold operators exactly which grant the engine's
database login needs, and the engine had no way to see what it had actually been given: no
fixed-server-role probe and no database-role probe existed anywhere, and
[store].require_managed_identityconstrains the credential's kind rather than its privilege — a
sysadmingMSA satisfies it clean. On a first deployment an over-granted store principal would
therefore have gone unobserved.servenow reads fixed server-role and database-role
membership plusCONTROL SERVER/ databaseCONTROLon SQL Server, and role attributes
(SUPERUSER,CREATEROLE,CREATEDB,REPLICATION,BYPASSRLS), assumable predefined roles and
database ownership on PostgreSQL — before any listener binds. The PostgreSQL attributes are read
across every role the principal may assume, not only its own row: attributes are never
inherited, but a member maySET ROLEto the holder and exercise them, so a wrapper role carrying
CREATEROLEis named (CREATEROLE via role site_ops) instead of reading clean.
It observes and warns; it does not refuse by default — refusing on an over-grant could block a
legitimate deployment mid-setup, and the engine does not own the grant. Every start logs what it saw,
writes astore_privilege_preflightaudit row, and names each excess grant in
security_loosenings()andGET /security/posture. Set[store].require_least_privilege = trueto
turn the warning into a refusal (refuse/warn splits on[security].enforcement, exactly like
require_managed_identity).
It does not fail open, and that is the part to know before reading its output. A probe that
cannot run — permission denied, a driver error, a store handle with no probe — reports
unobservable, which is a different result from "observed, and it is fine" in the log line, in the
audit row and in the posture response, and which a declaredrequire_least_privilegealso refuses.
SQLite reportsnot_applicableand says why: a local file has no server principal, and the control
there is the filesystem ACL. The PostgreSQL least-privilege grant is now documented
(docs/DEPLOY-SERVER-DB.md§1.2), which it previously was not.
(BACKLOG #1008) - Three new alert events:
approval_stale_requester,ad_session_revokedand
ad_reconcile_aborted. The first fires when a dual-control release is refused because the
requester no longer holds the authority it needs (under Security below). The other two come from
the directory session reconciler. It raises onead_session_revokedper account whose sessions it
revoked, and onead_reconcile_abortedwhen its mass-revoke breaker stops a pass. Each has a
matching audit row:approval.stale_requester,auth.ad_session_revokedand
auth.ad_reconcile_aborted. A pass that stops because the whole directory is unreachable raises
no alert, and neither does a pass that fails part-way. An[[alerts.rules]]event_typecan now
name all three, andanymatches them too. (BACKLOG #289)
Removed
- BREAKING:
[security].handles_real_patient_datais gone, and with it the whole data-class axis.
Every instance carries patient data; the PHI gates apply unconditionally. Setting the key — or its
pre-ADR-0118 spelling[ai].data_class— now refuses at load with a message naming the switch to
reach for instead. Removed with it: theDataClassenum,HopPosture.is_phi, thedata_classand
synthetic_relaxationfields onSecurityPosture, anddata_classonAiPolicy.
derived_posture()/require_posture()return the production tier alone.
Why, in one line: it turned off nineteen start-up gates on one line, and it was not the audited
opt-out the documentation claimed.security_loosenings()never named it, so the serve-time
loosening warning — the thing that fires for every other deviation — did not fire for the widest
relaxation the product shipped. The completeness test that should have caught that exempted the field
with a reason that was false, in an exemption branch that could never execute.
What to use instead: the gate you actually mean. Each is separately named, separately audited and
separately reported —allow_unencrypted_phi(plusallow_unencrypted_phi_under_strict_enforcement
under the shippedenforcement = enforce),block_unlisted_outbound,
allow_keeping_phi_indefinitely,allow_single_factor_admin_when_exposed,
allow_unverified_alert_smtp_tls,[alerts].security_notifications_required, a per-connection
cleartext_accepted, a CRL for a revocation-checked hop, or the[security].enforcementdial.
(A per-connectiontls_revocation_attestedexists in the engine but no factory parameter or
connections.tomlkey can set it.)
What this costs: a box that ran key-free on the declaration now needs a key or the audited
per-gate ack. Nothing is deployed (there is no migration), and both in-repo users of the declaration —
CI's SQL Server load leg and the failover load harness — moved to per-gate relaxations that are
narrower than what they replace. See
docs/adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md
andBACKLOG #1279. - BREAKING —
[egress].fhir_require_structured_paramsis gone, and with it the flat?-query form of
fhir_lookup. In 0.3.2 the setting defaulted tofalse, so a read query such as
"Patient?identifier=..."was sent as written unless a site opted out. The flat form appended the
author's string unencoded, so it was removed rather than left behind a setting. A query carrying?
now raisesFhirLookupErrorwhatever the config says. With nothing left to switch, setting the key
now refuses at load, under the unknown-key refusal in Changed below.
Migration: delete the key frommessagefoundry.toml. Move each flat query toparams=, so
fhir_lookup("epic", "Patient?identifier=MRN|" + mrn)becomes
fhir_lookup("epic", "Patient", params={"identifier": FhirToken("MRN", mrn)}).
(BACKLOG #1243,docs/adr/0043-fhir-read-lookup.md) - BREAKING — the Active Directory password sign-in (LDAP simple bind) is gone.
POST /auth/loginwith theadprovider now answers401and writes an audit row with the reason
pathway_retired.GET /auth/providersreportsad: false, and the web console no longer offers
the form. AD users sign in through Windows SSO (Kerberos,POST /auth/negotiate) or OIDC. The
directory bind itself stays, because group mapping, the session reconciler and an AD account's
step-up re-authentication still use it. Migration: turn on[auth].kerberos_enabledor OIDC
for AD users, and change any client that posted an AD username and password to/auth/login.
(BACKLOG #1137) - BREAKING —
Soap(ws_password_type="digest")is gone. The WS-Security PasswordDigest form
hashes the password with SHA-1, so it was retired with the other weak algorithms. A SOAP outbound
that sets it now fails at load and atmessagefoundry check. Migration: set
ws_password_type="text"over TLS; the partner must accept PasswordText. (BACKLOG #1171) - BREAKING —
messagefoundry.apiclientdrops theaudit_summarykeyword fromlist_messagesand
list_dead_letters. No route ever read it. A call that passes it raisesTypeError.
Migration: drop the keyword. (BACKLOG #1645)
Changed
- Setting
[integrity].fail_closed_on_drifton an editable install now says so at startup, and two
claims about startup attestation are corrected. An install that declares itself editable is exempt
from attestation by design, so a dev checkout is never bricked. That exemption silently cancels the
fail-closed opt-in, and the code path returned with no log, no audit row and no alert -- so a first
deployment that opted into hard enforcement on an editable install would have started with its
tripwire disarmed and nothing in the boot log to read. It now logs a WARNING naming the reason. This
reports a misconfiguration; it closes no hole -- an actor who can write the virtual environment can
plant the editable marker or rewrite the check in the same single write. AC-12's exemption is
unchanged: still no refusal, no audit row, no alert, and silence under the default alert-only posture.
Two ADR 0041 D3 claims were false in the shipped code and are narrowed rather than left standing: the
non-editable hash-locked wheel is a recommended production default, not an enforced one (nothing
in the engine refuses an editable install), and attestation runs at startup only -- there is no
on-demand surface, noattestCLI subcommand and no API route. ADR 0041 D3 also now records the
resolution of the baseline's trust domain: the wheel's ownRECORDstays the baseline, no runtime
out-of-domain anchor is adopted, and the control detects an inconsistent in-place edit and not a
consistent one. (BACKLOG #1679) - BREAKING — an Active Directory login is now identified by the directory's immutable id, not by
sAMAccountName. A directory frees a deleted account's name and may reissue it to a different
person. The engine resolved an AD principal by that name, so a recycle without a matching
MessageFoundrydelete_useradopted the departed operator's row and re-bound itsuser_id-- the
value uploaded-file ownership, the per-uploader quota and saved search presets all key on. Nothing
reported it.AdPrincipalnow carries the normalisedobjectGUID,users.directory_object_id
stores it on all three store backends (nullable, in-place upgraded, no index), and_upsert_ad_user
resolves by that id. A login whose id disagrees with the row holding its username is refused and
audited (directory_identity_conflict), never adopted or backfilled -- backfilling on first sight
would leave the recycle window open for every account that had not signed in yet. A directory that
returns no immutable identifier still resolves by username, and the engine warns once per distinct
cause -- the attribute absent, or present in a shape it cannot read -- so a site on that path is
told rather than left to assume the control is running. A directory-side rename now keeps the
account instead of minting a second one, which is the other half of the same defect: before this,
a rename resolved to nothing and silently orphaned the uploads and presets keyed to the first row.
Who this bites: every AD account row that 0.3.2 created has no stored id. Its first 0.4.0
sign-in through a directory that returnsobjectGUIDis therefore refused as
directory_identity_conflict. Migration: an administrator deletes each such MessageFoundry
user row (DELETE /users/{user_id}; on a store created by 0.3.2, that call fails until the
migration in the BREAKING saved-search entry below is applied, including its recovery step on
PostgreSQL and SQL Server), and the person's
next directory sign-in creates a new row bound to the id. Uploaded files, saved search presets and a
per-user channel scope keyed to the old row do not carry over. (BACKLOG #1471) - The directory session reconciler is keyed on that same immutable id, and the stored username is
now a cache the directory refreshes. Identifying a login byobjectGUIDwhile
reconcile_directory_sessionswent on probingresolve_principal(<the stored name>)left a renamed
account reading as absent on every pass -- the same answer a deleted or disabled account gives -- so
at the shippedad_session_recheck_seconds = 300andad_session_recheck_strikes = 2a deploying
site would have seen a renamed person's sessions revoked, a security notice emailed, and the cycle
restart at the next sign-in, roughly every ten minutes and with no administrative escape, because
nothing in the engine could writeusers.username._probe_principalnow asks the directory by
directory_object_idwhere the row carries one, and the directory's currentsAMAccountNameis
copied down onto the row -- from the login path and from the reconciler pass alike -- so the
user_idthat uploaded-file ownership, the per-uploader quota and saved search presets key on never
moves. A rename onto a name another row already holds is refused, not forced (usernameis
NOT NULL UNIQUE): the login path refuses at thedirectory_identity_conflictguard, and the
reconciler leaves both rows alone and auditsauth.ad_username_refresh_conflict, costing the renamed
person neither their session nor their roles. A directory returning no readableobjectGUIDstill
probes by name, unchanged.set_user_usernameis engine-internal and reachable from no API route,
deliberately -- an operator able to set it could point a row at a directory account it is not bound
to, which is the privilege transfer#1471closes; there is still no setter for
directory_object_id. (BACKLOG #1532) - BREAKING — web console engine UI seam: this release ships
75c4117d21fd0b98. 0.3.2 shipped the integer seam
14. The seam is now a digest of the surface the console uses (BACKLOG #1220), and it moved several
times in this release. One move,93ba1f10b9dccfc8->b93f38d097f97a45, came whenSecurityPosture
gained the additivestore_privilegeobject above andStorePrivilegeViewjoined the discovered
surface. That field is additive with a default, but the seam still moves, because the golden seam
contract introspects that model's field set. A console accepts exactly one seam, so with the console
on, the engine refuses to start against a console built for any other seam. Migration: upgrade
the web console wheel together with the engine, to the release built for this seam. DEPLOY-SERVER-DB.md§1.2 posture B now states its prerequisite. "A DBA pre-creates the objects"
is not sufficient on its own: the engine skips its DDL batch only when theschema_metamarker
records the current batch, and on PostgreSQLCREATE TABLE IF NOT EXISTSagainst an existing table
is still refused for a role holding onlyUSAGE(the schema ACL is checked before the existence
skip, measured on 16.14). Bootstrap once with a DDL-capable principal, then hand over.- The advisory
raise-fstringlint inmessagefoundry checknow reads three more spellings of the
same risk. It matched only an f-string, soraise ValueError("bad " + x), the%form and
.format(...)carried an interpolated message past it — the identical free-text PHI payload, in the
spellings an author is most likely to reach for after an f-string. It now shares the predicate the
ADR 0144 lookup lint already used, so the two cannot drift on what counts as interpolation. A
deploying site's existing config dir may therefore report hits it did not report before: the check
is advisory and still only ever prints, so it cannot block the gate, and its detail names a file and
line, never the message text. The check keeps the nameraise-fstring. It stays a nudge rather
than a boundary: it reads only the first positional argument of theraise, so a message assigned
to a local first, passed as a keyword or a later positional, or wrapped in a call is still
unflagged. (BACKLOG #1676) - BREAKING — an API request body with an unknown or misspelled key is now refused with HTTP 422
instead of being accepted and silently dropped. Pydantic's default isextra="ignore", and in
0.3.2 no model inmessagefoundry/api/models.pyormessagefoundry/api/auth_models.py
overrode it — so a
key the engine did not recognise vanished and the route answered success. The sharpest case was
PUT /users/{id}/channel-scope: in 0.3.2channelswas optional andNonemeant all channels,
so{"chanels": ["IB_ACME_ADT"]}asked for one connection and granted every one of them. (None
now means no channels; see the BREAKING channel-scope entry under Security.)
The posture is request-scoped, and that is the whole design. Those two files now hold 130
models. The 33 that FastAPI parses out of a request body subclass
messagefoundry.api.request_model.RequestModel, which forbids unknown keys; the 97 response-only
models stay tolerant, becausemessagefoundry.apiclientreads
engine responses into those same classes and the web console ships as a separately-versioned wheel
— a strict response model would make an older client raise on a newer engine that merely grew a
field. Five shapes (AdGroupMap,AdGroupMapEntry,AdGroupScopeEntry,AdGroupScopeMap,
ChannelScope) travel in both directions; they carry the request rule because a dropped key on the
RBAC writes is a mis-grant, so adding a field to one of them needs the client bump in the same
release. Migration: send only the keys each route defines. The422lists every refused key
as its owndetailentry, of typeextra_forbidden. The key is the last element ofloc, as in
["body", "limt"]. Fix its spelling or drop it. (BACKLOG #1109) - BREAKING — a
fhir_lookupsearch value now states its KIND, and a plain string carrying one of
FHIR's value-layer separators is refused rather than sent. Percent-encoding is a URL-layer
control: it stops one value becoming two search parameters, and it cannot help at the FHIR value
layer, where
,|and$are FHIR's own separators. The FHIR specification is explicit that a server
percent-decodes a parameter value first and reads FHIR's syntax second (R4 section 3.1.1.4.19, R5
section 3.2.1.5.7), so%7Carrives as a live token separator. A message-derived value carrying one
could therefore change what the search means.
Three kinds, because one string cannot carry two provenances. A plainstris data and raises a
PHI-safe error if it carries,|or$— the error names the parameter key and the character,
never the value.FhirToken(system, code)splits"MRN|" + mrninto its two halves: the system is
your literal and passes through, the code is data and screens.FhirRaw("...")is FHIR search syntax
you wrote — a composite, a quantity, a comma-separated OR or_sortlist — percent-encoded only.
Refusal rather than FHIR's backslash escape, deliberately: the escape is correct only if the far
end implements the unescape, and server behaviour there varies, whereas a value that never leaves the
process cannot be misread by any server. Escaping is not built; it is left as a possible additive
fourth kind for a site that has a real FHIR server and can verify it.
Migration:{"identifier": "MRN|" + mrn}becomes{"identifier": FhirToken("MRN", mrn)}, which
puts identical bytes on the wire. ImportFhirToken/FhirRawfrommessagefoundry. A non-string
scalar also raises now — it was never in the declared type, buturlencodeused to coerce it, so
{"_count": 50}has to become{"_count": "50"}.
What is NOT screened: the backslash. FHIR names it alongside these three because it introduces
the escape, so a server that implements the unescape reads a bare\as an introducer. Widening a
refusal is a behaviour change that should be ruled, so it is recorded on
messagefoundry/fhirsearch.pyrather than folded in here.
(BACKLOG #1243,docs/adr/0043-fhir-read-lookup.md) - The authorization-grant audit trail now defaults ON, so a deployment records every authorization
grant rather than only the state-changing ones.[security].audit_all_authorization_decisionsand
the internal[diagnostics].audit_all_authzit desugars to both defaulttrue. Until now only a
fixed set of state-change / configuration / user-management permissions wrote an
auth.permission_grantedrow, so every authenticated read was authorized and never recorded — and
a site could not reconstruct a read history afterwards, because the rows did not exist.
What the old default guarded against was measured, and it named the wrong surface. The reason on
record was that full tracing would flood the hash-chained audit log through console polling and the
/ws/statsfeed. The web console never traversesrequire()— it is server-rendered in-process and
gates on its own cookie-world check, which records denials only — and WebSocket authorization fires
once per connection, not per message.
The volume moves rather than vanishing, so size it. The JSON API is the surface that changes: 33
require()-gated GET routes inapi/app.pyand 9 more inapi/auth_routes.pygo from no grant row
to one row per authenticated request (a per-request ceiling of one, whatever a route's permission
count), bounded by your API clients' polling cadence. Nothing prunesaudit_log—
[retention].audit_daysis reserved and unenforced by design — so[retention].max_db_mbis the
signal to watch. The per-request cost is a commit, not just a row: the grant write is awaited
before the route body runs, takes the store write lock, and commits standalone (audit is excluded
from the group committer), so a busy JSON-API deployment pays one extra commit per authenticated
request on the same lock the pipeline handoffs use.
Set[security].audit_all_authorization_decisions = falseto restore the previous
narrow trail; that is now reported as a loosening atserveand onGET /security/posture. PHI-view
grants stay excluded at either value, because the PHI-access audit path already records them.
(BACKLOG #1277,docs/adr/0118-secure-by-default-security-configuration-section.md§5 amended) - BREAKING — a PHI instance reached through a declared reverse proxy with
[security].require_mfa
explicitly off would refuse to start on first deployment, where it previously would not have. The
MFA-at-exposure gate derived "is this instance exposed?" from[api].serve_ui, a field the ADR 0143
console degrade arms rewrite in place earlier in the same startup. On the topology the runbooks
recommend — a loopback bind behind a declared TLS terminator, with the web console left at its
default — the auto-degrade cleared that flag first, so the gate evaluated "not exposed" and the
refusal was unreachable, while the ASVS 11.7.1 arm in the same startup classified the identical boot
as exposed. The gate now reads a single console-independent predicate (an off-loopback bind or
[api].tls_terminated_upstream), so it also fires when the console is auto-degraded, when
serve_web_console = falsedisables it outright, and when the console package is simply not
installed: the surface authenticating with one factor is the JSON operator API, which the proxy
serves either way. The#189dual-control advisory reads the same predicate and gains the same reach
(still warn-only).
Who this would bite: a deploying site that has explicitly setrequire_mfa = falseon a
PHI-carrying environment behind a declared TLS terminator, underenforcement = enforce. A plain
loopback bind with nothing declared is not exposed and is byte-identical. An undeclared proxy
(web_console_public_addressset, notls_terminated_upstream) deliberately still does not refuse —
exposure there would be an inference — but it no longer passes in silence: a new warning names
single-factor admin directly on a PHI instance withrequire_mfaoff.
Migration: set[security].require_mfa = true, or set the existing acknowledgment
[security].allow_single_factor_admin_when_exposed = true, which turns the refusal into a loud
audited warning. (BACKLOG #326,docs/adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.mdamendment) - BREAKING — an
[[alerts.rules]]block that routes to an unconfigured transport now refuses at
startup instead of being silently ignored.notifier_from_settingsreturned early when no
transport was configured, before the loop that cross-checks each rule'stransportsagainst the
ones that exist. So the fail-loud guarantee held everywhere except the state an operator is most
likely to be in while first setting alerts up: with one transport configured, a rule naming a
different one was a hardValueErrorat startup; with zero configured, the identical rule was
accepted and then never applied, and nothing said so. Validation now runs first.
Who this bites: an instance where[[alerts.rules]]exist AND at least one rule or escalation
tier sets a non-emptytransportsAND no transport is actually configured (webhook_urlunset, and
not all three ofemail_smtp_host+email_from+email_toset — the email transport needs
all three, which is what makes a half-filled[alerts]block look configured). Such an instance
starts today and will refuse after upgrading.
Why this is safe to take: in that state the rule has never routed a single alert. The
refusal removes no working behaviour — it converts a permanent silent no-op into a startup error
that names the exact keys to add. A rule that names no transport is unaffected and still starts
(now with a warning when rules exist that cannot notify anyone), so the ordinary "write the rules
first, wire the transport later" flow keeps working.
The same cross-check now runs at authoring time:messagefoundry alert add(which the VS Code
"New Alert" command shells) refuses a rule routing to an unconfigured transport rather than
persisting a file that only fails at the next boot. It is scoped to the rule being added, so a file
that already contains a bad rule can still be repaired withalert remove.
Migration: configure each transport a rule or escalation tier names (webhook_url, or all
three ofemail_smtp_host,email_fromandemail_to), or remove it from thattransportslist. - A mail-only or Direct-only PHI instance can now satisfy the open-egress startup gate by declaring
its destinations.[egress]has eightallowed_*lists and all eight are enforced downstream,
but the startup gate hand-enumerated six:allowed_smtpandallowed_directwere absent, so an
instance whose only egress isEmail()orDirect()exited 2 with "outbound egress is
UNRESTRICTED" while holding a fully-enumerated allow-list, and nothing in the message named the two
lists that did not count. The two are now counted — deliberately only when
[security].block_unlisted_outboundis left unset, which is exactly the state the deny-by-default
flip turns ON, so such an instance starts fail-closed. An instance that explicitly set
block_unlisted_outbound = falseis unchanged and still refused, because there the other six
transports stay allow-any; the refusal now names that override as the reason. No shipped refusal
stops firing. - BREAKING — a non-loopback DICOM C-STORE SCP now requires a verifiable peer control;
calling_ae_allowlistno longer satisfies the gate on its own. The fail-closed peer-control check
refuses a remotely-reachable SCP that has no peer control, and it accepted any one of three:
calling_ae_allowlist,source_ip_allowlist, or mTLS. It counted them rather than weighing
them. But a Calling AE Title is a string the caller asserts about itself in the association
request — no key, no signature, nothing to verify — and AE Titles are published in conformance
statements and visible in any capture. An SCP whose only control was an AE-title list was therefore
reachable by anyone who could route to it and knew one string, while passing a check named
"fail-closed peer controls". Server TLS does not close this: withouttls_ca_filethere is no client
certificate, so the cleartext bind guard (confidentiality) and this gate (authentication) are
orthogonal.
What changed: off-loopback, the gate now requiressource_ip_allowlistor mTLS
(tls+tls_ca_file).calling_ae_allowlistis kept and still enforced at association time —
it is a genuinely useful filter that catches a misrouted sender and pins intent — it simply has to be
paired with one of the two. Measured: AE-title-only off-loopback goes from starting to refused;
AE-title paired with an IP allowlist starts; IP-only and mTLS-only are unchanged; and every
loopback bind (the common dev/single-box case) is unchanged.
Who this bites: a site running a non-loopback SCP whose only peer control is
calling_ae_allowlist. It starts today and will refuse after upgrading. The fix is one line — add
source_ip_allowlist=[...]to theinbound(...)call, which for a DICOM SCP is the only authoring
surface — and the refusal names it. Keep the AE list; it is still doing work.
Tracked asBACKLOG #316. Options considered and declined: an audited opt-out switch, and
documenting the weakness without changing the gate. - BREAKING — an unrecognized key in a known config section now fails the start instead of loading
silently. Every section model inheritsextra="ignore", so a mistyped or stale key in
messagefoundry.tomlloaded clean, did nothing, and said nothing: an operator could misspell
block_unlisted_outboundand believe a posture control was on while the engine applied its
permissive default. Exactly one section,[security], warned about it; the other 27 were silent.
The loader now refuses, naming the section and the offending key and suggesting the closest valid
field name.
Scope, and it is deliberate — the refusal covers the config FILE only.MEFOR_*environment
variables and CLI flags are still accepted silently, because about a dozen documentedMEFOR_*
variables (the Vault store and secrets providers, the TLS revocation attestation, the lane timing
probes) are read straight fromos.environby consumers that are not settings fields — so refusing
an unrecognized environment key would refuse a deployment configured exactly as the shipped
documentation instructs.[security]is the exception and is refused from the environment too.
The check lives in the loader rather than a pydanticextra="forbid"for a second reason: pydantic
echoes the offending value in its error, and the CLI prints validation errors verbatim to
stderr, which the Windows service captures to a log file — so a mistyped secret key would have
written the secret to disk. Both existing config refusals name keys only, never values.
Who this would bite on first deployment: a config file carrying a key that is not a field of
its section — a typo, a key copied from newer documentation, or a setting since removed from the
engine. Remedy: correct the spelling; the error names the section and the key. Nothing needs
migrating, because a key that is refused now was doing nothing before. - BREAKING —
convert_hl7_timestamp(..., from_tz=...)now raises at the daylight-saving edges
instead of guessing. Twice a year a local wall-clock time happens twice (the fall-back overlap) or
never (the spring-forward gap), and a timestamp with no offset cannot say which instant it means.
0.3.2 silently picked one, which could be an hour wrong. The function now raises
AmbiguousLocalTimeErrororNonExistentLocalTimeError, so a Handler that calls it at those times
fails the message instead of sending a shifted time. Both subclassDstTransitionError, a
ValueError, and they are exported frommessagefoundrywith theDstEdgePolicytype. Only a time
the sender wrote is refused: a value with no time field keeps its 0.3.2 result, and a timestamp that
carries its own offset is unaffected.
Migration: passon_dst_edge="earlier"to keep the 0.3.2 result exactly, or"later"for the
other offset. Better, have the sender include its offset. (BACKLOG #1686) - BREAKING —
gzip_decompress,deflate_decompressandzip_decompressnow require
max_output_bytes. In 0.3.2 it defaulted toNone, which meant no ceiling, so a Handler that
forgot it could be handed a decompression bomb. It is now keyword-only with no default, so a 0.3.2
call such asgzip_decompress(data)raisesTypeErrorand the message fails. Migration: pass
a byte ceiling, for examplegzip_decompress(data, max_output_bytes=64 * 1024 * 1024), or pass
max_output_bytes=Noneto keep the 0.3.2 behaviour on input you have already bounded.
(BACKLOG #1237) - BREAKING — three smaller changes to the helpers a Router or Handler calls. Each one makes a
0.3.2 call raise, so the message goes toERRORinstead of being processed.- An HL7 field path with an index below 1, such as
PID-5.0orPID-0, now raises
HL7PeekError. In 0.3.2 index 0 wrapped to the last item, so a read returned a value nobody
asked for and a write overwrote the last component or the segment id. Migration: use
1-based indexes. (BACKLOG #1089) XmlMessage.find,get,get_all,exists,setandset_attributenow take their
expression, value and attribute name positionally only, because the keyword slots now carry
$variablebindings for safe XPath. A 0.3.2 call such asmsg.get(expression="//x")raises
TypeError. Migration: pass those arguments by position, and bind message data as a
$variablerather than formatting it into the expression. (BACKLOG #1049)messagefoundry.parsing.validate()no longer takesprofile=. It was accepted and never read.
Migration: drop the argument.
- An HL7 field path with an index below 1, such as
- BREAKING — a Handler that returns anything but
Send,SetState,SetMeta, an iterable of
those, orNonenow fails the message. In 0.3.2 an unrecognised item fell silently out of the
result: returning theMessageitself, astr,bytes, adictor a(name, msg)tuple
finalized the messageFILTERED, and a stray item in a list was dropped while theSends beside it
were delivered. Each of those is now anERROR(dead-lettered and replayable), and a list holding
one bad item delivers nothing. ANoneinside a list counts as a bad item. The reverse also
changed: a tuple or generator ofSends used to deliver nothing and now delivers. Migration:
return only those types, and filterNoneout of a list you build conditionally (for example
[s for s in (a, b) if s is not None]). (BACKLOG #1687) - BREAKING — a network intake now checks a non-HL7 body against the connection's declared content
type. In 0.3.2 only the File and remote-file sources checked it. Now a body on any listener or
poller that contradicts its declared type is stored asERRORand never routed:jsonorfhir
must start with{or[,xmlwith<,x12withISA, anddicomneedsDICMat byte
128.text,binaryandhl7v2are not checked. An HTTP sender still gets202, but with no
message_id. Migration: declare the content type the feed really sends, ortext/binary.
(BACKLOG #1109) - BREAKING — an
Http()inbound bound off loopback now needs a peer control, or it refuses to
start. 0.3.2 checked only that an exposed HTTP listener used TLS, so an off-loopback intake with
TLS and no caller identity passed. Under[security].enforcement = enforce, the default, the
connection now starts only with one of:intake_auth(api_key,bearerormtls_subjectwith
subjects), or asource_ip_allowlistwhose entries are no wider than /8 (IPv4) or /32 (IPv6).
tlswithtls_ca_filealone does not count, and--allow-insecure-binddoes not waive it. Only
that connection fails. Migration: addsource_ip_allowlist=[...]to theinbound(...)call,
or setintake_auth="api_key"withintake_api_key=env(...). (ADR 0154) - BREAKING — two new default limits on the MLLP listener.
max_connections_per_host(32)
refuses the 33rd concurrent connection from one source address, andmax_frame_seconds(60)
closes a connection whose frame has not finished 60 seconds after its start byte, with no ACK and
no NAK. 0.3.2 had neither. Behind a source-NAT proxy or load balancer every partner arrives as one
address, so 32 becomes the whole listener's capacity. A large frame on a slow link (below about
2.2 Mbit/s for the 16 MiB frame cap) never completes, and the sender resends it. Migration:
setMLLP(max_connections_per_host=None)behind NAT, and raisemax_frame_secondswhenever you
raisemax_frame_bytesor serve a slow link. (BACKLOG #1725) - BREAKING —
DatabasePollnow reads at most 500 rows per poll (poll_max_rows). In 0.3.2 a poll
fetched every row. Rows past the 500th wait for the next poll, which picks them up only if
mark_statementtakes each handled row out ofpoll_statement's result. With no
mark_statement, or one that does not remove rows, the same 500 rows come back every poll and the
rest are never read. A mark keyed on a column that is not unique to one row can mark unread rows
as done. The File, FTP and SFTP pollers' newpoll_max_files(500) only delays unread files.
Migration: key the mark on one row, or setpoll_max_rows=Noneto fetch every row as before.
(BACKLOG #1114) - BREAKING — at startup, the backlog of an inbound connection that is no longer configured is
dead-lettered. In 0.3.2 its ingress, routed and response rows stayed pending, and resumed if the
connection came back. Now each is marked dead ("inbound removed from registry"), the message
becomesERROR, and dead-letter retention applies. Migration: before removing or renaming a
busy inbound, let it drain. After a restart, replay the dead-lettered messages once the inbound is
back. (BACKLOG #1612) - BREAKING —
zip_decompressrefuses more archives. An archive with two members of the same
name, an unsafe member name, or a member whose content contradicts its file extension (.hl7,
.json,.xml,.pdfand others) now raisesCompressionError. In 0.3.2 the last duplicate
won and any member was accepted. Migration: fix the archive at its source.
(BACKLOG #1128,#1581) - BREAKING — an HTTP-family reply over 16 MiB now fails. 0.3.2 read a partner's response with no
size limit. A REST, SOAP, FHIR or DICOMweb delivery whose reply is larger than 16 MiB now raises
ResponseTooLargeError, which is retried and then dead-lettered. Afhir_lookupreply over the cap
raises inside the Handler, so under the defaultinternal_error = "continue"that message goes
toERRORwith no retry. An OAuth2 or SMART token
response is capped at 256 KiB. There is no setting to raise either ceiling. Migration: none in
configuration; the partner must send a smaller reply. (ASVS 15.2.2) - BREAKING —
File(sort=)accepts only"name"or"mtime". In 0.3.2 any other string, such as
"Name"or"size", silently gave name order. It now fails the connection at build.
Migration:sort="name". (BACKLOG #1655) - BREAKING — an
env()value thatconnections.tomlreads withcast = "bool"now honours its
spelling. When the value arrives as text (aMEFOR_VALUE_*variable, or a quoted value in
environments/<env>.toml), 0.3.2 cast it with Python'sbool(), so"false","0","no"and
"off"all becametrue, and only an empty string becamefalse. Those four spellings now give
false, and an empty or unrecognised value refuses at load. Migration: use one oftrue,
1,yes,on,false,0,nooroff, and check each such value still means what you
intended. (BACKLOG #1651) - BREAKING —
serverefuses a config directory that declares no connections. In 0.3.2 a
directory with no inbound and no outbound loaded, served an idle engine, and passed
messagefoundry check.servenow refuses it, with no opt-out, andcheckfails on it unless
given the new--allow-empty-config. Migration: declare at least one connection before
serve; pass--allow-empty-configto acheckrun over an intentionally empty directory.
(BACKLOG #1648) - BREAKING — the engine API always serves HTTPS, and mints a self-signed certificate on first run
when none is configured. 0.3.2 served plainhttp://127.0.0.1:8765unless[api].tls_cert_file
was set. 0.4.0 serveshttps://on the same address. With no certificate configured, it writes
api-generated-cert.pemandapi-generated-key.pembeside the store database on first start and
reuses them. That certificate names only[api].host, sohttps://localhostfails the host-name
check. The only plaintext topology left is a declared reverse proxy terminating TLS in front
([api].tls_terminated_upstream).messagefoundry.apiclientstill defaults to
http://127.0.0.1:8765, which a default 0.4.0 engine no longer answers. Off loopback, the client
now refuses to send a password or token over plainhttpeven withallow_insecure=True, where
0.3.2 sent it with a warning.
Who this bites: every script,curlcall, monitoring probe,ws://stats client and
EngineClient()built with default arguments. Migration: usehttps://127.0.0.1:8765and
trustapi-generated-cert.pem(for examplecurl --cacert, orEngineClient(url, cacert=...)),
or set[api].tls_cert_fileand[api].tls_key_fileto a certificate your clients already trust.
(docs/adr/0172-the-engine-always-serves-tls-minting-a-self-signed-certificate-on-first-run.md,
BACKLOG #1276,#1179) - BREAKING — search criteria that can carry patient data left the query string, and the old form
now returns more, not less.GET /messages/search,GET /messages/exportand
GET /uploads/{file_id}/messagesno longer declarecontentorfield_value, and an undeclared
query parameter is dropped silently. So a 0.3.2 call withcontentalone answers400, a call
withfield_pathandfield_valuematches every message that merely has that field (and export
streams them), and the uploads browse lists the whole file unfiltered. Migration: send the
criteria in the JSON body ofPOST /messages/search,POST /messages/exportor
POST /uploads/{file_id}/messages/search. (BACKLOG #1184) - BREAKING — list and search responses mask the message summary and metadata.
GET /messages,
both message searches,GET /dead-lettersand the layered search now returnsummaryand
metadatamasked for display (for exampleMRN ****0001). OnlyGET /messages/{id}reveals them,
which needsmessages:view_rawand spends the PHI-read budget. No setting turns the mask off.
Migration: fetch each message you need in full withGET /messages/{id}.
(BACKLOG #1187) - BREAKING —
GET /uploadsis paged and owner-scoped, and files uploaded under 0.3.2 are visible
only to administrators. It now returns at mostlimitfiles (default 50, up to 500) from
offset, withtotalcounting the whole visible set. A caller sees only its own uploads unless it
holdsfiles:access_any(Administrator). A 0.3.2 upload's metadata has no owner id, so it matches
no ordinary user, and browsing, resending or deleting it answers404for them. Migration: page
withoffsetuntil you reachtotal, and have an administrator handle files uploaded before the
upgrade. (BACKLOG #1152) - BREAKING — a connection name that does not match
^[A-Za-z][A-Za-z0-9_-]{0,255}$can no longer
be named through the API. Such a name still loads and runs, but every/connections/{name}/...
route, the connection filters on message, dead-letter and event queries, resend targets and
channel-scope grants now answer422for it. SoADT.In,Lab Resultsor2ndLabcannot be
started, stopped, tested, purged or filtered on. Migration: rename such connections to fit the
pattern; stored history stays under the old name. (BACKLOG #1108) - BREAKING — three storage fields in the status response can now be
null.
DbInfo.disk_free_bytes,LogInfo.disk_free_bytesandLogInfo.size_byteswereint. They are
nownullwhen the engine cannot measure them, which is always the case fordisk_free_byteson
PostgreSQL and SQL Server, where 0.3.2 reported0. A 0.3.2messagefoundry.apiclientfails to
parse that response. Migration: treat the fields as optional, and upgrade API clients with the
engine. (BACKLOG #1563) - BREAKING — a request that has not started its response after 120 seconds now answers
503.
0.3.2 had no request deadline. A long integrity check, deep search or export on a large store can
now hit it, and no setting raises it. Migration: narrow the request, for example with a smaller
limitorscan_limit. (BACKLOG #1044) - BREAKING — in
messagefoundry.apiclient, a JSON dump of a result withholds its patient-data
fields. Message, dead-letter, event and response models the client parses now emitnullfor
summary,errorandmetadataundermodel_dump_json()ormodel_dump(mode="json"). Reading
the attributes still works. Migration: read the attributes, or use a Python-mode
model_dump(). (BACKLOG #1045) - BREAKING — a dual-control approval still pending from 0.3.2 cannot be approved. Its row has no
requester id, so approving it answers409. Migration: settle pending approvals before the
upgrade, or reject and request them again after. (BACKLOG #1540) - BREAKING — on a store created by 0.3.2, saved searches and user deletion fail. The
search_presets.ownercolumn was renamedowner_user_id, and the store upgrade does not rename it,
so every preset call andDELETE /users/{user_id}fails withno such column: owner_user_id.
Measured on SQLite: a store created by 0.3.2 fails both calls under 0.4.0, and a store 0.4.0
created passes both. 0.3.2 also keyed presets on the username, where 0.4.0 keys them on the user id.
Migration: until the upgrade handles it, drop thesearch_presetstable before the first 0.4.0
start; 0.4.0 creates it again, empty. Saved presets are lost. On PostgreSQL and SQL Server, drop
it before that start, never after. Once a start has applied the 0.4.0 schema, those backends skip
their table DDL. A table dropped later is not created again. If that happens, run
DELETE FROM schema_metaand start once with DDL rights. SQLite creates it again at every start.
(BACKLOG #1232) - BREAKING — on a store created by 0.3.2 with a keyed audit chain, the chain now reads as broken.
audit_chain_metagains akey_idcolumn that names the key of the chain's first keyed range. The
upgrade adds the column empty and never fills it: ADR 0193 decision 2 built no path to fill it,
because no store was deployed. A 0.3.2 chain is keyed oncerekey-auditran. It is also keyed if a
store key or the Vault Transit MAC was present at an open while its audit log was empty. On such a
store,audit-verifyprintsFAIL:and exits 1. It names the first keyed row, with the reason
the audit chain does not record which key its keyed range is under. A broken keyless row below
that one is reported first.rekey-auditprintsFAIL:and exits 1 too. The startup check under
[integrity].audit_verify_on_startreports the same break, and every open logs an ERROR. From the
first keyed row on, the walk checks no MAC. Nor does it reach an--expected-anchoror
[integrity].audit_anchor_filecomparison, because the chain already reads as broken. With a local
store key, the audit step ofrotate-keyrefuses every time, as the BREAKINGrotate-keyentry
under Fixed describes. Measured on SQLite with synthetic data: a store 0.3.2 created fails the
verify under this release, and a store this release created verifies and rolls. PostgreSQL and SQL
Server add the column the same way; that was read in the code, not run. A store with no key is not
affected.
Migration: none is built. Before the upgrade, runmessagefoundry audit-verifyunder 0.3.2 and
keep its output. It records that the chain verified up to that point, but it holds no head hash to
check against later.
(BACKLOG #1904,docs/adr/0193-audit-chain-key-ranges-survive-a-store-key-rotation.md) - BREAKING — the default retry limit is now 100 attempts, not unlimited.
[delivery].retry_max_attemptsandRetryPolicy.max_attemptsdefaulted toNone(retry forever)
in 0.3.2. At 100, with the default backoff, a destination that stays down for about 7 hours 50 minutes
dead-letters the message at the head of its lane, and the lane moves on. The dead-letter queue is
replayable. A global or environment value of0now refuses at load. Migration: set
retry_max_attempts = "forever"(orMEFOR_DELIVERY_RETRY_MAX_ATTEMPTS=forever,
max_attempts = "forever"in[outbound.retry], orRetryPolicy(max_attempts=None)in code) to
keep 0.3.2's behaviour, and replace0with1. (BACKLOG #1051) - BREAKING — if the application log cannot be written, the engine now stops its connections. In
0.3.2 a failed log write was reported to stderr and processing went on. Under the new default,
[logging].on_write_failure = "stop", the engine first rolls the log aside; if the replacement is
unwritable too, it stops every connection the process owns, and delivery stays halted until the log
is writable and the lanes are restarted. Migration: set[logging].on_write_failure = "continue"to keep running without a log record, which is reported as a loosening.
(BACKLOG #122) - BREAKING — an expired store encryption key now stops the engine from starting. In 0.3.2 an
overdue store key only raised an alert. Underenforce, an encrypted store whose key is older than
store_key_max_age_daysplusenforce_grace_days(365 + 30 by default), or whose age cannot be
determined, now refuses to start. Age runs from[secret_rotation].store_key_last_rotatedif set,
otherwise from the date the engine first saw that key. Migration: runmessagefoundry rotate-key, correctstore_key_last_rotated, or set[secret_rotation].enforce_store_key_expiry = false, which is reported as a loosening. (BACKLOG #1004) - BREAKING — message bodies are purged after 30 days on every instance that sets no retention
window. 0.3.2 kept bodies forever on an instance it did not treat as carrying patient data.
Every instance does now, so each unset window among[security].delete_message_bodies_after_days,
[retention].dead_letter_daysand[retention].reference_snapshot_daysdefaults to 30 days, and
the purge runs.dead_letter_daysnow also purges a dead row at the ingress and routed stages. An
explicit0refuses to start underenforce. Migration: set each window explicitly, or set
[security].allow_keeping_phi_indefinitely = trueto keep bodies. (BACKLOG #1279,#1188) - BREAKING — behind a declared TLS-terminating proxy under
enforce, startup now measures the
proxy. With[api].tls_terminated_upstreamon,serverefuses to start without
[security].web_console_public_address. It then connects to that address at startup and refuses
unless the proxy is reachable, refuses TLS 1.0 and 1.1, and negotiates TLS 1.3. An IP-literal
address is refused. Migration: set the address to the DNS origin browsers use, enable TLS 1.3 on
the proxy, and start the proxy before the engine. (BACKLOG #1026) - BREAKING — on Windows, the config directory's owner is now checked. 0.3.2 trusted the owner
unconditionally. The owner must now be the service's run-as account, a well-known administrator
identity, or a direct member of the local Administrators group, and an owner whose membership
cannot be resolved is refused. A directory owned by an operator who is an administrator only
through a domain group no longer loads. Migration:icacls <config dir> /setowner "*S-1-5-32-544" /T /C, or re-run the service installer with-LockConfigDir.
(BACKLOG #1647) - BREAKING —
connections.toml[settings]values must match their parameter's type. A quoted
number or boolean such asport = "2575"orpersistent = "yes"loaded in 0.3.2 and is now
refused, naming the connection and setting. So is anenv()default of the wrong type, such as
{ env = "port", cast = "int", default = "16" }. Migration: write numbers and booleans
unquoted. (BACKLOG #1650) - BREAKING — two settings that 0.3.2 silently ignored now take effect.
validate_directory = trueon a File or remote-file outbound now refuses to start the lane when the directory is
missing, and nothing creates it; 0.3.2 created it on first write.MEFOR_SANDBOX_MODEis now read,
sosubprocessthere runs Routers and Handlers in the sandbox, with its time and memory caps.
Migration: create the directory first, or drop the setting; unset the variable if you did not
mean it. (BACKLOG #114,#1365) - BREAKING — more settings are range-checked at load.
[ai].providermust be"claude", the only
provider the engine can serve.[cluster]timings now refuse a leader fence and lease TTL pair that
leaves no detection margin (roughly, keep the TTL more than 2 seconds above the fence), whether or
not clustering is on.[store].db_schemais refused on a backend other than PostgreSQL.
Migration: correct each value the error names. (BACKLOG #95,#1497) - BREAKING — a rolling upgrade of a SQL Server cluster can elect two leaders, and a 0.3.2 node
cannot verify a 0.4.0 backup. The SQL Server lease key changed from
<db_schema, or dbo>:mefor_cluster_leadertomefor_cluster_leader, so a 0.3.2 node and a
0.4.0 node contend for different lease rows. A 0.4.0 backup manifest counts every table, and
0.3.2's verifier compares it against its own four and fails. 0.4.0 still reads and restores a
0.3.2 backup. Migration: stop every node, upgrade them all, then start; upgrade a DR standby
before it seeds from a 0.4.0 primary. - BREAKING — the first 0.4.0 start on PostgreSQL or SQL Server runs the schema upgrade and needs
DDL rights. The PostgreSQL migration revision and the schema hash both moved, so the first open
runs the whole DDL batch. The 0.3.2 runbook allowed revokingdb_ddladminafter the first start;
such a principal now fails to open the store, andserverefuses. Migration: grant DDL rights
for the first 0.4.0 start, then revoke them again. - BREAKING — several CLI commands now fail where 0.3.2 reported success, or print differently.
messagefoundry checkexits 1 when fixtures exist but no dry-run ran, when a pinned.expect
now readsNOT_DEPLOYED, and when amessagefoundry.tomlis found but fails to load (0.3.2
skipped that leg). (BACKLOG #1671,#1318)messagefoundry verifyfails, not skips, an unknown--inboundor a config with no inbound. It
fails a missing SQLite store and a missing writable directory, which 0.3.2 created and then
passed. Thehost.consolecheck is gone. (BACKLOG #1708,#1713)messagefoundry backup, and any caller ofopen_store(), no longer creates a missing SQLite
store;backupexits 2 andopen_storeraisesStoreNotFoundError.servestill creates it,
and code that provisions a store passescreate=True. (BACKLOG #1780)messagefoundry rotate-keyon avault_transitstore exits 2 instead of printing "re-encrypted
0 value(s)" and exiting 0. (BACKLOG #1165)- Text-mode error lines now go to stderr, not stdout, for
alert,backup,codeset,
connection,dryrun,graph,impact,import,init,lens,restore-verifyand
security. (BACKLOG #1673) messagefoundry dryrunprints the dispositionnot_deployedwhere 0.3.2 printedfilteredfor
a message whose only destinations are not deployed. (BACKLOG #1690)messagefoundry --versionprints a second line,package: <path>.
(BACKLOG #1677)- Migration: for all of the above, read each new failure as the real result it is; capture
stderr (2>&1) or use--json; runserveonce beforebackuporverifyon a new install.
- BREAKING — with
[integrity].fail_closed_on_drift = true, an install the engine cannot attest
now refuses to start. In 0.3.2 an install with noRECORD, a stripped one, or code loaded from
outside the install root was silently skipped even under fail-closed. It now raises
IntegrityError. The default (false) is unchanged. Migration: install the non-editable wheel,
or leavefail_closed_on_driftoff. (BACKLOG #1679) - BREAKING — the forward proxy and the OAuth2 token host must now be on an egress allow-list.
Aproxy_urlother than"default"now needs its host in the new[egress].allowed_proxy, and an
oauth2_token_urlhost must be in[egress].allowed_http; otherwise the graph refuses to load.
Migration: add[egress] allowed_proxy = ["proxy.example.org:3128"]and the token host to
allowed_http. (BACKLOG #1659) - BREAKING — the MLLP listener's automatic ACK now stamps MSH-7 with a UTC offset. 0.3.2 wrote
local time as 14 digits (YYYYMMDDHHMMSS). The ACK now writesYYYYMMDDHHMMSS±ZZZZ, which HL7
allows and which pins the instant across a daylight-saving change. Migration: none in
configuration; a partner or test that parses a fixed 14-digit MSH-7 must accept the offset. - BREAKING — an attachment download is served as
application/octet-streamwith a.binname
unless its type is on a short allow-list. 0.3.2 passed through any type a browser would not run
and took the extension from the host's type table. Now onlyapplication/dicom,
application/json,application/pdf,image/bmp,image/gif,image/jpeg,image/png,
image/tiff,text/csvandtext/plainkeep their type and extension; the bytes are unchanged.
Migration: a client that files downloads byContent-Typeor file name identifies other types
from the bytes. - BREAKING — a few narrower refusals. Each worked in 0.3.2:
db_lookuprefuses a statement carrying a write keyword anywhere outside a literal, so a read
with aMERGE JOINhint, or an unquoted column namedmerge, is now refused. Migration:
drop the hint or quote the name. (BACKLOG #1574)anonymize_checked()and the anonymizer tooling refuse a salt with too little entropy, not just
a short one. Migration: use a random salt; a new salt changes every pseudonym.- Under a pinned
[tls]trust anchor, every off-loopback HTTP-family hop now uses it: REST,
SOAP, FHIR and DICOMweb deliveries,fhir_lookup, and the SMART and OAuth2 token requests. In
0.3.2 none of them read the anchor, so a partner or token endpoint on a public CA outside it now
fails. Migration: add that CA to the anchor, or leave[tls].trust_anchor_modeatsystem.
(BACKLOG #1180,#1660,#1794) - The 8 KiB limits on an outbound URL and header value are now checked at send time as well as at
build, and a header name over 256 characters is refused. A per-message header or token that
grows past them now fails the delivery; afhir_lookupURL that does fails the Handler's
message. messagefoundry codeset renamevalidates the old name, so a code set whose file stem carries a
dot, such aslab.results, can no longer be renamed with it. Migration: rename the file by
hand.- DR activation on SQLite refuses a seed that restored nothing: a config-only seed, which 0.3.2
activated, and a drill seeded from an empty primary. Migration: seed from a full backup of
a primary that holds data. (BACKLOG #1717)
- BREAKING — the
[vault]clients no longer follow HTTP redirects. 0.3.2 let the Vault client
follow a redirect, carrying its token to the new location. A Vault address that answers with a
redirect, such as a standby node pointing at the active one, now fails. Migration: point the
[vault]address at the active node or at a load balancer that forwards rather than redirects.
(BACKLOG #1042)
Security
- BREAKING — raising a session's authority now re-keys it: each of the five elevation steps
issues a fresh session token and retires the old one. Re-authentication (POST /me/reauth),
MFA verification (POST /auth/mfa-verify), MFA enrolment (POST /me/mfa/confirm), and the web
console's passkey registration and passkey second-factor step each rotate the session (ASVS
7.2.4). So a token captured before the second factor is never elevated in place. The rotation keeps
the session's MFA state, and the old token stops resolving at once, with no grace window.
/me/reauthand/auth/mfa-verifynow return{"detail": ..., "token": ...}where 0.3.2
returned{"detail": ...}./me/mfa/confirmreturnstokenbesiderecovery_codes. All three
responses are sentCache-Control: no-store. The web console re-issues its own cookie. A rotation
also closes an open/ws/statssocket at its next check, and the console falls back to polling.
Migration: after any of those three calls succeeds, replace the stored bearer token with the
response'stoken. A client that keeps the old one gets401on its next call and must sign in
again. (BACKLOG #1146) - BREAKING — the per-channel scope is deny-by-default: an account with no channel scope now
reaches no channel. In 0.3.2 an empty (NULL) scope meant all channels, and every account was
created with one, so the per-channel checks narrowed nobody. An empty scope now denies. All
channels is a grant somebody types: the token*, stored as["*"]. Administrators still reach
every channel through their role.PUT /users/{user_id}/channel-scopestill accepts
{"channels": null}, and it now means no channels, so a 0.3.2 client that sentnullto grant
everything now revokes everything. Who this bites: every non-administrator account carried over
from 0.3.2. Its channel-scoped lists come back empty and its per-connection actions are refused.
Migration: grant each such account its connections, or{"channels": ["*"]}for all of them,
and change any client that sendsnullto send["*"]. (BACKLOG #1152) - BREAKING — TOTP codes are now computed with HMAC-SHA-256 instead of SHA-1, so every
authenticator enrolled on 0.3.2 stops producing codes the engine accepts. The engine stores no
per-user algorithm, so this is a cutover, not a migration. An enrolled user just sees
invalid code, with nothing else to say why. New enrolments advertisealgorithm=SHA256in the
otpauth://URI. Some authenticator apps ignore that parameter and compute SHA-1 anyway, and
their codes never match. Recovery codes and passkeys from 0.3.2 still work. Migration: each
TOTP user enrols again, in an app that honours thealgorithmparameter. The simplest route is
an administrator clearing the factor withPOST /users/{user_id}/reset-mfa; an administrator
cannot reset their own. Otherwise the user signs in with a 0.3.2 recovery code, registers a
passkey, removes the TOTP factor (DELETE /me/mfarefuses to remove the last factor while MFA is
required) and enrols again. A sole administrator whose only factor is TOTP must take that route. - BREAKING — a Windows (Kerberos) sign-in now starts MFA-pending, and a directory account may enrol
an engine second factor. In 0.3.2 the Kerberos leg marked the session MFA-satisfied on the
directory's behalf, so an AD user never met the engine's MFA gate. The engine never receives that
assertion, so it no longer grants it. With[security].require_mfaon, which is the default, a
Kerberos session is held to the MFA-exempt routes until the user completes an engine factor, and
POST /auth/negotiatenow reportsmfa_required: truefor it. Migration: each AD user enrols
TOTP or a passkey at their first 0.4.0 sign-in (enrolment is now open to directory accounts) and
completes it at each sign-in after that. (BACKLOG #1144) - BREAKING — security notices now go to an engine-owned address that a directory sign-in does not
change. In 0.3.2 one column was both the profile email and the address every out-of-band
security notice went to, and each AD or OIDC sign-in overwrote it with the directory's value. The
newusers.notify_emailcolumn is seeded once from the account's email, at upgrade and at account
creation. After that, only an administrator'sPATCH /users/{user_id}with a non-blankemail
moves it. So after the upgrade a directory repoint no longer redirects notices, and clearing an
account's email no longer stops them.UserSummarygains a read-onlynotify_email.
Migration: when a directory account's address changes, also set it withPATCH /users/{user_id}, and readnotify_email, notemail, to see where notices go.
(BACKLOG #1139,docs/adr/0182-split-the-account-mirror-address-from-the-engine-owned-notification-address.md) - BREAKING — under
enforce, the engine refuses to start unless an enabled Administrator has a
notification address. 0.3.2 checked only that an SMTP transport was configured, so notices about
the most privileged accounts could go nowhere. With[auth].notify_security_eventsand
[alerts].security_notifications_requiredon, both defaults, startup now fails if no enabled
Administrator has anotify_email. Migration: before upgrading, give at least one enabled
Administrator an email address (0.4.0 copies it intonotify_emailat upgrade), or set
[alerts].security_notifications_required = false. (BACKLOG #1020) - BREAKING — three sensitive actions now need a re-authentication bound to that one action.
DELETE /me/sessionsandDELETE /me/sessions/{id},POST /users/{user_id}/reset-passwordand
POST /users/{user_id}/reset-mfawere satisfied in 0.3.2 by any recent step-up. Each now needs a
single-use grant. Without one the call answers403with anX-Step-Up-Actionheader naming the
action.reset-mfaon the caller's own account now answers400. Migration: before each call,
sendPOST /me/reauthwithpurposeset to that header's value (session_terminate,
admin_reset_passwordoradmin_reset_mfa), and adopt the new token it returns. Another
Administrator resets your own MFA. (BACKLOG #1148,#1149) - BREAKING — revocation checking reaches more hops, and the blanket attestation no longer waives
it underenforce. In 0.3.2 the process-wideMEFOR_TLS_REVOCATION_ATTESTED=1let every verified
outbound TLS hop through the revocation refusal. Underenforceit now does not, and no
per-connection attestation can be written in config. So an off-loopback verified outbound hop
needs an in-engine CRL check. The syslog TLS forwarder, the SMART token hop and the remote
PostgreSQL store hop are now guarded too. An inbound MLLP, HTTP or DICOM listener that requires
client certificates (tlswithtls_ca_file) and loads no CRL now refuses to start, on loopback
as well. Migration: set[tls].crl_file(a PEM with the CA and its CRL) for outbound hops,
[logging].forward_tls_crl_filefor the syslog forwarder,[store].ssl_crl_filefor PostgreSQL,
andtls_crl_file=on each mTLS listener; or run with[security].enforcement = "warn".
(BACKLOG #299,#1005) - BREAKING — SMTP connections now verify the server certificate. 0.3.2 called
starttls()with
no TLS context, which checks neither the certificate nor the host name.Email(),SMTP(),
Direct()and the alert and security-notice mailer now verify both by default (tls_verify,
tls_check_hostname,[alerts].email_tls_verify). A relay with a self-signed or private-CA
certificate, or one reached by an address its certificate does not name, now fails: deliveries
retry and dead-letter, and alert mail stops. Underenforce,[alerts].email_use_tls = falseor
email_tls_verify = falsenow refuses to start. On any posture, alert and security-notice mail
withemail_use_tls = falseand anemail_usernamenow fails at send rather than sending the
password in cleartext.
SMTP login now offers onlyPLAINandLOGIN, so a relay that accepts onlyCRAM-MD5refuses
it. Migration: pointtls_ca_file(per connection),[alerts].email_tls_ca_fileor
[tls].internal_ca_fileat the relay's CA, and reach it by the name on its certificate. For alert
mail only,[security].allow_unverified_alert_smtp_tls = trueis the audited opt-out.
(BACKLOG #323) - BREAKING —
MEFOR_ALLOW_INSECURE_TLSno longer lets a cleartext hop cross, and three more
cleartext or unverified hops are refused. In 0.3.2 that variable let an off-boxhttp://REST,
SOAP, FHIR, DICOMweb orFhirLookuphop, or a credential sent over one, cross with a warning when
enforcement was off. Only a per-connectioncleartext_acceptedwithcleartext_reasondoes that
now (docs/adr/0153-collapse-the-posture-gradient-no-data-label-may-allow-a-cleartext-hop.md).
A plain-httpSMART token endpoint is refused even underenforce. An off-loopback
generic-dialectDatabasePollor database hop with no TLS refuses underenforce; an outbound
can declarecleartext_accepted, aDatabasePollcannot. An LDAPS directory with
ad_tls_verify = false, whichMEFOR_ALLOW_INSECURE_TLSlet start in 0.3.2, no longer starts
underenforce.[logging].forward_hop_attested = truenow needsforward_hop_attested_reason.
Migration: use TLS, or declarecleartext_accepted = truewith a reason on each hop you accept;
give the directory a CA withad_tls_ca_cert_file; add the reason. - BREAKING — weaker algorithms and short keys are refused. Each of these worked in 0.3.2:
- HTTP Digest now accepts only
SHA-256andSHA-512-256. A challenge namingMD5, or naming
no algorithm (which means MD5), fails every send. (BACKLOG #1171) - An RSA key under 2048 bits is refused for JWS and SMART signing, and for a
Direct()S/MIME
key, the partner'srecipient_certand thetrust_anchor. ADirect()EC key must be on
P-256, P-384 or P-521. (BACKLOG #1166) - XML signature verification refuses SHA-224 and SHA3-224 digests. (
BACKLOG #1171) - A new passkey whose authenticator offers only RS256 cannot be registered; TPM-backed Windows
Hello is that group. Passkeys registered on 0.3.2 still work. (BACKLOG #1166) [api].tls_ciphersmust now resolve only to suites on the approved list, so a string that
reaches a CBC suite, orDHE-RSA-CHACHA20-POLY1305, refuses at load. (BACKLOG #1317)- The Vault key provider refuses a Transit key whose type is weaker than the floor, such as
rsa-2048. (BACKLOG #1166) - SFTP now offers only SHA-2 ETM MACs with AES-CTR or AES-GCM; 0.3.2 passed no restriction, so
a server offering only older MACs or CBC ciphers now fails the handshake. (BACKLOG #1170) - Migration: for all of the above, the partner or key owner must offer the stronger option:
SHA-256 Digest, a key of 2048 bits or more, an ES256 passkey on P-256 or an EdDSA passkey (or
TOTP),tls_ciphers = "ECDHE+AESGCM:ECDHE+CHACHA20", an AES or RSA-3072 Transit key. There is
no setting that re-admits them.
- HTTP Digest now accepts only
- BREAKING — a new ES256 passkey must use the P-256 curve. 0.3.2 also registered an ES256
passkey on P-384 or P-521. Registration now refuses one. This is not a strength rule: those keys
verify and are strong enough. It follows the pairing RFC 9053 recommends, SHA-256 with P-256
only, which is also how the WebAuthn specification describes ES256. A passkey already registered
on another curve still signs in. Migration: register an ES256 passkey on P-256 or an EdDSA
passkey, or use TOTP. (BACKLOG #1166) - BREAKING — directory sessions are now rechecked every 5 minutes by default. In 0.3.2
[auth].ad_session_recheck_secondsdefaulted to0, so a signed-in AD user's session was never
checked against the directory again. The default is now300: each pass looks the signed-in AD
users up in the directory (up to 200 per pass), and after two consecutive passes in which the
directory no longer returns an account, its sessions are revoked. Migration: set
[auth].ad_session_recheck_seconds = 0to turn it off, which is reported as a loosening. - BREAKING — editing an AD group map signs out every AD session, the caller's included. In 0.3.2 a
change toPUT /ad-group-maporPUT /ad-group-scope-maptook effect at each AD user's next
sign-in. Both now revoke every live directory session at once, as the other authorization setters
already did. Migration: a script that edits a map as an AD user signs in again before its next
call. (BACKLOG #1154) - BREAKING —
DELETE /me/mfarefuses to remove the last second factor while MFA is required.
0.3.2 allowed it. It now answers400. Migration: enrol another factor first, or have another
administrator reset it. (BACKLOG #1022) - BREAKING — an unclaimed bootstrap administrator now expires under
[auth].initial_password_expiry_hourstoo. 0.3.2 exempted it from that clock and left it to
bootstrap_expiry_hours, sobootstrap_expiry_hours = 0kept it alive indefinitely. It now also
dies 72 hours after it was issued, by default. Migration: claim the bootstrap account before
the upgrade, or set[auth].initial_password_expiry_hours = 0. (messagefoundry provision-admin
helps only on a fresh store: it refuses once an enabled Administrator exists.)
(BACKLOG #1245) - BREAKING — the OIDC id_token is checked more strictly. A token whose
typheader is present
and is notJWT, a token withoutiatorsub, and a token carrying aneventsclaim are now
refused.[auth].oidc_flow_ttl_secondsmust be between 30 and 1800. Migration: correct the
identity provider's token profile, and set the flow TTL inside that range. - The web console's step-up actions would have refused an MFA-pending session without the audit
row the console's other MFA refusals write.require_ui_step_upandrequire_ui_step_up_actionswitched off
require_ui's second-factor gate to keep their/ui/reauth?next=continuation, then refused a
pending session with a bare redirect of their own. On a first deployment with
[security].require_mfaon, which is the default, a stolen password-only session cookie would have
probed all 42 step-up route gates and left noauth.mfa_deniedrow. The same switch put the
permission check first, so the refusal would also have shown which of those permissions the account
holds, and it spent the account's admin-write budget before refusing. The gate now refuses, audits
and orders its checks as it does on every other/uiroute. Only where it sends the browser
differs. The JSON API was never affected. (BACKLOG #1542) - The web console's message editor would have opened the raw body to a custom role holding
messages:editwithoutmessages:view_raw.GET /ui/messages/{id}/editand
POST /ui/messages/{id}/edit-resendgated onmessages:editalone, while the JSON handler they
call in-process (GET /messages/{id},require_phi_read(messages:view_raw)) has its own gate
skipped by that direct call — so the console re-asserted a different permission than the one it
stood in for. Both verbs now require both permissions and fail closed on either, and both charge
the per-actor PHI-read budget (require_ui_step_upgainedphi=). Custom-role minting is
deliberately unchanged:messages:editis still not inCUSTOM_ROLE_FORBIDDEN_PERMISSIONS, so a
role meaning "may resubmit, must not read" remains mintable — it simply cannot open an editor that
displays the body it edits. Who this would bite: a deploying org whose admin had minted such a
custom role; that role would have exceeded its stated scope (HIPAA minimum-necessary) on first
deployment. No built-in role reaches it —ADMINISTRATORandOPERATORgrant both permissions —
and every such read was already audited. (BACKLOG #324) - A dual-control release now re-checks the person who asked for it (ASVS 8.3.2). A held
request can wait hours for its second approver, and the requester's authority can be withdrawn in
that time.POST /approvals/{approval_id}/approvenow answers409when the requester's account
is gone or disabled, no longer holds the operation's permission (messages:replay,
messages:purgeorconfig:deploy), or has left the channel scope the operation needs. The
refusal writes anapproval.stale_requesteraudit row and raises theapproval_stale_requester
alert. The request stays pending, so an approver can reject it. The check reads the engine's own
copy of the account, so a change made only in Active Directory counts once it reaches that copy.
(BACKLOG #289) - Three more admin writes now count against the per-account write limit (ASVS 2.4.2).
PATCH /logging/level,DELETE /search/presets/{preset_id}andPOST /alerts/test-emailwere
not rate-limited. They now share the budget the other paced admin writes draw on: past 12
writes a second from one account, each answers429withRetry-After: 1. A client that stays
under the limit sees no change.[auth].admin_write_rate_limit_per_actorand
admin_write_rate_limit_window_secondsset the limit, andadmin_write_rate_limit_enabled = falseturns it off for every paced write. (BACKLOG #287) - A keyed store whose audit chain is keyless is now reported, and
provision-adminrefuses to
start a chain without the store key. A store that had audit rows before it had a key keeps a
keyless chain: a keyed open keys only an empty audit log. Anyone able to writeaudit_logcould
then forge a row that verifies clean, and 0.3.2 said nothing. A 0.3.2 store created without a key
and given one later is in that state. Such a store now logs a WARNING at each open naming
messagefoundry rekey-audit, andGET /security/posturelists the loosening
audit_chain_unkeyed. To clear it, stop the engine and runmessagefoundry rekey-auditwith the
key set.provision-admin, new in this release, writes a store's first audit row, so it applies
the at-rest checkserveapplies. Before it asks for the password, it refuses unless it can see the
store key:MEFOR_STORE_ENCRYPTION_KEYin its own shell, or[store].encryption_key_file. Under an
audited keyless opt-out it goes ahead, with a warning on stderr. It also refuses when a key is
configured but[store].key_providerresolved none. That check runs after the password prompt,
once the store is open (on SQLite, once it is created). (BACKLOG #1905)
Fixed
- The load harness's no-loss reconcile failed a run for being SLOW, and ejected pull requests from
the merge queue. Three of its detectors were fractions of the volume the phase OFFERED —
read >= sent // 2(the intake floor),timeouts <= max(connections, 3 * sent // 4)(the
stranding budget) andacked >= sent // 4intests/test_load_runner.py. An open-loop phase paces
sends by the wall clock, sosentreaches its nominal count whatever the host serviced, and all
three reduce to "this runner confirmed at least X percent of the offered rate": a throughput
reading wearing a loss label. Two unrelated pull requests were ejected inside twenty minutes on
windows-2025merge_groupruns (engine_read 36 < intake floor 45with every delivery arriving;
and 90 sent / 11 acked / 79 timeouts) while the same heads were green on the same leg as
pull_request— a filed split of 2 reds in the last 40merge_groupruns against 0 in the last
40pull_requestruns. Why the trigger matters is not established: hosted runners are one VM
per job, so pull requests do not share one, and all that is known of the difference is that the
queue launches entries in batches. The fix deliberately does not rest on a mechanism. Earlier work
on this test swept offered rate across three runner SKUs and measured stranding at 0 percent at
60/s, 150/s and 300/s alike — it never varied the trigger, which is the discriminator.
The replacements are engine invariants rather than tuned numbers.sent == acked + nak + timeouts, so the existing shortfall check with the excusal made unconditional is exactly
read >= acked + nak: every message the engine replied to must have an ingress row. Both reply
paths commit before they reply (an accept-ACK followsenqueue_ingress, a NAK follows
record_received), so a reply with no row is a real defect, and this fails it at magnitude one
under any stranding width and at any host speed. A systemic dead ACK path is caught on its
signature instead of on a volume fraction: a run that offered messages and got back not one reply,
neither accept-ACK nor NAK. That closes a gap the retired floor recorded as open — a dead ACK
path's signature is a high read with no ACKs, which clears a floor onreadby construction.
This is a NET RELAXATION, not a tightening, and the ledger of it is exact. The shortfall check
itself is unchanged while the run is inside the stranding budget and looser outside it, because
cancelling the excusal used to demandread >= sentthere; what is stricter is only the comparison
against the retired floor, which is a different detector. Three things are no longer failed at any
magnitude: an unconfirmed send that never reached the engine, a partial reply-path regression, and
a partial timeout flood. Nothing in the harness can tell the first from a frame that never left the
socket, sincesentis counted at write-buffer time, and the second is the same shape. They stay
visible as a heavily-stranded note on the report; the drain and rate SLOs are where a throughput
verdict belongs.
Scope, stated so this is not read as closing the class. Only the load runner's copy changes —
the copytests/test_load_runner.pyexercises. The connscale and estate copies keep the
offered-volume detectors, and a test now pins that divergence as deliberate rather than as drift.
tests/test_connscale_smoke.py::test_no_loss_reconciles_at_every_stepalso reds on the same
windows-2025 leg, and this change does not fix it: its failure (engine_read 15 < confirmed sent 18) is the exact-shortfall arm, which is untouched here, so its cause is a different one —
either genuinely absent rows or a shortengine_readsample, which is the question
harness/load/connscale/intake_audit.pyexists to settle per message.
(BACKLOG #1866) - BREAKING —
audit-verifyaccepted a zero-byte database, wrote a schema into it, and reported a
clean chain of nothing. The existing guard onaudit-verifyandrekey-audit, which the new
audit-anchorshared, only asked whether the--dbpath existed. A zero-byte file exists and is a valid, empty SQLite
database —
what atouchin an install script, a failed copy or a log-rotation mistake leaves behind — so it
walked past the guard,open_storemigrated 372,736 bytes of schema into the file that was
meant to be the evidence, and the command printedOK: verified 0 audit row(s)and exited 0. A
scheduled compliance job reads the exit code, so a first deployment with one would have reported
OK forever while the real audit log went unchecked. All three subcommands now probe the path over
a read-only SQLite handle before the store opens — it can neither create the file nor migrate
it — and exit 2 when there is noaudit_logtable. The message tells the cases apart: no file,
a database with noaudit_logtable, or a file that is not a database.
audit-verifyalso splits "verified nothing" out of its success code: a clean walk over an
empty log is now exit 3, and--allow-empty(new) turns that back into 0, as does an expected
anchor of0:, which asserts emptiness and is checked. Exit 1 stays a BROKEN CHAIN, so a job can
no longer read an empty log as detected tamper.audit-anchor, new in this release, exits 0 on a
real store whose log is legitimately empty — sealing a fresh instance as0:is a supported workflow — and refuses
only the non-audit-database paths.
Migration: on an empty log, a scheduledaudit-verifyjob now gets exit 3 where 0.3.2 gave
it 0. If an empty log is expected, as on a new instance, pass--allow-empty. Not
--expected-anchor 0:, which is an exact seal and fails once the log gains a row. Anywhere else,
treat 3 as a finding.audit-verifyandrekey-auditnow
exit 2 on a SQLite--dbwith noaudit_logtable, a zero-byte file included. A file that is not
a database also gets exit 2. Check that each job names the live store. (BACKLOG #1669) - BREAKING — a store key rotation broke the audit chain;
rotate-keynow carries the chain to the
new key, and it andrekey-auditprint and exit differently. In 0.3.2 the audit chain's MAC key
came from the active store key alone, androtate-keynever touched the chain. Take the rotation
docs/PHI.mddescribes: new key active, old key retired,rotate-key, drop the old key. After it,
audit-verifyreported the chain broken at its first keyed row, for good once the old key was
gone.rekey-auditstill printedOK. The keyed chain is now a series of ranges, one per key.
rotate-keyverifies the whole chain, then appends oneaudit.key_epochrow under the new key.
That row records a digest of the range it closes, the link to the row before that range, and a tag
made with the outgoing key. So the old range stays provable after its key is dropped. No existing
row is rewritten, so the off-box tee stays consistent and an[integrity].audit_anchor_fileprefix
stays valid. Untilrotate-keyruns, new audit rows stay under the retired key, and each open
warns not to drop it.
Output and exit codes.rotate-keynow prints a second stdout line:OK:and the audit step's
result. When the audit step refuses, the first line startsPARTIAL:instead and stderr names the
reason. No range row is written, and the exit code is 1, although the data re-encryption before it
has completed. The audit step refuses, at least, on a chain that does not verify and on key ranges
that do not authenticate. It also refuses to rotate back to a key that keyed an earlier range. It
refuses, too, when an audit row lands during the roll because the engine was left running.
rekey-auditon an already-keyed chain now verifies it. It printsOK:with the result, orFAIL:
and exits 1 when the chain does not verify; 0.3.2 printedOKwithout checking.
Migration: stop the engine beforerotate-key, and rotate to a new key, never back to an
earlier one. Read the exit code, and keep the retired key configured untilrotate-keyexits 0. A
keyed store created by 0.3.2 never reaches exit 0; see the BREAKING keyed-audit-chain entry under
Changed. (BACKLOG #1904,docs/adr/0193-audit-chain-key-ranges-survive-a-store-key-rotation.md) - BREAKING —
verify --smoke selfreported PASS on a synthetic message the config would have
dropped.
smoke_selffailed only onDryRunResult.error, whichdry_runsets for a parse failure, a
strict-validation failure or a Router/Handler raise.UNROUTED(the Router selected no handler) and
FILTERED(Handlers ran and sent nothing, including a sole destination that is
present-but-not-deployed) carryerror=None, so the disposition was written into the row's summary
and never gated on. A deploying site whose Router matched nothing, or whose only outbound was not
yet deployed, would read a green acceptance report off a message the engine would have dropped. The
row now PASSES only on a delivering outcome and otherwise FAILs, naming the disposition and the
handler/delivery counts. That is the verdict_classify_dispositionalready reaches for the live
smoke on the same synthetic message, so the two halves ofverifyno longer answer one question
two ways; an unrecognised disposition fails closed instead of falling through to PASS. Visible
change: a config whose Router declines the fixed syntheticADT^A01fromMAINHOSPnow reds this
row, and the failure text says to point--inboundat a connection that takes one. The happy-path
test asserted"deliveries=" in detail, whichdeliveries=0also satisfies, so neither the defect
nor theFAILbranch had a covering test; both do now.
Migration: read a FAIL on this row as a real result. If the Router declines the synthetic
message, pass--inboundnaming a connection whose Router takes anADT^A01fromMAINHOSP, or
fix the Router or Handler. If every destination the Handler sends to is not deployed, deploy one.
(BACKLOG #1707) - The shipped VS Code snippet generated a FHIR lookup the engine now refuses. The
meforfhirlookupsnippet built its search by concatenating a message field into a flat?-query —
the form removed along with[egress].fhir_require_structured_params— so the snippet emitted a
Handler that raises on first use. TheFhirLookupdocstring, thefhir_lookupdocstring and the
Steps palette taught the same removed form. All now use the per-value-encodedparams=form; the
read-by-id form is unchanged. Why it shipped broken: nothing read that file. A new test parses
every shipped snippet body and asserts none teaches the removed form — a test that merely checked
the JSON parses would not have caught it.
(docs/adr/0043-fhir-read-lookup.md) - BREAKING — a CR/LF inside an exception message could forge a whole log line on the text sink.
ControlCharScrubFilterescaped only the rendered message, andlogging.Formatterappends a record's
traceback (exc_text) and stack dump (stack_info) verbatim — so a newline-bearing exception
string landed at column 0 on its own physical line, where a payload padded to the record layout was
byte-indistinguishable from a real entry to an operator or a line-oriented SIEM parser. Both fields
are now scrubbed too (ASVS 16.4.1; the residual ADR 0034 §1 disclosed,BACKLOG #335). Visible
change: a traceback is not collapsed onto one line — its line breaks are kept and every line is
indented with|, so it stays readable while no line of it can start at column 0. A log parser
keyed onTraceback (most recent call last):at the start of a line needs that prefix added. The
JSON sink is unchanged in substance (json.dumpsalready escaped these fields); itsexception
andstackvalues now carry the same indent.
Migration: a log parser or SIEM rule that reads a traceback or stack line from column 0 must
first strip the|prefix, on the text sink and in the JSONexceptionandstackvalues. - The DICOM C-STORE SCP's fail-closed refusal named a settings key that does not exist. It told
the operator to set[inbound].source_ip_allowlist;InboundSettingshas no such field and, in
0.3.2, section models ignored unknown keys (they now refuse one, under the BREAKING unknown-key
entry in Changed), so an operator following the engine's own error message wrote a key
intomessagefoundry.tomlthat was accepted and silently discarded — leaving a non-loopback SCP
with no peer-IP gate while believing it had one. Aggravated by the construction gate counting
controls: acalling_ae_allowlist(a caller-asserted AE Title with no cryptographic binding) plus
the discarded key passed the check. The message now names the working surface — theinbound(...)
keyword, which for a DICOM SCP is the only one, sinceDICOM()is not authorable in
connections.toml— and distinguishes it from theconnections.toml[[inbound]]key that is
real, so a site running MLLP alongside DICOM cannot read it as licence to delete a working
allowlist. The same wrong spelling is corrected in the module docstring, the gate comment,
config/wiring.py,config/settings.py,docs/SECURITY.mdanddocs/ASVS-L2-PHASE0-CHANGES.md.
Whether an AE-title list alone should keep satisfying that gate was tracked asBACKLOG #252— it
is an ADR 0025 §9 contract change and was deliberately not decided in this fix. It has since been
decided: see the BREAKING DICOM C-STORE SCP entry under Changed (BACKLOG #316). - Two startup gates described themselves against the deployment tier rather than the enforcement
dial. Comments on the managed-identity and security-notification gates read "refuse (production) /
warn (non-production)" over branches that readenforcing— andenforceis the shipped default on
devandstagingas much as onprod, so all three refuse. Comment-only, no behaviour change,
but these are the comments two published documentation defects were copied from. - The load harness's no-loss reconcile did not enforce the
read >= sent // 2intake guarantee
0.3.2 documented. The unconfirmed-send excusal was capped atmax(connections, half the run), but
thatmax()takes the connection count as a floor, and every call site passes a connection count
— so on a short, low-rate step (connscale-smoke's N=100 cell: ~105 sends, 100 connections) the count
won the max() and the intake bound degraded toread >= 5, the very vacuity the cap exists to
prevent. Nothing clamped the excusal tosenteither, sotimeouts > sentdegraded it to
read >= 0. In the connscale and estate copies the run-fraction cap still decides the systemic
no-ACK verdict (it has since widened from half to three quarters of the run), and an
unconditional intake floor the excusal cannot lower enforcesread >= sent // 2at every call
site. The load runner's copy had both as well, until the#1866entry above retired them there. The
estate copy also gained the honest-reporting branch its two siblings had: it previously printed
read>=sent, …on a bounded-excused run whose read was demonstrably belowsent, and its
over-budget detail string now matches connscale's — a test pins those two in step, since nothing
enforced the claim that they were.
Known gap, unfixed in the connscale and estate copies: their systemic no-ACK verdict is still gated
on the same capped budget, so a dead ACK path that nonetheless delivered everything still passes
whenconnections >= sent; an intake floor cannot catch a fault whose signature is a high read with
no ACKs. The load runner's copy now catches it on that signature, under the#1866entry above. - BREAKING —
messagefoundry adr-analyzeexited 0 over an ADR directory that does not exist.
Path.globyields nothing and raises nothing for a missing directory, so a missing, non-directory, or
ADR-less--adr-dirproduced zero reports andAnalysisResult.ok = True— the exact shape of a
clean run. Withdrawing the ADRs would have silently turned a failing advisory check into a
passing one.AnalysisResultnow carries anerrorfield, set to a line naming the directory
when it is missing, is not a directory, or holds no file matching the ADR glob; discovery also
drops a directory that happens to be named like an ADR, which the glob alone would have matched.
Visible change:adr-analyzenow exits 2, with or without--strict, when there is no
corpus to analyze — the same "could not start" code the CLI's other subcommands already spend on
a store that fails to open, and distinct from--strict's own coverage-gap exit of 1.--json
output gains a permanenterrorkey (nullon a normal run), andokis now
error is None and not coverage_gaps. The error line says what was looked for, not why nothing
matched: bothPath.existsandPath.globswallowOSError, so a directory the process cannot
read is indistinguishable here from one that is absent, and a message guessing between them would
send an operator after the wrong cause.
Migration:--adr-dirdefaults todocs/adrunder the current directory. The installed
package carries no ADRs, so run the command from the root of a source checkout, or pass
--adr-dir. Exit 2 is also argparse's usage-error code. To tell them apart, run with--json: a
missing corpus prints JSON witherrorset, and a usage error prints no JSON. - A passkey that could never sign in is now refused when it is registered. A credential whose
curve was unknown, whose point was not on its curve, or whose key type did not match its algorithm
used to enrol and then fail at every sign-in. Registration now builds the key the way sign-in
does, and refuses it there. A malformed key at either step used to answer500; it now lands on
the audited invalid-input path. A passkey that can sign in is not affected.
(BACKLOG #1166)