diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index ec15f076..17e73286 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -28622,3 +28622,87 @@ ADR 0078's stated residuals were meant to land under ADR 0173, and ADR 0173's on **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. + +--- + +## 1503. ADR 0075's AC-1 asserts a batched `mark_done` that does not exist, and cites a test name that does not exist + +> 🔢 **Filed 2026-09-09 -- not started. Scored at filing.** Value **5/10** · Difficulty **3/10** · _fill-in_. Found by an adversarial over-claim hunt across 125 ADR build claims. The record is otherwise honest and the feature is real; the defect is that a counted acceptance criterion names a third hop that was never built, and anchors it to a test that does not exist. Value 5 because a cited-but-absent test reads as MORE verified than no citation at all. Difficulty 3: either build the third hop or correct the criterion, and the ADR's own open items already frame the choice. +> Verdict: owner-ruling +> Research: none -- the measurement is in the body +> Closing-act: code + +**Cluster:** ADR record integrity / SQL Server store. **Priority:** P3. **Verdict:** decide batch-or-defer, then make AC-1 match. +**Severity:** no deployment axis (sec. 0). The shipped batching is correct for the two hops it covers; nothing mis-executes. What is wrong is the record. + +**What AC-1 asserts.** [ADR 0075](adr/0075-per-hop-sql-statement-batching.md), inside its counted Acceptance Criteria block: *"WHEN `batch_handoff_statements=true` on a SQL Server store, THE SYSTEM SHALL emit, for each of `route_handoff` / `transform_handoff` / `mark_done`, the identical logical `(sql, params)` sequence as the unbatched path"* -- cited to `tests/test_adr0075_batch_golden_sql.py::test_batched_matches_unbatched_sequence`. The ADR is **Accepted** and the flag is **promoted default-ON**. + +**What the tree has.** Two of the three hops. + +| Hop | Batched method | Reads the flag | +|---|---|---| +| `route_handoff` | `_route_handoff_batched` | yes | +| `transform_handoff` | `_transform_handoff_batched` | yes | +| `mark_done` | **none** | **no** | + +`store/sqlserver.py` reads `_batch_handoff_statements` in exactly two places, both handoff dispatchers. `mark_done` never reads it, so the third hop is unreachable by construction rather than merely untested. + +**The cited test does not exist.** `tests/test_adr0075_batch_golden_sql.py` defines `test_route_batched_matches_unbatched_sequence` and `test_transform_batched_matches_unbatched_sequence`. Neither that file nor `tests/test_adr0075_rt_count_gate.py` contains the string `mark_done`. + +**The ADR already knows.** Its own open items carry an **unchecked** box: *"`mark_done` inclusion ... decide whether to batch it in v1 or defer it."* So a counted criterion asserts as delivered the exact thing the same document lists as undecided. + +**Scope, stated so nobody over-corrects.** The README index cell says only *"fold a multi-statement handoff BODY"*, which is accurate. The over-claim is in the ADR body, not the index row. The §Evidence round-trip table also prices `mark_done` at 11 to 7/8 round-trips, which the code does not do either. + +--- + +## 1504. ADR 0133 D3 asserts content-triggered alerts are built, but a Handler cannot reach `content_match` + +> 🔢 **Filed 2026-09-09 -- not started. Scored at filing.** Value **5/10** · Difficulty **4/10** · _fill-in_. Found by the same over-claim hunt. Two of the ADR's three capabilities are genuinely wired across all three backends; the third is a method with no reachable caller and no path from the authoring surface. `docs/BACKLOG.md:698` already records the capability as absent, so the ledger and the ADR disagree with each other. Value 5: an operator reading the ADR would plan a Handler they cannot write. Difficulty 4: either export an emitter onto the authoring surface or retract D3. +> Verdict: owner-ruling +> Research: none -- the measurement is in the body +> Closing-act: code + +**Cluster:** alerting / ADR record integrity. **Priority:** P3. **Verdict:** build the seam or retract D3. +**Severity:** no deployment axis (sec. 0). No mis-execution; a documented capability simply is not reachable. + +**What is genuinely built.** Do not let this row read as though ADR 0133 failed. D1 and D2 are complete: + +- **D1, escalation tiers** -- `EscalationTier` and `AlertRule.escalate` in `config/settings.py`, tier selection and occurrence counting in `pipeline/alert_sinks.py`, persisted monotonically on all three backends with each dialect's own idiom (`MAX` / `GREATEST` / `CASE`) and the DDL plus migration present in `store.py`, `postgres.py` and `sqlserver.py`. +- **D2, schedule-aware thresholds** -- `AlertRule.schedule` plus the `is_active(now_dt)` gate in `alert_sinks.py`. + +**What D3 asserts.** [ADR 0133](adr/0133-alert-escalation-tiers-schedule-aware-thresholds-and-content-triggered-alerts-the-56-remainder.md) status reads `Accepted (2026-07-18, built)`, and AC-3 is *"WHEN a Handler emits a `content_match`, THE SYSTEM SHALL emit a PHI-free event."* The README index row repeats that a Handler *"emits off the routing hot path"*. + +**Why a Handler cannot do that.** `content_match` exists only as `NotifierAlertSink.content_match` in `pipeline/alert_sinks.py`. It is: + +1. **absent from the `AlertSink` Protocol** in `pipeline/alerts.py` -- that protocol's methods do not include it; +2. **absent from `LoggingAlertSink`**; +3. **not exported** -- `messagefoundry/__init__.py`'s `__all__` carries no alert emitter; +4. **unreachable from a `@handler`**, which receives only `msg` (`config/wiring.py`). + +Zero non-test callers exist in the engine. + +**The ledger already says so.** `docs/BACKLOG.md:698` records content-triggered alerting as not delivered. This row exists because the ADR does not, and the ADR is the document a reader trusts for build state. + +--- + +## 1505. ADR 0089 claims a Phase A read-atom row that was never built, and names a `set_segment` the Message API lacks + +> 🔢 **Filed 2026-09-09 -- not started. Scored at filing.** Value **3/10** · Difficulty **3/10** · _fill-in_. Found by the same over-claim hunt, and one of only two findings in it that were not already written down somewhere in the repo. The bulk of Phase A shipped; the Status line claims the whole of it. Value 3: the affected rows render as opaque code in the Steps view rather than failing, so the cost is a wrong expectation, not a defect. Difficulty 3: either recognise the read atom or narrow the Status line and the Phase A table. +> Verdict: build +> Research: none -- the measurement is in the body, and it was measured by running the lens, not by grep +> Closing-act: code + +**Cluster:** Steps view / lens. **Priority:** P3. **Verdict:** narrow the claim, or build the read row. +**Severity:** no deployment axis (sec. 0). IDE authoring surface only, no engine path, no PHI. + +**What the Status line claims.** [ADR 0089](adr/0089-recognition-first-lens-native-idioms.md): *"Phase A (native-idiom recognition) is built and adopted."* Phase A is titled *"native write/**read** atoms"*, and its table lists `x = msg.field("Y")` as a **Read Field to var** row (22 occurrences in its own estate scan) and names `set_segment`. + +**What the lens recognises.** `_recognize_native_method` in `messagefoundry/lens.py` handles `msg.set` (set_field), the `msg.set(dst, msg.field(src))` copy form, `msg.delete_segments` / `delete_segment`, plus `msg.add_segment` and `msg.add_repetition` (both tagged ADR 0106, so credited to a later ADR). + +**Why the read atom can never reach it.** The recogniser is invoked only inside an `isinstance(s, ast.Expr)` branch, and the comment there says so outright -- *"a mutating method call, so always a bare expression statement, never an assignment."* An assignment is structurally excluded. There is no `read_field` action anywhere in the product. + +**Measured, not inferred.** Running `messagefoundry.lens.parse_source` over a handler containing all four forms returns `{"kind": "code"}` for `x = msg.field("PID-5.1")`, while `msg.set`, `msg.delete_segments` and `msg.add_segment` in the same body return editable `action` rows with populated `literal_params`. So the 22 statements the ADR's own scan counted still render as opaque grey code rows. + +**`set_segment` does not exist.** `parsing/message.py` has `field` and `add_segment`; there is no `def set_segment` anywhere. + +**Scope, stated so nobody over-corrects.** The README index row is **honest** -- it names only `msg.set` / `msg.field`-copy / `msg.delete_segments` and the actions `set_field` / `copy_field` / `delete_segment`. `tests/test_lens_native.py` covers exactly the shipped forms, consistent with there being no read row. The over-claim is internal to the ADR file. diff --git a/docs/adr/0002-phase2-transport-security-and-strong-auth.md b/docs/adr/0002-phase2-transport-security-and-strong-auth.md index 2ad3151c..7674de23 100644 --- a/docs/adr/0002-phase2-transport-security-and-strong-auth.md +++ b/docs/adr/0002-phase2-transport-security-and-strong-auth.md @@ -17,7 +17,36 @@ SAML via Entra) and SMART on FHIR are *out of this ADR's scope*** — they get a dedicated federated-SSO ADR when 0.2 design begins. *(Originally Proposed 2026-06-12 as design-only under the "design now, build then" rule; this acceptance supersedes that deferral.)* -- **Built:** Nothing in this ADR is built yet — it designs **WP-13a** (API/WebSocket TLS), **WP-13b** +- **Built:** **All four work packages are BUILT.** Corrected 2026-09-09. The stale sentence below is + kept rather than deleted, because a reader planning off it would plan to build code that already + exists. Its WP-14 parenthetical is that sentence's only build marker, so the leading claim reads as + scoping the exception to MFA alone while WP-13a, WP-13b and WP-15 shipped underneath it. Verified by + symbol against the tree on 2026-09-09, because line numbers rot and symbols do not: **WP-13a** is + `build_api_ssl_context` and `ensure_api_tls_material` ([api/tls.py](../../messagefoundry/api/tls.py)), + imported and called on the `serve` path ([__main__.py](../../messagefoundry/__main__.py)) for every + topology that terminates TLS in-process -- a declared `[api].tls_terminated_upstream` proxy is the + deliberate exception, where `ensure_api_tls_material` returns `None` and the serve path builds no + context at all + ([ADR 0172](0172-the-engine-always-serves-tls-minting-a-self-signed-certificate-on-first-run.md) + decision 3); **WP-13b** is `_mllp_ssl_context` + ([transports/mllp.py](../../messagefoundry/transports/mllp.py)), built once per connection and wired + on both the outbound (`MLLPDestination`) and the inbound (`MLLPSource`) side, each call site + self-labelled WP-13b; **WP-14** is `auth/totp.py` (RFC 6238) with the enroll / verify / step-up flow + in [auth/service.py](../../messagefoundry/auth/service.py); **WP-15** is `[api].trusted_proxies` and + `[api].tls_terminated_upstream` ([config/settings.py](../../messagefoundry/config/settings.py)), + reaching uvicorn as `forwarded_allow_ips` and the app as `app.state.trusted_proxies` + ([api/app.py](../../messagefoundry/api/app.py)); `ApiSettings.exposure_protected` is the §0 + exposed-gate predicate. **Scope: the four work packages shipped. That is NOT a claim that every + clause of this ADR is live.** Two sub-clauses it already scopes stay unbuilt on purpose -- the mTLS + client-certificate identity resolver is inert under stock uvicorn (§4, + [ADR 0083](0083-mtls-client-certificate-identity.md)), and in-engine OCSP/CRL revocation is delegated + rather than implemented (*Certificate revocation*, + [ADR 0078](0078-certificate-revocation-posture.md)). + **STALE AS WRITTEN, kept for the record. The stale span ENDS at "forwarded-header hardening)" and + the claim was true only of the original design-only ADR. The Phase-0/1 groundwork sentence after + that point is NOT stale and NOT corrected here -- its four anchors (`_security_headers`, + `_ws_origin_allowed`, the `--allow-insecure-bind` bind guard, `insecure_tls_allowed()`) each still + resolve to a live symbol, checked 2026-09-09:** Nothing in this ADR is built yet — it designs **WP-13a** (API/WebSocket TLS), **WP-13b** (MLLP-over-TLS, v0.1), **WP-14** (MFA, **built 2026-06-17 — see §3**), and **WP-15** (reverse-proxy / forwarded-header hardening). The Phase-0/1 groundwork it *builds on* is already shipped and must **not** be redesigned: diff --git a/docs/adr/0003-non-hl7-transports-database-rest-soap.md b/docs/adr/0003-non-hl7-transports-database-rest-soap.md index 5069a98e..1bb8f7b7 100644 --- a/docs/adr/0003-non-hl7-transports-database-rest-soap.md +++ b/docs/adr/0003-non-hl7-transports-database-rest-soap.md @@ -6,12 +6,27 @@ router/store changes) is a **follow-up ADR** before any non-HL7 source is built, so §3 stays forward-looking. **First database backend: SQL Server** (reuses the store's existing `aioodbc` path — no new driver). (Proposed → Accepted same day.) -- **Built:** Nothing yet. The extension *point* it uses is already built and is explicitly meant for - this: the connector **registry** (`register_source` / `register_destination` keyed by - `ConnectorType`, [transports/base.py](../../messagefoundry/transports/base.py)) and the - transport-agnostic `Source`/`Destination` models ([config/models.py](../../messagefoundry/config/models.py), - whose docstring says adding a transport "never requires touching this file"; the enum already carries - the placeholder comment `# Phase 2+: TCP, DATABASE, REST, FHIR`). +- **Built:** BUILT, not pending — the §1 connectors shipped, and the §3 database source with them. + **AMENDED 2026-09-09.** This line previously read *"Nothing yet. The extension point it uses is + already built and is explicitly meant for this"*, which was true when written (2026-06-12, before + §2 was started) and is now wrong by the whole feature. It under-claimed, so a reader planning off + it would plan to build what already ships. + **Verified in the tree 2026-09-09, by symbol:** `ConnectorType.REST` / `.DATABASE` / `.SOAP` are + declared in [config/models.py](../../messagefoundry/config/models.py), each citing this ADR; and + `RestDestination` ([transports/rest.py](../../messagefoundry/transports/rest.py)), + `DatabaseDestination` + `DatabaseSource` ([transports/database.py](../../messagefoundry/transports/database.py)) + and `SoapDestination` ([transports/soap.py](../../messagefoundry/transports/soap.py)) each register + themselves into the connector registry. §5's gate cleared: the follow-up landed as + [ADR 0004](0004-payload-agnostic-ingress.md), and `content_type` now reaches `store/` and `pipeline/`. + **Scope of that check:** symbols and registry registration only — not test coverage, and not every + §2/§4 sub-decision. There is no REST or SOAP *source* connector, and none is planned. + **The extension point named above is unchanged and is still the mechanism:** the connector + **registry** (`register_source` / `register_destination` keyed by `ConnectorType`, + [transports/base.py](../../messagefoundry/transports/base.py)) and the transport-agnostic + `Source`/`Destination` models ([config/models.py](../../messagefoundry/config/models.py)), whose + docstring still says adding a transport never requires touching that file. One detail of it went + stale: the enum's placeholder comment `# Phase 2+: TCP, DATABASE, REST, FHIR` is gone, replaced by + the real members. - **Related:** [ADR 0001](0001-staged-pipeline-architecture.md) (the staged pipeline these feed: sources → ingress, destinations ← outbound), [ADR 0002](0002-phase2-transport-security-and-strong-auth.md) (TLS/egress posture these inherit), the Corepoint-migration estate (185 MLLP but also database, REST, diff --git a/docs/adr/0004-payload-agnostic-ingress.md b/docs/adr/0004-payload-agnostic-ingress.md index efbe9908..21be53d9 100644 --- a/docs/adr/0004-payload-agnostic-ingress.md +++ b/docs/adr/0004-payload-agnostic-ingress.md @@ -6,7 +6,31 @@ §"To resolve" leans are taken as the answers: **DB-IN first**; `RawMessage` = `.raw`/`.text`/`.json()` (`.xml()` shipped #31); a **callable** summary hook; a small `content_type` **enum**. (Proposed → Accepted same day.) -- **Built:** Nothing here yet. It builds on the **already-shipped** non-HL7 *destinations* (REST/DATABASE/ +- **Built:** BUILT, in the parts named here — §7.1's ingress contract shipped, and so did §7.2's first + non-HL7 sources. `ContentType` ([config/models.py](../../messagefoundry/config/models.py)) carries + `HL7V2`/`JSON`/`XML`/`TEXT`/`X12`/`FHIR`/`BINARY`/`DICOM`; `content_type` is a field on + `InboundConnection` and a parameter of the `inbound(...)` factory + ([config/wiring.py](../../messagefoundry/config/wiring.py), coerced by `_coerce_content_type`, with + §5's fail-loud `WiringError` when `strict` meets a non-HL7 type). §2's ingress branch and §4's generic + store fields are in [pipeline/wiring_runner.py](../../messagefoundry/pipeline/wiring_runner.py), at + both `_handle_inbound` and `_handle_inbound_http`. The `hl7v2` path keeps the HL7 peek and optional + strict-validate; any other type skips both and commits the body with no HL7 parse — decoded text for + the text types, base64-carried through `RawMessage.from_bytes` for the binary ones, per ADR 0028 — + with `message_type` = the `content_type` value and a null `control_id`/`summary`. §3's `RawMessage` + ([parsing/message.py](../../messagefoundry/parsing/message.py)) exposes + `.raw`/`.text`/`.json()`/`.xml()`/`.raw_bytes`/`.binary()`/`.encode()`/`.copy()`, and the 2026-07-17 + amendment's magic-byte check was hoisted to [parsing/sniff.py](../../messagefoundry/parsing/sniff.py) + (`_content_matches_declared`) out of `transports/file.py`, where that amendment placed it; + `transports/file.py` re-imports and re-exports it, so the name still resolves there. Both first + sources are registered: `DatabaseSource` + ([transports/database.py](../../messagefoundry/transports/database.py)) and `HttpSource` + ([transports/http_listener.py](../../messagefoundry/transports/http_listener.py), ADR 0023). + **NOT BUILT:** §4's optional per-inbound id/summary extractor hook — `control_id` and `summary` stay + null on a non-HL7 inbound. *This bullet opened "Nothing here yet." until 2026-09-09. That was true at + acceptance (2026-06-12); nobody revised it as the build landed, so the record under-reported what + shipped, and the Status line above already contradicted it by recording `.xml()` as shipped. Only the + symbols named above were re-verified against the tree — read the rest of this bullet as written in + June.* It builds on the **already-shipped** non-HL7 *destinations* (REST/DATABASE/ SOAP, ADR 0003) and on a reliability core that is **already format-agnostic**: the staged queue + ingress/routed/outbound stages and the disposition finalizer ([store/store.py](../../messagefoundry/store/store.py) `_maybe_finalize_message`) carry no HL7 coupling diff --git a/docs/adr/0007-gui-manageable-connections-toml.md b/docs/adr/0007-gui-manageable-connections-toml.md index 5bcf8904..fedfd33e 100644 --- a/docs/adr/0007-gui-manageable-connections-toml.md +++ b/docs/adr/0007-gui-manageable-connections-toml.md @@ -1,8 +1,37 @@ # ADR 0007 — GUI-manageable connections as a config-as-data TOML artifact -- **Status:** Proposed (2026-06-13) — drafted on the owner's go; ratified-on-build. Supersedes the +- **Status:** **Accepted** — drafted on the owner's go (2026-06-13); the ratified-on-build condition + is met, recorded here 2026-09-09. Supersedes the implicit "connections are code-only" stance of earlier ADRs for the *transport/wiring* layer. -- **Built:** Not yet — design record. Build is phased (see *Phasing*); Phase 1 (read path) first. + **This line read "Proposed (2026-06-13)" until 2026-09-09.** That was true at drafting; it was left + behind by the build it was conditioned on — see *Built* below for what shipped and where. +- **Built:** **Yes — Phases 1, 2 and 3 are in the tree; re-verified against the working tree + 2026-09-09.** *Phase 1, read path* — `CONNECTIONS_FILE_NAME` and `load_connections_file` in + [`config/connections_file.py`](../../messagefoundry/config/connections_file.py), called from both + loader entry points (`load_config` and `validate_config`) in + [`config/wiring.py`](../../messagefoundry/config/wiring.py), with a sample + `samples/config/connections.toml` and `tests/test_connections_file.py`. *Phase 2, CLI write path* — + the style-preserving `upsert_connection` / `remove_connection` writer in + [`config/connections_edit.py`](../../messagefoundry/config/connections_edit.py) on `tomlkit` + (`tomlkit>=0.12` in `pyproject.toml`, its comment naming this ADR), behind the + `connection list|upsert|remove|schema` subcommand registered and dispatched in + [`__main__.py`](../../messagefoundry/__main__.py). *Phase 3, VS Code editor* — the engine-derived + `SCHEMA_VERSION` schema in + [`config/connection_schema.py`](../../messagefoundry/config/connection_schema.py) plus the + `connectionEditor` / `connectionForm` / `connectionQuickInput` modules under `ide/src/`. + The 2026-07-19 amendment below is built too: `flagged` on both connection models and in the read and + write key sets, `Engine.set_connection_flag` + ([`pipeline/engine.py`](../../messagefoundry/pipeline/engine.py)) behind + `POST /connections/{name}/flag` ([`api/app.py`](../../messagefoundry/api/app.py)), and + `tests/test_connection_flag.py`. *Phase 4* is confirmed only in part: the decomposition + convention is documented ([docs/CONNECTIONS.md](../CONNECTIONS.md) section "Decomposing by role") + with the `IB_DEMO_ORU_*` sample, but the re-port of the deferred multi-destination fan-out feeds + sits outside this repository and is **not claimed** here. + **This line read "Not yet — design record. Build is phased (see *Phasing*); Phase 1 (read path) + first." until 2026-09-09.** It was accurate on 2026-06-13 and then went stale in silence: the + *Amendment (2026-07-19)* section below describes code in the tree while this header still said + nothing was built. The defect is an **under-claim** — a reader planning off the old line would plan + to build what already exists. - **Decision in one line:** add an **optional `connections.toml`** data artifact in the config dir that the loader reads into the **same** `InboundConnection`/`OutboundConnection` registry entries the code-first `inbound()`/`outbound()` factories produce — a flat, hand-editable **and** diff --git a/docs/adr/0017-consumer-deployment-model.md b/docs/adr/0017-consumer-deployment-model.md index bc54ee42..969c8abc 100644 --- a/docs/adr/0017-consumer-deployment-model.md +++ b/docs/adr/0017-consumer-deployment-model.md @@ -8,7 +8,25 @@ stand-in for any adopter) consumes MessageFoundry: the engine as a **read-only installed dependency it never edits**, and HC's Connections/Routers/Handlers as its **own, separately-versioned config repo** that drives **multiple deployed instances** (at minimum Test + Production; optionally POC, Staging, …). -- **Built:** Nothing in this ADR is built. It builds **on** mechanisms already shipped that must **not** +- **Built (partial, re-verified 2026-09-09):** Both **Blocker** work-packages under "Engine work + required" are **BUILT**. Free-form environment names: `AiEnvironment` is gone from the Python source + and no `--env` argument carries `choices=`; `AiSettings.environment` + ([config/settings.py](../../messagefoundry/config/settings.py)) holds the free-form name. Explicit + posture tier, decoupled from that name: `DataClass` (`synthetic`/`phi`) and `SecurityEnforcement` in + [config/ai_policy.py](../../messagefoundry/config/ai_policy.py), read through + `AiSettings.require_posture()` in [config/settings.py](../../messagefoundry/config/settings.py). + Required active environment: `serve` in [__main__.py](../../messagefoundry/__main__.py) exits 2 when + no environment is set, so the silent PROD default is gone. One clause landed conditionally rather + than flatly. The `.toml` existence check has two sites, both in + [__main__.py](../../messagefoundry/__main__.py): `_emit_anchor_diagnostics`, which `serve` runs at + startup, and `_check_env_file_present`, reached through `_resolve_offline_anchor` by the offline + `validate`, `graph`, `dryrun` and `check` subcommands. Both hard-fail with exit 2, and both fire only + when a project root is set and the loaded graph calls `env()`. Outside those conditions + `load_environment_values` in [config/environments.py](../../messagefoundry/config/environments.py) + still reads the value file only if it is present, and never fails. **Scope of this pass:** only the + two Blocker rows were re-verified, so this records that the Blockers shipped, **not** that the whole + ADR is built. *The sentence that stood here, "Nothing in this ADR is built", is now wrong. It is kept + as history, not as current state.* This ADR builds **on** mechanisms already shipped that must **not** be redesigned: the directory-level engine/config split via the loader ([config/wiring.py](../../messagefoundry/config/wiring.py) `load_config`, the `_SiblingHelperFinder`, and `codesets/` + `connections.toml` resolved relative to `--config`); the per-environment value layer diff --git a/docs/adr/0028-base64-binary-carriage-codec.md b/docs/adr/0028-base64-binary-carriage-codec.md index aa0c2748..a7f28f39 100644 --- a/docs/adr/0028-base64-binary-carriage-codec.md +++ b/docs/adr/0028-base64-binary-carriage-codec.md @@ -1,7 +1,7 @@ # ADR 0028 — base64 binary-carriage codec (+ HL7 OBX-5 ED embedding) -- **Status:** **Accepted (2026-06-20) — ratified on the owner's go.** Build may start (per the land-order, the carriage codec + `RawMessage` additions land first; consumers like DICOM [ADR 0025](0025-dicom-codec-store-connectors.md) build on `.raw_bytes`). Design-only so far (no code yet). The "To resolve on acceptance" confirmations are recommended at the stated positions and are taken as the answers on acceptance (the `## Decision (proposed)` heading stays captioned "(proposed)" after the status flips, as in [ADR 0022](0022-fhir-resource-codec-rest-client.md), mirroring [ADR 0012](0012-x12-edi-codec.md)'s `## Resolved`). **Amended 2026-07-13 ([below](#amendment-2026-07-13--error-path-latin-1-retrofit-closed-ingest-4)):** the open ERROR-path latin-1 retrofit follow-up (final *To resolve on acceptance* item) is **CLOSED (INGEST-4)** — the four ERROR sites plus a new post-decode NUL guard now carry NUL-bearing bytes via the `mfb64:v1:` codec instead of `raw.decode("latin-1")`, restoring the CLAUDE.md §2 count-and-log invariant a Postgres bind-reject was breaking. -- **Built (this ADR):** Nothing here yet. It layers a **binary-carriage** semantic over **already-shipped** substrate the way FHIR ([ADR 0022](0022-fhir-resource-codec-rest-client.md)) and X12 ([ADR 0012](0012-x12-edi-codec.md)) layered codecs over the payload-agnostic ingress — no new dependency, stdlib `base64` only. Reused seams: +- **Status:** **Accepted (2026-06-20) — ratified on the owner's go.** Build may start (per the land-order, the carriage codec + `RawMessage` additions land first; consumers like DICOM [ADR 0025](0025-dicom-codec-store-connectors.md) build on `.raw_bytes`). Design-only so far (no code yet) — true on 2026-06-20, wrong now. The build landed; the **Built (this ADR)** bullet below records what a 2026-09-09 re-verification against the tree found. The "To resolve on acceptance" confirmations are recommended at the stated positions and are taken as the answers on acceptance (the `## Decision (proposed)` heading stays captioned "(proposed)" after the status flips, as in [ADR 0022](0022-fhir-resource-codec-rest-client.md), mirroring [ADR 0012](0012-x12-edi-codec.md)'s `## Resolved`). **Amended 2026-07-13 ([below](#amendment-2026-07-13--error-path-latin-1-retrofit-closed-ingest-4)):** the open ERROR-path latin-1 retrofit follow-up (final *To resolve on acceptance* item) is **CLOSED (INGEST-4)** — the four ERROR sites plus a new post-decode NUL guard now carry NUL-bearing bytes via the `mfb64:v1:` codec instead of `raw.decode("latin-1")`, restoring the CLAUDE.md §2 count-and-log invariant a Postgres bind-reject was breaking. +- **Built (this ADR):** **BUILT** — sections 1 to 5 and section 7 are on `main`, re-verified against the tree 2026-09-09. *"Nothing here yet" was true when this ADR was accepted on 2026-06-20 and is wrong now; a reader planning off that sentence would plan to build code that already exists.* Found by symbol: the pure codec [parsing/binary.py](../../messagefoundry/parsing/binary.py) (`MARKER = "mfb64:v1:"`, `encode`, `decode`, `is_marked`, `BinaryCarriageError`); all four `RawMessage` additions in [parsing/message.py](../../messagefoundry/parsing/message.py) (`from_bytes`, `is_binary`, `raw_bytes`, `binary`); the §7 OBX-5 ED helpers `embed_obx_document` / `extract_obx_document`; the root [parsing/__init__.py](../../messagefoundry/parsing/__init__.py) re-exports; the INGEST-4 `_nul_safe_error_raw` seam in [pipeline/wiring_runner.py](../../messagefoundry/pipeline/wiring_runner.py), carrying the four ERROR sites the Amendment names (over-cap plus decode-error in each of `_handle_inbound` and `_handle_inbound_http`) plus a post-decode NUL guard in each of those two handlers — the seam has **at least seven** call sites today, the extra ERROR site being the declared-content-type reject in `_declared_content_mismatch`, which post-dates the 2026-07-13 Amendment; and `tests/test_binary_carriage.py` (codec round-trip, corrupt-base64 rejection, OBX-5 ED round-trip). **NOT claimed built:** the connector binary-mode knob that *To resolve on acceptance* recommends deferring — no such setting is in the tree; the cross-backend NUL tests the Amendment names, not re-checked here; and the closing line's *corrupt-input-dead-letter* test, which is in neither `test_binary_carriage.py` nor the NUL dead-letter file `tests/test_nonhl7_ingress_size_cap.py`. It layers a **binary-carriage** semantic over **already-shipped** substrate the way FHIR ([ADR 0022](0022-fhir-resource-codec-rest-client.md)) and X12 ([ADR 0012](0012-x12-edi-codec.md)) layered codecs over the payload-agnostic ingress — no new dependency, stdlib `base64` only. Reused seams: - the payload-agnostic ingress ([ADR 0004](0004-payload-agnostic-ingress.md)): the non-HL7 branch of `_handle_inbound` ([wiring_runner.py:748-761](../../messagefoundry/pipeline/wiring_runner.py)) already commits a non-HL7 body verbatim to the ingress stage and hands the Router/Handler a **`RawMessage`** ([parsing/message.py:430-476](../../messagefoundry/parsing/message.py)); - the str/TEXT store substrate on all three backends — `messages.raw` + `queue.payload` are `TEXT`/`NVARCHAR(MAX)` ([store/store.py:413,435](../../messagefoundry/store/store.py), [store/postgres.py:110,125](../../messagefoundry/store/postgres.py), [store/sqlserver.py:68,94](../../messagefoundry/store/sqlserver.py)); - the store cipher seam — `IdentityCipher` stores verbatim and `AesGcmCipher` wraps with the `mfenc:v1:` envelope ([store/crypto.py:37,68-80,105-138](../../messagefoundry/store/crypto.py)); the carriage marker rides *beneath* it as an independent inner layer. diff --git a/docs/adr/0041-load-path-attestation-and-change-attribution.md b/docs/adr/0041-load-path-attestation-and-change-attribution.md index 0f8de5dd..f644c0fd 100644 --- a/docs/adr/0041-load-path-attestation-and-change-attribution.md +++ b/docs/adr/0041-load-path-attestation-and-change-attribution.md @@ -1,8 +1,12 @@ # ADR 0041 — Load-path attestation & code-change attribution -- **Status:** Proposed (2026-06-27) — drafted on the owner's go (insider-code-tampering review). The - **first slice (D1, the config fingerprint)** is built alongside this ADR on branch - `config-fingerprint`; D2/D3 are staged (BACKLOG #53, #54). +- **Status:** Accepted (2026-06-27) — drafted on the owner's go (insider-code-tampering review). + **D1, D2 and D3 are all BUILT:** D1 alongside this ADR, then D2 (BACKLOG #53) and D3 (BACKLOG #54) + shipped in 0.2.9. +- **Status as drafted, kept for history:** *"Proposed (2026-06-27) — ... The **first slice (D1, the + config fingerprint)** is built alongside this ADR on branch `config-fingerprint`; D2/D3 are staged + (BACKLOG #53, #54)."* That was true when written. It under-claims now: #53 and #54 both closed and + shipped in 0.2.9, so a reader planning off it would plan to build code that already exists. - **Decision in one line:** on top of [ADR 0036](0036-windows-config-source-trust.md)'s load-time *write-access* refusal, add the **attribution + integrity-binding** layer it does not cover — bind every reload/startup to a **content fingerprint** of what loaded (D1), require a **second approver** @@ -102,7 +106,7 @@ behaviour with an unchanged fingerprint. **Implementation:** [`config/fingerprint.py`](../../messagefoundry/config/fingerprint.py) + a splat into the existing reload-audit detail; a fingerprint failure is logged and never blocks the audit. -### D2 — Dual-control `config:deploy` *(planned — BACKLOG #53)* +### D2 — Dual-control `config:deploy` *(BUILT — BACKLOG #53, shipped in 0.2.9; drafted as "planned")* Add `config:deploy` / `POST /config/reload` to the configurable dual-control `[approvals]` gated set, so a **distinct** second approver (server-enforced; the requester can never self-approve; both identities @@ -110,7 +114,7 @@ audited) must release a reload. The maker-checker machinery already exists; relo This is the one *preventive* control that makes the code author and a second authorizer both required for a change to go live. **Opt-in / deny-by-default** — single-operator deployments are unchanged until enabled. -### D3 — Startup self-attestation + enforced non-editable wheel *(planned — BACKLOG #54)* +### D3 — Startup self-attestation + enforced non-editable wheel *(BUILT — BACKLOG #54, shipped in 0.2.9; drafted as "planned")* At startup (and on demand) hash the loaded `messagefoundry` module files against the wheel's `*.dist-info/RECORD` (a zero-new-artifact baseline already shipped in the wheel); on drift, **fail-closed @@ -143,7 +147,10 @@ fingerprint+git-HEAD covers more cheaply for now. ## Acceptance Criteria -> EARS form; each linked (`→`) to its test. D1's tests land in this change; D2/D3 are planned targets. +> EARS form; each linked (`→`) to its test. D1's tests landed with this change; D2's and D3's landed +> with BACKLOG #53 / #54 in 0.2.9. **The as-drafted "D2/D3 are planned targets" was true when written +> and under-claims now** — all twelve ACs below have a test on disk. (AC-5 to AC-8 name +> `tests/test_approvals.py`; those four tests live in `tests/test_dual_control_reload.py`.) - **AC-1** — WHEN `config_fingerprint(dir)` is called twice on an unchanged bundle, THE SYSTEM SHALL return the identical 64-hex SHA-256 (stable, order-independent). @@ -162,7 +169,7 @@ fingerprint+git-HEAD covers more cheaply for now. - **AC-4** — WHEN an operator applies a non-dry-run `POST /config/reload`, THE SYSTEM SHALL include the config `fingerprint` in the `config_reload` audit detail, matching `config_fingerprint(dir)`. → `tests/test_api_reload.py::test_reload_audit_records_fingerprint` -**D2 — dual-control `config:deploy`** *(planned — BACKLOG #53)* +**D2 — dual-control `config:deploy`** *(BUILT — BACKLOG #53, shipped in 0.2.9)* - **AC-5** — WHERE `config_reload` is in `[approvals].operations` and `[approvals].enabled` is true, WHEN an operator applies a non-dry-run `POST /config/reload`, THE SYSTEM SHALL hold it as a pending request and @@ -181,7 +188,7 @@ fingerprint+git-HEAD covers more cheaply for now. before — single-operator deployments are unchanged until dual-control is opted in. → `tests/test_approvals.py::test_config_reload_inline_when_not_gated` -**D3 — startup self-attestation + enforced non-editable wheel** *(planned — BACKLOG #54)* +**D3 — startup self-attestation + enforced non-editable wheel** *(BUILT — BACKLOG #54, shipped in 0.2.9)* - **AC-9** — WHEN the engine starts (and on demand) on a non-editable wheel install, THE SYSTEM SHALL hash every loaded `messagefoundry` module file and compare it against the wheel's `*.dist-info/RECORD` baseline. diff --git a/docs/adr/0075-per-hop-sql-statement-batching.md b/docs/adr/0075-per-hop-sql-statement-batching.md index 8192e5b9..66dea264 100644 --- a/docs/adr/0075-per-hop-sql-statement-batching.md +++ b/docs/adr/0075-per-hop-sql-statement-batching.md @@ -1,6 +1,6 @@ # 0075 — Per-hop SQL statement batching (`batch_handoff_statements`) -- **Status:** Accepted (2026-07-07) — owner ratified; prototype built (#820, CI SS-gated). **Promote target reframed 2026-07-07: `default-ON` gated on a _harmless-near + helps-far_ rig result (see Amendment below), not the single-RTT ≥10% bar.** **PROMOTED default-ON 2026-07-08** — Bench B met the reframed criterion over a green SS correctness precondition (see the _Promoted_ section below + `docs/benchmarks/results/2026-07-08-adr0075-batch-ab/`); the flag is retained only as an emergency off-switch. +- **Status:** Accepted (2026-07-07) — owner ratified; prototype built (#820, CI SS-gated). **Promote target reframed 2026-07-07: `default-ON` gated on a _harmless-near + helps-far_ rig result (see Amendment below), not the single-RTT ≥10% bar.** **PROMOTED default-ON 2026-07-08** — Bench B met the reframed criterion over a green SS correctness precondition (see the _Promoted_ section below + `docs/benchmarks/results/2026-07-08-adr0075-batch-ab/`); the flag is retained only as an emergency off-switch. **SCOPE CORRECTION (2026-09-09): the batched hops are `route_handoff` and `transform_handoff` only. `mark_done` is NOT batched -- see the correction under AC-1.** - **Deciders:** throughput working group (owner ratifies; build + promote gated on a live-rig A/B) - **Related:** **builds the lever [ADR 0069](0069-durable-write-throughput-lever.md) named and left un-attacked** ("batching SQL statements per executor hop" — the round-trip half of the feed wall, distinct from its rejected durable-write levers) · **complements [ADR 0071](0071-cut-executor-round-trips-b5.md)** (B5 thread-hop fusion — NO-GO 2026-07-06; cut executor→loop *crossings* but not the per-hop *network round-trips* the fusion NO-GO explicitly attributed part of its dilution to) · [ADR 0055](0055-group-commit-durable-write.md) / [ADR 0053](0053-free-threaded-multicore-engine.md) (the other throughput levers) · [ADR 0001](0001-staged-pipeline-architecture.md) / [ADR 0066](0066-pooled-stage-claimers.md) (staged-pipeline + pooled-claimer invariants) · CLAUDE.md §2 (reliability / at-least-once / count-and-log invariants) · the throughput-microbench statement/RT inventory (`docs/benchmarks/results/2026-07-04-adr0071-b5-executor-marshaling/statement_rt_inventory.py`) @@ -142,6 +142,33 @@ A microbench measured off the **real shipped store methods** (driving `route_han - **AC-1** — WHEN `batch_handoff_statements=true` on a SQL Server store, THE SYSTEM SHALL emit, for each of `route_handoff` / `transform_handoff` / `mark_done`, the **identical logical `(sql, params)` sequence** as the unbatched path, grouped into fewer `execute()` round-trips, with **`commits/msg` unchanged (2.000 per handoff pair)**. → `tests/test_adr0075_batch_golden_sql.py::test_batched_matches_unbatched_sequence` + + > **CORRECTION (2026-09-09) -- AC-1 OVER-CLAIMS, AND IT CITES A TEST THAT DOES NOT EXIST.** Both lines + > above are kept verbatim, because they are what the record asserted. Both are wrong against the tree. + > + > **`mark_done` is NOT batched, and never was.** `messagefoundry/store/sqlserver.py` defines no + > `_mark_done_batched`, and no such symbol exists anywhere in the repository. Its batched handoff + > bodies are `_route_handoff_batched` and `_transform_handoff_batched`. A grep for `_batched` in that + > file returns a third method, `_maybe_finalize_batched` -- that one is the disposition finalizer + > `_transform_handoff_batched` calls, not a hop, and `_route_handoff_batched` does not call it. + > `route_handoff` and `transform_handoff` are the only two write-path readers of + > `self._batch_handoff_statements`; `mark_done` never reads it. **BUILT: `route_handoff`, + > `transform_handoff`. NOT BUILT: `mark_done`.** + > + > **Deferring it was a decision, not an oversight.** `tests/test_adr0075_batch_sqlserver.py` says so at + > the call site: `mark_done is not batched (by design); it finalizes PROCESSED`. The open item + > **`mark_done` inclusion** at the foot of this ADR is that question, and DEFER is its answer. + > + > **The cited test name does not exist.** `tests/test_adr0075_batch_golden_sql.py` defines + > `test_route_batched_matches_unbatched_sequence` and `test_transform_batched_matches_unbatched_sequence`. + > It defines no `test_batched_matches_unbatched_sequence`, and the string `mark_done` does not appear in + > that file at all. Those two real names are AC-1's evidence, for the two hops that exist. + > + > **The false citation survived because nothing checks the node id.** `messagefoundry/adr_analyze.py` + > splits each `→` reference on `::` and tests only that the FILE exists. So the note above this list -- + > *"checks each `→` resolves"* -- holds for the path and not for the test name. Citing a test nobody + > wrote reads as verified evidence, which is worse than citing none. Filed as BACKLOG #1503. + - **AC-2** — WHEN `batch_handoff_statements=true`, THE SYSTEM SHALL preserve per-handoff atomicity and idempotency: a crash after the claim commit / after the body but before the handoff commit / after the handoff commit SHALL, on restart + `reset_stale_inflight`, re-run in seq order with **zero loss and zero duplicate next-stage rows**. → `tests/test_staged_pipeline.py::test_batched_handoff_crash_replay` (SQLite skip; SS CI leg) - **AC-3** — IF a batched `execute()` fails, THEN THE SYSTEM SHALL attribute the failure to the correct statement and classify it CONTENT vs INFRA — an **infrastructure fault re-pends** the message (never a content dead-letter), a content/constraint fault dead-letters — identically to the unbatched path. @@ -181,4 +208,4 @@ A microbench measured off the **real shipped store methods** (driving `route_han - [ ] **The applock-rc-fold behaviour question.** The re-review found folding technically sound (rc<0 raises → whole-transaction rollback, so any statement that ran server-side before the rc is read is never committed and is invisible to other sessions) — but **confirm it against the FINAL batched control flow** (the guard-DELETE-opens-the-transaction ordering and the finalize applock's position in the trailing batch), and decide whether v1 ships `applock_soft` (fold) or `applock_hard` (gate). This single choice is what separates a ≥40%-clearing model from the strict 27–33% one. - [ ] **The live-rig A/B result** — the GO/NO-GO. Meets the conjunctive bar (≥10% median AND >2σ, zero-loss, `delivered/offered ≥ 0.98`, per-lane FIFO, `in_pipeline` flat-or-lower) at C ≥ 256 on a real two-box SQL Server rig, or it banks nothing and the flag stays default-OFF. - [ ] **Statement-level error attribution under a batched `execute()`** — confirm pyodbc surfaces enough to attribute WHICH statement in a batch failed (or fix the batch boundaries so a fetch/gating statement that needs distinct attribution stays its own `execute()`). -- [ ] **`mark_done` inclusion** — the delivery-complete hop clears neither floor's ≥40% (36.4% soft / 27.3% strict); decide whether to batch it in v1 or defer it behind the route/transform pair. +- [x] **`mark_done` inclusion** — the delivery-complete hop clears neither floor's ≥40% (36.4% soft / 27.3% strict); decide whether to batch it in v1 or defer it behind the route/transform pair. **RESOLVED -- DEFERRED.** Recorded 2026-09-09 from the tree; see the AC-1 correction for what is built and what is not. diff --git a/docs/adr/0089-recognition-first-lens-native-idioms.md b/docs/adr/0089-recognition-first-lens-native-idioms.md index d44c579c..9e946bc0 100644 --- a/docs/adr/0089-recognition-first-lens-native-idioms.md +++ b/docs/adr/0089-recognition-first-lens-native-idioms.md @@ -1,6 +1,6 @@ # ADR 0089 — Recognition-first lens: render native Message-API idioms as editable action rows -**Status:** Accepted (2026-07-13) — owner-ratified. Extends ADR 0076. Phase A (native-idiom recognition) is built and adopted (owner Steps-view endorsement); the ADR 0104 §2.3 HL7 field picker and the remaining phases build against it. (The 0/1,283 scan measured non-use of the ADR 0076 vocabulary API — **not** Steps-view adoption.) +**Status:** Accepted (2026-07-13) — owner-ratified. Extends ADR 0076. Phase A (native-idiom recognition) is built and adopted (owner Steps-view endorsement); the ADR 0104 §2.3 HL7 field picker and the remaining phases build against it. (The 0/1,283 scan measured non-use of the ADR 0076 vocabulary API — **not** Steps-view adoption.) **Correction 2026-09-09 -- "Phase A (native-idiom recognition) is built and adopted" OVER-CLAIMS this ADR's own §2 Phase A table: three of its six rows claim more than the tree delivers.** That sentence is kept for the record. The §2 correction block carries the verified build state and is the statement of record; BACKLOG **#1505** files two of the three findings. `docs/adr/README.md` is not edited -- its index row for this ADR enumerates only forms that are built. **Deciders:** owner + IDE/DX working group **Related:** ADR **0076** (the Steps lens this extends — vocabulary + `lens parse`/`rewrite` machinery, byte-stability gate 2, sync-on-save, one-editor, text fallback), BACKLOG **#222** (the lens), **#225** (live values), **#226–#230** (this ADR's build phases; *erratum 2026-07-16: this pre-allocated range is stale — the live #226–#229 are unrelated ledger items and phases are tracked per-item at filing time; see the §7 erratum*), ADR **0072** (traced dry-run / live values rendered beside rows), ADR **0010/0043** (`db_lookup`/`fhir_lookup` — the read-only lookups a value expression may call), CLAUDE.md §8 (Message API), §9 (PHI). **Code references** are `origin/main @ 1bbb409`; line numbers drift — locate at implementation time. @@ -28,6 +28,41 @@ Pivot the lens from *vocabulary-first* to **recognition-first**: teach the parse Recognition is added in phases, ordered by leverage (statement counts from the scan in §5): ### Phase A — native write/read atoms → editable rows (~1,035 statements) + +> **CORRECTION 2026-09-09 -- the Status line's "Phase A ... is built and adopted" OVER-CLAIMS this +> table.** The table is kept unchanged for the record; read it with this block. Measured on this tree +> by running `messagefoundry.lens.parse_source` over a handler holding every form below, and by +> reading `_recognize_native_method` (`messagefoundry/lens.py`). Nothing built changed for this +> correction. **Scope: Phase A only** -- this block makes no claim about Phase B, C or E, and Phase D +> stays DECLINED in its own block below. +> +> **BUILT -- rows 1, 2 and 5.** `msg.set(path, "lit")` (Set Field), `msg.set(dst, msg.field(src))` +> (Copy Field) and `msg.delete_segments("SEG")` (Delete Segment). `occurrence=` survives as the +> bullet below requires: it lands in the row's `params` and is withheld from `literal_params`, so it +> renders read-only. Also built, though this table never lists it: `msg.add_repetition(path, value)`. +> +> **OVER-CLAIM -- row 3, "value editable as an expression string".** The Set Field row IS emitted for +> `msg.set("X", localvar)` and its `path` IS editable. But an expression-valued `value` is left out +> of `literal_params`, and `editableParamNames` (`ide/src/stepsModel.ts`) offers only literal params. +> The value renders read-only. +> +> **NOT BUILT -- row 4, `x = msg.field("Y")`.** It renders as a read-only `code` row. +> `_recognize_native_method` is called only inside an `isinstance(s, ast.Expr)` guard, at both of its +> call sites, so an `ast.Assign` can never reach it; the function carries no `field` branch in any +> case. The lens defines no read-field action. +> +> **HALF BUILT -- row 6.** `msg.add_segment(line)` is built, and emits an **Add Segment** row, not +> "Add/Copy Segment" (the lens credits it to ADR 0106 §3 Group 1). `set_segment` names a method the +> `Message` API does not have: `messagefoundry/parsing/message.py` defines, among others, `field`, +> `set`, `__setitem__`, `add_repetition`, `add_segment` and `delete_segments`, and no `set_segment`. +> Nothing under `messagefoundry/` defines one and the class has no `__getattr__`, so +> `msg.set_segment(...)` would raise `AttributeError`; the lens renders it as a `code` row. +> +> **Filed as BACKLOG #1505**, which carries the row-4 and `set_segment` measurement -- read it there +> rather than re-deriving it. The row-3 finding is recorded here only. `docs/adr/README.md` needs no +> edit: its index row for this ADR enumerates `msg.set` / `msg.field`-copy / `msg.delete_segments`, +> all three built. + | Native idiom | Editable row | Editable fields | |---|---|---| | `msg.set("X", "lit")` | **Set Field** | path, value (883) | diff --git a/docs/adr/0133-alert-escalation-tiers-schedule-aware-thresholds-and-content-triggered-alerts-the-56-remainder.md b/docs/adr/0133-alert-escalation-tiers-schedule-aware-thresholds-and-content-triggered-alerts-the-56-remainder.md index 4fdaf337..b89f7884 100644 --- a/docs/adr/0133-alert-escalation-tiers-schedule-aware-thresholds-and-content-triggered-alerts-the-56-remainder.md +++ b/docs/adr/0133-alert-escalation-tiers-schedule-aware-thresholds-and-content-triggered-alerts-the-56-remainder.md @@ -1,6 +1,48 @@ # ADR 0133 — Alert escalation tiers, schedule-aware thresholds, and content-triggered alerts (the #56 remainder) -- **Status:** Accepted (2026-07-18, built) — DEMAND-GATE-BACKLOG Wave 4 (lane `dg-s1b`). +- **Status:** Accepted (2026-07-18) — DEMAND-GATE-BACKLOG Wave 4 (lane `dg-s1b`) — **PARTIALLY BUILT; corrected 2026-09-09**, see **Built** below. +- **Built (re-verified against the tree 2026-09-09):** **D1, D2 and D4 are BUILT. D3 is NOT BUILT as a + reachable capability** — it is plumbing nothing outside the tests can fire. The words that stood on the + Status line — *"Accepted (2026-07-18, built)"* — claimed all four decisions shipped. They are corrected + here rather than deleted, because that is the sentence a reader would otherwise carry forward, and an + unqualified "built" over a D3 with zero non-test callers over-claims what the tree delivers. + - **BUILT — D1 escalation tiers.** `EscalationTier` and `AlertRule.escalate` (`config/settings.py`); the + `_occurrences` counter and highest-satisfied-tier selection in `NotifierAlertSink._emit` + (`pipeline/alert_sinks.py`); the tier persisted through `upsert_alert_instance` (`store/base.py`) and + read back on `AlertInstance.escalation_tier` (`store/store.py`). + - **NOT BUILT — D1's operator-visible half.** D1 says the persisted tier is there so "the dashboard shows + the escalation level". It does not. `escalation_tier` appears nowhere under `messagefoundry/api/` or + `messagefoundry_webconsole/`, and `AlertInstanceInfo` (`api/models.py`) carries no such field, so + `GET /alerts/active` never returns it. The #143 `suspended_until` beside it does appear in both. The + rules API's `escalate_tiers` (`AlertRuleInfo`, `api/models.py`) is not this half: it reports how many + tiers a RULE configures, not which tier an instance reached. + - **BUILT — D2 schedule-aware rules.** `AlertRule.schedule` (`config/settings.py`), gated inside + `AlertRuleSet.decide` on `rule.schedule.is_active(...)` (`pipeline/alert_sinks.py`), reusing the ADR 0095 + `Schedule`/`ActiveWindow` verbatim. + - **BUILT — D4's column on all three backends.** `alert_instance.escalation_tier` on SQLite (`ALTER TABLE + ADD COLUMN`, `store/store.py`), Postgres (`ADD COLUMN IF NOT EXISTS`, `store/postgres.py`) and SQL Server + (`COL_LENGTH`-gated `ADD`, `store/sqlserver.py`), kept monotonic by `MAX`/`GREATEST`/`CASE`. + - **BUILT — D3's config and notifier half only.** `content_match` is in `_ALERT_EVENT_TYPES` + (`config/settings.py`); `AlertRule.content_label` filters on it in `AlertRuleSet.decide`; and + `NotifierAlertSink.content_match(connection, *, label, rule_id=None)` exists with the PHI-free shape this + ADR specifies — no value parameter. + - **NOT BUILT — D3's reachability, which is the capability itself.** `content_match` is absent from the + `AlertSink` Protocol and from `LoggingAlertSink`, both in `pipeline/alerts.py`, while the engine holds + its sink as `self._alert_sink: AlertSink` (`pipeline/wiring_runner.py`). So the type the engine programs + against does not carry the method, and a deployment configuring no `[alerts]` transport gets + `LoggingAlertSink`, which cannot record the event at all. `messagefoundry/__init__.py` exports no alert + symbol of any kind, and a Handler is called as `HandlerFn = Callable[[Payload], HandlerResult]` + (`config/wiring.py`) — one payload argument, no sink. `.content_match(` has **zero** non-test callers. + - **D3's sentence "calls this via the alert sink the engine already threads into its runners" is FALSE as + written**, and is kept below so the correction sits beside the claim. The engine threads a sink into its + runners. It threads nothing into a Handler, and the threaded type lacks the method. + - **Every test this ADR names EXISTS.** `test_escalates_by_occurrence_count`, `test_schedule_aware_decide`, + `test_content_match_event_is_phi_free` and `test_content_match_reemit_is_idempotent` are all in + `tests/test_alert_escalation.py`, and `test_three_backend_parity_columns` is in + `tests/test_alert_state.py`. The defect is what AC-3 and AC-4 assert, not a missing test — see the note + under the Acceptance Criteria. + - **This is build state, not live impact.** There are zero deployments (CLAUDE.md section 0), so nothing + is exposed and no operator depends on this. The remainder is BACKLOG #81, which already records it. - **Date:** 2026-07-18 - **Related:** BACKLOG #81 (the confirmed remainder of #56) · **refines** [ADR 0014](0014-alerting-rules-engine.md) (the rules engine + the pure `AlertRuleSet.decide` + the per-`(type, connection)` throttle this escalation @@ -133,6 +175,14 @@ no new PHI tier. fold it into the one open instance (throttle/dedup) rather than open a second — the purity/at-least-once reconciliation. → `tests/test_alert_escalation.py::test_content_match_reemit_is_idempotent` +- **NOTE added 2026-09-09 — AC-1, AC-2 and AC-5 are MET. AC-3 and AC-4 are NOT MET, and the tests they name + DO exist.** Both tests are real, and both call `NotifierAlertSink.content_match` **directly**. Neither + exercises the "WHEN a Handler emits a `content_match`" premise, because no Handler can emit one: + `content_match` is on neither the `AlertSink` Protocol nor `LoggingAlertSink` (`pipeline/alerts.py`), no + alert emitter is exported from `messagefoundry/__init__.py`, and a Handler is called with the payload + alone (`HandlerFn = Callable[[Payload], HandlerResult]`, `config/wiring.py`). What the two tests verify is + the event shape and the dedup grain. What stays unverified — and is unbuildable at this build state — is + the trigger the criteria open with. See the **Built** bullet at the top. - **AC-5** — THE SYSTEM SHALL create + operate the `escalation_tier` column identically on SQLite, Postgres, and SQL Server (schema/accessor parity), with the ADR 0064 schema hash bumped. → `tests/test_alert_state.py::test_three_backend_parity_columns` diff --git a/docs/adr/0150-client-address-on-audit-entries.md b/docs/adr/0150-client-address-on-audit-entries.md index 9c393038..916c13e9 100644 --- a/docs/adr/0150-client-address-on-audit-entries.md +++ b/docs/adr/0150-client-address-on-audit-entries.md @@ -6,6 +6,19 @@ Date: 2026-07-22 Accepted (2026-07-22) — built; pushes/PR owner-approved. +**CORRECTED 2026-09-09 -- decision item 6 over-claims one of the three surfaces it names.** Five of +the six decision items are built as written. Item 6 is BUILT on `GET /audit` and on the +`audit:export` CSV. It is NOT BUILT on the web console audit page: +`messagefoundry_webconsole/pages/audit.py::audit_log` renders the columns When / Actor / Action / +Channel / Detail and never reads `AuditEntry.client`, so `/ui/audit` renders no address column. The +value does reach that page -- the route hands it an `AuditList` whose entries carry `client` -- and +only the table drops it. The over-claiming sentence is corrected in place in item 6 below rather +than deleted, because it is what a reader would otherwise carry forward. The other five decision +items hold: the column ships on all three backends, `store/store.py::audit_row_hash` takes `client` +and appends it conditionally, and both tests this ADR cites (the crafted-`detail` injectivity test +and the pre-existing-store migration test) exist in `tests/test_audit_integrity.py`. The +Consequences section's audit-site counts were not re-checked. + ## Context An `audit_log` row named **who** (`actor`) and **what** (`action`, `detail`) but never **where @@ -104,6 +117,18 @@ field so a SIEM can index it without parsing the redacted `detail`; it is an inf identifier, not message content, so it is **not** run through `safe_text`. `AuditEntry` gains `client`, so `GET /audit`, the webconsole audit page, and the `audit:export` CSV all carry it. +> **CORRECTED 2026-09-09 -- the sentence above over-claims one surface. It is kept rather than +> deleted because it is what a reader would otherwise carry forward.** `AuditEntry` does gain +> `client` (`api/auth_models.py::AuditEntry`), and two of the three named surfaces carry it: +> `GET /audit` builds every entry with `client=r["client"]` (`api/auth_routes.py::_audit_list`), and +> the CSV export emits a `client` column (`api/auth_routes.py::export_audit`). **The web console +> audit page is NOT BUILT for it.** `messagefoundry_webconsole/pages/audit.py::audit_log` builds its +> rows as When / Actor / Action / Channel / Detail and never reads `e.client`. An operator reading +> `/ui/audit` would see no address, so only the JSON route and the CSV export attribute a row to a +> host. The data is already in hand -- the same `_audit_list` core feeds the page through the +> `_audit_ui_list` seam -- so closing this is a page change with no store, model or route work +> behind it. + ## Consequences **Attribution now exists where it was needed.** The raw-PHI view, the bulk export, the admin actions, diff --git a/docs/adr/0167-phi-security-notification-readiness-gates-on-a-deliverable-address-checked-early-in-the-asgi-lifespan.md b/docs/adr/0167-phi-security-notification-readiness-gates-on-a-deliverable-address-checked-early-in-the-asgi-lifespan.md index af21f40e..13f40f9f 100644 --- a/docs/adr/0167-phi-security-notification-readiness-gates-on-a-deliverable-address-checked-early-in-the-asgi-lifespan.md +++ b/docs/adr/0167-phi-security-notification-readiness-gates-on-a-deliverable-address-checked-early-in-the-asgi-lifespan.md @@ -3,7 +3,8 @@ # ADR 0167 — PHI security-notification readiness gates on a deliverable address, checked early in the ASGI lifespan -- **Status:** Proposed (2026-08-15) — the predicate is built (`29a026e2`); the gate that consumes it is not yet written +- **Status:** Accepted (2026-09-09) — the gate is BUILT and wired, in the form the overturn banner below settled and NOT the one this ADR's title names. Build state corrected 2026-09-09; see the bullet beneath +- **Build state corrected 2026-09-09.** The status line above previously read *"Proposed (2026-08-15) — the predicate is built (`29a026e2`); the gate that consumes it is not yet written."* **That was true when written and is false now, in the UNDER-claiming direction** — a reader planning off it plans to build something that already exists — so it is recorded here rather than deleted. **The parenthetical is the date this record was corrected.** Nothing records a ratification on 2026-08-15, which is the day the ADR was proposed; the code landed earlier, at `4a8f062d6` (2026-08-19, PR #394), whose parent does not carry the symbol. Re-verified against the tree by symbol, not by line number: `_assert_security_notice_is_deliverable` in [`api/app.py`](../../messagefoundry/api/app.py) runs inside `create_managed_app`'s `lifespan`, after `bootstrap = await auth.initialize()` and therefore AFTER `engine.start()`. That is the **LIFESPAN (post-bootstrap)** row of the table below — the placement the overturn banner said this check would have to take, and **NOT** the EARLY-LIFESPAN placement this ADR's title and filename still assert. Under its three preconditions (auth notices on, `[alerts].security_notifications_required` true, derived data class PHI) it raises at `enforce` and logs a warning otherwise; `tests/test_security_notice_deliverability.py` carries the wiring test (`test_the_LIFESPAN_refuses_and_not_merely_the_predicate`) with a warn-side positive control beside it. **The shipped gate does NOT call `has_notifiable_admin()`** — it inlines the same enumeration (`store.list_users()`, the disabled-skip, the `notify_email` test, the `Role.ADMINISTRATOR` test), so the predicate has **no non-test call site**, and the open-coded copies of "who is an enabled administrator" now number **four**: the three in `auth/service.py` that Consequences records, plus the gate's own in `api/app.py`. **At least two things this ADR promised are still owed:** `docs/DEPLOYMENT.md` carries only its two exit-2 statements, so the exit-3 line accepted below is not written; and the re-measurement demanded under *"What is NOT demonstrated"* is not closed here. On #1020's rider, the termination evidence is no longer only a minimal repro — `tests/test_lifespan_startup_unwinds.py` patches this gate's own call site, runs uvicorn in a subprocess and asserts the process exits — but it injects a `RuntimeError` there rather than arranging the gate's preconditions, and the wiring test drives the real refusal under `lifespan_context` rather than uvicorn, so **no single test yet drives the real refusal under uvicorn end to end** - **Date:** 2026-08-15 - **Related:** [BACKLOG #1020](../BACKLOG.md) (the item, owner-ruled 2026-08-13) · [BACKLOG #1257](../BACKLOG.md) (a startup refusal after `engine.start()` hangs) · [`__main__.py`](../../messagefoundry/__main__.py) (the existing SMTP-only gate) · [`api/app.py`](../../messagefoundry/api/app.py) (the lifespan) · [`auth/service.py`](../../messagefoundry/auth/service.py) (`has_notifiable_admin`) · [DEPLOYMENT.md](../DEPLOYMENT.md) (exit codes) · [CLAUDE.md](../../CLAUDE.md) §0 (not deployed), §11 (SDS-3.8) diff --git a/docs/adr/README.md b/docs/adr/README.md index e42b6987..85b2944c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -34,12 +34,12 @@ what is withheld and what you can request. | ADR | Title | Status | |---|---|---| | [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) | +| [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) -- **all four work packages are now BUILT**; corrected 2026-09-09 and verified by symbol, see the ADR's *Built* line. Named sub-clauses stay unbuilt on purpose: the mTLS client-certificate identity resolver is inert under stock uvicorn ([ADR 0083](0083-mtls-client-certificate-identity.md)), and in-engine OCSP/CRL revocation is delegated ([ADR 0078](0078-certificate-revocation-posture.md)) | | [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 | +| [0004](0004-payload-agnostic-ingress.md) | Payload-agnostic ingress (non-HL7 sources) | Accepted — built: `ContentType` ([config/models.py](../../messagefoundry/config/models.py)), the `content_type` field and `inbound(...)` parameter in [config/wiring.py](../../messagefoundry/config/wiring.py), the §2 ingress branch and §4 generic store fields in [pipeline/wiring_runner.py](../../messagefoundry/pipeline/wiring_runner.py), `RawMessage` in [parsing/message.py](../../messagefoundry/parsing/message.py), and the magic-byte check `_content_matches_declared` defined in [parsing/sniff.py](../../messagefoundry/parsing/sniff.py); §7.2's first non-HL7 sources `DatabaseSource` + `HttpSource` (ADR 0023) are registered. NOT BUILT: §4's optional per-inbound id/summary extractor hook. | | [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 | +| [0007](0007-gui-manageable-connections-toml.md) | GUI-manageable connections as a config-as-data TOML artifact | Accepted (2026-06-13) — **built**: Phases 1-3 plus the 2026-07-19 flag amendment, recorded 2026-09-09; this cell read "Proposed" through the whole build. Phase 4's fan-out re-port sits outside this repository and is not claimed | | [0008](0008-cluster-observability-api.md) | Read-only cluster observability API (`/cluster/status` + `/cluster/nodes`) | Proposed (built) | | [0009](0009-run-scoped-context-providers.md) | Run-scoped context providers | Accepted | | [0010](0010-handler-callable-db-lookup.md) | Handler-callable live database lookup (`db_lookup`) | Accepted | @@ -49,7 +49,7 @@ what is withheld and what you can request. | [0014](0014-alerting-rules-engine.md) | Alerting rules engine — configurable `[alerts].rules` over the built notifier | Proposed (built) | | [0015](0015-ws-soap-outbound-mtls-wssecurity.md) | WS-\* SOAP outbound — mutual-TLS client cert + WS-Security / WS-Addressing (extends the SOAP destination) | Accepted (2026-06-15) — Amended 2026-07-14: body-secret substitution (BACKLOG #236) | | [0016](0016-synchronous-x12-request-response.md) | Synchronous X12 request/response feeds (real-time eligibility 270/271 + friends) — capture/re-ingress + TA1 classifier | Accepted (2026-06-15) | -| [0017](0017-consumer-deployment-model.md) | Consumer deployment model — engine as a read-only installed dependency + org-owned config repo across multiple instances | Accepted (2026-06-16) | +| [0017](0017-consumer-deployment-model.md) | Consumer deployment model — engine as a read-only installed dependency + org-owned config repo across multiple instances | Accepted (2026-06-16) — both **Blocker** work-packages built (free-form environment names; the explicit posture tier + required active environment). The lower-severity rows of the ADR's "Engine work required" table are not covered by that marker | | [0018](0018-per-message-signatures-accepted-risk.md) | Per-message digital signatures (ASVS 4.1.5) — accepted risk / deferred-by-design | Accepted (2026-06-16) | | [0019](0019-pluggable-keyprovider-hsm-kms-vault.md) | Pluggable KeyProvider seam (HSM/KMS/Vault envelope decryption) for store-key material + connector SecretProvider for AD/SQL/SMTP credentials (ASVS 13.3.3 / 13.3.1) | Accepted (2026-06-17) — core seam + Vault KeyProvider built; amended 2026-07-12 (connector SecretProvider, #196) | | [0020](0020-protocol-diagnostic-capture.md) | Protocol-level diagnostic capture (Corepoint "Protocol Data" + "Protocol Text") — per-connection RAM ring + on-error/snapshot flush to a `protocol_trace` table | **Dropped (Plan-3 §G)** — raw-PHI-at-rest tier, no demand; superseded by ADR 0021 §7's metadata-only connection-error log | @@ -60,7 +60,7 @@ what is withheld and what you can request. | [0025](0025-dicom-codec-store-connectors.md) | DICOM codec + C-STORE store connectors — pure `parsing/dicom/` codec (`pydicom`; two-tier `DicomPeek`/`DicomDataset` + SR→HL7 helpers), `content_type=dicom` over payload-agnostic ingress, inbound C-STORE SCP source; Phase-2 SCU/C-ECHO/DICOMweb STOW-RS destinations. Code-first SR→HL7 Handler (binary carriage via ADR 0028, not latin-1) | Accepted (2026-06-20) | | [0026](0026-off-box-egress-update-check.md) | Off-box egress posture for the MEFOR version update-check (#30): a no-network "pinned-vs-current lock diff" as the default + only MVP build (zero egress); a future live-egress check defined as a constrained, off-by-default, env-clamped, https-only/no-redirect/host-allowlisted, advisory-only opt-in (not built); the auto dep-vuln-scan half dropped (§G) | Accepted (2026-06-19) | | [0027](0027-per-connection-retention.md) | Per-connection retention / pruning windows — a per-connection `messages_days` (inbound) / `dead_letter_days` (outbound) override layered over the global `[retention]` default (`None` = inherit), reusing the #46 per-connection-override plumbing + ADR 0007 `connections.toml`; threads a `{connection → cutoff}` map through the existing purge SQL on all three backends; one `retention_purge` audit row per pass; null-body-keep-metadata + the in-flight guard preserved (#34) | Accepted (built, 0.2.9) | -| [0028](0028-base64-binary-carriage-codec.md) | base64 binary-carriage codec (+ HL7 OBX-5 ED embedding) — carry arbitrary bytes over the str/TEXT ingress+store as `mfb64:v1:` unbroken base64; pure stdlib `parsing/binary.py` + `RawMessage.from_bytes`/`.raw_bytes`. Supersedes ADR 0025's latin-1 round-trip (NUL-unsafe across the store) | Accepted (2026-06-20) — Amended 2026-07-13: ERROR-path + happy-path NUL carriage (INGEST-4) | +| [0028](0028-base64-binary-carriage-codec.md) | base64 binary-carriage codec (+ HL7 OBX-5 ED embedding) — carry arbitrary bytes over the str/TEXT ingress+store as `mfb64:v1:` unbroken base64; pure stdlib `parsing/binary.py` + `RawMessage.from_bytes`/`.raw_bytes`. Supersedes ADR 0025's latin-1 round-trip (NUL-unsafe across the store) | Accepted (2026-06-20) — Amended 2026-07-13: ERROR-path + happy-path NUL carriage (INGEST-4) — built (pure `parsing/binary.py` codec, the four `RawMessage` additions, OBX-5 ED helpers) | | [0029](0029-email-smtp-destination.md) | Email (SMTP-send) outbound destination — a stdlib `smtplib`/`email` `DestinationConnector` + new `ConnectorType.EMAIL` + a code-first `Email()`/`SMTP()` factory; STARTTLS-by-default with the existing insecure-TLS escape; a new `[egress].allowed_smtp` deny-by-default allowlist arm; `DeliveryError` → staged-queue retry (transform stays pure, SMTP is the side effect, at-least-once like other one-way destinations). IMAP/POP inbound read + XOAUTH2 deferred to Phase 2 (#23; was earmarked 0024 before SMART claimed it) | Accepted (2026-06-27, built) | | [0030](0030-anonymization-test-harness-tee.md) | Anonymizer / de-identification for the test harness + tee (`messagefoundry.anon`) — build PHI-free test datasets from real traffic; pure-stdlib surrogate pools/encoders (lifted from `generators/_hl7data.py`); tee + harness hooks | Accepted (2026-06-20) | | [0031](0031-startup-connection-fault-isolation.md) | Startup connection fault isolation — a single connection that fails to build/bind at startup is isolated (logged + alerted + reported `failed`), not fatal: the engine starts the rest of the graph and serves the API; a failed outbound retries (never drops) and self-heals on reload/restart. Reload stays fail-fast | Accepted (2026-06-21) | @@ -161,7 +161,7 @@ what is withheld and what you can request. | [0130](0130-runtime-ephemeral-log-verbosity-control-and-phi-redacted-log-tail-viewer.md) | **Runtime (ephemeral) log-verbosity control + PHI-redacted log-tail viewer** (BACKLOG #171, demand-gate) — `set_runtime_level` / `current_log_level` in [`logging_setup.py`](../../messagefoundry/logging_setup.py) re-level the **root + uvicorn** loggers WITHOUT rebuilding handlers (validated against `LOG_LEVELS`); the override is **ephemeral** — a process restart re-asserts `[logging].level`, and a **`/config/reload` does NOT reset it** (`configure_logging` does not re-run there), so it survives a reload and resets only on restart. Three routes on [`api/app.py`](../../messagefoundry/api/app.py): `GET`/`PATCH /logging/level` (gated by `monitoring:diagnose`, PATCH writes a `logging_level_change` audit + 400s a bad level) and `GET /logs/tail` — a paginated **redacted** view of the newest app-log file's tail (reusing `support.redact.redact_log_line`, the same coverage as the #49 support bundle), a genuine **new PHI read surface** gated by the new **`logs:view`** permission via `require_phi_read` (folding the [ADR 0092](0092-posture-keyed-transport-hop-refusal-refuse-the-insecure-phi-hop.md) `enforce_phi_read_hop` guard + anti-automation throttle), writing a `logs_view` audit counting exposed lines (metadata only). Degrades to `available=false` when no `[logging].log_dir` is set. `set_runtime_level` lands level-only so the later S7a file-handler work builds on it cleanly. #26-clean | Accepted (2026-07-17) — DEMAND-GATE-BACKLOG Wave 3 (lane `dg-s7b`), pushes/PR owner-approved | | [0131](0131-bulk-raw-message-body-export-from-a-search-result-step-up-audited-phi-egress.md) | **Bulk raw-message-body export from a search result (step-up, audited PHI egress)** (BACKLOG #124, demand-gate) — `GET /messages/export` on [`api/app.py`](../../messagefoundry/api/app.py) streams decrypted message bodies to a downloadable **NDJSON** file, the LARGEST PHI surface in the S7b cluster. Selection is an explicit `ids` set (*save-selected*) or the **basic** `/messages/search` filters (*save-all* — reusing `search_messages`), then it **LOOPS `get_message` per id** — **NO store schema change** (no 3-backend bulk iterator; `store_schema` stays false). Gated by `require_step_up(messages:export, messages:view_raw)` — a NEW **`messages:export`** capability distinct from view_raw (bulk ≠ opening one message), granted to OPERATOR + administrator — plus an explicit `enforce_phi_read_hop` ([ADR 0092](0092-posture-keyed-transport-hop-refusal-refuse-the-insecure-phi-hop.md)). **Per-row `_scope`** (`can_access_channel`) is re-checked on EVERY streamed body (load-bearing for the attacker-suppliable `ids` path; an out-of-scope id is skipped + `auth.channel_denied` audited). A single tamper-evident **`messages_export`** audit — actor + selection mode + basic filters + needle SHAPE (never the value, via `_search_audit_detail`) + the count of selected bodies — is written **BEFORE streaming** (mirroring `/audit/export`), so a scripted save-all can't harvest unaudited. `app.js` save-selected/save-all with a progress readout + stop control. PHI-safe destination is the operator's responsibility. Reuses [ADR 0046](0046-message-content-search.md); #26-clean | Accepted (2026-07-17) — DEMAND-GATE-BACKLOG Wave 3 (lane `dg-s7b`), pushes/PR owner-approved | | [0132](0132-per-endpoint-alternate-windows-credential-for-file-unc-shares-win32-ctypes-no-pywin32-no-impersonation-privilege.md) | **Per-endpoint alternate Windows credential for File/UNC shares (win32 ctypes, no pywin32, no impersonation privilege)** (BACKLOG #111, demand-gate) — a **File** endpoint may authenticate to a local/UNC (SMB) share under a Windows identity **distinct from the engine service account**, configured per-endpoint (`credential_username`/`credential_domain`/`credential_password`, the password **`env()`-only**, enforced by the `File()` factory and redacted via `_SECRET_SETTING_KEYS`). Established with **win32 ctypes** `advapi32.LogonUserW` (**`LOGON32_LOGON_NEW_CREDENTIALS`** — no privilege, no pywin32) + per-thread `ImpersonateLoggedOnUser`/`RevertToSelf`, mirroring the `tray/winsvc.py` + `service.py:124/270` + [ADR 0113](0113-windows-tray-service-manager-stdlib-ctypes-tokenless.md) precedent. A new `transports/wincred.CredentialContext` owns a **dedicated single-worker thread** and brackets every call `LogonUser→Impersonate→fn→RevertToSelf→CloseHandle` (per-call token → nothing leaks across reload; released on source `stop()` / dest `aclose()`). A single `_run_fs` **wraps** the S3a `_scan_once`/`validate_startup`/`_probe_dir_startup`/`after_read='leave'`/`_file_key`/ledger logic (ADR 0129) under the identity — the scan hook stays on the shared pool (unrelated AV/ICAP I/O). **Win32-only**: a non-Windows host raises `CredentialUnsupportedError` at build (loud, never a silent no-op — the CI-testable path); `CredentialLogonError` is an `OSError` so a bad credential rides the existing `except OSError` (DeliveryError / SourceStartupError / logged-retry, never a crash). A disjoint `POST /connections/{name}/test-credential` credentialed endpoint tester. Real UNC alt-cred is a Windows-CI/manual gate. Additive, default-off byte-identical; stdlib only. Wraps [ADR 0129](0129-process-in-place-file-disposition-and-cross-backend-processed-file-dedup-ledger.md); PHI-surface per [ADR 0092](0092-posture-keyed-transport-hop-refusal-refuse-the-insecure-phi-hop.md); #26-clean | Accepted (2026-07-18) — DEMAND-GATE-BACKLOG Wave 4 (lane `dg-s3b`), pushes/PR owner-approved | -| [0133](0133-alert-escalation-tiers-schedule-aware-thresholds-and-content-triggered-alerts-the-56-remainder.md) | **Alert escalation tiers, schedule-aware thresholds, and content-triggered alerts (the #56 remainder)** (BACKLOG #81, demand-gate) — three additive capabilities on the [ADR 0014](0014-alerting-rules-engine.md) rules layer + [ADR 0044](0044-operator-alert-state.md) state, off by default. **Escalation tiers** (`AlertRule.escalate`) are **occurrence-driven** (NOT the ADR 0014 §3-declined timed chain): the notifier keeps a per-`(type,connection)` occurrence count and applies the highest tier whose `after_count` is reached (severity/transports/recipients override), persisted to a new `alert_instance.escalation_tier` column; any future timed re-eval sweep MUST be leader-gated. **Schedule-aware** rules reuse the #147/[ADR 0095](0095-connection-lifecycle-scheduler-and-credential-fault-stop.md) `Schedule` model verbatim — a rule matches only when `schedule.is_active(now)`. **Content-triggered (`content_match`)** alerts are a new PHI-FREE event (connection + operator label/rule id — **never the matched field value**) a code-first Handler emits off the routing hot path; the **purity/at-least-once carve-out** is reconciled by the existing **`(event_type, connection)` throttle/dedup** so a transform re-run's re-emit folds into the one instance (idempotent) + throttled notification. One additive `escalation_tier` column across SQLite/Postgres/SQL Server (SQL Server `COL_LENGTH`-gated ADD; Postgres `ADD COLUMN IF NOT EXISTS`) bumps the [ADR 0064](0064-schema-init-fastpath.md) schema hash; #26-clean | Accepted (2026-07-18) — DEMAND-GATE-BACKLOG Wave 4 (lane `dg-s1b`), pushes/PR owner-approved | +| [0133](0133-alert-escalation-tiers-schedule-aware-thresholds-and-content-triggered-alerts-the-56-remainder.md) | **Alert escalation tiers, schedule-aware thresholds, and content-triggered alerts (the #56 remainder)** (BACKLOG #81, demand-gate) — three additive capabilities on the [ADR 0014](0014-alerting-rules-engine.md) rules layer + [ADR 0044](0044-operator-alert-state.md) state, off by default. **Escalation tiers** (`AlertRule.escalate`) are **occurrence-driven** (NOT the ADR 0014 §3-declined timed chain): the notifier keeps a per-`(type,connection)` occurrence count and applies the highest tier whose `after_count` is reached (severity/transports/recipients override), persisted to a new `alert_instance.escalation_tier` column; any future timed re-eval sweep MUST be leader-gated. **Schedule-aware** rules reuse the #147/[ADR 0095](0095-connection-lifecycle-scheduler-and-credential-fault-stop.md) `Schedule` model verbatim — a rule matches only when `schedule.is_active(now)`. **Content-triggered (`content_match`)** alerts are a new PHI-FREE event (connection + operator label/rule id — **never the matched field value**) a code-first Handler emits off the routing hot path; the **purity/at-least-once carve-out** is reconciled by the existing **`(event_type, connection)` throttle/dedup** so a transform re-run's re-emit folds into the one instance (idempotent) + throttled notification. One additive `escalation_tier` column across SQLite/Postgres/SQL Server (SQL Server `COL_LENGTH`-gated ADD; Postgres `ADD COLUMN IF NOT EXISTS`) bumps the [ADR 0064](0064-schema-init-fastpath.md) schema hash; #26-clean | Accepted (2026-07-18) — DEMAND-GATE-BACKLOG Wave 4 (lane `dg-s1b`), pushes/PR owner-approved. **PARTIALLY BUILT — corrected 2026-09-09:** D1 (escalation tiers), D2 (schedule-aware rules) and D4 (the three-backend `escalation_tier` column) are BUILT. **D3 content-triggered alerts are NOT BUILT as a reachable capability** — `content_match` exists only as `NotifierAlertSink.content_match` (`pipeline/alert_sinks.py`), is absent from both the `AlertSink` Protocol and `LoggingAlertSink` (`pipeline/alerts.py`), is exported from no public surface, reaches no Handler (`HandlerFn = Callable[[Payload], HandlerResult]`, `config/wiring.py`), and has zero non-test callers. D1's persisted tier is also never surfaced on `AlertInstanceInfo` / `GET /alerts/active`. AC-3 and AC-4 name tests that DO exist but call the sink directly, so neither exercises the Handler premise it states. Remainder tracked as BACKLOG #81; see the ADR's **Built** bullet | | [0134](0134-offline-uploaded-logs-viewer-connection-decoupled-upload-browse-resend-deletion-phi-at-rest-posture-stdlib-multipart.md) | **Offline uploaded-logs viewer — connection-decoupled upload/browse/per-message resend + deletion; PHI-at-rest posture; stdlib multipart** (BACKLOG #125 + #126, demand-gate) — an operator page to upload a partner-supplied `.hl7`/`.txt`/`.xml` file and browse it as a filterable/searchable offline log with per-message resend, decoupled from any live connection. Files are stored on the **filesystem** under an opt-in `[store].uploads_dir` (unset ⇒ every route 503s — no PHI surface unless configured), **AES-256-GCM-encrypted at rest under the store DEK** via the same `store/crypto.py` cipher (cell-AAD `("uploaded_file", "body"\|"meta", file_id)`; no-key ⇒ plaintext-on-disk, the File-connector spill-dir tier, documented in `docs/PHI.md` §2). Upload/browse/resend/delete are **deny-by-default** (`files:upload`/`files:browse`/`files:delete`, OPERATOR+admin) + **audited metadata-only**; browse (decrypts PHI bodies) additionally requires **step-up** + `enforce_phi_read_hop` ([ADR 0092](0092-posture-keyed-transport-hop-refusal-refuse-the-insecure-phi-hop.md)) and records the needle **shape** only ([ADR 0046](0046-message-content-search.md)). **Per-message resend uses a DISTINCT inject path — `store.enqueue_ingress(channel_id=…, raw=…)`, NOT `reingress`** ([ADR 0090](0090-resend-a-stored-message-to-an-alternate-outbound-connection.md)): `reingress` presupposes an origin `messages` row an uploaded file never had, whereas `enqueue_ingress` (the live listener's primitive) takes a running inbound channel directly and mints a fresh `RECEIVED` message. Multipart upload is **hand-parsed with stdlib** (`api/multipart.py`) — **no `python-multipart`** (honours the `routes/core.py` no-multipart stance; the 1 MiB body middleware is made path-aware for the upload cap). **#126 deletion (section under this ADR)**: confirm step + `upload.delete` audit + **path-traversal validation** — `file_id` must match `^[0-9a-f]{32}$` and resolve within the canonical uploads root or the route 404s without a filesystem touch. Console `/ui/uploaded-logs` reaches the engine via new `CoreHandlers` seam handlers (`ENGINE_UI_SEAM` bump). Extends [ADR 0001](0001-staged-pipeline-architecture.md) / [ADR 0019](0019-pluggable-keyprovider-hsm-kms-vault.md) / [ADR 0065](0065-web-ops-dashboard.md); #26-clean. **Amendment A (2026-08-11, owner-ratified):** object-level authorization (ASVS 8.2.2, BACKLOG #1152) - uploaded files are **owner-only** plus a `files:access_any` Administrator override never mintable onto a custom role; ownership keys on the **immutable `uploader_id` (`Identity.user_id`)**, never the reusable `username` (a recycled account name would otherwise inherit a departed operator's files); a missing `uploader_id` fails closed with no username fallback; the per-uploader quota re-keys to the same id so ownership and the budget cannot disagree; the channel axis was **rejected** because `allowed_channels` defaults to every channel and would protect nobody on a default install; the check sits in the handler **bodies**, not a `Depends` gate, because the console invokes them by reference across the seam | Accepted (2026-07-18) — DEMAND-GATE-BACKLOG Wave 5 (lane `dg-s8b`), pushes/PR owner-approved | | [0135](0135-engine-brokered-ai-assistance-customer-managed-llm-egress-with-per-use-audit.md) | **Engine-brokered AI assistance — customer-managed LLM egress with per-use audit** (BACKLOG #95, demand-gate) — turn the reserved `[ai]` broker keys into a real integration: a NEW authenticated `POST /ai/chat` gated by `AI_ASSIST` that **re-resolves `resolve_effective_policy` server-side** (the server is the SOLE enforcement point — an IDE-claimed `data_scope` above the effective scope is **denied 403**, never honoured) and brokers the IDE assistant's call to a customer-managed / self-hosted LLM. **MVP boundary UNCHANGED: `code_only` regardless of mode** — never message bodies / PHI; an optional `AiMode.MANAGED_ENDPOINT` is kept **OUT** of `resolve_effective_policy`'s phi-granting branch (on-prem ≠ phi). **SSRF fail-closed:** a NEW broker in `transports/ai_broker.py` (mirrors `smart.py`, reuses `rest.py`'s hardened no-redirect `_NO_REDIRECT_OPENER` + `_redact_url`, runs off the event loop via `asyncio.to_thread`, **never imports `api/`**) validates the configured endpoint against a dedicated `[ai].allowed_endpoints` list — un-listed/empty ⇒ **REFUSE** — because `[egress].allowed_http` is permissive-when-empty and cannot be the gate. Broker credential `[ai].api_key` is **env-only** (`MEFOR_AI_API_KEY`, `_FILE_SECRET_KEYS` + `_SECRET_SETTING_KEYS`); prompts/responses/keys are **never** logged. **Per-use audit reuses the existing hash-chained `audit_log`** (`record_audit("ai.assist", …)`, PHI-safe metadata only — **no schema change**, no bespoke `ai_egress` table; `store_schema = false`). **No new dependency** (stdlib `urllib` via rest.py's opener — no vendor SDK). `ide/src/chat.ts` flips the managed path to the engine broker, keeping the `code_only` context. Extends [ADR 0024](0024-smart-backend-services-token-provider.md) / [ADR 0126](0126-outbound-forward-egress-web-proxy-for-the-stdlib-http-family.md); reuses [ADR 0092](0092-posture-keyed-transport-hop-refusal-refuse-the-insecure-phi-hop.md); #26-clean | Accepted (2026-07-18) — DEMAND-GATE-BACKLOG Wave 5 (lane `dg-s10`), pushes/PR owner-approved | | [0136](0136-per-user-saved-and-layered-log-search-filter-presets-extends-the-adr-0046-search-seam.md) | **Per-user saved & layered Log-Search filter presets** (BACKLOG #151, demand-gate) — save named content-search filter presets server-side per user, then recall + **layer** several into one combined query. A new **per-user `search_presets` table** across SQLite/Postgres/SQL Server (id-keyed; `UNIQUE(owner, name)`; save-by-name is an in-place replace preserving the id) whose PHI-shaped **`criteria` column is AES-256-GCM-encrypted at rest** through the store cipher (`cell_aad("search_presets", "criteria", id)`; added to each `_CIPHER_COLUMNS` so `rotate-key` re-encrypts it — id-keyed loop). Appending the DDL to the server `_SCHEMA` lists **moves `_schema_hash()`** — the [ADR 0064](0064-schema-init-fastpath.md) bump (no `_MIGRATION_REV` change); SQLite gets `CREATE TABLE IF NOT EXISTS`. Presets are **owner-scoped** (a user sees/recalls/deletes only their own); create + layered-recall are **step-up-gated + audited** (`preset.create`/`preset.layered_search`, needle **shape** only — never the value, reusing [ADR 0046](0046-message-content-search.md) `_needle_shape`), list/delete are `messages:read` + audited (no new permission). **Layering = bounded AND-compose over the typed `search_messages` params** (≤ 8 layers): metadata scalars take the first non-empty value and reject a conflicting second (400); **exactly one** content predicate is allowed (>1 or 0 → 400); the single needle builds a `SearchSpec` (ADR 0046 caps unchanged) and runs the **existing** `search_messages` (coupled with the S7b #124 basic-filter path — the composer stays bounded). The content term is loaded server-side from the encrypted column and **never round-trips to the client**, preserving the `routes/search.py` deliberate-drop posture. New `CoreHandlers` seam handlers (`ENGINE_UI_SEAM` bump) back the /ui save/recall/layer UI on the content-search page. Follows the [ADR 0045](0045-custom-rbac-roles.md) 3-backend precedent; extends [ADR 0046](0046-message-content-search.md); #26-clean | Accepted (2026-07-18) — DEMAND-GATE-BACKLOG Wave 5 (lane `dg-s8b`), pushes/PR owner-approved | @@ -176,21 +176,21 @@ what is withheld and what you can request. | [0147](0147-hardened-runtime-isolation-for-router-handler-code-ipc-brokered-sandbox-extends-adr-0087.md) | **Hardened runtime isolation for Router/Handler code — IPC-brokered sandbox** (extends [ADR 0087](0087-sandbox-subprocess-isolation.md)) — the *runtime* half of the ASVS 15.2.5 defense. Adds an opt-in `[sandbox].mode=isolated` that (1) re-enables the sanctioned read-only `db_lookup`/`fhir_lookup` via a **parent-held IPC request-broker** (validated against the same `[egress]` allowlist — a compromised child can't widen it), turning ADR 0087's fail-closed into a working brokered read; and (2) confines the worker at the OS level, default-deny egress/filesystem/imports — **Linux** Landlock (unprivileged self-sandbox) + seccomp, **Windows** AppContainer/lowbox + WFP (broker over an inherited handle, since AppContainer blocks loopback). Research-driven: the IPC broker beats an OS egress-proxy for the "deny-all-but-two-brokered-lookups" model; confinement is platform-asymmetric. gVisor/Firecracker (Linux-only, throughput tax) + WASI/Pyodide (immature) deferred; in-language sandboxes are not boundaries. **Design only — no code in this lane.** Framing: HANDLER-CODE-SHARED-RESPONSIBILITY.md; #26-clean | Proposed (2026-07-21) | | [0148](0148-phi-default-posture-and-an-explicit-security-enforcement-level.md) | **PHI-default posture and an explicit `[security].enforcement` level (deployment scoring collapses to loopback vs off-loopback)** — two coupled givens. **GIVEN 1:** the default/CI env runs the **PHI** path (`_KNOWN_ENV_POSTURE["dev"]` → `(PHI, False)`); `synthetic` becomes a rare, explicit, warned opt-out (`[security].handles_real_patient_data=false`) — adopts the *direction* of the rejected [ADR 0109](0109-at-rest-encryption-fail-closed-on-an-undeclared-phi-posture.md) by a **narrower** mechanism (remap `dev`, provision CI; `require_posture()` stays fail-closed — **not** an undeclared→PHI resolver). **GIVEN 2:** a new `[security].enforcement` enum (`enforce` default \| `warn`) **replaces `production` as the serve-gate refuse/warn dial AND the [ADR 0092](0092-posture-keyed-transport-hop-refusal-refuse-the-insecure-phi-hop.md) escape-clamp key**; every site re-keys `production`→`enforcement is ENFORCE` (dials) / clamp-shut-at-enforce (clamps) **retaining every `is_phi` conjunct**, `HopPosture.production`→`enforcing`. **Byte-identical at the default:** `(is_phi=True, enforcing=True)` == today's production-PHI on every gate + clamp (adversarially verified). The **one behaviour change:** a *today-non-production* PHI box (staging / custom PHI-loopback) now **refuses** by default instead of warning, until `enforcement=warn` (a loud audited loosening). `production` is **retained informational** (AI data-scope ceiling + reporting). **FIX 1** `require_posture()` stays fail-closed (no fail-open); **FIX 2** the AI ceiling stays decoupled on `production`, not `enforcement` (owner-confirmed); **FIX 3** the DEBUG-logging refusal keys on the retained `production` tier fact (not `enforcement`, not `data_class`); **FIX 4** `enforcement=warn` voids the [ADR 0140](0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md) acks like every refuse arm (the two acks stay the surgical stay-at-`enforce` path). Direct-read wiring (not in `_SECURITY_PASSTHROUGH`/`_RELOCATED_TO_SECURITY`). Amends [ADR 0092](0092-posture-keyed-transport-hop-refusal-refuse-the-insecure-phi-hop.md) §5/§7, [ADR 0115](0115-asvs-l3-drive-to-pass-secure-by-default-flips-and-residual-closure.md), [ADR 0118](0118-secure-by-default-security-configuration-section.md) §1/§3/§5, [ADR 0140](0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md); scorecard re-score **deferred** (held for the ADR 0143 `docs/security/ASVS-L3-*` re-sign); #26-clean | Accepted (2026-07-21) — build phased (GIVEN 1 → GIVEN 2), docs/re-score deferred; pushes/PR owner-approved | | [0149](0149-multi-ecosystem-sbom-vex-and-sbom-quality-gate.md) | **Multi-ecosystem SBOM, VEX, and an SBOM quality gate** — supply-chain transparency hardening (procurement-driven; MessageFoundry is **not** FDA-regulated). Closes four deep-research-identified gaps on top of the shipped baseline (CycloneDX SBOM + Sigstore + SLSA + PEP 740): (1) the Python SBOM becomes **license-complete + lifecycle-declared** (`cyclonedx-py environment` from the hash-locked **core** lock + new stdlib [`scripts/security/sbom_finalize.py`](../../scripts/security/sbom_finalize.py) injecting `metadata.lifecycles=[{phase:build}]` and backfilling the hatchling-dynamic root version; `--output-reproducible` deliberately unused so the timestamp survives); (2) **per-artifact SBOMs** for all three surfaces — Python engine, VS Code **npm** extension (`@cyclonedx/cyclonedx-npm@6.0.0 --package-lock-only`), and **container image** (`trivy image --format cyclonedx`, OS+Python); (3) **sbomqs** quality scoring (pinned v2.0.11, advisory); (4) a maintained **OpenVEX** ([`security/vex/`](../../security/vex/messagefoundry.openvex.json)) wired into our Trivy gate (`--vex --show-suppressed`) and shipped/signed/attested with each release. No engine code; reliability + count-and-log invariants untouched; SBOM/VEX/operator doc kept **public** (outside the `docs/security` deny-list). SPDX dual-emit + promote-to-blocking deferred. | Accepted (2026-07-21) — build in progress; pushes/PR owner-approved | -| [0150](0150-client-address-on-audit-entries.md) | **Client address on audit entries** — an audit row named *who* and *what* but never *where from*, so after an incident there was no way to tell which host performed an action (e.g. pulled a bulk PHI export). The two apparent answers both fail: the one address-bearing event (WP-L3-13 `auth.admin_action_new_ip`) is emitted **only** from the four step-up dependencies — the raw-PHI view uses `require_phi_read` and never reaches it — and is default-off (`[auth].admin_new_ip_step_up`) + change-only; and `sessions.client` holds the address captured at **login**, so on a **replayed token** it names the original victim's host — *actively misleading*, not merely lossy. Adds a nullable `client` column to `audit_log` on **all three backends** (SQLite/PG `TEXT`, SQL Server `NVARCHAR(256)`, each backend's own additive-migration idiom). **The address is folded into the CHAINED payload** (an unchained sibling column an attacker could rewrite without breaking tamper-evidence would be worse than none) **as a CONDITIONAL trailing element** — appended only when non-`None`, so a clientless row (every legacy row, every engine-internal write) hashes over the same 6-element list and its `row_hash` still verifies **byte-identically**; an *unconditional* 7th element would have appended `null` to every legacy payload and broken the chain at the first row on upgrade. Composes with the #190 keyed-chain watermark (keying picks SHA-256 vs HMAC; this picks the bytes). Threaded **explicitly** (`client=`) — a `ContextVar` was **rejected**: it leaks across `asyncio.create_task` and would stamp a live operator's address onto unrelated `system` rows. `NULL` = *no client in scope*, never inherited (summary coalescer, dual-control executor, console callback keep NULL by design). One extraction path (`_client_ip` → public `client_ip`); teed off-box as a discrete SIEM field; surfaced on `GET /audit` + the CSV export. Plaintext at rest by decision (integrity-protected via the chain; matches `sessions.client`) — recorded in the [PHI.md](../PHI.md) at-rest inventory | Accepted (2026-07-22) — built; pushes/PR owner-approved | +| [0150](0150-client-address-on-audit-entries.md) | **Client address on audit entries** — an audit row named *who* and *what* but never *where from*, so after an incident there was no way to tell which host performed an action (e.g. pulled a bulk PHI export). The two apparent answers both fail: the one address-bearing event (WP-L3-13 `auth.admin_action_new_ip`) is emitted **only** from the four step-up dependencies — the raw-PHI view uses `require_phi_read` and never reaches it — and is default-off (`[auth].admin_new_ip_step_up`) + change-only; and `sessions.client` holds the address captured at **login**, so on a **replayed token** it names the original victim's host — *actively misleading*, not merely lossy. Adds a nullable `client` column to `audit_log` on **all three backends** (SQLite/PG `TEXT`, SQL Server `NVARCHAR(256)`, each backend's own additive-migration idiom). **The address is folded into the CHAINED payload** (an unchained sibling column an attacker could rewrite without breaking tamper-evidence would be worse than none) **as a CONDITIONAL trailing element** — appended only when non-`None`, so a clientless row (every legacy row, every engine-internal write) hashes over the same 6-element list and its `row_hash` still verifies **byte-identically**; an *unconditional* 7th element would have appended `null` to every legacy payload and broken the chain at the first row on upgrade. Composes with the #190 keyed-chain watermark (keying picks SHA-256 vs HMAC; this picks the bytes). Threaded **explicitly** (`client=`) — a `ContextVar` was **rejected**: it leaks across `asyncio.create_task` and would stamp a live operator's address onto unrelated `system` rows. `NULL` = *no client in scope*, never inherited (summary coalescer, dual-control executor, console callback keep NULL by design). One extraction path (`_client_ip` → public `client_ip`); teed off-box as a discrete SIEM field; surfaced on `GET /audit` + the CSV export. Plaintext at rest by decision (integrity-protected via the chain; matches `sessions.client`) — recorded in the [PHI.md](../PHI.md) at-rest inventory | Accepted (2026-07-22) — built; pushes/PR owner-approved. **Corrected 2026-09-09 -- decision item 6 over-claims one surface: the address is BUILT on `GET /audit` and the `audit:export` CSV, and NOT BUILT on the web console audit page (`messagefoundry_webconsole/pages/audit.py::audit_log` never reads `AuditEntry.client`). The summary cell to the left is accurate as written; the other five decision items hold.** | | [0151](0151-operator-surface-source-network-allow-list-security-allowed-client-networks.md) | **Operator-surface source-network allow-list (`[security].allowed_client_networks`)** — a CIDR allow-list for the operator API + web console, enforced in the OUTERMOST ASGI middleware (before routing, dependencies, the body cap and auth; covers `/ui`, the `/ui/static` mount and the `/ws/stats` WebSocket). **Empty = the default = no restriction, byte-identical to today.** Turns "the console is reachable only from the hospital network" from a firewall-only assumption into a config assertion the engine enforces, `GET /security/posture` reports and a test verifies. **D-1:** it evaluates `scope["client"]` and parses NO forwarding header — uvicorn's `ProxyHeadersMiddleware` is the single XFF trust point and already rewrote it from `[api].trusted_proxies`, so a declared proxy (R2) yields the real client and an undeclared one (R1) means an attacker's XFF is ignored outright; **R3** (proxy in front, nothing declared) leaves the control **INERT** — an honest limit, pinned by a test, detected by an address-monoculture tripwire and never to be documented as covered. **D-2:** loopback is allowed UNCONDITIONALLY (the XFF-conditional variant would lock the on-box operator out of the recommended proxy-on-the-engine-box topology); the ingest `source_ip_allowlist` does NOT inherit the carve-out. **D-3:** setting the allow-list REFUSES a multi-address `[api].trusted_proxies` entry — any host in a trusted range can forge its own XFF, so a `10.0.0.0/8` entry would silently nullify the restriction just asked for. **D-4:** the loosening advisory keys on EXPOSURE, not the bind, so it fires in R2. **D-5:** diagnosability is part of the control (marker header, address-echoing 403 page, rate-limited log, posture counters, `/health` left exempt and echoing `observed_client`). Matcher hoisted to a neutral stdlib-only `messagefoundry/netaddr.py` shared with the ingest allow-list. Defence-in-depth BEHIND the host firewall, never the primary network control; does not cover ingest, a confused deputy on an allowed subnet, or NAT; startup-only (a lockout costs a restart). IDE field deferred; #26-clean | Accepted (2026-07-22) — built + green; default-off | | [0152](0152-in-use-data-protection-for-phi-platform-memory-encryption-attestation-asvs-11-7-1.md) | **In-use data protection for PHI — platform memory-encryption attestation (ASVS 11.7.1)** — the only Fail on the 2026-07-22 single-posture scorecard. 11.7.1 is **L3-only and net-new in ASVS 5.0** (`ADDED`, no 4.0.3 lineage), so L1+L2 is untouched (zero Fails); we pursue it because L3 is the target for a PHI system. **Decision: the engine MEASURES and REPORTS, the deployment DETERMINES the verdict** — no Pass claimed from documentation, configuration, or a local capability flag. Three rungs: (1) platform read-out surfaced in `GET /security/posture` beside the FIPS attestation (ADR 0120), report-only and explicitly NOT satisfying the requirement; (2) `[security].memory_encryption_operator_declared`, on the `MEFOR_TLS_REVOCATION_ATTESTED` pattern — the rung that moves **Fail → Partial**. **Amended 2026-07-22:** a missing declaration on an exposed PHI instance **warns**; the refusal is opt-in via `[security].require_memory_encryption_declaration` (default off), because ADR 0148 makes even `dev` derive PHI, "exposed" includes the recommended loopback-behind-proxy topology, and no operator can satisfy the property on Windows — a default refusal would have hard-stopped deployments that boot today, which the ADR 0151 opt-in-scoping rule forbids. Named `operator_declared`, NOT `attested`: in confidential computing "attestation" means the rung-3 signed quote, and `"memory_encryption_attested": true` is exactly the quotable false-compliance artifact this ADR exists to prevent. The posture body carries its own disclaimer (`memory_encryption_note`), and the read-out is never accepted as a substitute for the declaration; (3) CPU-signed quote verified against vendor root PKI (SEV-SNP `/dev/sev-guest`, TDX `/dev/tdx_guest`) with an operator-supplied CACHED vendor chain because air-gapped is the norm here — the only rung supporting **Partial → Pass**, and deployment-gated on SEV-SNP/TDX hardware. Memory HYGIENE is explicitly not this requirement: OWASP deleted 4.0.3's V8.3.6 memory-zeroing as NOT PRACTICAL and kept 11.7.1, and our shipped mlock/zeroize (#198) moves the cell by zero. No cloud-provider SDKs (on-prem product); The Windows spike RAN (2026-07-22) and its answer is recorded in the ADR: Windows rung 3 is **platform-blocked, not API-blocked** — a vTPM NV-index path exists under a Microsoft paravisor (Azure CVMs) and needs no new dependency, but no on-prem Hyper-V/ESXi boots a Windows confidential guest at all, so **on-prem Windows is capped at rungs 1+2 = Partial**, disclosed. It also corrects the Linux framing: `/dev/sev-guest` is absent on Azure Linux CVMs too, so rung 3 is two acquisition backends (direct guest device \| vTPM NV index) × two OSes, not "Linux vs Windows". Residual that survives all rungs: plaintext PHI and the unwrapped DEK remain in CPython heap, protected from host/hypervisor but not from in-guest code execution. **Built: rungs 1 + 2 (report-only read-out + the operator declaration) and the deployment requirements; rung 3 is NOT built, so no Pass is reachable today on any platform.** Accepting the ADR deliberately does NOT re-score 11.7.1 — that is an owner decision on the assessment of record. Plan: docs/security/ASVS-11-7-1-IN-USE-DATA-PLAN.md | Accepted (2026-07-22) | | [0153](0153-collapse-the-posture-gradient-no-data-label-may-allow-a-cleartext-hop.md) | **Collapse the posture gradient — no data label may allow a cleartext hop** — ADR 0092's precedence arm 3 is `not is_phi -> ALLOW`, so an instance whose environment file declares `data_class = "synthetic"` crosses **every** cleartext transport hop silently, with no warning and no audit record. Found by upgrading a dogfood instance whose environment file had been quietly unparseable: the moment it loaded and `data_class = "phi"` took effect, a cleartext MLLP egress that had been crossing without comment became a refusal — nothing about the hop had changed, only a label in a different file, and nothing had reported the guard was off. Deletes that one arm: `insecure_hop_disposition` loses `is_phi` and the precedence becomes loopback -> ALLOW, attested -> ALLOW, `cleartext_accepted` -> WARN, not-`enforcing` -> WARN, else REFUSE. Because the deleted arm returned ALLOW, removing it can only turn a crossing into a WARN or a REFUSE, so **0092's no-loosen rule holds by construction** and an unchanged config is byte-identical unless it relied on the label. Adds `cleartext_accepted` + `cleartext_reason` on **Destination**, deliberately SEPARATE from `tls_hop_attested`: attestation claims *this hop IS secure by means the engine cannot see* and passes silently, the new pair admits *it is NOT, and we accept that* and warns + audits every construction — merging them would leave the audit trail unable to tell a proxy-terminated hop from plaintext PHI on a flat network. **No TLS default is flipped** (an earlier draft proposed it): redundant, since an undeclared cleartext outbound already REFUSES and a refusal naming the hop beats a handshake failure; it hard-fails 14 of 14 inbound MLLP listeners because `_mllp_ssl_context(server=True)` demands a cert before any policy runs, unreachable by posture/attestation/the new flag and not loopback-exempt; and it would silently disarm the four inbound exposed-gates, which early-return when `tls` is truthy. `Tcp()`/`X12()` have no TLS support at all, so for them the declaration is **permanent and structural**, not transitional. Neither `data_class` nor `MEFOR_ALLOW_INSECURE_TLS` is removed — the hop authority stops READING the label, and the variable is unhooked from the authority but survives for the six non-connection cells (store TLS, LDAPS, webhook sink, AI broker) that have no per-connection field to carry an escape. Retains ADR 0148's `[security].enforcement` dial as an arm (its AC-3 requires it on every gate). Scope is the cleartext transport-hop decision ONLY; an explicit out-of-scope table names all four callers of the authority and the three other readers of `HopPosture` | Accepted (2026-07-25) — reworked after adversarial review; owner-ratified; **BUILT 2026-07-28** | -| [0154](0154-synchronous-captured-downstream-reply-and-intake-authentication-for-the-inbound-http-listener-adr-0023-deferred-tail.md) | **Synchronous captured-downstream-reply and intake authentication for the inbound HTTP listener** — the ADR 0023 deferred tail: `reply_from` blocks the HTTP turn on a **committed** ADR 0013 `response` row (never an in-flight `DeliveryResponse`), and `intake_auth` (API key / bearer / mTLS subject) adds a peer control behind a posture-keyed gate. Also closes a live hole — `check_http_tls_exposure` returns early on truthy `tls`, so an off-loopback `Http(tls=True)` listener authenticates nobody today | **Accepted (2026-07-31)** — owner-ratified at rev 5; authorises **increment A only** (intake-auth + peer-control gate), which is **built and merged** (2026-08-01, `f2ef0ea9`); sync-reply (increment B) deferred pending a customer | +| [0154](0154-synchronous-captured-downstream-reply-and-intake-authentication-for-the-inbound-http-listener-adr-0023-deferred-tail.md) | **Synchronous captured-downstream-reply and intake authentication for the inbound HTTP listener** — the ADR 0023 deferred tail: `reply_from` blocks the HTTP turn on a **committed** ADR 0013 `response` row (never an in-flight `DeliveryResponse`), and `intake_auth` (API key / bearer / mTLS subject) adds a peer control behind a posture-keyed gate. Also closes a live hole — `check_http_tls_exposure` returns early on truthy `tls`, so an off-loopback `Http(tls=True)` listener authenticates nobody today | **Accepted (2026-07-31)** — owner-ratified at rev 5; **both increments built and merged**: increment A (intake-auth + peer-control gate) 2026-08-01 (`f2ef0ea9`, PR #109), increment B (sync-reply) 2026-08-01 (PR #119). Increment B was deferred at ratification pending a customer; the owner lifted that deferral on 2026-08-01. See the ADR's Status note for the `AC-18` tail | | [0155](0155-dast-dynamic-security-testing-of-the-running-engine.md) | **DAST — dynamic security testing of the running engine** (BACKLOG #318) — no DAST had ever run against this project: every security test was static or in-process, leaving the [Secure_Development_Standards](../Secure_Development_Standards.md) §6.1 *Dynamic* tier row empty. Increment 1 builds a **self-run, authenticated authorization sweep** with **no new dependency**: one `uvicorn` listener on loopback in front of a real Engine + real AuthService, both identities minted over the wire through `POST /auth/login`, and the authorization expectation **derived from the live route table** by a single shared `require*()`-closure walk ([`scripts/security/route_gates.py`](../../scripts/security/route_gates.py), hoisted out of the security doc-drift guard so exactly one derivation of *is this route gated* exists in the tree) rather than a hand-kept list that goes stale the day a route lands. Measured shape: 105 route rows, 100 gated, 87 permission-gated, exactly 5 anonymous. Three passes — **negative** (every gated HTTP row sent with no credential and with an invalid bearer; anything but 401 is a finding), **authorized reach** (how many gated `GET` rows a *privileged* token got past authentication and authorization on — the positive number that stops a wall of 401s reading as *all endpoints protected*), and **viewer BFLA** (anything but a refusal, **including 404**, is a finding, because a 404 on a matched path template means the caller got past authorization into resource lookup). The receipt names what it examined and **fails closed** — below any floor it exits **2 (could not measure)**, never 0 — and records method, path template, status codes, counts and the *relaxed* posture it scanned, never a body, header or token. Two canaries are built from **supported configuration, not source patches** (authentication disabled at the target; the low-privilege identity over-granted while the expectation set stays the viewer's), avoiding the patch-rot failure where a canary silently stops applying; CI runs both **before** the real scan and requires each to exit exactly **1** (findings) *with* its receipt on disk — a 0 (blind), a 2 (could not measure, which is what a neutered canary actually produces) or a crash fails the job and the real scan never runs. The inversion is the design: the sweep and both canaries run as ordinary pytest in the **existing required** test legs, so a change that blinds the detector reds a PR, while the nightly workflow is advisory — **not** a required context (it has no `pull_request` trigger, so it cannot report on a PR) and deliberately **not** `continue-on-error`. **Scope boundary: see the ADR's *Scope boundary* section** — it is stated once there, verbatim, and this row deliberately carries a pointer and no wording of its own. Rejected/deferred: schemathesis (the shipped OpenAPI declares no `securitySchemes` and no per-operation `security`, so `ignored_auth` would pass on every operation having probed none of them; adoption also needs a fifth DEP-1 lock over a ~30-distribution closure that could pull a shipped runtime floor down), ZAP (a SHA-pinned action still pulls a mutable `:stable` image, and the published images do not install the web console), nuclei (template matcher; `pip install nuclei` is an abandoned unrelated package), Dredd/RESTler/CATS, in-process ASGI transport (bypasses the HTTP parser, leaves `request.client` unset), the unauthenticated MLLP/TCP/X12/DICOM ingress plane, the `/ui` console plane, a TLS black-box target, and non-`GET` reach/BFLA | Accepted (2026-07-31) — increment 1 built; advisory, not a required context | | [0156](0156-asvs-scorecard-as-data-a-derived-count-verified-evidence-anchors-and-a-fail-closed-drift-gate.md) | **ASVS scorecard as data — a derived count, verified evidence anchors, and a fail-closed drift gate** — the ASVS score is maintained as prose, and nothing checks it. One re-anchoring session (2026-08-01) re-derived the headline count **6 times**, found **12 residuals of record factually false at HEAD** (five of them *absence* claims that had silently stopped being true), and found **10 cells missing from an enumeration described as "arithmetic-checked and complete"** — which survived because the arithmetic closed to 345 and closure was read as proof. **Closure only proves the four buckets sum, not that every cell landed in one.** Decision: hold one `[[cell]]` record per requirement (all 345) in `asvs-scorecard.toml`; **compute** the count so no document can state one; assert **every corpus id appears exactly once** (the check whose absence cost ten cells); machine-verify each cell's `evidence` anchor by asserting an expected **token** still resolves, so code movement reds a test instead of rotting a sentence; require an absence claim to record the **search that proved it plus a positive control that must still hit**, because a grep naming the wrong token returns zero and reads exactly like proof; make **`unverified` a first-class verdict** so inherited-versus-verified Pass is countable (~219 Passes have never been read against the requirement text); and **fail closed rather than skip**. Tool + schema + fixture tests live in this repo and run in public CI; the real scorecard lives in the vault with a vault-CI job — which closes **ASVS 15.1.3**, currently open precisely because six `*_doc_drift` modules assert against documents that `git ls-files docs/security/` shows are **not present** in the tree where CI runs. Rejected: *keep prose and review harder* (every false residual **read as true**; the project's own standard says the mitigation must be structural, not diligence), *one document to rule them all* (that is the current lineage, and it produces five documents asserting three counts), and *publish the vault documents so the guards see them* (attacker roadmap, `SECURITY-DOCS-POLICY.md`). Explicitly **does not** make the score correct — only consistent, derived and drift-detecting; adversarial verification remains the only cure for a wrong verdict | **Accepted (2026-08-01)** — built and merged the same day. **§7 was amended at ratification**: it proposed a vault CI job, and the actions API showed every vault workflow `disabled_manually` (last run 2026-07-27; two vault PRs merged that day with zero checks), so a CI-only design would have shipped dead. Built instead as a vault **pre-commit hook** plus one **narrow new workflow**. That §7 was a confident, unchecked claim about system state is the ADR own thesis applied to itself | | [0157](0157-demotion-safety-fence-scope-on-post-claim-writes-and-a-bounded-graph-stop.md) | **Demotion safety — fence scope on post-claim writes, and a bounded graph stop** — an HA re-check found the leadership lease itself **sound** (DB-clock expiry on both backends, atomic acquire/renew, a real `leader_epoch` token checked inside the claim transaction; scopes B and C were probed and cleared, not assumed) and two things wrong around it. **F1:** the epoch fence guards *some* claims and **nothing after them** — `claim_ready` (the UNORDERED path) carries no epoch predicate on either backend, and every post-claim disposition write (`mark_done`, `mark_failed`, `dead_letter_now`, `complete_with_response`, plus batch twins) resolves by bare `id` with no epoch, owner or status precondition, while `release_claimed` two methods away *does* carry `AND status=$4`. The sharp write is `dead_letter_now`: a demoted node assigning a **terminal** disposition and finalizing the message, breaching the store finalizer's single authority — and a DEAD row is never re-claimed, so H2 skip-and-complete cannot heal it. **F2:** demotion budgets **detection only, never the stop** — `_check_fence` flips a boolean and cancels no listener, worker or in-flight send; `engine.py`'s graph-poll interval is the *only* arithmetic consumer of `(ttl − fence)` in the package and it sizes a poll. Measured budget on stock defaults is **≈8.0 s** (fence 20 + a 1.0 s fence tick + a 1.0 s poll against a 30 s DB-clock expiry) *minus* the renew round trip, which is bounded only by `[store].command_timeout = 30` — **exactly equal** to `leader_lease_ttl_seconds`, so the margin can reach zero and `_fence_ordering` (ordering-only) never notices. Against that, teardown stops inbounds **sequentially** at up to 10.0 s per socket listener (5.0 client grace + 5.0 `wait_closed`, off a module constant unrelated to lease timing) and **unbounded** for file/DB/DICOM inbounds, at a 1,500-connection target. **Decision (6 clauses):** guard writes that make a row **TERMINAL**, never one that returns it to PENDING (fencing the L1 hand-over would convert a permitted duplicate into a forbidden **strand**); two predicates of **opposite polarity** (claim fail-closed, resolve fail-**open**, because a rejected resolve leaves the row INFLIGHT); no `status` conjunct; a demoted node **retains** its stale epoch (`None` means *no fence*, so clearing it disarms the guard); fence every claim path incl. `claim_ready`; and a `TeardownReason{SHUTDOWN,DEMOTE}` bounded, concurrent, edge-triggered demotion stop. Rejected: an `owner=` predicate (dead on SQL Server, which claims `owner=NULL`), a per-claim token (correct, but changes the `Store` protocol — filed, not folded in), fencing writes that ADMIT a message (converts a duplicate into a **loss**), a lease-anchored absolute deadline (makes the monotonic clock load-bearing on Windows, and degrades to an unconditional cancel at the 30/30 collision), and wrapping teardown in `wait_for` **from outside** — `self._running = False` is the last statement of `_teardown_unsafe`, so a cancelled teardown leaves the node **permanently un-re-promotable, silently**. Also records the sequencing asymmetry: Postgres bounds a stranded INFLIGHT row at ~90 s via its periodic sweep, while **SQL Server has no periodic in-flight recovery at all** (`reclaim_expired_leases` is Postgres-only; the runner's `hasattr` gate is the sole exclusion), so the same row is an **unbounded strand today**, with no HA scenario involved. Corrects a code comment attributing a teardown-ordering constraint to "ADR 0066 D3" — that decision does not exist (`grep -c D3` → 0). Single-node SQLite byte-identical (structurally: `set_leader_epoch` is a hard `return None`). #26-clean | **Accepted (2026-08-01)** — C1 (terminal writes only, fail-open) and C6 (bounded, abandon-don't-await) owner-decided; **increments 1, 4 and 5 built 2026-08-02**. Inc 1 fences `claim_ready` plus the eight terminal resolves on Postgres, a rejected resolve rolling the whole disposition back — queue flip, ledger row, event row and finalize together — and then **re-pending** the row. **Three drafted clauses were corrected during the build, each because the draft was strand-direction or false.** C3 said leave the fenced row INFLIGHT for recovery: on SQL Server there is no periodic in-flight recovery at all, so that is an unbounded strand *manufactured by the fence itself* — the one outcome the ADR forbids. C4 alone was a silent total halt: `_reconcile_graph` had only two branches, so `is_leader() and running` matched neither and a live leader held a stale epoch, claiming **nothing**, with no exception and no alert — closed by re-stamping the epoch every reconcile pass. And the cross-backend claim that a demoted SQL Server node "claims nothing" was **wrong**: `claim_ready` is unguarded there, so retaining the epoch covers only the three FIFO claim paths until Inc 3. Evidence is mutation-verified in both directions, which is how the *first* structural gate was caught being blind to its own subject — it keyed on whether a method **mentioned** the guard constant, so deleting the guard from `claim_fifo_heads`' emitted SQL left the mention intact and the gate stayed **green**. **Inc 0/2/3 not built, and Inc 2 is MIS-SPECIFIED in this ADR** — its owner-blind, age-based sweep has no populated `owner` column on SQL Server to discriminate with and would re-pend rows a live leader is working; the real defect is the absence of recovery at graph re-start. Do not build it as written | | [0158](0158-silent-controls-green-signals-that-mean-nothing-and-shape-over-detection.md) | **Silent controls -- green signals that mean nothing, and shape over detection** -- a defect class that recurred at least a dozen times across independent surfaces in one working day (2026-08-01), in at least two sub-classes: **(1) a bound or claim stated INDEPENDENTLY of the thing it bounds** (test: *what measurement backs this?*) and **(2) a control that cannot OBSERVE or ACT ON its own failure** (test: *if this control were broken, what would tell me?* -- if the answer is the control, that is the defect). Spine: **a signal that does not carry enough information to act on forces every reader to re-derive significance by hand, and eventually one of them derives it wrong**; a correct-but-useless RED costs what a silent green costs. Anchor instances: `ci.yml`'s unsourced "~2x headroom" against a real margin near 1.0x, after a PR was killed at 26:07 on the 26:00 step cap with no test failing and passed at 22:25 on a re-run of the same commit; a `UserPromptSubmit` hook that probed a script path this repo has never contained, printed a reassuring status message and exited 0; a gate shim with no `else` on the miss path; **a validator whose input is derived from its subject is satisfied by construction** (zizmor's paths filter excluded the lock its own pinned version arrives through); **an equality check satisfiable by coincidence is not one** (three copies of a hook at identical byte counts differing on one comment line by a 5-for-5-character substitution). Carries the worked inversion: **a measurement beats an estimate only when it measures THE SAME QUANTITY** -- a correct estimate was retracted for a stated "measurement" that was the JOB not the STEP, and a peer amplified it. Seven retractions are recorded **inside the ADR**, including of its own corrections (the pool size, the maximum's filter, the sizing criterion, a BACKLOG number, and line numbers copied from a commit message). Every instance and retraction carries a **found by:** tag, because **no retraction in this document's own production was made by the author of the claim it retracts** -- the bound on that finding is stated. **Shape over detection is reported as a RATIO, not flattered:** three fixes are covered by tests in required CI legs, two by tests that always skip in CI, one by a workflow change with a live residual, the rest corrected prose or still open. Decision splits ENFORCED rules (each naming its gate) from CONVENTION (unenforced, knowingly re-breakable); links rather than restates [CLAUDE.md](../../CLAUDE.md) section 11 and [Secure_Development_Standards](../Secure_Development_Standards.md) section 3; no engine behaviour changes | Proposed (2026-08-01) -- records a class; the coordination-layer fixes it cites are already built | | [0159](0159-cancellation-safe-pooled-connection-release-mid-txn-discard-at-the-acquire-chokepoint.md) | **Cancellation-safe pooled-connection release — quarantine at the `_acquire` chokepoint** (BACKLOG #348) — `SqlServerStore`'s write idiom is `except Exception: await conn.rollback(); raise`, used at **90 of the 91** `self._acquire()` sites. `CancelledError` derives from `BaseException`, so on a cancellation **no rollback runs**, and aioodbc does not compensate: `Pool.release()` appends a non-closed connection straight back onto the free deque with no rollback, reset or transaction check (0.5.0 `pool.py:196-205`), and `_ContextManager.__aexit__` uses the *same* `release` on the exception path (`utils.py:60-62`). The next borrower inherits an open transaction still holding X locks. **Measured on a live SQL Server**: cancelling `release_claimed` left **7** X locks on `queue`, `reschedule_claimed` 7, `mark_done` 9, `enqueue_ingress` 11 (the pre-ACK ingress commit), against **0** for the `claim_fifo_heads` control; the connection returned to the free list, a raw writer got **1222**, and a real second claim yielded **EMPTY-all** — which ADR 0066 §9 sanctions, so the symptom is *silence*, not an error. Fix: at `_acquire`, on a non-`Exception` `BaseException` only, **synchronously** drop the driver handle (`conn._conn = None` — aioodbc derives `closed` from it and re-adds only `if not conn.closed`) **with no await in front of it**, then close the raw handle off-loop under a 5s bound. The ordering *is* the guarantee: a cleanup that awaits first is defeated by the **second** cancellation that shutdown's cancel-then-gather delivers. A plain `await conn.rollback()` was built and **rejected on measurement** — it runs in the default executor bounded only by `command_timeout` (30s) with no upstream timeout, and a cancel measured **1.005s** against a 1.0s rollback, stalling exactly the **demotion** path (`_stop_graph` cancels but does **not** close the store, so the poisoned connection is re-borrowed there). Corrects the lead that found it on two points: it is **not** a two-method asymmetry, and `claim_fifo_heads` does **not** shield against it — its guard is a `SET LOCK_TIMEOUT` *reset* guard and ADR 0114 §2 plus a frozen test record that **no rollback** runs on its cancellation path; it ends clean because the guard **commits**. Ordinary errors keep today's rollback-and-recycle behaviour, pinned by controls that pass before and after. SQL Server only — Postgres is safe twice over (asyncpg rolls back on any `BaseException` and its pool resets under `shield`), SQLite has no pool | Accepted (2026-08-02) — built and verified the same day; gate failed 6/12 pre-fix, passes 12/12 post-fix, live repro 7→0 locks | | [0160](0160-public-repo-content-policy-operator-and-security-review-material-only.md) | **Public-repo content policy -- operator and security-review material only** -- the owner stated twice on 2026-08-04 that the public repo should carry only what someone RUNNING MessageFoundry needs plus what a SECURITY REVIEW needs (ADRs named explicitly as the second), and that material about how the project is built with Claude Code comes out. **It was recorded NOWHERE** -- a session grepped every local and remote ref and found nothing -- while being cited as a constraint on four other sessions' work, which is exactly the standing the coordinating session had REFUSED from a peer the same day over a relayed owner ruling. That asymmetry is why this exists: an unrecorded policy enforced across isolated sessions is indistinguishable, to the session receiving it, from an invented one. The question is new because the 2026-07-27 cutover retired the publish-time deny-list that used to answer it, so it is now answered per file, at authoring time, with no gate and no written rule. Measured at `c90dcb5f`: `docs/` 580 tracked, `docs/adr/` **156 (stays)**, `docs/releases/` **101** (mostly `*-MULTISESSION-PLAN.md`, the largest concentration of process material), `scripts/` 61. Decision: a two-part test (operator need OR reviewer need), applied in three reversible phases -- `docs/releases/` first, then the individually-tracked process docs, then in-file prose -- with **no enforcement gate proposed and the reason stated**, because a path-based gate is evaded or wrong and a keyword gate fires on every legitimate use of "session". ⭐ **`CLAUDE.md` is a genuine policy-versus-tooling conflict and stays tracked as a named exception**: by the test it should go, but `git worktree add` CANNOT deliver an untracked file, so while it was untracked every new worktree came up with **zero project conventions loaded** (verified on two live worktrees; it only looked right in the primary because an untracked copy happened to sit on disk). Rejected: keep everything (a public `docs/` that is majority process material misrepresents the project to its intended reader), reinstate a publish deny-list (retired deliberately; re-adds a sync problem and the slug-rewrite damage class), and move the process DOCS to `claude-multisession` (the tooling move is owner-authorised but recorded in no ruling -- context, not constraint). ⚠️ Explicitly **NOT a confidentiality control** -- `git log` retains everything removed, nothing here was ever treated as secret, and anything genuinely sensitive belongs in the vault and always did | **Accepted (2026-08-06). Phase 1 EXECUTED; Phase 2 ACCEPTED 2026-08-31 WITH PRECONDITIONS, reversing the 2026-08-06 decline; Phase 3 still Proposed; Phase 4 EXECUTED 2026-08-31, merged 2026-09-01 (46 files). Phase 2's preconditions MEASURED 2026-09-01 and NOT met.** Phase 1 removed **103 files** -- `docs/releases/` (101) plus two session handoffs found tracked in the repository ROOT, now covered by a `/HANDOFF-*.md` pattern that fails closed on the next one. Custody moved to the vault and was PUSHED BEFORE removal, because gitignoring alone leaves unversioned single copies that `git clean -xdf` erases -- the two mechanisms are complements, not alternatives. Owner rulings recorded: the vault is the destination (open question 3), `claude-multisession` is REJECTED for this material, and the governing rule is **do not break anything**, applied per item as *prove the mechanism or leave the item alone*. ⛔ **D5 DECLINES the process tooling move** (`scripts/coord\|worktree\|hooks`, ~28 files + 26 tests, and the four docs describing them) on measured cost rather than blocking it on anyone: the tempting cheap gate does not work, because the 26 test files split **exactly 13/13** between `which("pwsh")`-only skips (which run on hosted ubuntu) and `or os.name != "nt"` skips (which **silently skip** there) -- so a Linux-only vault leg covers half the suite and reports GREEN, the same silent-control defect relocated. A correct gate needs ubuntu AND windows, the vault is private so Windows bills at 2x, and the vault's CI is `disabled_manually` on every workflow but one (`ci.yml` last ran 2026-07-27, failed). Benefit is cosmetic -- unlike Phase 1's plans, nobody mistakes dev tooling for the product -- so it is DECLINED, not deferred, because a permanently-blocked item reads as work someone still owes. ⚠️ Records two findings that outlive it: `tests/test_feature_map_claims.py` resolves links against the FILESYSTEM, so ignored-but-present files make it **blind in any working tree** (measured: a re-introduced dangling link passed locally, failed against a `git archive` export) -- verify link changes against a tracked-files-only export; and `git rm --cached` spares only the tree it runs in, CONFIRMED when the Phase 1 rebase deleted the files from the working tree, which is what will happen to the primary and all active worktrees on merge. Corrects the draft's "cannot be docs-only" reasoning: two of its three cited non-doc files are comments and the third's entry is an EXCLUSION list, so the real cause is that the mechanism IS a `.gitignore` edit, which ci.yml classifies as code by construction. Followed by a 104th file: `docs/releases/HANDOFF-232-router-steps.md` entered main via PR #225 WHILE the Phase 1 PR was open, and since a removal that enumerates paths cannot cover a file created after its commit was built -- and gitignore does not untrack -- the directory came back holding one file. **D6 records three sets considered and LEFT** so a later sweep does not re-derive them: 11 prose citations (provenance, not links; 0160's own must never be "cleaned"), 22 handoff documents outside `docs/releases/` (a name sweep reports 155, but 133 are benchmark DATA under `HANDBACK_*` dirs, and the rest carry the measurement narrative for the data beside them), and unanchoring `/HANDOFF-*.md` (rejected -- both locations that matter are already covered, and it would fail closed on `docs/benchmarks/` where handoffs are tracked on purpose). Open: **does `docs/BACKLOG.md` stay?** | -| [0161](0161-async-session-mail-for-unreachable-peers.md) | **Async session mail for unreachable peers** (BACKLOG #1028) — the realtime session channel cannot address two peer classes, **structurally rather than by a filter**: a session launched by the VS Code extension is never entered into the Desktop app's in-memory map of sessions it spawned, and a session under a different login sits behind an independent config root (measured 2026-08-05: one repo, one moment, Desktop sessions on one root and VS Code sessions on a second, neither side visible to the other). The client's compiled peer-to-peer protocol is inert — the registry field carrying a peer's socket address is written by no code path and the lookup **fails silently green**, an empty peer list rather than an error. Decision: an **async file-drop lane** under `/mefor-coord/mail/`, keyed by the recipient's normalised **worktree path** (not session id, which `/clear` re-mints; not worktree name, which is a creation-time label — one worktree was observed on four branches under four sessions in a day), with the key function held in **one definition** dot-sourced by both ends because a drifted second copy fails silently on both sides. Location is the leak control: nothing under `.git` can enter a commit, and `mefor-coord` is not a ref namespace, so `push --mirror` cannot carry it — which is why worktree paths are plain text in the queue deliberately. Explicitly **does not replace** the realtime channel for desktop-to-desktop. **The claim primitive is the load-bearing part, and it was forced by measurement:** `[System.IO.File]::Move` **returns success without moving** for losers under contention — 16 racers x 500 rounds on .NET 10.0.9 / Windows 10.0.26200, instrumented in C# because PowerShell scriptblock closures do not capture loop variables reliably; **every** round had more than one racer return with no exception, in **375 of 500** rounds **all sixteen** did, and the obvious fix — verify `File.Exists(dst) && !File.Exists(src)` — was **true for all 16 racers in all 500 rounds**, because the winner's move makes it true for everybody. A destination unique per claimer yielded exactly one winner in **500 of 500**. Controls confirm the no-op is contention-only (a `Move` of a nonexistent or already-moved source raises). `scripts/coord/claim.ps1` is **not** affected — exclusive `CreateNew` plus a per-PID-unique target — do not "fix" it. Also decided: the **on-disk filename is authoritative and the JSON `id` is discarded** (sanitising it would be a weaker control that looks identical), the drain **never emits a runnable command**, one body sanitiser at the single injection point so a body cannot forge the frame, receiver-side caps (a send-time cap is bypassed by the write that *is* the transport), and a content rule with the force of the secrets rule. Bad consequences are stated rather than softened: the **write-side trust boundary cannot be enforced** by this design (any process running as the user can drop into any inbox, so every `from.*` field is a self-assertion), **delivery duplicates the body into a transcript no prune reaches**, and the urgent `asyncRewake` tier is **one-shot** because the rewake belongs to the process Claude Code spawned and a self-respawned grandchild's exit code is heard by nobody | **Proposed (2026-08-05)** — the code is a **PROTOTYPE and is deliberately NOT WIRED**; the installer rows exist but no config root has been installed from them. Wiring is a separate owner-approved step gated on BACKLOG #1028, and the ten EARS criteria link to functions that exist in `tests/test_session_mail.py`. **Arm B of the claim measurement was later corrected:** it was taken with 16 threads in ONE process, and re-measuring with 16 separate processes showed `File.Exists(own destination)` reporting a win to more than one racer in 46 of 800 rounds, so the shipped verdict is an exclusive open, not an existence check | +| [0161](0161-async-session-mail-for-unreachable-peers.md) | **Async session mail for unreachable peers** (BACKLOG #1028) — the realtime session channel cannot address two peer classes, **structurally rather than by a filter**: a session launched by the VS Code extension is never entered into the Desktop app's in-memory map of sessions it spawned, and a session under a different login sits behind an independent config root (measured 2026-08-05: one repo, one moment, Desktop sessions on one root and VS Code sessions on a second, neither side visible to the other). The client's compiled peer-to-peer protocol is inert — the registry field carrying a peer's socket address is written by no code path and the lookup **fails silently green**, an empty peer list rather than an error. Decision: an **async file-drop lane** under `/mefor-coord/mail/`, keyed by the recipient's normalised **worktree path** (not session id, which `/clear` re-mints; not worktree name, which is a creation-time label — one worktree was observed on four branches under four sessions in a day), with the key function held in **one definition** dot-sourced by both ends because a drifted second copy fails silently on both sides. Location is the leak control: nothing under `.git` can enter a commit, and `mefor-coord` is not a ref namespace, so `push --mirror` cannot carry it — which is why worktree paths are plain text in the queue deliberately. Explicitly **does not replace** the realtime channel for desktop-to-desktop. **The claim primitive is the load-bearing part, and it was forced by measurement:** `[System.IO.File]::Move` **returns success without moving** for losers under contention — 16 racers x 500 rounds on .NET 10.0.9 / Windows 10.0.26200, instrumented in C# because PowerShell scriptblock closures do not capture loop variables reliably; **every** round had more than one racer return with no exception, in **375 of 500** rounds **all sixteen** did, and the obvious fix — verify `File.Exists(dst) && !File.Exists(src)` — was **true for all 16 racers in all 500 rounds**, because the winner's move makes it true for everybody. A destination unique per claimer yielded exactly one winner in **500 of 500**. Controls confirm the no-op is contention-only (a `Move` of a nonexistent or already-moved source raises). `scripts/coord/claim.ps1` is **not** affected — exclusive `CreateNew` plus a per-PID-unique target — do not "fix" it. Also decided: the **on-disk filename is authoritative and the JSON `id` is discarded** (sanitising it would be a weaker control that looks identical), the drain **never emits a runnable command**, one body sanitiser at the single injection point so a body cannot forge the frame, receiver-side caps (a send-time cap is bypassed by the write that *is* the transport), and a content rule with the force of the secrets rule. Bad consequences are stated rather than softened: the **write-side trust boundary cannot be enforced** by this design (any process running as the user can drop into any inbox, so every `from.*` field is a self-assertion), **delivery duplicates the body into a transcript no prune reaches**, and the urgent `asyncRewake` tier is **one-shot** because the rewake belongs to the process Claude Code spawned and a self-respawned grandchild's exit code is heard by nobody | **Proposed (2026-08-05)** — **the drain IS WIRED AND LIVE** as of 2026-08-25 (BACKLOG #1215); the disposition stays `Proposed` because moving it off is an owner call the ADR deliberately does not make. **This cell used to say the code was a PROTOTYPE, deliberately NOT WIRED, with the installer rows present but no config root installed from them. That was true when written and was false by 2026-08-25.** It is kept rather than deleted because it is an UNDER-CLAIM a reader plans against, sending them to build a channel that already runs: `scripts/coord/install-coordination.ps1` carries `scripts/hooks/mail-drain.ps1` rows at **both** `SessionStart` and `Stop`, installed config roots carry those rows, and the ADR records the drain firing in a real session on 2026-08-25. **The wiring gate was met, not skipped:** the ADR's "To resolve on acceptance" checklist records the owner approval for the drain rows as given 2026-08-05/06 and scoped to the **default config root only**, and BACKLOG #1028 -- the item this cell named as the gate -- carries a closed banner dated 2026-08-11. What the ADR still leaves open is a different tier: `scripts/hooks/mail-watch.ps1` (the urgent `asyncRewake` tier) carries an installer row at `Stop` against a recorded 2026-08-06 owner decision of NOT WIRED, a contradiction the ADR records rather than re-decides. See §"Status and what gates wiring" in the ADR. The ten EARS criteria link to functions that exist in `tests/test_session_mail.py`. **Arm B of the claim measurement was later corrected:** it was taken with 16 threads in ONE process, and re-measuring with 16 separate processes showed `File.Exists(own destination)` reporting a win to more than one racer in 46 of 800 rounds, so the shipped verdict is an exclusive open, not an existence check | | [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 | +| [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 | **Accepted (2026-09-09)** -- BUILT and wired, in the form the ADR's own overturn banner settled and NOT the one the title names. **Build state corrected 2026-09-09**, from a cell that read *"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"* -- true when written, an UNDER-claim since the gate landed, and recorded here rather than dropped. **The parenthetical is the date this record was corrected**, not a ratification: the ADR was proposed on 2026-08-15 and the code landed at `4a8f062d6` (2026-08-19, PR #394), whose parent does not carry the symbol. Re-verified by symbol: `_assert_security_notice_is_deliverable` (`api/app.py`) runs inside `create_managed_app`'s lifespan after `bootstrap = await auth.initialize()`, hence AFTER `engine.start()` -- the LIFESPAN (post-bootstrap) placement, NOT the early-lifespan window this row's own description still argues for. Under its three preconditions (auth notices on, `security_notifications_required` true, derived data class PHI) it raises at `enforce` and warns otherwise, with `tests/test_security_notice_deliverability.py` carrying the wiring test plus a warn-side positive control. **The shipped gate does NOT call `has_notifiable_admin()`** -- it inlines the same enumeration (`list_users`, disabled-skip, `notify_email`, `Role.ADMINISTRATOR`), so the predicate has zero non-test call sites and the open-coded copies of "who is an enabled administrator" now number FOUR: the three in `auth/service.py` this row records, plus the gate's own in `api/app.py`. Still owed, at least: the `DEPLOYMENT.md` exit-3 line (that file carries only its two exit-2 statements) and the re-measurement the ADR leaves open. On #1020's rider, the termination evidence is no longer only a minimal repro -- `tests/test_lifespan_startup_unwinds.py` patches this gate's own call site, runs uvicorn in a subprocess and asserts the process exits -- but it injects a `RuntimeError` there rather than arranging the gate's preconditions, so no single test yet drives the real refusal under uvicorn end to end | | [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** |