diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 2f59d930a..49edbde9a 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -28410,3 +28410,130 @@ its audit fires on cannot simply check that the line is non-blank -- that passes on other code, which is the common case. The design question is what to pin: the simplest honest form is to record the anchored line's TEXT beside the anchor and fail when the two disagree, which turns a silent drift into a named failure at commit time rather than a red a day later. + +--- + +## 1497. ADR 0157 leaves increments 0, 2 and 3 unbuilt, says increment 2 is mis-specified, and no open item carries any of it + +> 🔢 **Filed 2026-09-09 -- not started. Scored at filing.** Value **6/10** · Difficulty **6/10** · _big bet_. Found by an ADR-to-backlog sweep. The ADR names three unbuilt increments in its own opening blockquote and warns that one of them must not be built as written. Its only backlog reference is a closed test-flake row about a wall-clock assertion, so the engine work has no home. Value 6: on a first deployment against SQL Server this is an absent in-flight recovery path plus two unfenced write paths on a demoted node. Difficulty 6: cross-backend store work under the fence invariant, and the specification has to be repaired before anyone can build it. +> Verdict: build +> Research: none -- the ADR's own Increments section states the defect +> Closing-act: code + +**Cluster:** HA correctness / store fences. **Priority:** P2. **Verdict:** re-specify Inc 2, then build 0, 2 and 3. +**Severity:** conditional per CLAUDE.md sec. 0 -- **zero deployments**, so nothing is losing rows today. This is what a first deployment running SQL Server with a demoted node would hit. + +**What is built and what is not.** [ADR 0157](adr/0157-demotion-safety-fence-scope-on-post-claim-writes-and-a-bounded-graph-stop.md) says it in its own words: *"Increments 1, 4 and 5 are BUILT. C1 and C6 were decided by the owner. Inc 0, 2 and 3 are **not** built -- and Inc 2's premise is wrong as written."* + +| Increment | State | What it is | +|---|---|---| +| 0 | not built | clamp the lease renew's own statement timeout below the margin | +| 2 | not built, **mis-specified** | SQL Server periodic in-flight recovery | +| 3 | not built | the same fences on SQL Server's `claim_ready` and terminal resolves | + +**Inc 2 must be re-specified before it is built.** The ADR carries its own warning: the drafted version proposes an owner-blind, age-based sweep, but SQL Server has **no populated `owner` column** to discriminate with, so the sweep would re-pend rows a live leader is working. The ADR states the real defect is *the absence of recovery at graph re-start*, not the absence of a sweep. + +**The ordering constraint is already stated.** Inc 2 blocks Inc 3, and one of the ADR's acceptance criteria *cannot pass before Inc 2*. So this is one item, not three: taking Inc 3 alone would leave that criterion unprovable. + +**Why nothing caught it.** The ADR is cited by exactly one backlog row -- a closed item about `tests/test_adr0157_demote_teardown.py` asserting on a wall clock. A citation search for `0157` therefore returns a hit, and the hit is about a test, not the increments. + +--- + +## 1498. ADR 0173's revocation remainder lost its tracking item when #1005 shipped and closed + +> 🔢 **Filed 2026-09-09 -- not started. Scored at filing.** Value **6/10** · Difficulty **4/10** · _quick win_. Found by an ADR-to-backlog sweep. #1005 shipped CRL checking on the mTLS-terminating listeners and closed. ADR 0173 was written to cover the surfaces #1005 did **not** reach, is still `Proposed` with no code, and its only backlog citation is that now-closed row. Value 6 because a revoked peer certificate would keep verifying on the unguarded hops on a first deployment. Difficulty 4: the ADR already names the surfaces and carries one small build rider. +> Verdict: build +> Research: none +> Closing-act: code + +**Cluster:** TLS posture / ASVS 12.1.4. **Priority:** P2. **Verdict:** ratify the accept half, then build the sec. 4.3 rider. +**Severity:** conditional per CLAUDE.md sec. 0 -- **zero deployments**. On a first deployment a revoked partner certificate would keep verifying on the hops this ADR names. + +**The gap is structural, not an oversight.** Three records share this subject and each closed a different slice: + +| Record | What it covers | State | +|---|---|---| +| ADR 0078 | the `[api]` in-process-TLS serve-time refusal + `MEFOR_TLS_REVOCATION_ATTESTED` | built; its own Status names the MLLP-over-TLS gate and the Postgres/REST/SOAP client paths as **documented residuals** | +| ADR 0173 | revocation + OCSP stapling across terminating **and originating** surfaces | `Proposed`, **no code** | +| BACKLOG #1005 | CRL on the mTLS-terminating listeners | SHIPPED 2026-08-22, `3c5cb988` | + +ADR 0078's stated residuals were meant to land under ADR 0173, and ADR 0173's only citation is #1005 -- which shipped a *different* slice and took the citation with it when it closed. + +**Two things this item must do.** First, the ADR's own Status says *"the accept half needs owner ratification"*, so it needs a decision before a build. Second, it names *"one build rider in sec. 4.3 [that] is a separate, small change that this ADR authorizes but does not perform."* Do not start the rider before the ratification: the ADR was deliberately written not to perform it. + +--- + +## 1499. ADR 0085's deferred Direct S/MIME phases lost their home when BACKLOG #157 closed on PR1 alone + +> 🔢 **Filed 2026-09-09 -- not started. Scored at filing.** Value **3/10** · Difficulty **2/10** · _fill-in_. Found by an ADR-to-backlog sweep. The ADR shipped PR1 (outbound send only) and names four further phases as *"deferred (named, not built)"*. #157 closed, so the named phases are recorded only inside an ADR whose index row reads `Accepted`. Filing the deferral is the deliverable here; building the phases is demand-gated. +> Verdict: demand-gate +> Research: none +> Closing-act: owner-ruling + +**Cluster:** connector parity / Direct Project. **Priority:** P3. **Verdict:** demand-gate -- record the deferral, build on a real feed. +**Severity:** no deployment axis (sec. 0). The shipped outbound path is complete for what it claims. + +**What shipped and what did not.** [ADR 0085](adr/0085-direct-hisp-smime-connector.md) is `Accepted (2026-07-10) -- PR1 outbound-only; later phases deferred`. [`transports/direct.py`](../messagefoundry/transports/direct.py) is the built outbound connector. Deferred and named in the ADR: + +1. **Inbound Direct** and MDN processing. +2. **DNS CERT / LDAP certificate discovery** (`dnspython` rejected for PR1 -- a dependency plus a network lookup on the send path). +3. **Multi-level certificate path building** (PR1 does one level, `verify_directly_issued_by`). +4. **CMS signed attributes** required by the Direct implementation guide. + +**Why this is a filing and not a build.** No adopter has asked for inbound Direct, and item 2 would add a dependency for a feed that does not exist. The point of this row is that the four phases are currently discoverable only by reading an ADR whose index row says `Accepted` -- which reads as done. + +--- + +## 1500. ADR 0090's web console Resend UI is the last unbuilt piece of #123, and #123 closed without it + +> 🔢 **Filed 2026-09-09 -- not started. Scored at filing.** Value **4/10** · Difficulty **3/10** · _fill-in_. Found by an ADR-to-backlog sweep. The API, the engine and all three store backends ship resend-to-alternate; the operator has no button. #123 closed on the API/engine deliverable, which the ADR says was the intent, so the UI residual has no item. +> Verdict: build +> Research: none +> Closing-act: code + +**Cluster:** web console / operator surface. **Priority:** P3. **Verdict:** build. +**Severity:** no deployment axis (sec. 0). A missing affordance over a shipped, audited API. + +**The residual, in the ADR's own words.** [ADR 0090](adr/0090-resend-a-stored-message-to-an-alternate-outbound-connection.md) documented residuals: *"(a) the **console/webconsole Resend UI** for #123 is a desired-if-clean follow-on; the API/engine capability is the deliverable of #123."* #123 closed on that deliverable, correctly, and residual (a) went with it. + +**Read residual (b) before scoping this.** The same list names a PySide6 desktop editor, and the ADR already annotates it: *"this residual can no longer be built, and is therefore not open work"* -- the desktop console was retired on 2026-07-13 (BACKLOG #103, [ADR 0032](adr/0032-console-desktop-launch.md) RETIRED). **Only residual (a), the web console, is open.** Do not repoint the desktop bullet at `/ui`; the ADR explicitly declines to. + +**The affordance has a stated requirement.** The ADR rules that a resend lands at the destination lane **tail** and may be delivered after newer same-partner messages, and that *"the console affordance (residual) will warn."* The warning is part of the deliverable, not polish. + +--- + +## 1501. ADR 0084's declined-handler `message_events` mitigation was deferred behind a gate that has since shipped, and nothing tracks it + +> 🔢 **Filed 2026-09-09 -- not started. Scored at filing.** Value **3/10** · Difficulty **3/10** · _fill-in_. Found by an ADR-to-backlog sweep. The mitigation was deferred from v1 on the condition that it ride the `message_events` verbosity gate. That gate shipped in #899, so the stated precondition is met and the mitigation is the only part left -- with no open item naming it. +> Verdict: build +> Research: none +> Closing-act: code + +**Cluster:** routing observability. **Priority:** P3. **Verdict:** build, default-off behind the existing gate. +**Severity:** no deployment axis (sec. 0). Diagnostic detail, opt-in by design. + +**The deferral and its condition.** [ADR 0084](adr/0084-accepts-router-seam.md) ruling 2: *"The `message_events` declined-handler mitigation is DEFERRED from v1 -- and when it is built it must ride the **existing `message_events` verbosity gate** (BACKLOG #63, shipped in #899) rather than writing unconditionally."* + +**The condition is now met.** #63 closed BUILT 2026-07-10: `[diagnostics].message_events` is the operator verbosity dial, and the gate is applied at every emission path. + +**What it restores.** One metadata-only event per declined handler (`event = "accepts_declined"`, `detail = `), written **inside the same router `route_handoff` transaction** so it is atomic with the disposition and re-derives identically on replay. Without it, the mixed case loses per-declined-handler granularity. + +**Why it must stay default-off.** The ADR does the arithmetic: on a 20-handler hub this writes up to 16 events per message on the default path, re-spending in `message_events` rows a meaningful share of the durable writes the `accepts=` seam exists to recover. + +--- + +## 1502. ADR 0102 defers option (a) to an owner decision that was scheduled separately and never filed + +> 🔢 **Filed 2026-09-09 -- not started. Scored at filing.** Value **3/10** · Difficulty **1/10** · _fill-in_. Found by an ADR-to-backlog sweep. The ADR records a risk acceptance and builds the optional cross-check; option (a), the one thing that would close completeness, is deferred to the owner. #223 closed on the built half. No open item carries the decision, so it is waiting on nobody. +> Verdict: owner-ruling +> Research: none +> Closing-act: owner-ruling + +**Cluster:** DR completeness / risk register. **Priority:** P3. **Verdict:** owner ruling -- schedule it, or accept the residual permanently. +**Severity:** conditional per CLAUDE.md sec. 0 -- **zero deployments**. It bounds what a first deployment could attest about a restored server-DB store. + +**The deferral.** [ADR 0102](adr/0102-server-db-dr-restore-vintage-completeness-attestation-residual.md) option (a): *"the full engine-driven server-DB store seed -- owner decision, scheduled separately; not started."* The ADR grades it *"strongest (engine-verifiable vintage end to end)"* and states plainly that **only (a) closes completeness**; (b) and (c) leave the residual *"documented, not closed."* + +**What did ship.** (b), the optional restore-token vintage cross-check, is built, and (c), the risk acceptance, is recorded. BACKLOG #223 closed on that, verified against `origin/main` 2026-07-28. That closure is correct for what it claims and is not what this row reopens. + +**What this row asks for.** One ruling: schedule (a), or accept the residual as permanent and say so in the ADR and the risk-acceptance register. Either answer closes this. What must not persist is a deferral pointed at an owner decision that no artifact ever surfaces. diff --git a/docs/adr/0015-ws-soap-outbound-mtls-wssecurity.md b/docs/adr/0015-ws-soap-outbound-mtls-wssecurity.md index 3b811c92d..10220e968 100644 --- a/docs/adr/0015-ws-soap-outbound-mtls-wssecurity.md +++ b/docs/adr/0015-ws-soap-outbound-mtls-wssecurity.md @@ -1,8 +1,8 @@ # ADR 0015 — WS-* SOAP outbound (mutual-TLS client cert + WS-Security / WS-Addressing) -- **Status:** **Accepted (2026-06-15)** — ratified on the owner's "go"; the open questions below are resolved. **No code written yet** (build authorized; not started). +- **Status:** **Accepted (2026-06-15)** — ratified on the owner's "go"; the open questions below are resolved. **Built.** - **Resolved open questions (owner go, 2026-06-15):** (1) **Fragment validation** = the hardened, non-resolving, no-DTD well-formedness check on the isolated `` (XXE-negative, and it still catches a malformed HL7-derived body) — adopted over the zero-parser balance check. (2) **XML-DSig body signing stays deferred** to a follow-up ADR (it needs a non-stdlib C14N+RSA dependency, which would breach ADR 0003's stdlib-only rule); a stable engine-side **idempotency key is designed alongside it**, not now. (3) **UsernameToken default = `ws_password_type="text"`** (PasswordText over mutual TLS); `PasswordDigest` is opt-in for partners that mandate it. (4) **WS-\* requires SOAP 1.2** — the `Soap()` factory raises on `soap_version="1.1"` when `ws_addressing`/`ws_security` is set. (5) **Egress gate** keeps the project-wide *fail-closed-once-configured* framing (no special hard-require); a `docs/CONNECTIONS.md` warning + dry-run surfacing flag a PHI mTLS destination whose `[egress].allowed_http` is empty. (6) **Idempotency** = accept that an at-least-once re-send mints a fresh `` (correct WS-\* retry semantics); the partner's submit operation must dedup — a stable engine-side key is deferred with #2. -- **Built:** nothing yet. This document is the design only. +- **Built:** yes — the `ws_security` / `ws_addressing` / `client_cert_file` knobs on `Soap()` in [`config/wiring.py`](../../messagefoundry/config/wiring.py), the `` timestamp + ``/`Action`/`To` stamping and the permanent-vs-retryable WS-Security fault classifier in [`transports/soap.py`](../../messagefoundry/transports/soap.py), and the queue-boundary signer in [`transports/signing.py`](../../messagefoundry/transports/signing.py). - **AMENDMENT (2026-09-06, BACKLOG #1171, ASVS 11.4.1) -- resolved question 3 is PARTLY REVERSED: `ws_password_type="digest"` is RETIRED.** The 2026-06-15 ruling kept PasswordDigest as an opt-in for partners that mandate it, and section 4a below still argues that case. That half no longer diff --git a/docs/adr/0016-synchronous-x12-request-response.md b/docs/adr/0016-synchronous-x12-request-response.md index 7c5506139..7a095ef3c 100644 --- a/docs/adr/0016-synchronous-x12-request-response.md +++ b/docs/adr/0016-synchronous-x12-request-response.md @@ -1,8 +1,8 @@ # ADR 0016 — Synchronous X12 EDI request/response feeds (real-time eligibility, 270/271 and friends) -- **Status:** **Accepted (2026-06-15)** — ratified on the owner's "go"; the open questions below are resolved. **No code written yet** (build authorized; not started). +- **Status:** **Accepted (2026-06-15)** — ratified on the owner's "go"; the open questions below are resolved. **Built** (the synchronous request/response path and the TA1 classifier). TA1 *generation* is a separate open item, BACKLOG #148. - **Resolved open questions (owner go, 2026-06-15):** (1) **TA1\*E (accepted-with-errors)** = **accepted-with-warning** by default (the interchange *was* accepted → delivered, **no retry**, an `AlertSink` notification), **superseding the draft body's tentative "transient/retry"** — retrying would re-send an already-accepted interchange (unsafe for a state-changing 278N); per-connection overridable. (TA1\*A = accepted; TA1\*R = permanent reject → dead-letter.) (2) **`ta1_required` default = `False`** (preserves fire-and-forget for non-RTE X12); eligibility/RTE connections should set it `True` (documented in `docs/CONNECTIONS.md`). (3) **SOAP-X12 sub-variant** = handler-side envelope build/un-wrap accepted; a dedicated SOAP-X12 helper / transport-level un-wrap is out of scope (a follow-up if needed). (4) The **residual non-idempotent crash-re-send window** (a real second reply at `response_seq=N+1`) is **accepted as inherited from ADR 0013** and not in scope to close for RTE. (5) **Reuse `ConnectorType.X12`** (sharing `[egress].allowed_tcp`) over a dedicated `X12Rte` type — adopted, consistent with ADR 0015's extend-don't-fork reasoning. -- **Built:** nothing yet. This document is the design only. +- **Built:** yes — the synchronous outbound blocks for the returned interchange on the same socket and classifies the **TA1** acknowledgement in [`transports/x12.py`](../../messagefoundry/transports/x12.py) (`_check_ta1`: `TA1*A` accepted, `TA1*R` permanent reject to dead-letter, `TA1*E` retryable), handing a business response to the ADR 0013 capture seam. TA1/997/999 **generation** remains deferred (BACKLOG #148). - **Decision in one line:** a real-time X12 request/response feed (a 270 eligibility inquiry → 271 response over one socket; 278N/277 variants; or X12 over a REST/SOAP web service) is **not a new pipeline mode** — it is the **already-built capture-then-re-ingress machinery** (ADR 0013) wired onto a diff --git a/docs/adr/0022-fhir-resource-codec-rest-client.md b/docs/adr/0022-fhir-resource-codec-rest-client.md index 78dc75c7d..aee915ac7 100644 --- a/docs/adr/0022-fhir-resource-codec-rest-client.md +++ b/docs/adr/0022-fhir-resource-codec-rest-client.md @@ -1,6 +1,6 @@ # ADR 0022 — FHIR resource codec + REST client -- **Status:** **Accepted (2026-06-19)** — ratified on the owner's go. Design-only (no code yet); the Lane B +- **Status:** **Accepted (2026-06-19)** — ratified on the owner's go. **Built** ([`parsing/fhir/`](../../messagefoundry/parsing/fhir) + [`transports/fhir.py`](../../messagefoundry/transports/fhir.py)); the ADR 0043 `fhir_lookup` read seam extends it. The original start-gate below is kept as history: the Lane B `fhir-codec` **build may start once `ci-py311-finalizer` (#17) has also merged** (the build start-gate is *ADR 0022 Accepted AND #17 merged*). The "To resolve on acceptance" confirmations are ratified at their recommended positions — see **Resolved** below. diff --git a/docs/adr/0026-off-box-egress-update-check.md b/docs/adr/0026-off-box-egress-update-check.md index 66ec86ba5..26d00de0e 100644 --- a/docs/adr/0026-off-box-egress-update-check.md +++ b/docs/adr/0026-off-box-egress-update-check.md @@ -1,7 +1,7 @@ # ADR 0026 — Off-box egress posture for the MEFOR version update-check (#30) -- **Status:** **Accepted (2026-06-19, owner go).** No code yet — #30 stays DEFER / on-trigger; this ADR records the accepted posture. The off-box-egress posture for `update-check` (#30) is delegated to the coordinator "per our security standards" (MULTISESSION-PLAN-3.md §"Deferred background monitor"); this ADR **records and justifies** that decision rather than re-opening it. (`0023` = inbound HTTP listener (#7), `0024` = email/SMTP (#23), `0025` = DICOM (#24), `0027` = per-connection retention (#34) — all earmarked-not-yet-authored in MULTISESSION-PLAN-3.md §E / PLAN-3-LANE-HANDOFFS.md; **0026 is the next free number, taken here** for the off-box-egress ADR the plan calls "the off-box-egress ADR for `update-check` #30".) -- **Built:** nothing yet. This document is the design only. #30 itself is **DEFER / on-trigger**: it builds nothing now and, when it does, joins whichever owning lane is live at trigger time (no separate worktree — see §F). +- **Status:** **Accepted (2026-06-19, owner go).** The zero-egress arm this ADR authorized shipped in 0.2.10; the live-egress envelope it bounds is still unbuilt and off-by-default (see **Built**). The line that stood here — *"No code yet — #30 stays DEFER / on-trigger"* — was written before #30 shipped and is corrected rather than deleted, because it is the sentence a reader would otherwise carry forward. The off-box-egress posture for `update-check` (#30) is delegated to the coordinator "per our security standards" (MULTISESSION-PLAN-3.md §"Deferred background monitor"); this ADR **records and justifies** that decision rather than re-opening it. (`0023` = inbound HTTP listener (#7), `0024` = email/SMTP (#23), `0025` = DICOM (#24), `0027` = per-connection retention (#34) — all earmarked-not-yet-authored in MULTISESSION-PLAN-3.md §E / PLAN-3-LANE-HANDOFFS.md; **0026 is the next free number, taken here** for the off-box-egress ADR the plan calls "the off-box-egress ADR for `update-check` #30".) +- **Built:** the accepted **zero-egress** arm shipped in 0.2.10 (BACKLOG #30, PR #618): a local pinned-vs-current lock diff that makes **no network call**, surfaced on `/status` and as an ADR 0014 `update_available` alert. On by default. The **live-egress** mode this ADR bounds is still not built and stays an off-by-default future option — that half of the posture remains design only. (#30 was **DEFER / on-trigger** when this ADR was written, joining whichever owning lane was live at trigger time — see §F. The trigger fired and it shipped under Plan-5 Wave 1.) - **Decision in one line:** the default **and the only MVP build** is a **no-network "pinned-vs-current lock diff"** — compare the bundled/pinned `messagefoundry` version against the installed/locked version with **zero outbound traffic**, surfaced as **one additive `/status` field** (on the pre-existing `GET /status`, sequenced after the merged `obs-metrics` #21 wave) and, optionally, **one `update_available` AlertSink event** (riding the #5 alert framework); a **live-egress version check is NOT built now** — this ADR only **defines its constrained, off-by-default future envelope** (env-clamped on the `[ai]` OFF→PHI-safe precedent, https-only + no-redirect + host-allowlist mirroring `WebhookTransport`, stdlib `urllib` only, advisory-only, RBAC-gated + audited), and the auto dependency-vulnerability-scan half is **dropped (§G)** because CI's DEP-1 audit + the hash-locked `requirements.lock` already cover dep staleness. - **Related:** - [BACKLOG #30](../archive/backlog/BACKLOG-CLOSED.md#30-automatic-dependency--messagefoundry-version-update-check-surfaced-in-the-console--ide) (the item this settles — trimmed to a constrained version-check core, §G); diff --git a/docs/adr/0027-per-connection-retention.md b/docs/adr/0027-per-connection-retention.md index 4fbf18d3e..8021c22f0 100644 --- a/docs/adr/0027-per-connection-retention.md +++ b/docs/adr/0027-per-connection-retention.md @@ -1,6 +1,6 @@ # ADR 0027 — Per-connection retention / pruning windows -- **Status:** Proposed +- **Status:** Accepted — **built in 0.2.9** (per-connection `messages_days` / `dead_letter_days` retention windows). BACKLOG #34 carries the closing record. - **Date:** 2026-06-27 - **Related:** BACKLOG #34 · [ADR 0001](0001-staged-pipeline-architecture.md) (staged queue + the count-and-log / at-least-once invariants this purge must preserve) · [ADR 0007](0007-gui-manageable-connections-toml.md) diff --git a/docs/adr/0042-embedded-document-pruning.md b/docs/adr/0042-embedded-document-pruning.md index 13b9b9e31..5dd76626b 100644 --- a/docs/adr/0042-embedded-document-pruning.md +++ b/docs/adr/0042-embedded-document-pruning.md @@ -1,8 +1,9 @@ # ADR 0042 — Embedded-document (base64 attachment) pruning -- **Status:** Proposed (2026-06-27) — drafted for Multisession Plan 4 (Lane 0 coordinator); owner ratifies - the **design fork** (build increment (a) now, or defer) before Lane A builds. Number reserved here (next - free after [0041](0041-load-path-attestation-and-change-attribution.md)). +- **Status:** Accepted — **increment (a) built in 0.2.9** (BACKLOG #47): the per-connection + `prune_documents_after` window strips OBX-5 ED and `mfb64:v1:` blobs in place to a tombstone, on all three + backends. The **ingest-time offload variant (b) stays deferred** to a future ADR. Drafted 2026-06-27 for + Multisession Plan 4 (Lane 0 coordinator); the owner ratified the design fork in favour of building (a). - **Decision in one line:** add a per-connection **`prune_documents_after`** window (with a size threshold) that, on a `RetentionRunner` pass, **rewrites the stored raw in place** to replace each base64 embedded document (the generic `mfb64:v1:` carriage marker and HL7 **OBX-5 ED** embeds) with a small diff --git a/docs/adr/0053-free-threaded-multicore-engine.md b/docs/adr/0053-free-threaded-multicore-engine.md index 2bf21434c..1062adaa8 100644 --- a/docs/adr/0053-free-threaded-multicore-engine.md +++ b/docs/adr/0053-free-threaded-multicore-engine.md @@ -1,9 +1,13 @@ # 0053 — Free-threaded (cp314t) multi-core engine as the committed unified-store scale path (supersedes 0040) -- **Status:** **Accepted** (2026-06-29) — the *commitment* is ratified; the cp314t feasibility + scaling - spike is **Phase 1 of execution** (see Acceptance Criteria), with ADR 0037 sharding + cross-shard - observability as the documented **fallback** if the spike fails. **Supersedes - [0040](0040-free-threaded-engine-support.md).** +- **Status:** **COMMITMENT RETIRED — the spike ran and free-threading was declined.** The fallback named + below is now the path: **ADR 0037 engine sharding** over the [0063](0063-no-split-store-unified-store-for-sharding.md) + unified store. BACKLOG **#90** closed DECLINED 2026-07-09 (thread-hop fusion measured +6.5/+9.3/+10.0%, + below the 10% bar) and **#91** closed DECLINED 2026-07-20 (the engine is not CPU-bound: ~0.06–0.36 cores + per shard, so parallel CPU cannot pay for itself at the current wall). **Accepted 2026-06-29** — that + ratification, and everything below it, is kept as **history, not current direction**. It still + **supersedes [0040](0040-free-threaded-engine-support.md)**: 0040's *reasoning* was reversed here, and + the *outcome* landed back where 0040 stood, by measurement rather than by deferral. - **Date:** 2026-06-29 - **Related:** **supersedes** [0040](0040-free-threaded-engine-support.md) (free-threading declined-by-design) · **driven by** [0052](0052-enterprise-scale-target.md) (the committed enterprise target) · **refines** diff --git a/docs/adr/0169-username-identity-is-case-sensitive-and-must-not-depend-on-store-collation.md b/docs/adr/0169-username-identity-is-case-sensitive-and-must-not-depend-on-store-collation.md index 89f5dd455..343024575 100644 --- a/docs/adr/0169-username-identity-is-case-sensitive-and-must-not-depend-on-store-collation.md +++ b/docs/adr/0169-username-identity-is-case-sensitive-and-must-not-depend-on-store-collation.md @@ -3,7 +3,10 @@ # ADR 0169 — Username identity is case-sensitive, and no identity decision may depend on store collation -- **Status:** Proposed (2026-08-20) +- **Status:** Proposed (2026-08-20) — **the fix is built.** BACKLOG #1268 shipped both limbs on 2026-08-20: + `users.username` now pins `COLLATE Latin1_General_100_BIN2` ([`store/sqlserver.py`](../../messagefoundry/store/sqlserver.py)) + and the bootstrap-retirement gate no longer depends on store collation. **Residual:** the `users` DDL is + creation-guarded, so an existing SQL Server database keeps the collation it was created with. - **Date:** 2026-08-20 - **Related:** [BACKLOG #1268](../BACKLOG.md) · [ADR 0164](0164-record-bootstrap-claimed-ness-never-infer-a-monotonic-lifecycle-fact-from-mutable-credential-state.md) (the other half of the WP-3 bootstrap lifecycle) · [SECURITY.md](../SECURITY.md) §"Auto-retirement (WP-3)" · [CLAUDE.md](../../CLAUDE.md) §0 (not deployed), §11 (SDS-3.7) diff --git a/docs/adr/README.md b/docs/adr/README.md index 63377f9c3..e42b69878 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -35,9 +35,9 @@ what is withheld and what you can request. |---|---|---| | [0001](0001-staged-pipeline-architecture.md) | Staged pipeline — per-stage durable queues | Accepted | | [0002](0002-phase2-transport-security-and-strong-auth.md) | Phase 2 — transport security & strong auth (off-loopback) | Accepted (TLS WP-13a/13b/15 → v0.1; MFA WP-14 built 2026-06-17) | -| [0003](0003-non-hl7-transports-database-rest-soap.md) | Non-HL7 transports — database, REST, SOAP connectors | Accepted (destinations); sources open | +| [0003](0003-non-hl7-transports-database-rest-soap.md) | Non-HL7 transports — database, REST, SOAP connectors | Accepted — destinations built, and the source direction shipped too: `DatabaseSource` ([transports/database.py](../../messagefoundry/transports/database.py)) plus the ADR 0023 inbound HTTP listener that closed BACKLOG #7. No separate REST/SOAP *source* connector is planned. | | [0004](0004-payload-agnostic-ingress.md) | Payload-agnostic ingress (non-HL7 sources) | Accepted | -| [0005](0005-transform-accessible-state.md) | Transform-accessible state (cross-message correlation) | Accepted (design; build pending) | +| [0005](0005-transform-accessible-state.md) | Transform-accessible state (cross-message correlation) | Accepted (2026-06-13) — **built**: `SetState`/`state_get`, the transactional write inside `transform_handoff`, the read-through cache, at-rest encryption + key-rotation coverage of the `state` table, and a `[retention].state_max_age_days` purge, on all three backends | | [0006](0006-external-data-lookups.md) | External data lookups for transforms (reference enrichment) | Accepted; Tier 1 (file + database sources) built — Amended 2026-07-16: reference sets implemented on SQL Server (BACKLOG #235, Plan-12 `store-235-flip`) | | [0007](0007-gui-manageable-connections-toml.md) | GUI-manageable connections as a config-as-data TOML artifact | Proposed | | [0008](0008-cluster-observability-api.md) | Read-only cluster observability API (`/cluster/status` + `/cluster/nodes`) | Proposed (built) | @@ -67,7 +67,7 @@ what is withheld and what you can request. | [0032](0032-console-desktop-launch.md) | Console desktop launch — a windowed `gui-script` (`messagefoundry-console`) + window/taskbar icon + Desktop/Start-Menu shortcut installer (Phase A); frozen installer (Phase B) retired. **RETIRED (2026-07-13, BACKLOG #103):** the whole PySide6 desktop console is removed — superseded by the browser web console (ADR 0065) + the `apiclient`/`messagefoundry service` extraction (ADR 0088) | RETIRED (2026-07-13) | | [0033](0033-gui-manageable-code-sets.md) | GUI-manageable code sets (translation tables) — a `messagefoundry codeset` CLI (mirrors `connection`: owns validate-before-write + atomic temp+replace + owner-only perms + post-write re-load) + a VS Code webview grid editor that shells it, CSV-first for v1 (TOML sets summarized/shown read-only). Reference sets (ADR 0006) + `db_lookup` (ADR 0010) are out of scope (externally-owned data); a rename/remove can dangle a `code_set(...)` reference, caught by `messagefoundry check`'s dry-run | Accepted (built, 0.2.3, #540) | | [0034](0034-static-analysis-triage-policy-accepted-risk-register.md) | Static-analysis & supply-chain (CodeQL + OSSF Scorecard) triage policy + accepted-risk register — every mirror finding is *fixed* or *dismissed with a recorded reason* (never silently open/suppressed); CodeQL dataflow default is "real" until the untrusted-source→sink path is confirmed mitigated, and a PHI-to-log / path-injection finding is never dismissed without verifying it. Class-level FP rationale + the accepted risks (cleartext bootstrap-admin password; Scorecard repo-hygiene — pinned-deps / token-permissions / mirror-governance) live in-repo so re-scans converge. First triage: 2 fixed (PR #554), 16 CodeQL + 48 Scorecard dismissed | Accepted (2026-06-26) | -| [0035](0035-ide-extension-workspace-trust-and-scope.md) | IDE extension hardening — VS Code Workspace Trust gating for the auto-detected `.venv` interpreter, machine-scoped engine/promote URLs (a checked-in workspace setting can't retarget promote or leak the session token), and a fail-closed offline AI policy (SEC-004/005/022) | Accepted (2026-06-26) | +| [0035](0035-ide-extension-workspace-trust-and-scope.md) | IDE extension hardening — VS Code Workspace Trust gating for the auto-detected `.venv` interpreter, machine-scoped engine/promote URLs (a checked-in workspace setting can't retarget promote or leak the session token), and a fail-closed offline AI policy (SEC-004/005/022) | Accepted (2026-06-26) — **built**: `untrustedWorkspaces` in [ide/package.json](../../ide/package.json) and the interpreter gate reading `vscode.workspace.isTrusted` in [ide/src/cli.ts](../../ide/src/cli.ts) | | [0036](0036-windows-config-source-trust.md) | Windows config-source trust — the loader parses the config dir's NTFS owner/DACL in-process and refuses to load when a broad/low-privilege principal can write it (no longer a Windows no-op), with a fail-closed-by-default `MEFOR_ALLOW_INSECURE_CONFIG_SOURCE` dev/test escape; renumbered from 0034 after #567's concurrent 0034 (SEC-003) | Accepted (2026-06-26) | | [0037](0037-multi-process-sharding-l3.md) | Multi-process sharding (L3) — per-connection, N engine subprocesses each owning a disjoint subset of inbound connections (own SQLite db + API port), supervised; intake partitioned, outbound/logic shared, per-channel FIFO preserved per shard. Per-message-key sharding rejected; active-active deleted (#396) | Accepted (built, PR #584) | | [0038](0038-internal-pass-through-connector.md) | Internal pass-through (PT) connector (L4) — a Handler `Send`s into an internal PT inbound (its own Router); the body re-ingresses as a new content-addressed child inside the parent's `transform_handoff` transaction (atomic, at-least-once, count-and-log, single-finalizer), with a correlation-depth loop cap; allow-listed to backends that implement it (SQLite + Postgres + SQL Server). Generalizes ADR 0013 re-ingress | Accepted (built, PRs #585/#587/#590) | @@ -79,13 +79,13 @@ what is withheld and what you can request. | [0044](0044-operator-alert-state.md) | Operator alert state — resolvable alert instances (open/acknowledged/resolved + first/last-seen + count) in a new `alert_instance` store table across all three backends, de-duped on the existing ADR 0014 `_emit` throttle key; `GET /alerts/active` + ack/resolve endpoints (RBAC `MONITORING_DIAGNOSE`); wires the stubbed `ConnectionRow.alerts_active` to the real open count; metadata-only, no new at-rest PHI tier; refines ADR 0014 (#56) | Accepted (2026-06-27, built) | | [0045](0045-custom-rbac-roles.md) | User-definable custom RBAC roles — an admin-defined named role = a chosen **subset** of the existing Permission catalog (no new permission kinds), persisted via a `roles`-table migration on all three backends (SQLite + Postgres + SQL Server), gated by `USERS_MANAGE`; the 6 fixed built-ins stay; custom roles are an additive overlay; deny-by-default preserved (#57) | Accepted (2026-06-27, built) | | [0046](0046-message-content-search.md) | Message-content search (HL7 field-path / raw-content matching) under at-rest encryption — because `messages.raw` is AES-256-GCM-encrypted (`store/crypto.py`) a plain SQL `LIKE` matches nothing, so the first slice is **scan-and-decrypt-per-row** (metadata-pre-filtered, hard row/result cap, decrypt+match off the event loop, behind `messages:view_*` + step-up + an audit row); the cleartext key-field index is **declined** (PHI-at-rest), a keyed-token HMAC index deferred to a 2nd slice (#51) | Accepted (2026-06-27, built) | -| [0047](0047-cloud-kubernetes-ha-deployment-packaging.md) | Cloud / Kubernetes HA deployment packaging — package the shipped, code-complete **active-passive** HA into a copyable target: a multi-replica reference manifest (Postgres-backed `replicas:3`, `[cluster].enabled`, PDB `maxUnavailable:1`, lease-TTL-aware grace), managed-Postgres-led cloud docs (SQLite/single-node = POC/edge), MLLP **L4** LB guidance (primary-only health check, **no L7/HPA** for MLLP), a hybrid edge-relay template, a cloud PHI/HIPAA arch doc, + ratifies the already-shipped raw-TCP/X12 startup-TLS guard (PR #558). Not active-active; the VIP is operator-LB-owned and **follows** failover. Packaging, not capability (#41) | Accepted (2026-06-28) | +| [0047](0047-cloud-kubernetes-ha-deployment-packaging.md) | Cloud / Kubernetes HA deployment packaging — package the shipped, code-complete **active-passive** HA into a copyable target: a multi-replica reference manifest (Postgres-backed `replicas:3`, `[cluster].enabled`, PDB `maxUnavailable:1`, lease-TTL-aware grace), managed-Postgres-led cloud docs (SQLite/single-node = POC/edge), MLLP **L4** LB guidance (primary-only health check, **no L7/HPA** for MLLP), a hybrid edge-relay template, a cloud PHI/HIPAA arch doc, + ratifies the already-shipped raw-TCP/X12 startup-TLS guard (PR #558). Not active-active; the VIP is operator-LB-owned and **follows** failover. Packaging, not capability (#41) | Accepted (2026-06-28) — **built**; [BACKLOG #41](../archive/backlog/BACKLOG-CLOSED.md) records all six deliverables shipped | | [0048](0048-third-tier-disaster-recovery-standby.md) | Third-tier disaster-recovery standby — a right-sized, under-provisioned DR box that activates only when the whole active-passive HA pair / site is gone and runs **only** high-priority feeds in degraded mode; a per-connection `priority` tier (global-default + per-connection-override) + a startup DR run-profile (`status:"filtered"`, distinct from ADR 0031's `failed`); **cold seed** from #60's encrypted backups + **manual-runbook** activation; the service VIP **follows** the box via ADR 0047's passive primary-only-health-check LB (acquire-VIP-or-abort fence; optional takeover hook for non-LB topologies); drain-then-hand-back fail-back. Not active-active; DB replication DBA-delegated (#61) | Accepted (2026-06-28, EARS) | | [0049](0049-turnkey-dr-backup-restore-verify.md) | Turnkey DR — engine-managed scheduled (+ on-demand) backup of the config bundle + **SQLite** store as a single **AES-256-GCM-encrypted** `.mfbak` archive (keyed by the ADR 0019 store DEK), to an operator-configured **local/UNC** path (no cloud), keep-N retention, with a **lightweight restore-verify** (consistent snapshot + `integrity_check` + row-count) after each run + one PHI-free `dr_backup` audit row; a `BackupRunner` sibling of `RetentionRunner` (leader-gated). Server-DB store backup stays **DBA-delegated**. Produces ADR 0048's cold seed (#60) | Accepted (2026-06-28) | | [0050](0050-single-project-root-config-anchoring.md) | Single project-root config anchoring — anchor the whole config bundle (`--config` dir + `environments/` + `messagefoundry.toml` + the DB) to **one** project root and extend `--project-root` / `--env` / `--service-config` to `validate` / `graph` / `dryrun` / `check` (today only `serve`); precedence explicit-flag > root > CWD; **fail-loud** on a missing `.toml` when `env()` is referenced (vs today's silent NSSM-non-repo-CWD miss); backward-compatible with existing `--config`. Implements config-UX review follow-up A (#33) | Accepted (2026-06-28) | | [0051](0051-corepoint-throughput-parity-strategy.md) | Throughput parity with Corepoint — **measure-first**; incremental durable-write + lean-write levers on the existing Python engine + multi-process sharding (ADR 0037); **no language rewrite, no broker**. Anchored on the qualified Corepoint 45M/day spec (9,200 8KB-random-write IOPS, ~11 KB/msg, 20+16 cores, multi-DB + AlwaysOn AG; the incumbent names DB durable-write I/O as the leading driver). The gate is an enterprise-hardware E_core/IOPS run (#40 / #28/#29); then group-commit (its own ADR) if durable-write-bound, lean-writes/carriage (#62/#63/#47/#34), shared-backend-only multi-DB log split; scoped native core / free-threading (0040) / DBSHARD (0039) deferred behind the gate. The storage gap is mostly carriage (NVARCHAR+base64) + encrypt-by-default, not inefficiency. Roadmap #64 | Proposed (2026-06-28) | | [0052](0052-enterprise-scale-target.md) | Commit to the enterprise scale tier as a product target — up to **45M msgs/day, 1,500 connections, remote DB** is a *committed* capability target (not met today; a single asyncio loop = one core). The enterprise market is **in-scope, no longer demand-gated** (funding base = one large / two medium customers). Sets the numeric bar the scaling ADRs must reach with FIFO / at-least-once / count-and-log + **unified-store** observability intact; *how* = 0053 (primary) / 0037–0039 sharding (fallback) / 0051 durable-write levers. Connection-scale (1,500) validation + enterprise-hardware run (#40) pending | Accepted (2026-06-29) | -| [0053](0053-free-threaded-multicore-engine.md) | Free-threaded (cp314t) multi-core engine as the committed unified-store scale path (**supersedes 0040**) — many real threads on **one** store / process / API port (vs sharding's fragmented K-DB store), the analog of Corepoint's one internally-multi-threaded engine; fits because the hot path is pure. **Phase 1 = a feasibility + scaling spike started now** (engine-path compiled-dep thread-safety — pydantic-core / cryptography / argon2-cffi / asyncpg / pyodbc; measured multi-core scaling on a concurrent-commit server DB; no invariant regression); **fallback = ADR 0037 sharding + cross-shard observability** on no-go. Reverses 0040's measure-first decline on the early-phase timing argument; **refines 0051** (brings free-threading forward of its enterprise-hardware gate; complements its durable-write levers — necessary-not-sufficient). SQLite stays single-writer; server-DB-first | Accepted (2026-06-29) | +| [0053](0053-free-threaded-multicore-engine.md) | Free-threaded (cp314t) multi-core engine as the committed unified-store scale path (**supersedes 0040**) — many real threads on **one** store / process / API port (vs sharding's fragmented K-DB store), the analog of Corepoint's one internally-multi-threaded engine; fits because the hot path is pure. **Phase 1 = a feasibility + scaling spike started now** (engine-path compiled-dep thread-safety — pydantic-core / cryptography / argon2-cffi / asyncpg / pyodbc; measured multi-core scaling on a concurrent-commit server DB; no invariant regression); **fallback = ADR 0037 sharding + cross-shard observability** on no-go. Reverses 0040's measure-first decline on the early-phase timing argument; **refines 0051** (brings free-threading forward of its enterprise-hardware gate; complements its durable-write levers — necessary-not-sufficient). SQLite stays single-writer; server-DB-first | **Commitment RETIRED — the cp314t path was measured and DECLINED.** BACKLOG #90 closed DECLINED 2026-07-09 (thread-hop fusion below the 10% bar) and #91 closed DECLINED 2026-07-20 (the engine is not CPU-bound: ~0.06–0.36 cores per shard). The committed scale path is this ADR's own documented fallback — [0037](0037-multi-process-sharding-l3.md) engine sharding over the [0063](0063-no-split-store-unified-store-for-sharding.md) unified store. Accepted 2026-06-29; kept as history, **not** as current direction. | | [0054](0054-low-allocation-builtins-hl7-parser.md) | Low-allocation built-ins HL7 parser (free-threading keystone, BACKLOG #88) — replace **python-hl7** as the tolerant-tier backing of the *existing* `Peek`/`Message` API with a parser over native **dict/list/str** (no per-node classes), as a behaviour-identical **drop-in**. WS3 measured the class-instance tree as the free-threading bottleneck: built-ins scale **6.44× multi-core + ~14× single-thread** vs python-hl7's 2.02× / 1× (hl7apy worse on both). MSH-eager / rest-lazy split; reads separators from MSH-1/MSH-2; preserves every `Peek`/`Message`/`SegmentGroup`/`parse_tree` method + the whole-value-no-component rule + the escape/XFORM semantics; **hl7apy `validate()` strict tier untouched**. Golden-corpus parity gate + Phase-1 python-hl7 fallback; unlocks [ADR 0053](0053-free-threaded-multicore-engine.md) and helps single-process + [ADR 0037](0037-multi-process-sharding-l3.md) sharding regardless | Accepted (2026-06-29; built + merged #655) | | [0055](0055-group-commit-durable-write.md) | Group-commit for the staged queue — the durable-write **ceiling-mover** (cut fsyncs/msg; ~7 commits/msg today). A committer coroutine coalesces N already-prepared mutations into one durable commit under the writer lock; group rollback rejects all members' futures → re-run (reuses the idempotent INFLIGHT-guarded crash-re-run). **Backend-dependent mechanism:** app-side committer on SQLite's single writer; on PG/SQL Server's concurrent pool, native `commit_delay` + concurrent submission (resolve the single-lock-vs-pool fact first — native GUC buys ~0 under single-writer serialization). `claim` poison-guard stays standalone; ACK waits on the durable ingress future; cache-publish only on member success. Build authorized now as a no-regret lever (ADR 0051 delayed-HW adjustment), proxy-measured on the 265KF (storage methodology verified); win is `synchronous=FULL`-dependent | Proposed (2026-06-29) | | [0056](0056-engine-managed-vip-failover.md) | Engine-managed virtual IP (VIP) failover | Proposed (2026-06-27, design-only) | @@ -113,8 +113,8 @@ what is withheld and what you can request. | [0078](0078-certificate-revocation-posture.md) | Certificate revocation posture (OCSP/CRL, ASVS 12.1.4, BACKLOG #201) — **enforced start-time refusal + delegated proxy**, NOT in-engine OCSP (stdlib `ssl` has no OCSP/CRL fetch; a hand-rolled responder fetch fights on-prem offline-by-default). Refines [ADR 0002](0002-phase2-transport-security-and-strong-auth.md)'s *documented* revocation residual into an **enforced** control: `serve` REFUSES to start an in-process, off-loopback `[api]` TLS bind (`tls_cert_file` set + non-loopback `host`) UNLESS revocation is *proven in front* — a declared TLS-terminating proxy (`tls_terminated_upstream` + `trusted_proxies`, which does its own OCSP-must-staple/CRL) OR the operator opt-out `MEFOR_TLS_REVOCATION_ATTESTED=1`. Secure default = refuse; the loopback default + proxy-terminated paths start **byte-identically** (the pure `config/tls_policy.py:in_process_tls_revocation_refused` predicate short-circuits). Compensating controls: the SQL-Server SChannel path already does OS-managed revocation, and `pipeline/cert_expiry.py` alerts on expiring certs (steering short-lived certs). **Amendment 2026-07-12 (BACKLOG #201 residual):** extends the SAME posture-keyed refusal to the OUTBOUND verifying-TLS connectors — pure `revocation_hop_disposition(*, is_phi, production, is_loopback_hop, proxy_proven, attested)` + `RevocationHopGuard` in `config/tls_policy.py`, wired into MLLP-over-TLS egress, the REST/SOAP/FHIR https paths (`refuse_unrevoked_verified_hop`), and the Postgres asyncpg store hop (`_refuse_store_revocation`); per-connection `tls_revocation_attested` + the blanket `MEFOR_TLS_REVOCATION_ATTESTED` env are the opt-outs. Composes with #200 (fires only on a VERIFYING hop — no double-refusal). Still out of scope: SQL-Server/SChannel (already OS-managed), DICOM-SCU/FTPS, the FhirLookup read path. Flips the ASVS 12.1.4 row from documented-residual to enforced-delegation | Accepted (2026-07-10; amended 2026-07-12) — built | | [0079](0079-kerberos-idp-session-coordination.md) | Kerberos/AD engine-session lifetime coordinated with the directory (IdP) — terminate engine sessions when the directory revokes or disables the account, rather than letting a local session outlive its AD principal. **Amendment 2026-07-21:** mechanism 1's preferred input — the Kerberos ticket `endtime` — is **unobtainable** via pyspnego 0.12.1 (no expiry on the public `ContextProxy`; `SSPIProxy.step()` discards sspilib's `AcceptContextResult.expiry`), so on the Kerberos/LDAPS path it would degrade to a second local constant dressed as directory data. ASVS 7.1.3 therefore **closed by ACCEPTANCE** (signed register row, theme 3) and this ADR's Proposed→Accepted trigger is **NOT fired**; mechanism 1 ships only where the datum genuinely exists — the federated `id_token.exp` session cap. Mechanism 2 (background re-validation loop) stays deferred. **Amendment 2026-07-22: mechanism 2 is BUILT.** The deferral's stated cost was void — the candidate set derives from the existing `list_users()` + `list_sessions()`, so **no `sessions` schema change on any backend** (provenance columns were only ever mechanism 1's need). Recorded narrowing: `require_step_up` performs **no** directory bind (it compares the stored `reauth_at`), so the step-up surface is protected by inability to REFRESH — leaving a ≤`step_up_max_age_seconds` residual — while **bulk/raw PHI reads** (`require_phi_read`, 120/min) and **connection start/stop** (`require_paced`) survived to the 12 h cap. Adds three properties the design did not name: **two-strike** before revoking (the lookup returns one `None` for disabled ∪ deleted ∪ wrong-search-base), **all-or-nothing passes** (planned in `auth/reconcile.py` before any write), and a **mass-revoke circuit breaker** — a bad search base answers "not found" for everyone, so a pass exceeding **both** `ad_session_revoke_max` (5) **and** `ad_session_revoke_max_fraction` (0.34) aborts + alerts. AND, not OR: the floor alone signs out a 5-person site, the proportion alone fires on a 3-of-3 offboarding. Group re-diff rides the pass free (demotions no longer wait for a login); channel scope deliberately excluded. Default OFF (`ad_session_recheck_seconds = 0`) | Accepted (mechanism 2 built 2026-07-22; mech 1 Kerberos path closed by acceptance, federated path shipped in ADR 0142) | | [0080](0080-offbox-forwarding-tls-defaults.md) | Native TLS-syslog off-box forwarding, default-on-when-configured, plus a startup time-sync gate (audit-integrity residual) | Accepted (2026-07-10) | -| [0081](0081-per-message-metadata-bag.md) | Per-message metadata bag (BACKLOG #150, channelMap/userdata parity) — a declarative `SetMeta(key, value)` op modelled on ADR 0005 `SetState`, applied exactly-once inside the pure `transform_handoff` transaction into the existing encrypted `messages.metadata` column under a reserved `"user"` sub-key; surfaced read-only via the already-reserved `MessageSummary.metadata` slot. Preserves at-least-once/FIFO/purity. Trigger fired (Corepoint cutover) | Accepted (2026-07-10) — owner ratified; build to follow | -| [0082](0082-outbound-batch-aggregation.md) | Outbound batch aggregation (BACKLOG #134) — a new batch-window seam at the delivery stage (Handlers are pure/per-message and cannot accumulate) that frames N outbound rows into one HL7 BHS…BTS envelope on send, with a time/count window; must preserve at-least-once + strict FIFO (a crash mid-batch neither loses nor reorders) and re-derive the same envelope on re-run | Accepted (2026-07-10) — owner ratified; build to follow | +| [0081](0081-per-message-metadata-bag.md) | Per-message metadata bag (BACKLOG #150, channelMap/userdata parity) — a declarative `SetMeta(key, value)` op modelled on ADR 0005 `SetState`, applied exactly-once inside the pure `transform_handoff` transaction into the existing encrypted `messages.metadata` column under a reserved `"user"` sub-key; surfaced read-only via the already-reserved `MessageSummary.metadata` slot. Preserves at-least-once/FIFO/purity. Trigger fired (Corepoint cutover) | Accepted (2026-07-10) — owner ratified; **built** (`SetMeta`, applied exactly-once inside `transform_handoff`; the per-message cap is in [config/wiring.py](../../messagefoundry/config/wiring.py)) | +| [0082](0082-outbound-batch-aggregation.md) | Outbound batch aggregation (BACKLOG #134) — a new batch-window seam at the delivery stage (Handlers are pure/per-message and cannot accumulate) that frames N outbound rows into one HL7 BHS…BTS envelope on send, with a time/count window; must preserve at-least-once + strict FIFO (a crash mid-batch neither loses nor reorders) and re-derive the same envelope on re-run | Accepted (2026-07-10) — owner ratified; **built 2026-07-10** (opt-in per-outbound `batch = { max_count, max_wait_ms }`, MLLP/HL7v2 only) — [BACKLOG #134](../archive/backlog/BACKLOG-CLOSED.md) carries the closing record | | [0083](0083-mtls-client-certificate-identity.md) | mTLS client-certificate identity (BACKLOG #200) — a **verified** peer cert's subject/SAN maps to a MessageFoundry principal via an explicit deny-by-default allow-list (`[api].tls_client_cert_identities`), rooted in `CERT_REQUIRED` and namespace-qualified against spoofing. An **attested service-to-service** identity (no MFA/session/step-up). **Activated** (PLAN-9 Wave 3): a fork-free scope-populating shim (`api/tls_client_cert.py`) surfaces the verified peer cert post-handshake, and a fenced cert-only dependency `require_service_cert` gates one non-interactive route (`GET /service/identity`) — never a bearer/PHI/step-up route (refuses PHI-view perms at build), so a cert-identity can never bypass step-up | Accepted (2026-07-10) — owner ratified; model+resolver built (#200), **activated** PLAN-9 Wave 3 | | [0084](0084-accepts-router-seam.md) | `accepts=` Router-stage seam — let a Handler declare a **pure** router-time applicability predicate (`Callable[[Message \| RawMessage], bool]`) so the Router declines it **before** a routed row is materialized, recovering the `2` transactions per self-filtering handler the ADR 0051 `txn/msg = 3 + 2H + 2N` model charges (the ADT hub's `wasted == 32`, ~63% of its durable writes). Purity enforced by construction (router stage already makes `db_lookup`/`fhir_lookup` raise — ADR 0010/0043); additive + default-identical (`accepts=None` = today). **Crux:** an all-declined message finalizes **`UNROUTED`** not `FILTERED` — count-and-log intact (still `RECEIVED` at ingress, still a final logged disposition, never accept-and-drop), only per-destination FILTERED granularity lost (optional `message_events` declined-handler mitigation). Ships an **advisory** `accepts-candidate` lint (flags a `@handler` opening with a guard-filter `if …: return []`). Spec + lint stub only, no engine build (build = BACKLOG #213) | **Accepted (2026-07-11, owner-ratified)** — `FILTERED → UNROUTED` accepted; `message_events` declined-handler mitigation deferred from v1, gated behind the #63 verbosity gate when built | | [0085](0085-direct-hisp-smime-connector.md) | Direct-Project S/MIME-over-SMTP outbound connector (DIRECT-HISP, BACKLOG #157) — PR1 **outbound send only**: a new `ConnectorType.DIRECT` + `DirectDestination` that **SIGNs then ENCRYPTs** the Handler body via core `cryptography` `serialization.pkcs7` (no new dep — `endesive` rejected, `dnspython` deferred) and submits it as `application/pkcs7-mime; smime-type=enveloped-data` over the reused EMAIL STARTTLS/`refuse_cleartext_credentials` posture. All signing key+cert / per-partner recipient cert / trust anchor loaded + cross-validated at construction (fail loud): key↔cert public-key match + one-level `verify_directly_issued_by` chain check. New fail-closed `[egress].allowed_direct` host gate (kept separate from `allowed_smtp`). Inbound Direct mail / MDN / DNS-CERT discovery / IHE XDR **deferred** to later phases | Accepted (2026-07-10) — PR1 outbound-only, later phases deferred | @@ -136,7 +136,7 @@ what is withheld and what you can request. | [0103](0103-steps-view-row-context-menu.md) | Steps view row context menu (right-click) over the existing row operations (BACKLOG #222 follow-up to [ADR 0100](0100-ide-native-surface-polish-and-open-to-messagefoundry-startup-experience-backlog-221.md)'s "right-click row menu … a follow-up" note) — a **webview-rendered** menu on step rows (NOT VS Code's `menus` contribution, which can't reach custom-editor webview DOM), emitted **server-side as one hidden template** (CSP-safe; insert catalog = the same `INSERT_ACTION_LABELS` source of truth) that the external script positions/greys/dismisses. It **reuses the existing execution paths** — posts the same `insertToolbar`/`deleteRow`/`moveTo`(via `walkMove`) messages the toolbar Add + per-row ↑/↓/🗑 already post, so the byte-stable `lens rewrite` path + F7 stale guard are unchanged (no new engine surface). Items: *Insert before ▸ / Insert after ▸* (Set Field / Copy Field / Delete Segment), *Delete*, *Move up*, *Move down*; Copy/Cut/Paste stay keyboard-served (out, owner decision). Enablement is a pure, unit-tested matrix (`contextMenuEnablement`) mirrored in the webview (Insert-after suppressed on a `send` row; Delete only on an editable row; ↑/↓ per the walk). **Explicit before/after**: `buildToolbarInsertRequest` + the `insertToolbar` message gain an **optional** `position` (engine `insert_row` already supports it) — backward-compatible, the toolbar Add is byte-identical. Paired UX fix: an empty editable param input shows a `[blank]` **placeholder** (a hint, never a value). Toolbar Insert dropdown UNCHANGED (the ADR 0100 insert-collapse deferred to owner). Ships IDE **v0.0.22** (v0.0.21 first cut had a submenu overlap — reveal is now JS-controlled + mutually exclusive). Extends [ADR 0076](0076-typed-action-vocabulary-action-list-lens.md)/[ADR 0089](0089-recognition-first-lens-native-idioms.md)/ADR 0100 | Accepted (2026-07-12) — built | | [0100](0100-ide-native-surface-polish-and-open-to-messagefoundry-startup-experience-backlog-221.md) | IDE native-surface polish + open-to-MessageFoundry startup experience (BACKLOG #221) — records and closes the VS Code extension's host-integration surface (IDE **chrome** around the code-first model; **#26 untouched** — the Steps lens itself is ADR 0076/0089). **(1) Native surface (shipped):** connections.toml/code-set `customEditors` (ADR 0007) with Reopen-With-Text; `.py` Steps editor at `priority:"option"`; a 9-step Get-Started walkthrough; engine-target + `MEFOR Live` status-bar items; TOML language association; a branded editor-title submenu; webview + keyboard-first QuickInput connection wizards. **(2) Startup experience:** opt-in per-workspace `messagefoundry.revealViewOnStartup` (default off) runs `workbench.view.extension.messagefoundry` on activation (config workspaces already activate via `workspaceContains:**/*.py`); a collapsed Home **"Setup"** group tucking one-time setup out of the way + an **"Extension Settings"** item (`messagefoundry.openSettings` → `@ext:`-filtered). **(3) Code-view toolbar:** the native editor can't host injected chrome, so an editor-title icon cluster (View as Steps / Test Bench / Validate) + a consolidated inline CodeLens row above each `@handler` — Monaco-in-webview REJECTED (loses Pylance). **(4) Brand:** distinctive action icons in molten amber (`#f59e0b`) via vendored codicon recolours (CC BY 4.0, attributed); generic verbs stay themeable (docs/BRAND.md accent scarcity). Extends [ADR 0089](0089-recognition-first-lens-native-idioms.md)/ADR 0076/[ADR 0091](0091-element-centric-connections-view.md)/[ADR 0007](0007-gui-manageable-connections-toml.md) | Accepted (2026-07-12) — native surface shipped; startup/toolbar/brand built (#967/#972) | | [0104](0104-copy-on-send-outbound-message-model-recognition-first-handler-message-type-and-hl7-field-picker.md) | Copy-on-Send outbound message model + recognition-first handler message-type + HL7 field picker — three linked design questions extending the Steps lens. **Q1:** `Send(to, msg)` captures a defensive **structural** snapshot at construction (copy-on-Send, copy-on-write) so within-handler fan-out delivers per-destination bytes with **zero** edits to the 1,283-site native `msg.set`/`Send(msg)` estate; `Message.copy()` as sugar; **structural clone, never `parse(encode())`** (avoids the built-in↔python-hl7 backend switch on a fallback-parsed source); inbound read-only **advisory**; no object-identity fan-out lint; `RawMessage.raw`-not-immutable corrected. **Q2:** handled type is **recognition-first** (inferred from the native guards handlers already write), optional `message_type=` doc kwarg; enforcement is author-written `accepts=message_type_of("ADT^A01")` on the ADR 0084 seam — **component-wise** MSH-9.1+9.2 matching (fixes 3-component `ADT^A01^ADT_A01` + custom separators), **fails loud** (ERROR) on non-HL7/batch. **Q3:** extend the **already-shipping** `ide/src/completion.ts` inline path autocomplete first; a Steps-view picker is gated on ADR 0089 Acceptance + measured lens adoption — path-arg splice only, occurrence/repetition **read-only**, dual-backend round-trip gate, version-pinned trigger→structure resolver centralizing `generators/adt.py`, Z-segments always unioned, no false-complete rows. Five spec blockers + the sandbox (ADR 0087) / inline fast-path (ADR 0057) execution-path matrix folded in; adversarially validated (Q1/Q2/Q3 majority-refuted then revised). Backing memo: `docs/research/message-model-eval.md`. Sibling to [ADR 0084](0084-accepts-router-seam.md); extends [ADR 0076](0076-typed-action-vocabulary-action-list-lens.md)/[ADR 0089](0089-recognition-first-lens-native-idioms.md) | Accepted (2026-07-13) — owner-ratified; Q1/Q2 built (#995), Q3 Steps picker shipped (#1001); §2.3 Step 1 autocomplete outstanding | -| [0105](0105-streaming-very-large-hl7-attachments-detach-the-opaque-document-from-the-transformable-skeleton.md) | Streaming very-large HL7 attachments — **detach the opaque document from the transformable skeleton** (BACKLOG #149, Phase 0 substrate). A base64 PDF in `OBX-5.5` that pushes the frame past the 16 MiB cap is lifted at ingress into a **content-addressed, chunked, in-store attachment** (each chunk `mfenc` AES-GCM-sealed — bounded plaintext window per seal), leaving a small `mfdoc:v1:ref::` **live handle** (a live sibling of the `mfdoc:v1:pruned:` tombstone, unified with #94's opaque-pointer contract); the small skeleton rides the existing ingress→routed→outbound stages unchanged, and delivery **re-attaches** the handle — splicing the verbatim value back into `OBX-5.5` (no decode/re-encode) — and streams the frame inline MLLP MDM. **Owner rulings:** (1) Epic's MLLP does not cap frame size → inline MLLP, no FHIR-Binary; (2) pure pass-through, **doc-mutating transforms a non-goal** on streaming feeds; (3) **Approach B — store the `OBX-5.5` value VERBATIM** (byte-for-byte, no decode/re-encode → trivially re-run-pure); (4) 3-backend parity before go-live. **Substrate generalizes the `shared_body` refcount+GC** (`attachment`/`attachment_chunk` tables, `put_attachment`/`read_attachment`/`attachment_incref`/`attachment_decref`, content-addressed + dedup) + a NEW startup **orphan/incomplete-attachment sweep** (reclaims refcount-0 AND orphaned chunks so no PHI at rest, wired where `reset_stale_inflight` runs) + the key-rotation re-encrypt sweep extended to **re-seal chunks** + a `supports_streaming_attachments` capability flag (SQLite True; SS/PG raise until Phase 4). **Ingress detach (Phase 1a) + delivery re-attach (Phase 1b) now built** — the full round-trip: an over-threshold `OBX-5.5` is detached VERBATIM at ingress and re-materialized (spliced back byte-for-byte) at the terminal egress via the pure `reattach_documents_in_hl7` (injected async reader) on both the single-item and batch delivery paths; hydration is fail-loud (a missing/GC'd attachment → retryable `DeliveryError`, never a handle on the wire) and a **pure read** (never decref — retry-idempotent, fan-out-safe). The outbound MLLP send is uncapped so the large frame streams inline. **Phase 3a (retention) now built:** a `message_attachment` join table persists the message→attachment linkage (populated atomically with the ingress incref) and `purge_message_bodies` **decrefs + deletes** those rows in the body-purge transaction — ordered so a crash-re-run is a no-op (no double-decref / underflow / premature GC of a shared attachment), **closing the Phase-1b over-retention gap** (a purged-but-referenced document is now reclaimed at its last referrer). **Phase 4 (SQL Server + Postgres substrate parity) now built** — the whole Phase-0→3a substrate (schema, put/read/incref/decref/sweep, ingress two-object commit, retention decref + dead-row split, key-rotation re-seal) is implemented on both server backends at byte-for-byte behavioral parity with the SQLite reference (dialect + txn-model adapted only), `supports_streaming_attachments` flipped True, with SS/PG parity tests on the CI legs — **go-live parity met (the production store is SQL Server)**. **Remaining:** Phase 3b (read-surface migration — raw view / content search / retention strip made attachment-aware) is the only phase left. Extends [ADR 0001](0001-staged-pipeline-architecture.md)/[ADR 0028](0028-base64-binary-carriage-codec.md) | Accepted (2026-07-12) — Phase 0 substrate + Phase 1a ingress detach + Phase 1b delivery re-attach + Phase 3a linkage/retention decref + Phase 4 SS+PG parity built (`stream_threshold_bytes`/`max_message_bytes`/in-flight budget, deferred-ACK, two-object commit, verbatim splice-back, inline MLLP, `message_attachment` decref, streaming on all three backends); Phase 3b (operator read surfaces) the only remainder | +| [0105](0105-streaming-very-large-hl7-attachments-detach-the-opaque-document-from-the-transformable-skeleton.md) | Streaming very-large HL7 attachments — **detach the opaque document from the transformable skeleton** (BACKLOG #149, Phase 0 substrate). A base64 PDF in `OBX-5.5` that pushes the frame past the 16 MiB cap is lifted at ingress into a **content-addressed, chunked, in-store attachment** (each chunk `mfenc` AES-GCM-sealed — bounded plaintext window per seal), leaving a small `mfdoc:v1:ref::` **live handle** (a live sibling of the `mfdoc:v1:pruned:` tombstone, unified with #94's opaque-pointer contract); the small skeleton rides the existing ingress→routed→outbound stages unchanged, and delivery **re-attaches** the handle — splicing the verbatim value back into `OBX-5.5` (no decode/re-encode) — and streams the frame inline MLLP MDM. **Owner rulings:** (1) Epic's MLLP does not cap frame size → inline MLLP, no FHIR-Binary; (2) pure pass-through, **doc-mutating transforms a non-goal** on streaming feeds; (3) **Approach B — store the `OBX-5.5` value VERBATIM** (byte-for-byte, no decode/re-encode → trivially re-run-pure); (4) 3-backend parity before go-live. **Substrate generalizes the `shared_body` refcount+GC** (`attachment`/`attachment_chunk` tables, `put_attachment`/`read_attachment`/`attachment_incref`/`attachment_decref`, content-addressed + dedup) + a NEW startup **orphan/incomplete-attachment sweep** (reclaims refcount-0 AND orphaned chunks so no PHI at rest, wired where `reset_stale_inflight` runs) + the key-rotation re-encrypt sweep extended to **re-seal chunks** + a `supports_streaming_attachments` capability flag (SQLite True; SS/PG raise until Phase 4). **Ingress detach (Phase 1a) + delivery re-attach (Phase 1b) now built** — the full round-trip: an over-threshold `OBX-5.5` is detached VERBATIM at ingress and re-materialized (spliced back byte-for-byte) at the terminal egress via the pure `reattach_documents_in_hl7` (injected async reader) on both the single-item and batch delivery paths; hydration is fail-loud (a missing/GC'd attachment → retryable `DeliveryError`, never a handle on the wire) and a **pure read** (never decref — retry-idempotent, fan-out-safe). The outbound MLLP send is uncapped so the large frame streams inline. **Phase 3a (retention) now built:** a `message_attachment` join table persists the message→attachment linkage (populated atomically with the ingress incref) and `purge_message_bodies` **decrefs + deletes** those rows in the body-purge transaction — ordered so a crash-re-run is a no-op (no double-decref / underflow / premature GC of a shared attachment), **closing the Phase-1b over-retention gap** (a purged-but-referenced document is now reclaimed at its last referrer). **Phase 4 (SQL Server + Postgres substrate parity) now built** — the whole Phase-0→3a substrate (schema, put/read/incref/decref/sweep, ingress two-object commit, retention decref + dead-row split, key-rotation re-seal) is implemented on both server backends at byte-for-byte behavioral parity with the SQLite reference (dialect + txn-model adapted only), `supports_streaming_attachments` flipped True, with SS/PG parity tests on the CI legs — **go-live parity met (the production store is SQL Server)**. **Remaining:** Phase 3b (read-surface migration — raw view / content search / retention strip made attachment-aware) is the only phase left. Extends [ADR 0001](0001-staged-pipeline-architecture.md)/[ADR 0028](0028-base64-binary-carriage-codec.md) | Accepted (2026-07-12) — Phase 0 substrate + Phase 1a ingress detach + Phase 1b delivery re-attach + Phase 3a linkage/retention decref + Phase 4 SS+PG parity built (`stream_threshold_bytes`/`max_message_bytes`/in-flight budget, deferred-ACK, two-object commit, verbatim splice-back, inline MLLP, `message_attachment` decref, streaming on all three backends); Phase 3b (operator read/download surface) built too — `GET /messages/{message_id}/attachments/{attachment_id}` plus the web console view — so BACKLOG #149 closed COMPLETE with no remainder | | [0106](0106-steps-view-add-dropdown-vocabulary-expansion-adr-0076-phase-b.md) | Steps-view **authoring palette** (ADR 0076 Phase B) — grow the Steps view's **Add** menu from 3 items to a grouped **~22-item palette** across four groups: **Transform** (16, incl. new pure helpers `trim_field`/`substring_field`/`pad_field`/`replace_literal`/`date_diff_field`/`arith_field`), **Translate & lookup** (Code Lookup via a **named code-set picker** — ADR 0033 data, not an inline dict — + live `db_lookup`/`fhir_lookup`), **Structure & flow** (If/Else If/Else, For Each, **Send**, **Filter**, **Raise Error**, Comment — making control/send rows *insertable*, not just recognized), **Diagnostics** (`log_note`/`checkpoint` in a separate `diagnostics.py`, `logger.debug`-only + **redact-by-default**). Core engine work: idempotent **import injection** + a multi-line **statement-template insert** through the audited paste machinery, with two sanctioned byte-scoping exceptions (import injection, clause-append). The adversarial pass caught + fixed real bugs (For Each would emit crashing `msg.segments("OBX")` and read it back false-green → arity-hardened; Filter's `return []` collides with a dynamic-destination Send → distinct `filtered:true` discriminant; regex `matches` → `test:{expr}` escape-hatch only; `code_lookup` `assign_to` dead-bind gate). **Block deferred → #231.** OUT: Loop/Call/ChooseFrom/Try-Catch, regex ops, inline-dict lookups, DB/web writes, clock reads. 3 phases: engine helpers + recognizer-safety → lens insert/recognize → IDE surface (owner lane). Extends [ADR 0076](0076-typed-action-vocabulary-action-list-lens.md)/[ADR 0089](0089-recognition-first-lens-native-idioms.md); sibling to [ADR 0103](0103-steps-view-row-context-menu.md) | Accepted (2026-07-12; palette shipped #1013/#1022) — the Steps-view Add palette is built (owner's `ide/` lane, #222) | | [0107](0107-phase-4-is-closed-transaction-reduction-is-a-measured-dead-end.md) | **Phase 4 is CLOSED — transaction reduction is a measured dead end.** The P0 falsifier [ADR 0099](0099-phase-4-group-commit-amortize-the-per-event-transaction-cost.md) pre-registered has run and returned **ABANDON**. Inline stage-fusion ([ADR 0057](0057-inline-step-a-fast-path.md)) **works** — it cut `committed_txns/msg` **10.47 → 7.49** (−28.5%; manipulation check passed, disarmed-arm trap avoided at `H=D=dests=1`) — and **buys nothing**: throughput moved **−0.56%**, inside the pre-registered NULL band and below either arm's replicate spread. **That null IS the verdict** (pre-registered primary A/B). **Arm E** adds the number worth remembering: sweeping H∈{1,2,4,8} on the unmodified split path, a **~3× swing in committed transactions (9.89→29.20/msg) moves throughput only −11.7%** → **elasticity −0.115** — the txn→throughput coupling is real but **far too weak to be a lever**. ⚠️ **CORRECTION (same-day, adversarial verify):** an earlier draft claimed arm E proves *"F2 cannot clear the bar at any shape"* and that F2's ceiling *"lands inside B5's rejected band"* — **both FALSE.** F2's arm-E ceiling at H=8 is **+13.2%**, which is **ABOVE** the +8% PROCEED bar and above B5's +6.5…+10% band; even net of the *measured* H=1 give-back (−4.49 pts) it is **+8.75%**. **The data does NOT exclude F2 clearing the bar at high H** — and **F2 cannot be measured without being built** (the fusion gate is `len(names)==1`, so inline fusion is H=1-only *by construction*). We therefore **decline on cost/risk/evidence, NOT on a proof of impossibility**: fusion buys nothing at the only measurable shape; the deratings that would sink it are argued, not measured; ADR 0071 B5 is the precedent (6× fewer round-trips → +6.5…+10%, NO-GO); and F2 is a large permanent 3-backend surface. Decisions: no F2/F3; **ADR 0057 ⛔ DO NOT PROMOTE, default-OFF permanently**; state the conclusion precisely (**NOT** "the wall is per-message" — transactions *do* matter, just far too weakly); **F1 survives on latency/cleanliness merit only, never a throughput claim**; **do NOT open a fifth store-side falsifier** (four negative: C5/C6/C7/P0). **Frontier: the ENGINE side has never been ATTRIBUTED** — note shardcert already publishes per-shard PIDs for an external per-PID CPU capture that nobody has ever taken | Accepted (2026-07-13) — closes options; authorizes no build | | [0108](0108-steps-view-accumulator-send-fan-out-copy-on-send-authoring.md) | **Steps-view accumulator Send fan-out** — author multi-destination, copy-on-Send fan-out ([ADR 0104](0104-copy-on-send-outbound-message-model-recognition-first-handler-message-type-and-hl7-field-picker.md)) as first-class mid-body **actions**, never a `Send`-in-a-`return`. The owner rejected both `return Send(...)` and the named-sends `return [a, b]` form: a send should read as an action, not the function's return. **Decision:** recognize + author the **accumulator idiom** `sends = []; sends.append(Send("OB", msg)); ...transform...; sends.append(Send("OB2", msg)); return sends` — each `sends.append(Send(...))` is an editable `send` row (additive `appended:true`) positioned AT the append; the `sends = []` init + bare `return sends` footer are managed **read-only scaffold** (`scaffold:"collector_init"/"return_collector"`, kind stays `code`); deleting every append leaves an empty accumulator = **FILTERED**, no name-scrub. The three legacy returned forms stay **byte-identical**; the palette's **Send** item repoints `template:"send"` → `op:"insert_send"`, plus a per-row **+ dest** `add_destination` button. **No engine runtime change** for this ADR — pure recognizer + rewrite + view (see the ADR's §2 invariant for the `_partition` behaviour it rests on, amended 2026-08-04 by BACKLOG #341; do not restate it here). **Honesty gate:** an append is a send row only where its collector is a *clean delivering accumulator* (single top-level `[]` init, top-level `return NAME`, bound nowhere else); a discarded/aliased/rebound/closure-local append degrades to a read-only `code` row. IDE keys insert-after suppression on a new `isReturnRow` (append allows after; return/footer suppresses) across the six position sites + the CSP mirror. Adversarial pass folded in 10 fixes (stale import index → `_leading_import_end`; ruff quote escape; delivering-accumulator gate; nested-guard convert; non-empty-tuple convert refused; nested-anchor placement) — see the ADR's §7 for each rationale. Extends [ADR 0076](0076-typed-action-vocabulary-action-list-lens.md)/[ADR 0089](0089-recognition-first-lens-native-idioms.md); repoints the [ADR 0106](0106-steps-view-add-dropdown-vocabulary-expansion-adr-0076-phase-b.md) Send item; #26-clean | Accepted (2026-07-14) — owner-directed; engine + IDE built, adversarially verified; **amended 2026-08-04** (two `_partition` rationales corrected by BACKLOG #341 — nothing built changed) | @@ -148,12 +148,12 @@ what is withheld and what you can request. | [0114](0114-phase-4-claim-path-call-complexity-reduction-driver-interface-redesign-ingress-routed-reset-fold.md) | **Phase-4 claim-path call-complexity reduction — driver-interface redesign + the INGRESS/ROUTED reset fold** — the frozen D1 verdict locates ~9.7 ms/call of the pooled `claim_fifo_heads` cost (18.0-18.2 ms at the N=4 240-offered pin) **inside the ODBC/TDS driver, per-call, ~2/3 fixed (6.5-7.0 ms), call-complexity-shaped** (the same driver runs a trivial 7-statement batch in 0.335 ms), so the remedy axis is what the call *carries*: three sub-levers, **one default-OFF SqlServerStore-only flag each** (`fifo_claim_fold_reset` / `fifo_claim_proc` / `fifo_claim_prepared`; PG/SQLite provably untouched). **C (fold, measured 1.87-1.91 ms/call, licensed ceiling +8.0% ingress+routed):** the finally-guard's `SET LOCK_TIMEOUT -1` + write-less commit#2 fold into the batch's confirmed-clean success path at INGRESS/ROUTED only (H2-noop code-confirmed + runtime-guarded); the shielded B1/M-6 guard is retained **verbatim on every non-clean exit** (1222, kept≠claimed, cancellation, any error). **A (proc-ification):** TWO lane-family versioned procs (`mefor_claim_fifo_heads_cid_v1`/`_dst_v1` — the lane column is a code literal), fixed-arity 9-param CALL with one JSON lanes parameter, bodies = the batch verbatim (no TRY/CATCH, no txn statements, no reset outside the conditional `@fold_reset` tail — `SET LOCK_TIMEOUT`'s session persistence past proc exit is load-bearing at outbound), guarded DDL that can never break a flag-OFF open, and a startup gate hashing `OBJECT_DEFINITION` + compat ≥ 130 that degrades loudly to the batch. **B (stable text + retained prepared cursor):** the non-DDL fallback, fail-closed-coupled to the fold, on store-owned dedicated connections reconciled with EF-6/STORE-3 — structural feasibility itself is a gate question. FIFO-always semantics preserved exactly; commit-amortization stays D2-scoped-out; **no throughput projections** — pre-registered ms/call bench gates (fixed 240-pin, replicate pairs, invariant battery, total accept/kill rules) are the only forward-looking numbers; the composed 6.35-6.64 ms/call removal (claim 18.8-19.0 → 12.2-12.65) is a target evaluated only at the post-build re-measure; the certification run remains the arbiter | Proposed (2026-07-16) — owner D0-accept + D2-GO (TO-ENGINE-033); design of record, build ships later, all flags default OFF, each flipped only after its own pre-registered bench gate | | [0115](0115-asvs-l3-drive-to-pass-secure-by-default-flips-and-residual-closure.md) | **ASVS L3 drive-to-Pass — secure-by-default flips and residual closure** (BACKLOG #242–#246). The 2026-07-16 ASVS re-score left **50/51 Partials + 2 Fails**; almost every Partial is a *shipped* control scored Partial because it is opt-in / off-by-default / delegated. Owner chose to **drive-to-Pass** rather than leave them all as accepted residuals — but a naïve "flip every default on" would break a valid deployment (a dev box with no SMTP collector, a partner with no JWS verifier, a single-operator loopback install). **Decision:** a **secure-by-default-where-safe, runbook-instructed-otherwise** posture applied per control — (1) flip the global default ON only where it is already gated on the PHI posture so a synthetic/CI box stays byte-identical (bounded retention 14.2.4/14.2.7, egress deny-by-default 13.2.4/13.2.5, cleartext-egress refusal 12.2.1); (2) **instruct** the control in `OFF-LOOPBACK-DEPLOYMENT.md` (+ a fail-closed prod-PHI serve gate where refusing to start is defensible) where a global flip would break a valid install (approvals 2.3.5, JWS signing 4.1.5, off-box forwarding 16.4.3, WebAuthn 6.3.3/6.5.7/6.7.2) — so the *documented deployment* earns Pass while loopback/no-collector/partner-less defaults are unchanged; (3) **build the small last-mile controls** (AEAD context-binding 11.3.3, AES-GCM invocation counter 11.3.4, time-sync enforce 16.2.2, log-all-authz 16.3.2, keyed audit chain default 16.4.2, magic-byte validation 5.2.2, extended pacing 2.4.2, action-bound step-up 7.5.1/7.5.2); (4) **refresh the drifted inventories** (11.1.2, 13.1.1, 13.1.4); (5) **formally accept** the genuinely delegated residuals (WP #246 — proxy-TLS 12.1.x/12.3.x the engine cannot inspect because it terminates no browser TLS, org-delegated backend creds 13.2.1/13.2.2, SMART AS enforcement 10.4.16, AV 5.4.3) into the register. **Out of scope (stay signed residuals):** the runtime sandbox (15.2.5), HSM key custody (13.3.1/13.3.3), the ECH/in-use-memory platform gaps (12.1.5/11.7.2), and the tolerant-HL7 accepted deviations (2.2.1/2.2.3). Each flip/build amends its owning feature ADR (0018 signing, 0080 forwarding, 0068 WebAuthn, 0014 approvals/notify, 0019 AAD/nonce, 0004 magic-byte, 0077 step-up, 0105 served-filename); no secure posture changes without its ADR record updated in the same work. Plan + per-cell mapping: `ASVS-REMEDIATION-2026-07.md`. Drives `ASVS-L3-ASSESSMENT-2026-07-16.md`; residuals owned in `ASVS-L3-RISK-ACCEPTANCE-REGISTER.md`; #26-clean | Accepted (2026-07-16) — owner-directed scope decision (drive-to-Pass); builds phased across BACKLOG #242–#246, pushes/PR owner-approved | | [0118](0118-secure-by-default-security-configuration-section.md) | **Secure-by-default `[security]` configuration section — plain-language, replace scattered keys** — a dedicated `[security]` TOML section as the *canonical, sole* home for the high-value security **posture switches** (network access, at-rest encryption, sign-in/MFA/session, egress deny-by-default, retention, audit) plus the master PHI-posture lever (`handles_real_patient_data` / `production_instance`, was `[ai].data_class`/`.production`), each **named in plain language and defaulting to the secure position**. Fixes config-UX defect **DD2** (the `[security]` heading is a future-only placeholder). Low-level *plumbing* (TLS cert paths, egress allow-list *contents*, DB identity, password policy, rate limits) **stays in its functional section** — CISA "minimize settings". Because **nothing is deployed**, the scattered legacy keys are **replaced outright** (no deprecation/alias); every serve gate + the `checks.py` mirror re-points at `[security]` with **no shipped refusal loosened** (No-loosen rule, [ADR 0092](0092-posture-keyed-transport-hop-refusal-refuse-the-insecure-phi-hop.md) §5). Loosening any protection is **deliberate + plain-language-warned** (CISA loosening-guide → new `docs/SECURITY-LOOSENING.md`) and still refuses on production-PHI (ADR 0092 clamp intact). **Editing is IDE-only** (VS Code extension GUI, [ADR 0007](0007-gui-manageable-connections-toml.md) precedent); the **web console stays read-only** (`GET /security/posture`, no settings-write API). Two owner-veto default judgments: `audit_all_authorization_decisions = false` (PHI access is always audited regardless), and preserve-but-surface the PHI-vs-synthetic relaxation split. **AMENDED 2026-09-02 (BACKLOG #1277): the first of those two now defaults `true`** — the owner delegated the call to the Console and the Console decided, so §5 and the acceptance checklist carry a dated amendment; the PHI-vs-synthetic judgment is unchanged. Research-grounded (CISA *Secure by Design*, NIST SP 800-66r2 §164.312 crosswalk, CIS tiers, Jupyter loopback precedent). #26-clean | Accepted (2026-07-17) — owner signed off; built under BACKLOG #270 | -| [0119](0119-test-bench-utf-8-byte-hex-pane-demand-gate-fill-in.md) | **Test Bench UTF-8 byte hex pane** (BACKLOG #84 demand-gate fill-in) — the hex remainder of #84 (the before/after diff + Coverage/Profiling panes shipped earlier). A classic `offset · hex · ASCII` dump behind a per-row **"Hex"** button, computed by a new **pure, `vscode`-free** `ide/src/hexdump.ts` (structured output, rendered host-side then posted — mirroring `hl7diff`/`traceView`) over `DryRunRow.raw`. **Scope narrowed on a verifier refutation:** it dumps the **UTF-8 bytes of the already-decoded `raw` string**, NOT the true wire bytes and NOT an `mfb64:`/base64 whole-body decode — `dryrun --show-phi --json` UTF-8/`errors=replace`-decodes file bytes in `peek.py` (binary is already lossily U+FFFD-corrupted) and never emits the `mfb64:v1:` marker into `raw`, so a marker-strip pane would have nothing to strip; a real binary hex view would need an engine/CLI read-path change (recorded as future work). Render **capped** (`maxBytes`, default 16 KiB) with a truncation notice; **in-memory only** — no temp file, no log (§9). No new egress / no new PHI-at-rest (re-renders bytes the panel already holds under `--show-phi`). IDE-only, no engine change; #26-clean | Accepted (2026-07-17) — DEMAND-GATE-BACKLOG session (lane `dg-s9`); build phased, pushes/PR owner-approved | +| [0119](0119-test-bench-utf-8-byte-hex-pane-demand-gate-fill-in.md) | **Test Bench UTF-8 byte hex pane** (BACKLOG #84 demand-gate fill-in) — the hex remainder of #84 (the before/after diff + Coverage/Profiling panes shipped earlier). A classic `offset · hex · ASCII` dump behind a per-row **"Hex"** button, computed by a new **pure, `vscode`-free** `ide/src/hexdump.ts` (structured output, rendered host-side then posted — mirroring `hl7diff`/`traceView`) over `DryRunRow.raw`. **Scope narrowed on a verifier refutation:** it dumps the **UTF-8 bytes of the already-decoded `raw` string**, NOT the true wire bytes and NOT an `mfb64:`/base64 whole-body decode — `dryrun --show-phi --json` UTF-8/`errors=replace`-decodes file bytes in `peek.py` (binary is already lossily U+FFFD-corrupted) and never emits the `mfb64:v1:` marker into `raw`, so a marker-strip pane would have nothing to strip; a real binary hex view would need an engine/CLI read-path change (recorded as future work). Render **capped** (`maxBytes`, default 16 KiB) with a truncation notice; **in-memory only** — no temp file, no log (§9). No new egress / no new PHI-at-rest (re-renders bytes the panel already holds under `--show-phi`). IDE-only, no engine change; #26-clean | Accepted (2026-07-17) — **built** ([ide/src/hexdump.ts](../../ide/src/hexdump.ts)); DEMAND-GATE-BACKLOG session (lane `dg-s9`) | | [0120](0120-fips-provider-mode-attestation-report-only-on-security-posture.md) | **FIPS-provider mode attestation (report-only on /security/posture)** (BACKLOG #73). Demand-gate item; the compliance/procurement trigger fired. MeFor owns no crypto — at-rest is `pyca/cryptography`, in-transit is stdlib `ssl` over the interpreter's linked OpenSSL — so whether the OpenSSL **FIPS provider** is active is a property of the operator's OS build, not anything the engine configures; today the only artifact is a FIPS-permitted-**curve** comment in `tls_policy.py`. **Decision:** *report* the interpreter's OpenSSL FIPS-provider state, **enforce nothing** — a pure `tls_policy.fips_attestation()` returns `(fips_mode: bool\|None, openssl_version: str)` from `_hashlib.get_fips_mode()` (int; `None`=undeterminable on a non-OpenSSL build via a **runtime** `getattr` guard — no `type: ignore`, typeshed declares `->int`) + `ssl.OPENSSL_VERSION`; two additive report-only `SecurityPosture` fields populated on the already-`MONITORING_READ`-gated + `security.posture_view`-audited route; a metadata-only console posture row. **Scope trap on the record:** `ssl`/`_hashlib` attest the *interpreter's* OpenSSL, **not** the separately-linked OpenSSL inside the `cryptography` wheel that encrypts PHI at rest — so the field + every UI/doc string are scoped to "the ssl/_hashlib OpenSSL" and worded **reported**, never "FIPS-140 certified". No new dep (stdlib), no behaviour change, no store/schema edit. Extends [ADR 0002](0002-phase2-transport-security-and-strong-auth.md) (reports alongside its TLS posture; **not** amended — adds no governance) | Accepted (2026-07-17) — owner-directed, demand-gated; report-only | -| [0121](0121-test-bench-saved-regression-collections-phi-at-rest-posture-hl7-aware-compare.md) | **Test Bench saved regression collections — PHI-at-rest posture + HL7-aware compare** (BACKLOG #168) — persisted, named, groupable Test Bench collections of `{input body, expected output bodies}` cases with one-click rerun flagging pass/fail, versus today's ad-hoc file-picker that saves no case and asserts no result. **Two up-front decisions:** (1) **PHI-at-rest** — a NEW IDE surface persisting message bodies, stored in **`context.workspaceState`** (machine-local, **not** Settings-Sync-eligible) — never a repo-tracked file, never `context.globalState` (would be sync-eligible → PHI off-box); authors steered to synthetic/de-identified cases (ADR 0030) with an in-UI notice. (2) **Compare semantics** — HL7-aware via reused `hl7diff.diffMessages` with a fixed **volatile-field ignore policy** (`DEFAULT_VOLATILE_FIELDS` = MSH-7 date/time + MSH-10 control ID; "ACK dates" = the ACK's MSH-7) so a conformant message doesn't always fail; a changed non-volatile field or an added/removed segment is a real regression. NEW pure `vscode`-free `ide/src/testCollections.ts` (`compareMessages` → `{pass, differences, diff}`). Rerun **materializes stored inputs to a per-run temp dir** (the `dryrun` CLI takes only file paths), dry-runs `--show-phi`, compares, and **deletes the temp dir in `finally`** (transient PHI, cleaned). No engine change; #26-clean | Accepted (2026-07-17) — DEMAND-GATE-BACKLOG session (lane `dg-s9`); build phased, pushes/PR owner-approved | +| [0121](0121-test-bench-saved-regression-collections-phi-at-rest-posture-hl7-aware-compare.md) | **Test Bench saved regression collections — PHI-at-rest posture + HL7-aware compare** (BACKLOG #168) — persisted, named, groupable Test Bench collections of `{input body, expected output bodies}` cases with one-click rerun flagging pass/fail, versus today's ad-hoc file-picker that saves no case and asserts no result. **Two up-front decisions:** (1) **PHI-at-rest** — a NEW IDE surface persisting message bodies, stored in **`context.workspaceState`** (machine-local, **not** Settings-Sync-eligible) — never a repo-tracked file, never `context.globalState` (would be sync-eligible → PHI off-box); authors steered to synthetic/de-identified cases (ADR 0030) with an in-UI notice. (2) **Compare semantics** — HL7-aware via reused `hl7diff.diffMessages` with a fixed **volatile-field ignore policy** (`DEFAULT_VOLATILE_FIELDS` = MSH-7 date/time + MSH-10 control ID; "ACK dates" = the ACK's MSH-7) so a conformant message doesn't always fail; a changed non-volatile field or an added/removed segment is a real regression. NEW pure `vscode`-free `ide/src/testCollections.ts` (`compareMessages` → `{pass, differences, diff}`). Rerun **materializes stored inputs to a per-run temp dir** (the `dryrun` CLI takes only file paths), dry-runs `--show-phi`, compares, and **deletes the temp dir in `finally`** (transient PHI, cleaned). No engine change; #26-clean | Accepted (2026-07-17) — **built** ([ide/src/testCollections.ts](../../ide/src/testCollections.ts)); DEMAND-GATE-BACKLOG session (lane `dg-s9`) | | [0122](0122-wsdl-import-pure-soap-type-tree-validate-against-wsdl-no-zeep.md) | **WSDL import — pure SOAP operation/message type-tree + validate-against-WSDL** (BACKLOG #69, demand-gate) — `parse_wsdl` walks a **WSDL 1.1** document (through the **already-hardened** `parsing/xml/harden.parse_bytes`: XXE/DTD/no-network off) into a frozen, read-only `WsdlDefinition` (operations → message parts → resolved body element QNames + soapAction), and `validate_request`/`validate_response` validate a SOAP envelope's body against the WSDL's **embedded XSD** via the **already-no-network** `schema.validate_against` — PHI-safe reasons (element QName + category, never content). **No zeep / no suds / no new dep** (lxml + xmlschema already in `[xml]`); the ADR 0015 SOAP transport's hand-built envelopes are **untouched** (WSDL *checks*, never *drives* — #70 WSCall stays declined). Closes the **distinct `wsdl:import` / `xsd:import` network-resolution seam** `xmlschema`'s `allow="local"` does not cover: a remote (`http(s)://`) import is **refused** (`WsdlSecurityError`, scheme-only message — no SSRF past the XSD lockdown), a local one is not fetched and fails closed. WSDL 1.1 / document-literal / embedded-schema first-class; import-graphs + WSDL 2.0 out of scope. Extends [ADR 0015](0015-ws-soap-outbound-mtls-wssecurity.md) + BACKLOG #31 (`parsing/xml/`); #26-clean | Accepted (2026-07-17) — demand-gate build (lane `dg-s6`), pushes/PR owner-approved | | [0123](0123-compression-codec-gzip-zip-deflate-file-connector-compress-decompress-option.md) | **Compression codec (gzip/zip/deflate) + file-connector compress/decompress option** (BACKLOG #172) — a **pure** stdlib-only `parsing/compression.py` (gzip/zlib-deflate/zip, `CompressionError(ValueError)`) plus an opt-in File-connector `compress="gzip"` (outbound, `.gz` on write) / `decompress="gzip"` (inbound). **Re-run purity:** `gzip_compress` fixes `mtime=0` so a gzipping Handler is deterministic (stock `gzip.compress` embeds a wall-clock mtime → would break at-least-once). **Decompression-bomb DoS:** every decompress takes a `max_output_bytes` ceiling enforced **incrementally** (bounded `GzipFile.read`/`decompressobj` `max_length`/per-entry zip reads) — refused after producing at most the ceiling, never after full in-memory expansion; the inbound path adds `max_decompressed_bytes` (default 64 MiB) that also bounds **post-split** expansion the compressed-only `max_file_bytes` cap can't. Inbound gunzip runs **before** the `_looks_like_hl7` sniff, the AV/ICAP scan hook, and the HL7 batch split (they must see the real bytes); a corrupt/oversized archive is **moved to `.error`, never accept-and-dropped**, logged codec-message-only (no body). Connector restricted to single-stream gzip; multi-entry zip / raw deflate stay Handler-composed. No new locked dep (stdlib only). Extends [ADR 0028](0028-base64-binary-carriage-codec.md) (orthogonal carriage) / [ADR 0004](0004-payload-agnostic-ingress.md); #26-clean | Accepted (2026-07-17) — built (BACKLOG #172, lane `dg-s4`) | -| [0124](0124-outbound-mllp-fire-and-forward-no-wait-for-ack-delivery-on-write.md) | **Outbound MLLP fire-and-forward (no-wait-for-ACK) — delivery-on-write** (BACKLOG #117) — an opt-in per-outbound `no_ack=false` knob on `MLLP()`: when `true`, `send()` frames + writes + drains and finalizes `PROCESSED` **on the TCP write**, reading no ACK — *at-most-once-confirmation* (no NAK-/timeout-driven retry; the ACK-waiting default stays byte-identical, BACKLOG #82.1). At-least-once for the WRITE is unchanged (a connect/drain failure is still charged → retry; receivers stay idempotent); composes with `persistent=true` ([ADR 0067](0067-persistent-outbound-mllp.md)) for the no-handshake-no-ACK-wait max-throughput non-acking lane; mutually exclusive with `capture_response`/`reingress_to` and MLLP-only (both a `WiringError` at `check`). #136 console waiting-state is inapplicable in no-ack mode. Extends [ADR 0067](0067-persistent-outbound-mllp.md) | Accepted (2026-07-17) — demand-gate (BACKLOG #117); lane `dg-s5` | +| [0124](0124-outbound-mllp-fire-and-forward-no-wait-for-ack-delivery-on-write.md) | **Outbound MLLP fire-and-forward (no-wait-for-ACK) — delivery-on-write** (BACKLOG #117) — an opt-in per-outbound `no_ack=false` knob on `MLLP()`: when `true`, `send()` frames + writes + drains and finalizes `PROCESSED` **on the TCP write**, reading no ACK — *at-most-once-confirmation* (no NAK-/timeout-driven retry; the ACK-waiting default stays byte-identical, BACKLOG #82.1). At-least-once for the WRITE is unchanged (a connect/drain failure is still charged → retry; receivers stay idempotent); composes with `persistent=true` ([ADR 0067](0067-persistent-outbound-mllp.md)) for the no-handshake-no-ACK-wait max-throughput non-acking lane; mutually exclusive with `capture_response`/`reingress_to` and MLLP-only (both a `WiringError` at `check`). #136 console waiting-state is inapplicable in no-ack mode. Extends [ADR 0067](0067-persistent-outbound-mllp.md) | Accepted (2026-07-17) — **built**: the `no_ack` knob on `MLLP()` in [config/wiring.py](../../messagefoundry/config/wiring.py), MLLP-only and rejected at wiring alongside `capture_response`/`reingress_to`; demand-gate (BACKLOG #117) | | [0126](0126-outbound-forward-egress-web-proxy-for-the-stdlib-http-family.md) | **Outbound forward/egress web proxy for the stdlib HTTP family** (BACKLOG #112 / #127 / #128, demand-gate) — route REST/SOAP/FHIR/`fhir_lookup`/DICOMweb + the OAuth2/SMART **token-endpoint** calls through a **per-connection forward proxy**, built into a per-connection `urllib` `ProxyHandler` threaded through **every** opener path (data + token) — **never** mutating the shared `_NO_REDIRECT_OPENER`. **#112:** `proxy` unset (byte-identical, incidental env) / `"default"` (explicit `getproxies()` "Use Default Web Proxy") / an explicit `http(s)://` address, plus a site-wide `[egress].proxy_url`/`proxy_no_proxy` default. **#127:** `proxy_auth_type` Basic (default) is **pre-emptive** — a `Proxy-Authorization: Basic …` header urllib **moves into the `CONNECT` tunnel** (`set_tunnel(host, headers=…)`) so it works for **https destinations** (the reactive `ProxyBasic/DigestAuthHandler` do NOT — the 407 is inside the tunnel); Digest is reactive → **http-destination only** (https refused loudly); **NTLM/Windows deferred** (urllib is connection-per-`open()`; pyspnego insufficient) — refused loudly. **#128:** a NO_PROXY-style `proxy_no_proxy` bypass evaluated **per target host at construction** (a bypassed host gets no proxy + no credential → no `Proxy-Authorization` leak to origin). A **cleartext-`http`-proxy-hop credential is refused REGARDLESS of destination scheme** via the ADR 0092 posture-keyed authority (loopback/attested/synthetic allowed). The **proxy host is OUT of the `[egress].allowed_http` gate scope** (it gates the PHI destination, not the operator-chosen intermediary); the proxy URL/creds are never logged unredacted (`_redact_url`), `proxy_password` is in `_SECRET_SETTING_KEYS`. No new dependency (stdlib); off → byte-identical. Extends [ADR 0003](0003-non-hl7-transports-database-rest-soap.md) / [ADR 0024](0024-smart-backend-services-token-provider.md); reuses [ADR 0092](0092-posture-keyed-transport-hop-refusal-refuse-the-insecure-phi-hop.md); #26-clean | Accepted (2026-07-17) — DEMAND-GATE-BACKLOG session (lane `dg-s2`); build phased, pushes/PR owner-approved | | [0127](0127-operator-editable-alert-email-templates-with-a-non-phi-variable-allowlist.md) | **Operator-editable alert-email templates with a non-PHI variable allowlist** (BACKLOG #138, demand-gate) — three optional `[alerts]` templates (`email_subject_template` / `email_body_template` / `email_html_template`) over a **CLOSED non-PHI variable allowlist** (`severity`/`type`/`connection`/`timestamp`/`depth`/`oldest_age_seconds`/`cooldown_seconds`/`rule_id`) **validated at config-load, fail-closed** — any other reference (a message body / arbitrary HL7 field) is **rejected** (`serve`/reload refuses, the `alert` editor rolls back). `string.Formatter().parse` (never `str.format`) closes the attribute/index/format-spec injection surface; the HTML alternative **escapes every substituted value** and the subject strips CR/LF (header-injection). The **plain-text part is always kept**; off by default (all three `None`) = today's fixed subject/body byte-for-byte. Adds `AlertRule.id` for `{rule_id}`. Extends [ADR 0014](0014-alerting-rules-engine.md); #26-clean | Accepted (2026-07-17) — demand-gate build (lane `dg-s1a`), pushes/PR owner-approved | | [0128](0128-alert-rule-connection-control-action-auto-stop-restart-on-fire.md) | **Alert-rule connection-control action (auto stop/restart on fire)** (BACKLOG #144, demand-gate) — an `AlertRule` may carry a **whitelisted** `control_action` (`restart_inbound` / `restart_outbound`) + optional `control_target` (default = the event's own connection) so a firing alert **auto-remediates** the wedged lane, not just pages. The `NotifierAlertSink` does **not** import `RegistryRunner`: it holds an **injected async control callback** (`set_control_callback`, mirroring `set_store`) the `api/app.py` lifespan wires to `RegistryRunner.restart_inbound`/`restart_outbound`; the sink→runner relation is layering-legal (pipeline→pipeline). The action is **dispatched OFF the delivery worker** (fire-and-forget `asyncio` task) and is **never-raise** (a rejected/hung restart never breaks alerting or delivery), **throttled with the notification** (≤ once per cooldown per event+connection), and **independent of transport suppression** (`transports=[]` ⇒ quiet auto-remediation). Off by default (`control_action = None`). Extends [ADR 0014](0014-alerting-rules-engine.md) + the [wiring_runner](../../messagefoundry/pipeline/wiring_runner.py) control seam; #26-clean | Accepted (2026-07-17) — demand-gate build (lane `dg-s1a`), pushes/PR owner-approved | @@ -191,7 +191,7 @@ what is withheld and what you can request. | [0164](0164-record-bootstrap-claimed-ness-never-infer-a-monotonic-lifecycle-fact-from-mutable-credential-state.md) | **Record bootstrap claimed-ness; never infer a monotonic lifecycle fact from mutable credential state** (BACKLOG #1245) -- bootstrap auto-retirement (WP-3) gates on `must_change_password` and reads it as *"this account was never claimed"* (`auth/service.py:584`, with the comment at `:585` naming the proxy outright). That reading is sound only while the flag has ONE writer; it has five, and `admin_reset_password` re-raises it at `:2733`. So an administrator resetting the password of the account named `admin` makes it look UNCLAIMED again and the next trigger disables it -- the victim's own next login (`:651`, which fires BEFORE the row is fetched at `:652` and before the credential is verified at `:669`), an engine restart through `initialize()` (`:518`), or any `create_local_user` (`:2551`). **The defect is structural, not a missing guard: "never claimed" is MONOTONIC while `must_change_password` is not** (self-rotation clears it at `:1911`, a reset re-sets it), and no non-monotonic bit can encode a monotonic predicate across a re-set. It is also on the READER, not the writer -- all five writers assert the same true proposition ("the credential now on this account is issuer-issued, not holder-chosen"); only the retirement gate over-reads that into a lifecycle claim. The same file already contains a second reader that gets it right by pairing the flag with a timestamp the reset refreshes (`:697-700` against `password_changed_at`); the retirement reader pairs it with `created_at`, which no reset touches -- a fresh flag held against a stale clock. Decision: a new `users.password_claimed_at` on all three backends, recording only *the holder set their own credential via authenticated self-service rotation*; ONE structurally-constrained writer (a `set_password` whose `must_change_password` is False, reachable only from self-service rotation, so **you cannot record a claim without being the authenticated holder** -- a conventional single-writer rule is precisely what this item documents failing); monotonicity enforced in SQL via `COALESCE(password_claimed_at, ?)` with no statement assigning NULL after creation; ONE named predicate called from both gates, because `_retire_superseded_bootstrap` (`:584`) and `bootstrap_expiry_warning` (`:618`) carried the identical open-coded test and two copies of one lifecycle question is how the warning path inherited the blind spot; and a one-time backfill INSIDE the column-creation guard, whose placement is load-bearing because hoisted out it becomes a permanent second writer of the field whose single-writer property is the entire point. **`admin_reset_password` stays byte-identical -- the fix the defect's own wording most naturally suggests is the wrong one**, since suppressing the flag there would mean an administrative reset no longer forces rotation, breaking the ASVS 6.4.6 property that path exists to provide. Rejected on measurement, both re-proposable and both wrong non-obviously: `last_login_at` (genuinely unforgeable by the reset, but written at `:716` AFTER the credential check, so a bootstrap that logs in once with the printed one-time password and never rotates becomes permanently non-retirable -- **silently deleting the ASVS 6.4.5 time-expiry arm for exactly the case it exists to cover**, and unrepairable within its own inputs because a reset re-arms BOTH of them), and `password_changed_at != created_at` (the reset refreshes it, `store.py:7725`). Records that the defeated property was already asserted in prose twice -- the docstring at `:579-581` and `SECURITY.md:58-59`/`:1164`, both corrected here -- which is the argument for an ADR: **prose is exactly what failed**, and bootstrap retirement had no ADR, living only in `SECURITY.md`, which is how the guarantee drifted from the code with nothing reporting it. Explicitly does NOT fix the second, stacked proxy at `:583` (username as bootstrap identity, `BOOTSTRAP_USERNAME = "admin"` at `:71`, no marker column and no role check, so ANY local account named `admin` is subject to this and delete-and-recreate mints a second silently-dead one) -- narrowed, not removed, and filed separately. Not a whole-system lockout: `USERS_MANAGE` is unmintable into a custom role and self-reset is refused (`auth_routes.py:762`), so the resetter is necessarily a second enabled administrator | Proposed (2026-08-13) -- written in the conditional throughout; **zero deployments** (CLAUDE.md section 0), so this is what a first deployment would hit, not a live exposure | | [0165](0165-a-builder-pr-satisfies-the-ledger-gate-with-a-paired-commit-authored-by-the-dispatcher-or-lander.md) | **A builder PR satisfies the ledger gate with a paired commit authored by the Dispatcher or Lander** — **two correct rules met and produced an unsatisfiable state**, which is why it needed a decision and not a fix. The required check *"a PR that implements BACKLOG #N must update BACKLOG.md"* demands a ledger edit in the PR's own diff; the owner's 2026-08-13 authoring ruling forbids a **builder** to author ledger content, on the property that **a mechanical union cannot invent a disposition but authoring a banner can, and a seat that can author its own item's banner can turn its own PR green**. Measured live: **PR #379 went red for OBEYING the ruling.** Decision: the Dispatcher or Lander **authors** the disposition and the commit rides **on the PR branch**. **The expected answer inverted on reading the gate rather than reasoning about it** — `backlog-hygiene.yml:64-98` computes `git diff --name-only BASE...HEAD` and passes if the changed set touches `docs/BACKLOG.md` or `docs/archive/backlog/`; it **never inspects authorship**, so a Dispatcher-authored commit cherry-picked onto the head is indistinguishable from a builder's. Evaluated against the real cherry-picked head: `touches_code` 1, ledger 1, **PASS** — so **no gate change was required and none is pending**. The ledger gate permits the cherry-pick for a non-obvious reason: it iterates **headings added relative to base**, and a banner flip or amendment on an item already on `main` adds no `## N.` heading, so ownership is never consulted and the committing seat is irrelevant (confirmed — pre-commit hooks ran clean on the cherry-pick); **this holds only for landed items, a PR that FILES an item is a different shape**. **(a2)** — land the ledger commit separately and correlate it — **rejected because it would undo a deliberate control**: the gate uses three-dot on purpose and its own comment says two-dot *"would pass while enforcing nothing"*. **(b)** — a builder carve-out to flip only its own item's banner — rejected, reopens the self-approval hazard (property identified by the Builder 2 seat before any ruling existed). **(c)** — the same pattern as an interim — **dissolved rather than rejected: (c) and the decision are one mechanism, so there is no transition.** Carries a recorded near-miss: the ruling was briefly written as *"(c) is fine until (a) lands"*, **an expiry whose trigger had ALREADY FIRED** — it looks like the safe construction and behaves like the unsafe one, and would have become permanent by default while appearing bounded. Consequence stated rather than softened: **one manual step per builder PR indefinitely**, a Dispatcher/Lander serialisation point, and the builder **MUST declare the withheld banner in its PR body** because a missing flip is visually identical to the BACKLOG #1237 defect — a fix on `main` with its item still reading *not started*, same shape, opposite cause. No engine behaviour changes | **Accepted (2026-08-13)** — **already in force; no code change was required or is pending.** Executed on PR #379 before the ADR was written. Provenance split three ways because each half is only checkable if attributed: the collision found by the **Lander** on #379's red check, the self-approval property by **Builder 2**, the gate measurement and the no-build finding by the **Dispatcher**, the ruling by the **owner** | | [0167](0167-phi-security-notification-readiness-gates-on-a-deliverable-address-checked-early-in-the-asgi-lifespan.md) | **PHI security-notification readiness gates on a deliverable address, checked early in the ASGI lifespan** (BACKLOG #1020, owner-ruled 2026-08-13 option (b)) -- the PHI startup gate computes `security_channel_ready` from the SMTP transport alone (`notify_security_events` + `email_smtp_host` + `email_from`), which asks *"is a transport configured"* and never *"can the account that matters actually receive"* (SDS-3.8). The two come apart on exactly the instance the gate protects: `_ensure_bootstrap_admin` creates the account holding `frozenset(Permission)` with **no** `email=`, and `SecurityEventNotifier.notify` opens `if not event.email: return`, so all ten notice types no-op for the most privileged account while the gate reports healthy. Decision: gate on `has_notifiable_admin()` -- at least one **enabled administrator with an address** -- scoped to the ROLE, not the bootstrap account, because `email` is optional for any Administrator so a hand-created privileged account has the identical hole. **Placement is the decision this ADR exists for, and it was settled by measurement, not preference.** LIFESPAN-after-`engine.start()` is OUT: BACKLOG #1257 records that an exception there unwinds nothing and **hangs** -- strictly worse than the defect, since an operator can see a wrong readiness answer but not a process that never finishes starting. PREFLIGHT was recommended **and withdrawn by its own author on measurement**: `_serve` (1042-2833) runs entirely before the lifespan and opens a store **zero** times, and `list_users()` is async, so it is not one cheap read but the first store open in a preflight that has never had one, from sync code. EARLY-LIFESPAN wins -- the 191-line window between `api/app.py:5540` (`open_store`) and `:5731` (`engine.start()`), where the store is open and no engine tasks exist, so the check is a plain `await`. **The exit code changes and the divergence is FORCED:** measured, raising there exits **3** in 0.49s, and `sys.exit(2)` there **also** exits 3 -- uvicorn catches `SystemExit` and treats it as a startup failure -- against a positive control that reached a RUNNING server and self-stopped with a distinct 99, which is what makes the exits mean anything. Cost stated no larger than it is: `_serve` returns 2 at 32 sites and `DEPLOYMENT.md` says "(exit 2)" twice, but **both citations are scoped to specific refusals and no line generalises it**, so this is an inconsistency with two documented refusals, not a contradiction of a universal claim -- an earlier draft called it a "documented-contract divergence" and that is corrected here rather than dropped. **It must not be claimed that exit 2 gives a clean stop today:** `install-service.ps1:463` sets NSSM `AppExit Default Restart`, so the shipped wrapper restarts on any code and the operational delta is approximately nil. Accepted with the cheap honest fix -- a `DEPLOYMENT.md` line recording that a startup-stage refusal exits 3. **Rejected: a sentinel** catching `SystemExit` at the `uvicorn.run()` call site to re-exit 2 -- **untested** (the "if" was never measured) and a cross-layer mechanism bought to remove an inconsistency the NSSM finding makes nearly free; recorded rather than omitted so it is not re-derived. Records that there are now **three** independent copies of "who is an enabled administrator" in `auth/service.py`, agreeing by convention with nothing binding them | **Proposed (2026-08-15)** -- the predicate is built (`29a026e2`, 3 asymmetric arms, mutation-proven: removing the role test reds the non-administrator arm ALONE); the gate that consumes it is not yet written. ⚠️ **Does NOT discharge #1020's rider:** the termination evidence is a MINIMAL REPRO, not the real gate, and a rider that exists because someone inferred is not satisfied by an inference | -| [0169](0169-username-identity-is-case-sensitive-and-must-not-depend-on-store-collation.md) | **Username identity is case-sensitive, and no identity decision may depend on store collation** (BACKLOG #1268) -- `users.username` was the one identifier column in the SQL Server schema with no `COLLATE` clause, so it inherited the DATABASE default (case-INsensitive on a stock install) while every sibling identifier column in the same file pinned `Latin1_General_100_BIN2` and both other backends were case-SENSITIVE -- `Admin` and `admin` two accounts on two backends and one on the third, under a `UNIQUE` constraint that reads as if it had settled the question. That portability defect became a SECURITY defect because a second site answered the same question by a different rule: `_login_local` gated WP-3 bootstrap expiry/supersession enforcement on a PYTHON `username == BOOTSTRAP_USERNAME` against the caller's input, while the lookup one line below was resolved by the COLUMN'S collation. The two disagree in exactly one direction -- `Admin` FAILS the Python guard so retirement never runs, then SUCCEEDS at the lookup and returns the very row the skipped call would have disabled. MEASURED on a lapsed unclaimed bootstrap with 6.4.1 disarmed so it could not mask the result: `login("admin")` refused and retired, `login("Admin")` returned ok=True with a session issued and `disabled` unset -- SDS-3.7 exactly, a compensating control resting on the false premise that the username the gate compared is the username the store matched. Decision, two rules: usernames ARE case-sensitive (the column now pins the collation its own file's convention already required), and **no identity decision may be delegated to store collation** -- the gate compares the value THE STORE RETURNED, never the caller's input, then re-reads by id since retirement may have disabled the row. The second rule is load-bearing and does NOT depend on the first: it stays correct under a collation the engine does not control (operator-supplied database, restored dump, column altered downstream), where limb 1 alone leaves the gate one `ALTER COLUMN` from being wrong again with nothing reporting it. Cost is one extra lookup ON THE BOOTSTRAP PATH ONLY, so the original guard's stated intent (normal logins free of extra lookups) is preserved rather than traded. Rejected: case-INsensitive normalisation (requires a canonicalisation that is not locale-neutral -- the Turkish dotless `i` -- so a wrong fold silently MERGES two accounts under a UNIQUE constraint that enforces rather than catches it; and it would have to hold across three backends plus the audit trail, every one a fresh place for the two rules to diverge again); column-only (makes the gate accidentally correct, contingent on a schema the engine stops controlling); gate-only (closes the security defect, leaves identity store-dependent). Existing SQL Server databases keep their original collation -- the DDL is creation-guarded and no re-type is attempted; zero deployments (CLAUDE.md section 0) so there is nothing to migrate, recorded so the schema-hash bump is not misread as a column alteration. Flagged undecided: two accounts differing only in case are themselves a confusability risk, closeable additively by a registration-time refusal without reopening this decision. Verification carries its own retraction -- the first version of both gate tests PASSED against unfixed code because they used the supersession arm, which `create_local_user` retires eagerly at `service.py:2685`, so the account was already disabled before the login ran; only the EXPIRY arm reaches the login path with retirement still pending | Proposed (2026-08-20) -- written in the conditional; **zero deployments**, so this is what a first deployment against a SQL Server store would hit, not a live exposure | +| [0169](0169-username-identity-is-case-sensitive-and-must-not-depend-on-store-collation.md) | **Username identity is case-sensitive, and no identity decision may depend on store collation** (BACKLOG #1268) -- `users.username` was the one identifier column in the SQL Server schema with no `COLLATE` clause, so it inherited the DATABASE default (case-INsensitive on a stock install) while every sibling identifier column in the same file pinned `Latin1_General_100_BIN2` and both other backends were case-SENSITIVE -- `Admin` and `admin` two accounts on two backends and one on the third, under a `UNIQUE` constraint that reads as if it had settled the question. That portability defect became a SECURITY defect because a second site answered the same question by a different rule: `_login_local` gated WP-3 bootstrap expiry/supersession enforcement on a PYTHON `username == BOOTSTRAP_USERNAME` against the caller's input, while the lookup one line below was resolved by the COLUMN'S collation. The two disagree in exactly one direction -- `Admin` FAILS the Python guard so retirement never runs, then SUCCEEDS at the lookup and returns the very row the skipped call would have disabled. MEASURED on a lapsed unclaimed bootstrap with 6.4.1 disarmed so it could not mask the result: `login("admin")` refused and retired, `login("Admin")` returned ok=True with a session issued and `disabled` unset -- SDS-3.7 exactly, a compensating control resting on the false premise that the username the gate compared is the username the store matched. Decision, two rules: usernames ARE case-sensitive (the column now pins the collation its own file's convention already required), and **no identity decision may be delegated to store collation** -- the gate compares the value THE STORE RETURNED, never the caller's input, then re-reads by id since retirement may have disabled the row. The second rule is load-bearing and does NOT depend on the first: it stays correct under a collation the engine does not control (operator-supplied database, restored dump, column altered downstream), where limb 1 alone leaves the gate one `ALTER COLUMN` from being wrong again with nothing reporting it. Cost is one extra lookup ON THE BOOTSTRAP PATH ONLY, so the original guard's stated intent (normal logins free of extra lookups) is preserved rather than traded. Rejected: case-INsensitive normalisation (requires a canonicalisation that is not locale-neutral -- the Turkish dotless `i` -- so a wrong fold silently MERGES two accounts under a UNIQUE constraint that enforces rather than catches it; and it would have to hold across three backends plus the audit trail, every one a fresh place for the two rules to diverge again); column-only (makes the gate accidentally correct, contingent on a schema the engine stops controlling); gate-only (closes the security defect, leaves identity store-dependent). Existing SQL Server databases keep their original collation -- the DDL is creation-guarded and no re-type is attempted; zero deployments (CLAUDE.md section 0) so there is nothing to migrate, recorded so the schema-hash bump is not misread as a column alteration. Flagged undecided: two accounts differing only in case are themselves a confusability risk, closeable additively by a registration-time refusal without reopening this decision. Verification carries its own retraction -- the first version of both gate tests PASSED against unfixed code because they used the supersession arm, which `create_local_user` retires eagerly at `service.py:2685`, so the account was already disabled before the login ran; only the EXPIRY arm reaches the login path with retirement still pending | Proposed (2026-08-20) -- **built**: [BACKLOG #1268](../BACKLOG.md) shipped both limbs 2026-08-20, so `users.username` now pins `Latin1_General_100_BIN2`. Written in the conditional; **zero deployments**, so this is what a first deployment against a SQL Server store *would have* hit, not a live exposure. **Residual:** the `users` DDL is creation-guarded, so an existing SQL Server database keeps its original column collation. | | [0170](0170-constant-work-recovery-code-verification-pad-to-the-configured-slot-count-rather-than-short-circuit.md) | **Constant-work recovery-code verification: pad to the configured slot count rather than short-circuit** (BACKLOG #1167, ASVS 11.2.4) -- `_verify_second_factor` walked the argon2id recovery-code hashes and `return`ed on the first match, so the NUMBER of ~64 MiB verifications was a function of which code was presented. **Two leaks and only one matters:** the matched INDEX is worthless (the attacker holds the code and the response answers them anyway), but on the FAILURE path the cost is one verify per REMAINING code -- so anyone holding the password can time a wrong-code refusal and learn how many recovery codes an account has left, without authenticating to the second factor. **The item rated this difficulty 7 on a premise that does not survive measurement:** the re-score says a constant loop 'converts a timing leak into a memory and CPU amplification target', which is the right objection to raise -- and the failure path ALREADY verifies every remaining hash, so making the walk unconditional introduces no new cost, it makes today's WORST CASE the only case. Decision: always run exactly `mfa_recovery_code_count` verifies, padding with the same fixed `_DUMMY_PASSWORD_HASH` the local login leg uses, and select the winner AFTER the loop. Ceiling unmoved (default 10, validator-capped 50); `_argon2`'s semaphore means the concurrent-argon2 footprint cannot widen either; and the path sits behind primary authentication, so it is not an unauthenticated flood surface. **Claims constant WORK, not constant TIME** -- the store round trip on a match is not equalized, the TOTP branch returns earlier, and argon2's own constant-timeness is INHERITED from `argon2-cffi` and has never been measured in this tree, a gap #1167 names and this does not close. No timing measurement was run by the item or by this change. Rejected: leaving the short-circuit as accepted (the fix cost nothing against the existing ceiling, so 'accepted' would have been a judgement made before the amplification premise was checked); and a non-secret lookup index so only ONE verify ever runs -- strictly better on both axes, rejected as OUT OF SCOPE rather than wrong, needing a schema change across three backends and a migration, and recorded so it is not re-derived if the constant walk's cost ever bites | **Accepted (2026-08-22)** -- built with the change. Three parametrized tests pin the count for a first-slot match, a last-slot match and a non-match; proven red-first, removing the padding reds ALL THREE and the file restores byte-identical by SHA-256. Severity conditional per CLAUDE.md section 0 -- **zero deployments**, so this is what a first deployment would have inherited | | [0171](0171-offline-administrator-unlock-a-host-gated-cli-recovery-path-for-a-sole-administrator-lockout.md) | **Offline administrator unlock: a host-gated CLI recovery path for a sole-administrator lockout** (BACKLOG #1236) -- a deployment with ONE administrator had no recovery from account lockout, and every exit is individually deliberate: the bootstrap account is literally `admin`, it is created with no email so the ACCOUNT_LOCKED notice never leaves the process, self-reset is refused, an admin reset needs ANOTHER admin, re-bootstrap fires only on an EMPTY users table, and none of 38 CLI subcommands managed users. **The defect is that they close SIMULTANEOUSLY for that deployment** and nothing notices the conjunction. **The filed acceptance criterion could not discriminate and was amended 2026-08-21:** "recover without hand-editing the database and without a second admin" PASSES ON THE SHIPPED SYSTEM BY WAITING, since the lock self-expires after `lockout_minutes`; a test both a fixed and a broken system pass is not a test. Decision: `messagefoundry admin-unlock --username `. **The gate is HOST ACCESS and it is a real gate rather than an absent one** -- reaching it needs the config, the store path and on an encrypted store the key material, so anyone holding all three already has the database and does not need an unlock to reach an account; that is why it ships unauthenticated, and it is the load-bearing claim. **Clears the lockout and does NOT reset the password** -- deliberately narrower, since a reset would hand the runner a working account. **Reuses `record_login_failure(failed_attempts=0, locked_until=None)` rather than adding a protocol method**, decided by a MEASURED cross-lane fact rather than taste: a named `clear_lockout` would touch base/store/postgres/sqlserver, and all four were uncommitted in a peer lane at the time, so reuse avoided a four-file collision. Exit codes follow the `--json` convention (`_emit_error`, 1) not the M-31 lineage (stderr, 2), verified against `audit-verify` which has no `--json` flag. Carries M-31 forward: a typo'd `--db` is refused rather than creating an empty SQLite store and reporting a false "no such account" | **Accepted (2026-08-22)** -- built with the change. Four tests; **exactly ONE is the control** and the other three are deliberately insensitive -- neutering the clearing call reds only the acceptance test, and the audit-row test still passes under that plant, so it evidences the flow RAN and never that it WORKED. Does NOT address #1236's repetition limb: lock cycles remain unbounded and an attacker can re-lock. Severity conditional per CLAUDE.md section 0 -- **zero deployments** | | [0172](0172-the-engine-always-serves-tls-minting-a-self-signed-certificate-on-first-run.md) | **The engine always serves TLS, minting a self-signed certificate on first run** (BACKLOG #1276) -- `[api].tls_cert_file` and `tls_key_file` both shipped `None` and `tls_enabled` was literally `bool(self.tls_cert_file)`, so an unconfigured engine opened a **cleartext socket**. The minting primitive already shipped and was already driven by a CLI verb, so the gap was wiring rather than cryptography. **SUPERSEDES [ADR 0143](0143-web-console-on-by-default-disableable-with-loopback-secure-context-browser-hardening.md)'s DECISION, not its analysis:** 0143 explicitly CONSIDERED terminating TLS on the loopback bind and DECLINED it as an XL requiring 'migrating every client (harness, `apiclient`, tray, IDE) in lockstep'. **That sizing was broadly right, and the measurement puts the client half at MORE work than 0143 estimated; what 0143 missed is that the halves are SEPARABLE, which is why this supersedes rather than amends:** only the TRAY infers the scheme (`engine_serves_https`, renamed from `service_toml_uses_tls` and repaired under BACKLOG #1126; ONE caller); `apiclient` has ZERO references to `tls_cert_file` and is GIVEN a URL it only validates; the IDE's hits are MLLP CONNECTOR schema, the same name for a different setting; and the harness does not infer at all, it hardcodes `http://127.0.0.1:8765`. One inference site, but the DEFAULTS are the larger half and are at least 14 (BACKLOG #1276, a measured floor; the 8 that circulated in mail was never measured). A flip alone does not work, because no client can VERIFY the minted pair: the tray needs a pin seam and the IDE has NO certificate-authority seam at all (zero files, against a positive control firing in 2 of 108). This change migrated ONE family, `harness/load/`, and it cost 4 one-line flips plus 200 inserted lines including a new 108-line `tlsmat.py` -- so flips occur, but none stands alone. The operator-facing clients (tray, `apiclient`, IDE, harness GUI) are ZERO files changed and move under #1276 part B, which authorised that split in its own words. Decision: an operator certificate always wins and this is a fallback BENEATH it; unconditional deliberately, because a CONDITIONAL scheme is what let three clients each decide it their own way and clients cannot disagree about a scheme with no conditional; **NOT in every topology** -- `tls_terminated_upstream` declares a proxy terminating TLS IN FRONT and speaking plaintext, so minting there breaks the proxy's own hop, and 'always serves TLS' means the engine never leaves a hop unprotected rather than that it terminates everywhere; the pair is a PLACEHOLDER to be replaced, self-signed and chainless; mint-once, since `_write_private_key` refuses to overwrite; and **re-minting an expired pair is AUDITED, never silent** (owner ruling) -- *silent* is the defect in replacing a key on disk, not *replaces*. Storage beside the store database: already the engine's own writable state, already operator-controlled via `--db`, and NOT operator-authored config. Rejected: an `[api].tls_generated_dir` knob for a question with one sensible answer; and **rejected outright**, the engine writing `tls_cert_file` into the operator's service TOML, which stopped being necessary once the scheme stopped being conditional. Lifetime 365 days, inheriting the CLI default rather than inventing a second one. **0143's analysis stands and is why this is better** -- its diagnosis that a secure cookie over cleartext http is dropped by Chrome and Safari and BREAKS LOGIN is correct; its `app.state.loopback` mechanism becomes vestigial where the engine terminates TLS, but NOT in the upstream-proxy topology, which still reaches the engine over plaintext | **Accepted (2026-08-22)** -- owner-ruled twice on this item, both forks found while SCOPING and handed back before code. Part A built; the existing suite caught the upstream-proxy break and the CODE was fixed rather than the test. Severity conditional per CLAUDE.md section 0 -- **zero deployments**, so nothing is served in the clear today |