From 29ad4af0df2e0a69b5b32426edc5fa472bd8598b Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Fri, 14 Aug 2026 17:28:55 -0500 Subject: [PATCH 1/9] docs(tls): stop asserting a harden_kex_groups call-site count nothing checks (BACKLOG #1105) The docstring said `APPROVED_KEX_GROUPS` reaches "zero of this function's six call sites" and, three paragraphs later, "a call at six sites with zero effect". Counted on origin/main rather than taken from the item: there are SEVEN sites that build and harden a real TLS context (api/tls.py:55, config/tls_policy.py:1001, transports/ dicom.py:145 and :463, transports/mllp.py:552 and :591, transports/remotefile.py:248) plus one report-only reference in kex_groups_report's throwaway probe at :181. The fix is not six-to-seven. A hardcoded seven is the same defect one increment later: a second definition of a fact the tests already DERIVE (test_tls_policy.py:594-630 scans for the call marker instead of listing sites), and nothing checks the docstring against it. So the count is removed and the derived list is named as the source. This is the rule the project already applies to the backlog reader and to ENGINE_UI_SEAM: one definition, derived, never a hand-maintained integer beside it. The item said the docstring was "the only place it is asserted". It was not. The same wrong count sat in two more places, both found by grep and both fixed here: tests/test_tls_floor_probe.py:206 and tests/test_tls_policy.py:124. Ledger copies are left alone -- authoring ledger content is not a builder's (owner ruling 2026-08-13), and docs/archive/backlog/BACKLOG-CLOSED.md additionally carries stale LINE numbers for the same sites, which is a separate correction for whoever owns that file. No behaviour change: comments and docstrings only. Verified: ruff check + ruff format --check clean on all three files; mypy strict clean on tls_policy.py; pytest tests/test_tls_policy.py tests/test_tls_floor_probe.py = 81 passed, exit 0. Import resolution confirmed to this worktree, not the primary checkout, before trusting that run. Co-Authored-By: Claude Opus 5 --- messagefoundry/config/tls_policy.py | 10 ++++++---- tests/test_tls_floor_probe.py | 2 +- tests/test_tls_policy.py | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/messagefoundry/config/tls_policy.py b/messagefoundry/config/tls_policy.py index c15fb6ba..e8aabefb 100644 --- a/messagefoundry/config/tls_policy.py +++ b/messagefoundry/config/tls_policy.py @@ -122,8 +122,10 @@ def harden_kex_groups(ctx: ssl.SSLContext) -> str | None: *assertable*. This docstring previously said "Python 3.13+"; that was wrong, and it was repeated into `docs/PHI.md`, `docs/ASVS-L2-PHASE0-CHANGES.md`, ADR 0092 §4(b) and the ASVS scorecard. Measured on this tree: Python 3.14.6 / OpenSSL 3.5.7, ``hasattr(ctx, "set_groups")`` is ``False``, - so :data:`APPROVED_KEX_GROUPS` reaches **zero** of this function's six call sites and every built - context falls back to OpenSSL's default group list. + so :data:`APPROVED_KEX_GROUPS` reaches **none** of this function's call sites and every built + context falls back to OpenSSL's default group list. **No count is stated here on purpose** — the + site list is *derived* by ``tests/test_tls_policy.py``, so a number written out here is a second + definition that nothing checks. The "six" this sentence used to carry was wrong (BACKLOG #1105). That default *is* forward-secret — which is the property the TLS 1.2+ floor guarantees and the one ASVS 11.6.2's first clause is about — but it is **wider than the approved list**. Measured against @@ -133,8 +135,8 @@ def harden_kex_groups(ctx: ssl.SSLContext) -> str | None: the engine's key-exchange groups as "pinned" anywhere — they are **inherited**. The **return value is the point.** A security control that cannot report whether it did anything - reports success forever; that is how a call at six sites with zero effect survived three - assessments. ``tests/test_tls_policy.py`` asserts the ``None`` *unconditionally*, so the first + reports success forever; that is how a call at every one of those sites with zero effect + survived three assessments. ``tests/test_tls_policy.py`` asserts the ``None`` *unconditionally*, so the first interpreter that grows the API turns that test red — which is the signal to re-derive this docstring, `docs/PHI.md` §4 and the 11.6.2 row, and to switch the test to ``get_groups()``. diff --git a/tests/test_tls_floor_probe.py b/tests/test_tls_floor_probe.py index bc874fde..e1346812 100644 --- a/tests/test_tls_floor_probe.py +++ b/tests/test_tls_floor_probe.py @@ -203,7 +203,7 @@ def test_a_missing_tlsversion_enum_raises_rather_than_skipping( """The `harden_kex_groups` failure mode, refused on purpose. That helper pins nothing on this interpreter because `SSLContext.set_groups` does not exist and it - returns silently — six call sites, zero effect, green tests. A probe that skipped when a + returns silently — every call site, zero effect, green tests. A probe that skipped when a deprecated `TLSVersion` disappeared would become a gate that cannot fail, and would report success forever afterwards. diff --git a/tests/test_tls_policy.py b/tests/test_tls_policy.py index ab5ef3f7..5b4c6a24 100644 --- a/tests/test_tls_policy.py +++ b/tests/test_tls_policy.py @@ -121,7 +121,7 @@ def test_the_group_pin_is_inert_on_this_runtime_and_says_so() -> None: """A liveness receipt for ASVS 11.6.2 — written to FAIL on the interpreter upgrade. ``SSLContext.set_groups`` is a **Python 3.15** addition, so ``harden_kex_groups`` pins nothing on - any interpreter this project runs on and ``APPROVED_KEX_GROUPS`` reaches zero of its six call + any interpreter this project runs on and ``APPROVED_KEX_GROUPS`` reaches none of its call sites. That was already true; what was missing was any way to NOTICE. The tests that stood here asserted (a) that the call does not raise, (b) that it no-ops on an object without the API, and (c) the contents of a string constant — all three pass identically whether or not a single group is From 29a026e21fe0d21880d1e097996378e8aecdb191 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Fri, 14 Aug 2026 19:04:49 -0500 Subject: [PATCH 2/9] feat(auth): a deliverable-address predicate for enabled administrators (BACKLOG #1020) The PHI startup gate computes `security_channel_ready` from the SMTP transport alone (`notify_security_events` + `email_smtp_host` + `email_from`). That answers "is a transport configured" and never "can the account that matters actually receive" -- SDS-3.8, the instrument answering the adjacent question. 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 it while the gate reports a healthy channel. `AuthService.has_notifiable_admin()` is the missing half. Scoped to the ROLE, not the bootstrap account: `email` is optional in `UserCreateRequest` and is not required for the Administrator role, so a hand-created privileged account has the identical hole, and keying on the bootstrap user would close this instance while leaving the class open. This is the predicate only. The startup gate that consumes it is the next layer, and its PLACEMENT is a separate decision (ADR 0167) -- measured: raising after `engine.start()` hangs (BACKLOG #1257), and raising earlier in the lifespan exits with uvicorn's 3 rather than the 2 every `_serve` refusal returns. THE ARMS ARE ASYMMETRIC ON PURPOSE, and the mutation proves it discriminates rather than merely passing. Removing the role test reds `test_a_non_administrator_with_an_address_is_ not_enough` ALONE, with the other two still green -- so the arms separate "keys on a deliverable ADMIN" from "is merely hard to satisfy". A uniform failure would have taught nothing (COMMON 4.5.2). The bootstrap arm reproduces the defect on the REAL first-run path rather than a hand-built fixture. RECORDED, NOT FIXED: this is now the THIRD copy of "who is an enabled administrator" in this file, beside `is_last_enabled_admin` and `_other_enabled_admin_exists`. Their agreement is a convention with nothing binding it -- if one gains a lockout or auth_provider condition the others keep the old answer silently. The docstring says so rather than claiming the copies cannot drift, which would be an unenforced property of exactly the kind #1105 was about. Extracting a shared enumeration is its own item; doing it here would rewrite two guards this change has no business touching. Verified: ruff check + ruff format --check clean; mypy strict clean on service.py; pytest tests/test_notifiable_admin.py tests/test_last_admin_guard.py = 7 passed, exit 0 (the sibling suite included deliberately -- it exercises the enumeration this sits beside). Co-Authored-By: Claude Opus 5 --- messagefoundry/auth/service.py | 31 ++++++++ tests/test_notifiable_admin.py | 126 +++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 tests/test_notifiable_admin.py diff --git a/messagefoundry/auth/service.py b/messagefoundry/auth/service.py index 70f2c50e..eef94928 100644 --- a/messagefoundry/auth/service.py +++ b/messagefoundry/auth/service.py @@ -2774,6 +2774,37 @@ async def is_last_enabled_admin(self, user_id: str) -> bool: admins.add(user.id) return admins == {user_id} + async def has_notifiable_admin(self) -> bool: + """True iff at least one ENABLED administrator has an email address on file. + + BACKLOG #1020. The PHI startup gate computes notification readiness from the SMTP transport + alone (``notify_security_events`` + ``email_smtp_host`` + ``email_from``), which answers + *"is a transport configured"* and never *"can the account that matters actually receive"*. + Those come apart on a first run: ``_ensure_bootstrap_admin`` creates the account holding + ``frozenset(Permission)`` with no ``email=``, and ``SecurityEventNotifier.notify`` starts + ``if not event.email: return`` -- so every notice about the most privileged account on the + instance no-ops while the gate reports a healthy channel. + + Deliberately scoped to the ROLE, not to the bootstrap account: ``email`` is optional in + ``UserCreateRequest`` and is not required for the Administrator role, so a hand-created + privileged account has the identical hole. Keying on the bootstrap user alone would close + the instance this was found on and leave the class open. + + Enumerates as :meth:`is_last_enabled_admin` and :meth:`_other_enabled_admin_exists` do -- + same store calls, same disabled-skip, same role test. **That agreement is a convention, not + a mechanism, and this docstring must not claim otherwise:** these are now THREE independent + copies of "who is an enabled administrator", and nothing binds them. If one gains a + condition -- a lockout check, an auth_provider filter -- the others keep the old answer + silently. Extracting a shared enumeration is worth its own item; it is deliberately not done + here, because it would rewrite two guards this change has no business touching. + """ + for user in await self._store.list_users(): + if user.disabled or not user.email: + continue + if Role.ADMINISTRATOR.value in await self._store.get_user_role_ids(user.id): + return True + return False + async def set_ad_group_map(self, entries: Sequence[tuple[str, str]], *, actor: str) -> None: await self._store.set_ad_group_role_map(entries) await self._audit( diff --git a/tests/test_notifiable_admin.py b/tests/test_notifiable_admin.py new file mode 100644 index 00000000..39e240c6 --- /dev/null +++ b/tests/test_notifiable_admin.py @@ -0,0 +1,126 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""BACKLOG #1020: an ENABLED administrator with a deliverable address, as a predicate. + +The PHI startup gate computes notification readiness from the SMTP transport alone +(``notify_security_events`` + ``email_smtp_host`` + ``email_from``). That answers *"is a transport +configured"* and never *"can the account that matters actually receive"* -- and the two come apart on +exactly the instance the gate is meant to protect: ``_ensure_bootstrap_admin`` creates the account +holding ``frozenset(Permission)`` with no ``email=``, so every notice about it no-ops while the gate +reports a healthy channel. + +``has_notifiable_admin`` is the missing half of that question. These arms are deliberately +ASYMMETRIC -- a control that failed on everything would not distinguish *"the predicate keys on a +deliverable admin"* from *"the predicate is just hard to satisfy"*: + +* the REAL bootstrap path yields False (the defect, reproduced rather than described); +* an administrator WITH an address yields True; +* a non-administrator with an address still yields False -- so the predicate keys on the ROLE, not + on "some mailbox exists somewhere", which is the scope the item asks for (``email`` is optional + for any Administrator, so a hand-created privileged account has the identical hole). +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from pathlib import Path + +import httpx +import pytest + +from messagefoundry.api import create_app +from messagefoundry.auth.service import AuthService +from messagefoundry.config.settings import AuthSettings +from messagefoundry.pipeline import Engine + +PW = "a-strong-test-passphrase" # >=15, no app/vendor terms -- satisfies the ASVS policy (WP-3) + + +@pytest.fixture +async def engine(tmp_path: Path) -> AsyncIterator[Engine]: + eng = await Engine.create(tmp_path / "notifiable_admin.db", poll_interval=0.02) + yield eng + await eng.stop() + + +def _client(engine: Engine, service: AuthService) -> httpx.AsyncClient: + transport = httpx.ASGITransport(app=create_app(engine, auth=service)) + return httpx.AsyncClient(transport=transport, base_url="http://t") + + +async def _admin_session(c: httpx.AsyncClient, service: AuthService) -> dict[str, str]: + """Bootstrap the first admin exactly as a first run does, and clear its must-change flag.""" + boot = await service.initialize() + assert boot is not None + tok = ( + await c.post( + "/auth/login", + json={"username": "admin", "password": boot.password, "provider": "local"}, + ) + ).json()["token"] + h = {"Authorization": f"Bearer {tok}"} + await c.post( + "/me/password", + headers=h, + json={"current_password": boot.password, "new_password": "a-rotated-passphrase-99"}, + ) + tok = ( + await c.post( + "/auth/login", + json={"username": "admin", "password": "a-rotated-passphrase-99", "provider": "local"}, + ) + ).json()["token"] + return {"Authorization": f"Bearer {tok}"} + + +async def test_the_bootstrap_admin_alone_is_not_notifiable(engine: Engine) -> None: + """The defect, on the REAL first-run path rather than a hand-built fixture. + + This is the state a deploying site is in at the moment the SMTP-only gate passes: one account, + holding every permission, with no address any notice could reach. + """ + service = AuthService(engine.store, AuthSettings(require_mfa=False)) + async with _client(engine, service) as c: + await _admin_session(c, service) + assert await service.has_notifiable_admin() is False + + +async def test_an_administrator_with_an_address_is_notifiable(engine: Engine) -> None: + service = AuthService(engine.store, AuthSettings(require_mfa=False)) + async with _client(engine, service) as c: + h = await _admin_session(c, service) + r = await c.post( + "/users", + headers=h, + json={ + "username": "root2", + "password": PW, + "roles": ["administrator"], + "email": "ops@example.org", + }, + ) + assert r.status_code == 201, r.text + assert await service.has_notifiable_admin() is True + + +async def test_a_non_administrator_with_an_address_is_not_enough(engine: Engine) -> None: + """The asymmetric arm: an address on a NON-privileged account must not satisfy the predicate. + + Without this, a predicate that merely asked *"does any user have an email"* would pass every + other arm here -- and would report a healthy channel on exactly the instance #1020 describes. + """ + service = AuthService(engine.store, AuthSettings(require_mfa=False)) + async with _client(engine, service) as c: + h = await _admin_session(c, service) + r = await c.post( + "/users", + headers=h, + json={ + "username": "viewer1", + "password": PW, + "roles": ["viewer"], + "email": "viewer@example.org", + }, + ) + assert r.status_code == 201, r.text + assert await service.has_notifiable_admin() is False From 4846b3257de95c366f8b2841718ced4ee2561bb2 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Fri, 14 Aug 2026 19:14:23 -0500 Subject: [PATCH 3/9] docs(adr): ADR 0167 -- gate PHI notification readiness on a deliverable address, early in the lifespan Records the PLACEMENT decision for BACKLOG #1020, which the owner ruling (2026-08-13, option (b): gate startup on a deliverable channel) settled the WHAT of but not the WHERE. The defect: `security_channel_ready` is `notify_security_events and email_smtp_host and email_from` -- SMTP wiring alone. It asks "is a transport configured" and never "can the account that matters actually receive" (SDS-3.8). Those come apart on exactly the instance the gate protects: the bootstrap admin holds `frozenset(Permission)` with no `email=`, and the notifier opens `if not event.email: return`. PLACEMENT, settled by measurement rather than preference: LIFESPAN after engine.start() -- OUT. #1257: an exception there unwinds nothing and HANGS, which is strictly worse than the defect. PREFLIGHT in _serve -- recommended, then WITHDRAWN BY ITS AUTHOR on measurement: _serve opens a store zero times across 1042-2833 and list_users() is async, so it is the first store open in a preflight that never has one. EARLY-LIFESPAN -- CHOSEN. The 191-line window between app.py:5540 (open_store) and :5731 (engine.start()); store open, no engine tasks, 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 rather than being the only outcome the rig could produce. The cost is stated no larger than it is, and an earlier draft is corrected in place: _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. And it must not be claimed exit 2 gives a clean stop today: install-service.ps1:463 sets NSSM `AppExit Default Restart`, so the wrapper restarts on any code. Rejected and recorded rather than omitted: a sentinel catching SystemExit at the uvicorn.run() call site to re-exit 2. UNTESTED -- that "if" was never measured -- and a cross-layer mechanism bought to remove an inconsistency the NSSM finding makes nearly free. An unmentioned alternative gets re-derived; a declined one with a reason does not. The ADR states plainly what it does NOT demonstrate: the termination evidence is a MINIMAL REPRO, not the real gate, so it does not discharge #1020's rider. A rider that exists because someone inferred is not satisfied by an inference. The DEPLOYMENT.md exit-3 line deliberately does NOT land here -- it lands with the gate, because documenting a startup-stage refusal before one exists describes behaviour the code does not have. Verified: pytest tests/test_feature_map_claims.py = 9 passed, exit 0 (the doc-link and ADR-index checks); ruff/format clean via pre-commit. Co-Authored-By: Claude Opus 5 --- ...ress-checked-early-in-the-asgi-lifespan.md | 161 ++++++++++++++++++ docs/adr/README.md | 1 + 2 files changed, 162 insertions(+) create mode 100644 docs/adr/0167-phi-security-notification-readiness-gates-on-a-deliverable-address-checked-early-in-the-asgi-lifespan.md 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 new file mode 100644 index 00000000..e09b4b8e --- /dev/null +++ b/docs/adr/0167-phi-security-notification-readiness-gates-on-a-deliverable-address-checked-early-in-the-asgi-lifespan.md @@ -0,0 +1,161 @@ + + + +# 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 +- **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) + +--- + +## Context + +### The gate answers the adjacent question + +On a PHI instance under `enforcement=enforce`, `serve` refuses to start without a security-notification +channel. It computes readiness as: + +```python +security_channel_ready = bool( + settings.auth.notify_security_events + and settings.alerts.email_smtp_host + and settings.alerts.email_from +) +``` + +That is **SMTP wiring alone**. It asks *"is a transport configured"* and never *"can the account that +matters actually receive"* — **SDS-3.8**, the instrument answering a neighbouring question. + +The two come apart on exactly the instance the gate exists to protect. `_ensure_bootstrap_admin` calls +`create_user` with no `email=`, so on a first run the only account — the one holding +`frozenset(Permission)` — has a NULL address, and `SecurityEventNotifier.notify` opens +`if not event.email: return`. **All ten notice types no-op for the most privileged account on the +instance while the gate reports a healthy channel.** + +Per **CLAUDE.md §0** this is written in the conditional: there are zero deployments, so nothing is +exposed today. It is wrong in the shipped code, and it is wrong in the direction a first deployment +would not notice. + +### Owner ruling + +**2026-08-13: option (b) — gate startup on a deliverable channel.** The design question was settled +before this ADR. What was not settled, and what this ADR is for, is **where the check runs**. + +--- + +## Decision + +**1. Gate on a deliverable address, scoped to the ROLE.** `AuthService.has_notifiable_admin()` is true +iff at least one **enabled** administrator has an email on file. Not the bootstrap account: `email` is +optional in `UserCreateRequest` and is not required for the Administrator role, so a hand-created +privileged account has the identical hole. Keying on the bootstrap user would close the instance this +was found on and leave the class open. + +**2. Run the check EARLY IN THE LIFESPAN — after the store opens, before `engine.start()`.** + +Measured on `api/app.py`: `async def lifespan` at `:5512`, `store = await open_store(...)` at `:5540`, +`await engine.start()` at `:5731`. **The window is 191 lines wide**, and inside it the store is open +and no engine tasks exist. + +### Placements considered + +| name | placement | store access | terminates? | exit code | +|---|---|---|---|---| +| **LIFESPAN** | after `engine.start()` | open | **NO — hangs** (BACKLOG #1257) | n/a | +| **PREFLIGHT** | `_serve`, before the ASGI app | **none available** | yes | **2** | +| **EARLY-LIFESPAN** | after the store opens, before `engine.start()` | **open, plain `await`** | **yes, measured** | **3** | + +**LIFESPAN is out on a measurement, not a preference.** #1257 records that an exception after +`engine.start()` unwinds nothing, so the refusal hangs the process instead of exiting — **strictly +worse than the defect this ADR fixes**, because an operator can see a wrong readiness answer but +cannot see a process that never finishes starting. + +**PREFLIGHT was recommended and withdrawn by its author on measurement.** `_serve` (lines 1042-2833) +runs entirely before the lifespan and never opens a store — zero `open_store` calls across the whole +function. `list_users()` is async. So PREFLIGHT is not "one cheap read": it is **the first store open +in a preflight that has never opened one, driven from sync code**, ahead of whatever `open_store` does +on first touch. That machinery would be bought to preserve an exit code (see below) that the shipped +service wrapper ignores. + +**EARLY-LIFESPAN gets the store as a plain `await`** — no extra open, no sync/async bridge — and its +refusal exits. + +--- + +## The exit code changes, and the divergence is forced + +**Measured** (uvicorn, CPython 3.14.6, minimal ASGI app, probe kept out of tree): + +| arm | result | +|---|---| +| raise early in the lifespan | **exited 0.49s, code 3** — `ERROR: Application startup failed. Exiting.` | +| `sys.exit(2)` early in the lifespan | **exited 0.50s, code 3** — stderr shows `SystemExit: 2`, then the same uvicorn line | +| **positive control**, no raise | reached a **RUNNING** server, self-stopped with a distinct code **99** | + +**The control is what makes the exits mean anything.** A non-exit was observable in the same rig, so +"it exited" was not the only outcome the harness could produce. + +**uvicorn catches `SystemExit`, treats it as a startup failure like any other exception, and exits 3 +regardless of the code requested.** From inside the lifespan there is **no spelling of a refusal that +keeps exit 2**. The divergence is forced, not chosen. + +### What that costs, stated no larger than it is + +`_serve` returns **2** at 32 sites, and `DEPLOYMENT.md` states "(exit 2)" twice — at `:210` for the +PHI/enforce TLS preconditions and at `:564` for the off-loopback bind refusal. **Both citations are +scoped to specific refusals. No line generalises exit 2 to all refusals.** + +So a new refusal exiting 3 is **an inconsistency with two documented specific refusals, not a +contradiction of a published universal claim.** An earlier draft of this reasoning called it a +"documented-contract divergence"; that was stronger than the text supports and is corrected here +rather than quietly dropped. + +**And it must not be claimed that exit 2 gives operators a clean stop today:** +`scripts/service/install-service.ps1:463` sets NSSM `AppExit Default Restart`, which restarts on **any** +exit code. Under the shipped service wrapper the operational delta between 2 and 3 is approximately +nil. **The cost is a reader's surprise, not a broken script.** + +**Accepted, with the cheap honest fix:** `DEPLOYMENT.md` gains a line recording that a **startup-stage** +refusal exits 3, beside the existing exit-2 statements. That converts an undocumented inconsistency +into a documented one for the cost of a sentence, and it is the only part of this decision an operator +will ever see. + +--- + +## Rejected: a sentinel that re-exits 2 + +`uvicorn.run()` does not return on the startup-failure path — it exits the process itself. **If** that +exit is a catchable `SystemExit` at the call site, a caller could catch it and re-exit 2 off a flag set +by the refusal, preserving the exit code with the lifespan placement. + +**Considered and declined. It is untested — that "if" was never measured.** It is a **cross-layer +mechanism** (lifespan sets state, caller intercepts, re-exits) bought to remove an inconsistency that +the NSSM finding above makes nearly free. **The sentinel costs more than the thing it fixes.** + +Recorded rather than omitted, because an unmentioned alternative gets re-derived by the next reader and +a declined one with a reason does not. + +--- + +## Consequences + +- A PHI instance under `enforce` with a configured SMTP transport and **no notifiable administrator** + will refuse to start, where today it starts and reports a healthy channel. +- **That refusal exits 3, not 2**, and is the first refusal in this codebase to do so. +- The check runs on **every** serve of a PHI instance, adding one `list_users()` plus a role lookup per + enabled user at startup only. +- **There are now three independent copies of "who is an enabled administrator"** in `auth/service.py` + (`has_notifiable_admin`, `is_last_enabled_admin`, `_other_enabled_admin_exists`). Their agreement is a + convention with nothing binding it. Extracting a shared enumeration is worth its own item and is + deliberately not done here. + +## What is NOT demonstrated + +**The termination evidence is a minimal repro, not the real gate.** It proves uvicorn's +lifespan-startup-failure path exits; it does **not** prove the real refusal exits with a real store open +and everything `api/app.py` has constructed by `:5540`. + +**BACKLOG #1020's rider asks for the refusal to be demonstrated to terminate under `uvicorn`, and a +rider that exists because someone inferred is not satisfied by an inference.** **This ADR must not be +cited as discharging it.** The real gate carries that obligation when it lands. diff --git a/docs/adr/README.md b/docs/adr/README.md index 3e54441a..a38ef71c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -189,3 +189,4 @@ what is withheld and what you can request. | [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 DECLINED; Phase 3 Proposed.** 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 | | [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 | From 58ef9e4eff28bb6c4c22e82fbfaa88f2c5f1676b Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Fri, 14 Aug 2026 19:33:15 -0500 Subject: [PATCH 4/9] docs(adr): scope ADR 0167's LIFESPAN rejection to a ref -- #1257 is fixed on PR #394 The ADR disqualified the LIFESPAN placement because raising after `engine.start()` hangs. That measurement was taken against `origin/main`, and I wrote it as though it were a property of the codebase. It is not. VERIFIED BY READING THE ARTIFACT, not the claim that reached me: `tests/test_lifespan_startup_unwinds.py` exists at refs/pull/394/head, docstring "BACKLOG #1257: a startup failure after `engine.start()` must let the PROCESS exit." So #1257's fix is already built, on a PR, while the item still reads "not started" on main -- the same unpushed-work-is-invisible shape that produced tonight's duplicate #1105 build and the #1216/#1272 duplicate filing. THE DECISION IS UNCHANGED AND DOES NOT DEPEND ON THE HANG. EARLY-LIFESPAN avoids the post-`engine.start()` window entirely, so it is correct whether or not #1257 has landed. What changes is only WHY the alternative was rejected. Why this correction is worth a commit rather than a footnote: a structural-sounding argument about a defect that no longer exists is worse than no argument. The next reader finds no hang, concludes the ADR is wrong, and distrusts the parts that are still right. This one was only ever right about a ref, and now says so. Also adds an open check the ADR could not have had: the exit-code arms were a minimal repro, unaffected by #394, so they remain valid about uvicorn -- but the REAL gate's behaviour inside a lifespan that now unwinds properly is unmeasured by anyone. Plausibly unchanged, and plausible is not measured. Verified: pytest tests/test_feature_map_claims.py = 9 passed, exit 0. Co-Authored-By: Claude Opus 5 --- ...ress-checked-early-in-the-asgi-lifespan.md | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) 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 e09b4b8e..37e909fc 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 @@ -62,14 +62,28 @@ and no engine tasks exist. | name | placement | store access | terminates? | exit code | |---|---|---|---|---| -| **LIFESPAN** | after `engine.start()` | open | **NO — hangs** (BACKLOG #1257) | n/a | +| **LIFESPAN** | after `engine.start()` | open | **hangs on `origin/main`; FIXED on PR #394** | n/a | | **PREFLIGHT** | `_serve`, before the ASGI app | **none available** | yes | **2** | | **EARLY-LIFESPAN** | after the store opens, before `engine.start()` | **open, plain `await`** | **yes, measured** | **3** | -**LIFESPAN is out on a measurement, not a preference.** #1257 records that an exception after -`engine.start()` unwinds nothing, so the refusal hangs the process instead of exiting — **strictly -worse than the defect this ADR fixes**, because an operator can see a wrong readiness answer but -cannot see a process that never finishes starting. +**LIFESPAN is out on a measurement, not a preference — AND THE MEASUREMENT IS SCOPED TO A REF.** +#1257 records that an exception after `engine.start()` unwinds nothing, so the refusal hangs the +process instead of exiting — **strictly worse than the defect this ADR fixes**, because an operator +can see a wrong readiness answer but cannot see a process that never finishes starting. + +> ⚠️ **THAT DISQUALIFICATION IS TRUE OF `origin/main`, NOT OF THE CODEBASE.** **#1257's fix is +> already built on PR #394** — verified by reading the artifact rather than the claim: +> `tests/test_lifespan_startup_unwinds.py` exists at `refs/pull/394/head`, docstring *"BACKLOG +> #1257: a startup failure after `engine.start()` must let the PROCESS exit."* **Once #394 lands, +> LIFESPAN stops being disqualified.** +> +> **This ADR still chooses EARLY-LIFESPAN, and the choice does not depend on the hang:** it avoids +> the post-`engine.start()` window entirely, so it is right whether or not #1257 has landed. What +> changes is only *why the alternative was rejected*. +> +> **Recorded this way deliberately.** A structural-sounding argument about a defect that no longer +> exists is worse than no argument: the next reader finds no hang, concludes the ADR is wrong, and +> distrusts the parts that are still right. **This one was only ever right about a ref.** **PREFLIGHT was recommended and withdrawn by its author on measurement.** `_serve` (lines 1042-2833) runs entirely before the lifespan and never opens a store — zero `open_store` calls across the whole @@ -159,3 +173,10 @@ and everything `api/app.py` has constructed by `:5540`. **BACKLOG #1020's rider asks for the refusal to be demonstrated to terminate under `uvicorn`, and a rider that exists because someone inferred is not satisfied by an inference.** **This ADR must not be cited as discharging it.** The real gate carries that obligation when it lands. + +**AND ONE OPEN CHECK AGAINST THE POST-#394 TREE.** The exit-code results above were taken on a +minimal repro, which #394 does not touch — so they stand as measurements of *uvicorn's* behaviour. +**But the REAL gate's behaviour inside a lifespan that now unwinds properly has not been measured by +anyone.** It is plausible that a correctly-unwinding lifespan changes nothing about the exit code, +and plausible is not measured. **Re-run the arms against the post-#394 tree before treating exit 3 as +settled for the shipped gate.** From c20ec1b1e7dfce78eb80682f2468b7739e71e89c Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Fri, 14 Aug 2026 22:17:29 -0500 Subject: [PATCH 5/9] docs(adr): OVERTURN ADR 0167's placement -- the data does not exist in the window I chose Found by writing the code, not by reading it. The ADR chose EARLY-LIFESPAN (after open_store at api/app.py:5540, before engine.start() at :5731). That placement is IMPOSSIBLE for this check, for two independent reasons either of which is fatal. Measured, one lifespan, in order: 5540 store = await open_store(...) <- the chosen window starts 5731 await engine.start() <- the chosen window ends 5837 auth = AuthService(...) <- the service does not EXIST until here 5852 bootstrap = await auth.initialize() <- CREATES the bootstrap admin 5923 yield (1) There is no AuthService in that window to call has_notifiable_admin() on. (2) On a first run there is no administrator at all -- initialize() is what creates it (auth/service.py:517, _ensure_bootstrap_admin). A check there would REFUSE EVERY FIRST RUN, before the account it is about exists, converting a gate that reports a wrong answer into one that prevents startup. SO THE #1257 DEPENDENCY IS REAL AND RETURNS. The check must sit after :5852, inside the post-engine.start() window #1257 records as hanging. The Dispatcher's original ruling -- do not close #1020 before #1257 -- was right on its own terms, and my narrowing of it was wrong. THIS VINDICATES THE RIDER'S AUTHOR, who assumed the lifespan placement and was right for a reason none of the three seats arguing about it identified: not "the store is there", but THE DATA THE CHECK NEEDS DOES NOT EXIST UNTIL AFTER THE ENGINE HAS STARTED. Unchanged: the decision to gate on a deliverable address, the predicate (29a026e2), the scoping to the ROLE, and the exit-code findings -- which were always about the post-start path. The placements table gains the column that actually decides it: "has the data?". The first two versions compared store access, termination and exit code -- three real properties, none of them the binding constraint. The ADR's TITLE is now wrong and is left alone deliberately: renaming the file would break the index row and every citation, and the overturn is stated in the first line of the Decision. Retract in place, keep the retraction. Verified: pytest tests/test_feature_map_claims.py = 9 passed, exit 0. Co-Authored-By: Claude Opus 5 --- ...ress-checked-early-in-the-asgi-lifespan.md | 54 ++++++++++++++++--- 1 file changed, 46 insertions(+), 8 deletions(-) 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 37e909fc..af21f40e 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 @@ -52,19 +52,57 @@ optional in `UserCreateRequest` and is not required for the Administrator role, privileged account has the identical hole. Keying on the bootstrap user would close the instance this was found on and leave the class open. -**2. Run the check EARLY IN THE LIFESPAN — after the store opens, before `engine.start()`.** +**2. Run the check AFTER the bootstrap admin is created — which is AFTER `engine.start()`.** -Measured on `api/app.py`: `async def lifespan` at `:5512`, `store = await open_store(...)` at `:5540`, -`await engine.start()` at `:5731`. **The window is 191 lines wide**, and inside it the store is open -and no engine tasks exist. +> ## ⚠️ OVERTURNED 2026-08-15 03:20Z, BY WRITING THE CODE. THE TITLE OF THIS ADR IS NOW WRONG. +> +> **This ADR chose EARLY-LIFESPAN — after `open_store` at `:5540`, before `engine.start()` at +> `:5731` — and that placement is IMPOSSIBLE for this check.** Measured on `api/app.py`, one +> lifespan, in order: +> +> ``` +> 5540 store = await open_store(...) <- the window I chose starts +> 5731 await engine.start() <- the window I chose ends +> 5837 auth = AuthService(...) <- the service does not EXIST until here +> 5852 bootstrap = await auth.initialize() <- CREATES the bootstrap admin +> 5923 yield +> ``` +> +> **Two independent blockers, either one fatal.** In the chosen window there is **no `AuthService`** +> to call `has_notifiable_admin()` on; and on a first run there is **no administrator at all**, +> because `initialize()` is what creates it (`auth/service.py:517`, `_ensure_bootstrap_admin`). A +> check there would **refuse every first run, before the account it is about exists** — turning a +> gate that reports a wrong answer into a gate that prevents startup outright. +> +> **SO THE `#1257` DEPENDENCY IS REAL AND RETURNS.** The check must sit after `:5852`, which is +> inside the post-`engine.start()` window that `#1257` records as hanging. **The Dispatcher's +> original ruling — do not close `#1020` before `#1257` — was right on its own terms all along, and +> my narrowing of it was wrong.** +> +> **THIS VINDICATES THE RIDER'S AUTHOR.** They assumed the lifespan placement and were right, for a +> reason neither the Dispatcher nor I identified while arguing about it: not merely *"the store is +> there"*, but **the data the check needs does not exist until after the engine has started.** +> +> **What does NOT change:** the decision to gate on a deliverable address; the predicate +> (`has_notifiable_admin`, built at `29a026e2`); the scoping to the ROLE; and the exit-code findings, +> which were always about the post-start path. +> +> **This was found by writing the code, not by reading it.** Three seats reasoned about this +> placement across two hours and none of us asked where the bootstrap admin is created — the one +> question the check's own subject makes load-bearing. ### Placements considered -| name | placement | store access | terminates? | exit code | +| name | placement | has the data? | terminates? | exit code | |---|---|---|---|---| -| **LIFESPAN** | after `engine.start()` | open | **hangs on `origin/main`; FIXED on PR #394** | n/a | -| **PREFLIGHT** | `_serve`, before the ASGI app | **none available** | yes | **2** | -| **EARLY-LIFESPAN** | after the store opens, before `engine.start()` | **open, plain `await`** | **yes, measured** | **3** | +| **LIFESPAN (post-bootstrap, after `:5852`)** | after `engine.start()` | **YES — the only placement that does** | hangs on `origin/main`; **fixed on PR #394** | n/a | +| **PREFLIGHT** | `_serve`, before the ASGI app | **no** — no store, no `AuthService` | yes | **2** | +| **EARLY-LIFESPAN** | `:5540`–`:5731` | **NO — no `AuthService`, and on a first run no admin exists yet** | yes, measured | 3 | + +**The "has the data?" column is the one that decides it, and it is the column this ADR originally +did not have.** The first two versions compared placements on store access, termination and exit +code — three real properties, none of which is the binding constraint. **The binding constraint is +that the check's subject does not exist until `:5852`.** **LIFESPAN is out on a measurement, not a preference — AND THE MEASUREMENT IS SCOPED TO A REF.** #1257 records that an exception after `engine.start()` unwinds nothing, so the refusal hangs the From 90a36cda9c19e2cee2c8d6300124512837bae55c Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Fri, 14 Aug 2026 22:27:01 -0500 Subject: [PATCH 6/9] test(tooling): pin the dangling-citation gate's exit-code contract (BACKLOG #1235) #1235's residual (1): `main()`'s exit code and its `--advisory` escape had ZERO coverage. Its last line IS the fail-closed contract -- return 1 if (live and not args.advisory) else 0 -- and nothing asserted it. Invert the `not`, or return 0 unconditionally, and the whole suite stayed green. This file's own header calls a detector that cannot fail "not a gate"; its exit code was in exactly that state. HOW THE GAP WAS FOUND, because the method transfers better than the finding: a SET DIFFERENCE over the module's public surface, not a grep for something missing. The module defines six top-level names (_load_backlog_module, allocation_floor, allocated_numbers, citations_in, unresolved_citations, main); the suite exercised four. `main` was the sole untouched public entry point. Positive enumeration on both sides, so it rests on nobody's choice of pattern -- an earlier token-grep version of the same claim was weaker and I replaced it. FIVE ARMS, AND THE LAST TWO ARE THE POINT. Both produce HITS and both must exit 0, because the contract keys on the LIVE SHAPE rather than the hit count: a number at or below the allocator's high-water mark can never be issued, and a PR/foreign-repo reference is not a backlog citation at all. Without those arms a gate that fired on any hit would pass -- and it would red the tree today on 26 permanently-harmless citations, which is how a gate gets switched off. TWO MUTATIONS, KILLING DISJOINT SETS, so the coverage is proven in both directions: return 0 -> reds {live, advisory} (cannot fail) return 1 if hits else 0 -> reds {advisory, below-floor, PR-shaped} (over-fires) Neither alone would have shown it. The detector was reverted after each and verified byte-identical to HEAD, with a control confirming the diff instrument fires on the file that did change. The below-floor number is DERIVED (first hole under the floor), not hardcoded: a literal becomes a RESOLVING number the day it is filed, at which point the arm stops testing the below-floor branch and nothing says so. Verified: pytest tests/test_dangling_citation_check.py = 39 passed, exit 0; ruff check + format clean; mypy strict clean -- it caught a real one, `cc` is importlib-loaded so allocation_floor() is Any and the arithmetic silently widened the return type. No ledger edit: authoring the banner is not a builder's (owner ruling 2026-08-13). This closes residual (1) only -- (2) the rule existing twice, and (3) the gate not running on doc-only PRs (#1262), are untouched. Co-Authored-By: Claude Opus 5 --- tests/test_dangling_citation_check.py | 81 +++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/tests/test_dangling_citation_check.py b/tests/test_dangling_citation_check.py index 42d00b26..b4b60a9f 100644 --- a/tests/test_dangling_citation_check.py +++ b/tests/test_dangling_citation_check.py @@ -224,3 +224,84 @@ def test_no_docs_citation_names_a_number_that_can_still_be_issued() -> None: assert not live, "citations naming a still-issuable number:\n " + "\n ".join( f"{p}:{n} #{num}" for p, n, num in live ) + + +# --- main()'s EXIT CODE, which nothing above asserts (BACKLOG #1235, residual) --------------------- +# +# HOW THE GAP WAS FOUND, because the method transfers: a SET DIFFERENCE over the module's public +# surface, not a grep for something missing. The module defines six top-level names -- +# _load_backlog_module, allocation_floor, allocated_numbers, citations_in, unresolved_citations, +# main -- and the suite above exercises four. `main` was the SOLE untouched public entry point. +# That is a positive enumeration on both sides, so it does not depend on anyone's choice of pattern. +# +# WHY IT MATTERS HERE SPECIFICALLY: main()'s last line IS the fail-closed contract -- +# return 1 if (live and not args.advisory) else 0 +# -- and it had ZERO coverage. Invert the `not`, or return 0 unconditionally, and every test above +# still passes. This file's own header calls a detector that cannot fail "not a gate"; its exit code +# was in exactly that state. +# +# THE LAST TWO ARMS ARE THE POINT. Both produce HITS and both must exit 0, because the contract keys +# on the LIVE SHAPE rather than on the hit count. Without them, a mutation to `return 1 if hits +# else 0` passes everything -- and that mutation reds the tree today on 26 permanently-harmless +# citations, which is how a gate gets switched off. + + +def _unissued_above_floor() -> int: + """A number the allocator CAN still issue -- the only shape that can ever arm.""" + # int() is load-bearing for mypy, not decoration: `cc` is loaded via importlib at runtime, so + # every attribute on it is Any and the arithmetic silently widens the return type. + number = int(cc.allocation_floor()) + 100 + assert number < 9000, "citations_in only scans [1000,9000); pick differently" + return number + + +def _unissued_below_floor() -> int: + """A permanent hole: at or below the high-water mark, so never issuable. + + Derived rather than hardcoded. A literal would silently become a RESOLVING number the day it is + filed, at which point this stops testing the below-floor branch and nothing would say so. + """ + allocated = cc.allocated_numbers() + floor = cc.allocation_floor() + for number in range(1000, floor + 1): + if number not in allocated: + return number + raise AssertionError("no hole below the floor; this arm needs a different construction") + + +def _doc(tmp_path: pathlib.Path, body: str) -> str: + path = tmp_path / "doc.md" + path.write_text(body, encoding="utf-8") + return str(path) + + +def test_a_live_shape_citation_makes_main_exit_1(tmp_path: pathlib.Path) -> None: + """FAIL CLOSED BY DEFAULT. The whole point of the flip from opt-in `--fail`.""" + doc = _doc(tmp_path, f"see #{_unissued_above_floor()} for the rationale\n") + assert cc.main([doc]) == 1 + + +def test_advisory_reports_the_same_hit_and_exits_0(tmp_path: pathlib.Path) -> None: + """The documented escape. Untested, an opt-out is indistinguishable from a broken gate.""" + doc = _doc(tmp_path, f"see #{_unissued_above_floor()} for the rationale\n") + assert cc.main([doc]) == 1 # same corpus, so the arms differ ONLY by the flag + assert cc.main([doc, "--advisory"]) == 0 + + +def test_a_file_with_no_citation_exits_0(tmp_path: pathlib.Path) -> None: + assert cc.main([_doc(tmp_path, "no citation here at all\n")]) == 0 + + +def test_a_citation_BELOW_the_floor_is_reported_but_does_not_fail(tmp_path: pathlib.Path) -> None: + """ASYMMETRIC ARM 1: a hit that must NOT fail. Below the high-water mark the allocator can never + issue that number, so the citation is permanently harmless -- reported for a human, not a defect. + """ + doc = _doc(tmp_path, f"see #{_unissued_below_floor()} for the rationale\n") + assert cc.main([doc]) == 0 + + +def test_a_PR_SHAPED_reference_is_reported_but_does_not_fail(tmp_path: pathlib.Path) -> None: + """ASYMMETRIC ARM 2, on the other axis: a foreign reference is not a backlog citation at all, + even when its number is above the floor.""" + doc = _doc(tmp_path, f"shipped in PR #{_unissued_above_floor()}\n") + assert cc.main([doc]) == 0 From c388d3afe105a6e8c53249fdc9bb40fbfc31ea36 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Fri, 14 Aug 2026 22:44:09 -0500 Subject: [PATCH 7/9] refactor(tooling): one definition of the live-shape rule (BACKLOG #1235 residual 2) The predicate the gate's exit code keys on existed TWICE: inline in `main()`, and re-derived inside the gate test. They agreed by convention with nothing binding them -- a rule with two definitions, inside the very tool that exists to catch that shape in other people's gates. `is_live_shape(hit, floor)` is now the one definition. `main()` calls it, and the test's `_live_shape_citations` calls it plus `unresolved_citations` instead of re-implementing both halves (`number in filed` duplicated the latter; `number <= floor or pr_shaped` duplicated the former). THE CONSEQUENCE IS CHECKABLE, AND IT IS THE PROOF RATHER THAN THE CLAIM. Mutating `is_live_shape` to `return True` now reds THREE tests across BOTH consumers: test_no_docs_citation_names_a_number_that_can_still_be_issued <- the real-tree GATE test_a_citation_BELOW_the_floor_is_reported_but_does_not_fail <- exit-code arm test_a_PR_SHAPED_reference_is_reported_but_does_not_fail <- exit-code arm Before this, the gate test carried its own copy: a mutation to main()'s inline predicate left it GREEN. That divergence is precisely what "agreeing by convention" buys, and one mutation reaching both consumers is what shows the convention has been replaced by a mechanism. The detector was reverted after the mutation and verified to carry no MUTANT text, with the suite back to green. Verified: pytest tests/test_dangling_citation_check.py = 39 passed, exit 0; ruff check + format clean on both files; mypy strict clean on both. No ledger edit -- authoring the banner is not a builder's. Residual (3), the gate not running on doc-only PRs, is #1262 and is untouched. Co-Authored-By: Claude Opus 5 --- scripts/docs/dangling_citation_check.py | 23 +++++++++++++++++- tests/test_dangling_citation_check.py | 31 +++++++++++++------------ 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/scripts/docs/dangling_citation_check.py b/scripts/docs/dangling_citation_check.py index 1aee0144..39a7248f 100644 --- a/scripts/docs/dangling_citation_check.py +++ b/scripts/docs/dangling_citation_check.py @@ -198,6 +198,27 @@ def unresolved_citations(paths: list[Path], allocated: set[int]) -> list[Hit]: return hits +def is_live_shape(hit: Hit, floor: int) -> bool: + """True iff this unresolved citation can EVER arm -- the shape the exit code keys on. + + THE ONE DEFINITION (BACKLOG #1235 residual 2). This predicate previously existed twice: once + inline in :func:`main` and once re-derived inside the gate test, agreeing by convention with + nothing binding them. Two definitions of the rule a gate fires on is the defect this whole tool + exists to catch in other people's gates, reproduced inside it. + + Both conditions exclude a citation that is REPORTED but is not a defect: + + * ``number > floor`` -- at or below the allocator's high-water mark a number can never be + issued (``alloc.ps1`` starts at ``$observed + 1`` and only ever scans upward), so the + citation resolves to nothing permanently rather than by luck. + * ``not pr_shaped`` -- a PR, issue or foreign-repo reference is not a backlog citation at all. + + Keeping them here rather than at the call site is what makes a single mutation red BOTH the + exit-code arms and the real-tree gate. Before this, mutating one left the other green. + """ + return hit.number > floor and not hit.pr_shaped + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter @@ -275,7 +296,7 @@ def main(argv: list[str] | None = None) -> int: # never be issued, and a PR/issue/foreign-repo reference is not a backlog citation at all; both # are reported for a human to read and neither is a defect. Failing on them would red the tree # today for hits that are correct, and a gate that cries wolf gets switched off. - live = [h for h in hits if h.number > floor and not h.pr_shaped] + live = [h for h in hits if is_live_shape(h, floor)] if live: print() print(f"LIVE SHAPE: {len(live)} citation(s) name a number that can still be issued.") diff --git a/tests/test_dangling_citation_check.py b/tests/test_dangling_citation_check.py index b4b60a9f..36cd3811 100644 --- a/tests/test_dangling_citation_check.py +++ b/tests/test_dangling_citation_check.py @@ -188,25 +188,26 @@ def _repo_root() -> Path: def _live_shape_citations() -> list[tuple[str, int, int]]: - """Citations naming a number ABOVE the floor that are not PR/foreign-repo shaped. + """Citations naming a number that can still be issued -- the shape that can arm. - Below the floor is unreachable forever, and a foreign reference is not a backlog citation at - all; both are reported by the tool for a human to read and neither is a defect. + CALLS THE SHIPPED PREDICATES RATHER THAN RE-DERIVING THEM (BACKLOG #1235 residual 2). This + helper previously re-implemented both halves inline -- `number in filed` duplicating + `unresolved_citations`, and `number <= floor or pr_shaped` duplicating what `main` filters on. + Two of the three agreed with the script by convention, with nothing binding them: a rule with + two definitions is the exact defect this tool exists to catch in other people's gates. + + The consequence is checkable and is the point: a single mutation to `cc.is_live_shape` now reds + BOTH this real-tree gate and the exit-code arms below. Before, mutating one left the other + green, which is what "agreeing by convention" buys you. """ root = _repo_root() floor = cc.allocation_floor() - filed = cc.allocated_numbers() - out: list[tuple[str, int, int]] = [] - for path in sorted((root / "docs").rglob("*.md")): - try: - text = path.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - continue - for lineno, number, _line, pr_shaped in cc.citations_in(text): - if number in filed or number <= floor or pr_shaped: - continue - out.append((str(path.relative_to(root)), lineno, number)) - return out + hits = cc.unresolved_citations(sorted((root / "docs").rglob("*.md")), cc.allocated_numbers()) + return [ + (str(hit.path.relative_to(root)), hit.lineno, hit.number) + for hit in hits + if cc.is_live_shape(hit, floor) + ] def test_the_docs_scan_actually_covers_something() -> None: From 3780c1a1a7cfa2b20ba9d732d05fa1eee7ed1adf Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Fri, 14 Aug 2026 22:50:31 -0500 Subject: [PATCH 8/9] refactor(security): one definition of the control-char alphabet (BACKLOG #1273) `controlchars.py` exists to state `ord(ch) < 0x20 or ord(ch) == 0x7F` once -- its own docstring records that it replaced the expression written seven times across six files. It then kept TWO copies of its own, one in each public arm. `_is_control_char` is now the one definition; both arms call it. THE RISK WAS ASYMMETRIC, AND I MEASURED IT RATHER THAN ASSERTING IT -- on the two-copy structure, before changing it: widen the PREDICATE arm alone -> CAUGHT. 4 tests red, because test_c1_and_unicode_separators_are_deliberately_NOT_caught pins the alphabet widen the STRIP arm alone -> NOT CAUGHT. 47 passed, exit 0 So the copies were PARTLY bound, and the unguarded direction is the one that matters. Widening the alphabet is the stated reason this module exists ("a behaviour change at seven call sites at once ... make it deliberately"), and a deliberate widening applied to the neutraliser would silently strip more than the screen refuses -- a screen and its neutraliser disagreeing about their own alphabet, with every test green. I PREDICTED THE SUITE WOULD MISS BOTH DIRECTIONS AND THE MEASUREMENT REFUTED HALF OF IT. The prediction is recorded here because the refutation is the useful part: the item says "nothing binds them", I said the tests would stay green, and the truth is neither -- one direction is pinned by an explicit alphabet test and the other is wide open. A claim about which mutations a suite kills is checkable in one command and is worth checking before it goes in a docstring. Proof that the definition is now shared: mutating `_is_control_char` to `return False` reds 39 tests spanning BOTH arms -- the has_control_char parametrisation, the strip tests, and the two cross-checks. One mutation reaching every consumer is the property; the probes above are what show it was not already true. Both probes and the mutant were reverted and the file verified to carry no probe text. Verified: pytest tests/test_controlchars.py = 47 passed, exit 0; ruff check + format clean; mypy strict clean. No behaviour change -- the alphabet is byte-identical. No ledger edit: authoring the banner is not a builder's. Co-Authored-By: Claude Opus 5 --- messagefoundry/controlchars.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/messagefoundry/controlchars.py b/messagefoundry/controlchars.py index f4308797..bc9c64ac 100644 --- a/messagefoundry/controlchars.py +++ b/messagefoundry/controlchars.py @@ -42,9 +42,34 @@ #: screens values destined for byte-oriented sinks -- a request line, a header, a path -- where C0 #: and DEL are the injection alphabet. Widening it is a behaviour change at seven call sites at #: once, which is exactly the leverage this module exists to provide; make it deliberately. +def _is_control_char(ch: str) -> bool: + """THE ONE DEFINITION of the alphabet this module screens for (BACKLOG #1273). + + It was previously written out TWICE -- once in each public arm -- inside the module whose whole + purpose is to state it once. The module docstring above records that this replaced the same + expression written seven times across six files; it then kept two copies of its own. + + That is not a cosmetic duplication, and the risk is ASYMMETRIC -- measured on the two-copy + structure before this change, not predicted: + + * widening the **predicate** arm alone was **CAUGHT** -- 4 tests red, because + ``test_c1_and_unicode_separators_are_deliberately_NOT_caught`` pins the alphabet directly; + * widening the **strip** arm alone was **NOT CAUGHT** -- 47 passed, exit 0. + + So the copies were partly bound and partly not, and the unguarded direction is the one that + matters: **widening the alphabet is the stated reason this module exists** ("a behaviour change + at seven call sites at once ... make it deliberately"), and a deliberate widening applied to the + neutraliser would silently strip more than the screen refuses -- a screen and its neutraliser + disagreeing about their own alphabet, with every test green. + + One definition closes both directions by construction rather than by a test noticing. + """ + return ord(ch) < 0x20 or ord(ch) == 0x7F + + def has_control_char(text: str) -> bool: """True if ``text`` contains any C0 control character or DEL.""" - return any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in text) + return any(_is_control_char(ch) for ch in text) def strip_control_chars(text: str) -> str: @@ -53,4 +78,4 @@ def strip_control_chars(text: str) -> str: The strip arm, used where a value must be neutralised rather than refused. See the module docstring: this is NOT the general remedy and must not be substituted for a rejection. """ - return "".join(ch for ch in text if not (ord(ch) < 0x20 or ord(ch) == 0x7F)) + return "".join(ch for ch in text if not _is_control_char(ch)) From abd03bf6fe1bbc6493f83ff154a5ccedeae228eb Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Thu, 20 Aug 2026 08:46:32 -0500 Subject: [PATCH 9/9] backlog: close 1105, and record what 1273 and 1020 do NOT close (lander-authored banners) ADR 0165 pairing on a stranded branch whose author session is gone. The fixes are that lane's; these three ledger entries are the lander's, because a builder may not author ledger content. 1105 CLOSES. The docstring's call-site count was DELETED rather than corrected, which is the right fix: the site list is derived by the tests, so a number in prose is a second definition nothing checks. Correcting six to seven would have produced a true sentence measuring the wrong thing and would have read as freshly checked forever. 1273 STAYS OPEN, and the banner says why. controlchars.py now states the set once and both its functions read it -- but logging_setup.py:66-70 still re-derives the same set and the branch never touches that file. The item names three spellings and asks for one definition of the SET. Two of three now share one. 1020 STAYS OPEN, and its stated hold condition has EXPIRED. The item says do not close before 1257 lands; 1257 HAS landed, content-tested against origin/main rather than read from its banner, which still reads open. The live reason is different and is measurable: __main__.py:2350-2354 still computes readiness from SMTP wiring alone, and this branch adds the predicate without wiring it in. A reason that can expire silently is a worse reason even while it is still true. 1235 IS DELIBERATELY UNTOUCHED. It belongs to the dispatcher seat's current wave; two independently-authored banners for one item is worse than a conflict, because both look correct and a merge may silently take one. parse_items before/after: 312/231/81 -> 312/230/82, the expected 0 items / -1 open / +1 closed. Neither open-item amendment contains any character from the status alphabets, asserted in the edit script, so neither can be misread as a second banner. Built with plumbing (read-tree, update-index, commit-tree); no working tree touched. Plumbing bypasses pre-commit, so the backlog status check was run by hand against the result. --- docs/BACKLOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index b6a85716..2872ce02 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -4204,6 +4204,7 @@ Retiring the tree costs the engine nothing operationally: **`tests/test_ech_egre ## 1020. The first-run bootstrap Administrator is created with no email address, and the PHI notification gate cannot see it +> **AMENDED 2026-08-20 -- THE HOLD CONDITION THIS ITEM NAMES HAS BEEN SATISFIED, AND THE ITEM STILL DOES NOT CLOSE. THE REASON CHANGED; THE VERDICT DID NOT.** The rulings below say *"do not close this before `#1257` lands"*. **`#1257` HAS LANDED** -- `tests/test_lifespan_startup_unwinds.py` is present on `origin/main` and `api/app.py` carries the unwind around `engine.start()`. Content-tested against `origin/main`, not read from `#1257`'s banner, which still reads open. **So anyone watching that banner flip would close this item for a reason that was never the real one.** **THE LIVE REASON, measured on `origin/main` the same day:** `__main__.py:2350-2354` still computes `security_channel_ready` as `notify_security_events AND email_smtp_host AND email_from` -- **SMTP wiring alone.** It asks *"is a transport configured"*, never *"can the account holding `frozenset(Permission)` receive"*, which is this item's second sentence verbatim. The landing branch adds the deliverable-address predicate in `auth/service.py` and **never wires it in**: `__main__.py` is not in its changed-file list and the whole file hashes identically on both refs. **A reason that can expire silently is a worse reason even while it is still true** -- this one is checkable in one command. Recorded by the lander under ADR 0165. > 🔢 **Filed 2026-08-04 — not started.** Value **5/10** · Difficulty **3/10** · _fill-in_. `_ensure_bootstrap_admin` calls `create_user` with no `email=`, so the account holding `frozenset(Permission)` has a NULL email and `SecurityEventNotifier.notify`'s `if not event.email: return` makes all ten notice types no-op for it. The PHI startup gate that refuses to serve without a notification channel computes readiness from the SMTP transport alone, so it would report a healthy channel while no notice about the all-permission account could be delivered. > ⚠️ **AMENDED 2026-08-11 — the bootstrap half shipped on a branch; THE PART WITH TEETH IS UNTOUCHED, so this stays OPEN.** `w3-l2-auth-policy` (`a46f7a83`, pushed, **unlanded**) retires the emailless bootstrap admin. But the item's second sentence — the PHI startup gate reporting a healthy channel while no notice about the all-permission account could be delivered — **is not addressed**. Re-read on `main` rather than relayed: `messagefoundry/__main__.py:2292-2296` still computes `security_channel_ready` as `notify_security_events and email_smtp_host and email_from`, which is **SMTP wiring alone**. It asks *"is a transport configured"*, never *"can the account that matters actually receive"* — SDS-3.8, the instrument answering the adjacent question. A deploying site would get a green readiness signal for a channel that cannot reach the one account holding `frozenset(Permission)`. @@ -5995,7 +5996,8 @@ rules out the family `#1014` already fixed and points at the one it did not. ## 1105. `harden_kex_groups`' docstring undercounts its own call sites, in the paragraph written to warn about exactly that -> 🔢 **Filed 2026-08-08 - not started. Measured on `main` at 166634c9, not hypothesised.** Value **3/10** · Difficulty **1/10**. `messagefoundry/config/tls_policy.py:125` says `APPROVED_KEX_GROUPS` reaches "zero of this function's **six** call sites"; `:136` repeats "a call at **six** sites with zero effect". Scanning `messagefoundry/`, `tests/`, `harness/`, `packaging/` and `ide/` for `harden_kex_groups(` finds **seven** sites that build and harden a real TLS context, plus an eighth reference added by `#338`. Nothing checks the number - the tests derive their site list instead - so the docstring is the only place it is asserted, and it is wrong. +> ✅ **SHIPPED 2026-08-20 -- the count was DELETED, not corrected, and that is the fix.** The docstring no longer states a number: `APPROVED_KEX_GROUPS` reaches **none** of the call sites, and the paragraph now says why no count is stated -- the site list is *derived* by `tests/test_tls_policy.py`, so a number written into prose is a second definition that nothing checks. **A cited number carrying no conclusion is a liability; delete it rather than correct it.** Correcting "six" to "seven" would have produced a true sentence measuring the wrong thing, and it would have read as freshly checked forever. **No behaviour change of any kind** -- the separately-recorded fact that `harden_kex_groups` pins nothing on any interpreter this project runs on (`SSLContext.set_groups` is a 3.15 API) is untouched. Fix authored by the builder lane; this banner by the lander under ADR 0165. Original filing follows. +> **Filed 2026-08-08 - not started. Measured on `main` at 166634c9, not hypothesised.** Value **3/10** · Difficulty **1/10**. `messagefoundry/config/tls_policy.py:125` says `APPROVED_KEX_GROUPS` reaches "zero of this function's **six** call sites"; `:136` repeats "a call at **six** sites with zero effect". Scanning `messagefoundry/`, `tests/`, `harness/`, `packaging/` and `ide/` for `harden_kex_groups(` finds **seven** sites that build and harden a real TLS context, plus an eighth reference added by `#338`. Nothing checks the number - the tests derive their site list instead - so the docstring is the only place it is asserted, and it is wrong. **Cluster:** Documentation accuracy / security-comment drift. **Priority:** P3. **Verdict:** build. **Severity:** no product effect and no PHI effect, and **no behaviour change of any kind** - the separate, @@ -10044,6 +10046,7 @@ _FHIR_ID_RE.fullmatch("abc\n") -> False the fix **Severity:** no deployment axis (§0) -- dev-box test tooling; **CI (Linux) is unaffected and reports nothing**. The cost is that every seat running a full suite on this box may or may not hit a 160-failure wall depending on its own `PATH`, and while it is hit the module cannot detect the defect it exists to catch. ## 1273. the module that exists to write the C0/DEL test once writes it twice, and logging_setup re-derives it a third time +> **PARTIAL 2026-08-20 -- ONE OF THE TWO SPELLINGS INSIDE THE MODULE IS GONE; THE THIRD, IN ANOTHER MODULE, IS NOT. THIS ITEM STAYS OPEN.** `controlchars.py` now states the set once, in `_is_control_char` at `:67`, and both `has_control_char` and `strip_control_chars` read it -- so the module's own interior no longer contradicts its docstring. **But `logging_setup.py:66-70` still re-derives the same set independently** (`for _i in range(0x20)` plus `[0x7F]`), and the landing branch does not touch that file at all. The item names **three** spellings and asks for **one definition of the SET**; two of three now share one. **The leverage the module exists to provide is still absent across the module boundary:** widen `_is_control_char` and it reaches neither `_CTRL_TRANSLATION` nor anything reading it, and nothing reports the omission. Measured on the landing branch, not inferred. Recorded by the lander under ADR 0165, because a closing banner that lists what a change fixed and not what it left is half a record. > 🔢 **Filed 2026-08-15 - not started. THE CONSOLIDATION DOES NOT CONSOLIDATE ITS OWN TWO FUNCTIONS.** [`controlchars.py`](../messagefoundry/controlchars.py) was created by [#1253](BACKLOG.md) to write the C0/DEL test **once**; its docstring is titled *"The C0/DEL control-character test, written once"* and ends *"THE POINT IS THE COPYING PRACTICE, not the seven known lines. If you need this test, import it."* **It then spells the predicate out twice inside itself**, and a third statement of the same set lives in `logging_setup`. > **MEASURED, three independent spellings of one set:**