From 4a3a8687a7522b8258b4f51d6ed8222b535e0174 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Tue, 4 Aug 2026 09:50:42 -0500 Subject: [PATCH 1/4] docs(backlog): record the owner rulings on #341 and #326 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both items reserved a fork that changes BEHAVIOUR, not style, and both were blocking a Wave-1 lane. The owner ruled 2026-08-04; recording it in the item bodies so it outlives the exchange -- a decision that lives only in a session message is one nobody can find, and a Workflow dispatched at either item would otherwise have made the call itself. #341 -> RAISE, do not widen. A Handler returning a tuple/set of Sends must raise, so the message lands ERROR/dead-letter. Widening would start DELIVERING messages the engine drops today: every live Handler returning a tuple begins flowing on upgrade, silently, including PHI currently dropped. An accept-and-drop is what CLAUDE.md ยง12 forbids, and turning it into an accept-and-send nobody asked for is the worse of the two. A loud dead-letter is visible and recoverable; a quiet new delivery path is neither. Recorded with two build constraints, because they decide how the fix lands rather than whether: test_partition_parity_table pins the current [0,0,0] behaviour deliberately and its docstring argues against normalising -- that rationale is superseded, so the test is rewritten and its reasoning replaced, not extended around. And _sandbox_codec.py preserves the container shape on purpose, so in-process and subprocess modes must agree or the fix creates a mode-dependent disposition. #326 -> WARN FIRST, with a DATED flip to refuse. The fix is unchanged (re-key admin_exposed off the serve_ui-independent predicate that already exists in the file, correct the two exposure_desc else-branches); what is settled is the upgrade behaviour. Because it makes a currently-starting configuration refuse, against this repo's own rule at docs/CONFIGURATION.md:1439, the refusal is deferred. The date is not decoration. A deferred refusal with no date is a refusal that never happens -- it decays into a warning everyone scrolls past, which is precisely how the inert control this item is about came to exist. The gap is real and measured (arm C starts rc=0 with single-factor admin on production PHI) but has been latent a while; breaking a running production instance on upgrade with no notice trades one silent failure for a loud one nobody scheduled. Neither item is closed -- these are rulings, not completions, and both keep their open banner. The first draft led each blockquote with โœ… and the status gate correctly rejected it as a closed banner contradicting an open one; they now use the file's glyph-free AMENDED convention. backlog_status_check.py --min-items 277: OK, 278 items. --- docs/BACKLOG.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 25ccb358..203ca443 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -2836,6 +2836,24 @@ No test covers it. `tests/test_scan_tokens_source.py:559-583` (`test_absolute_ho > ๐Ÿ”ข **Filed 2026-08-01 โ€” not started.** Value **6/10** ยท Difficulty **3/10** ยท _quick win_. ASVS 6.3.3's admin-MFA refusal and #189's dual-control warning are both inert in the topology the runbook recommends โ€” the ADR 0143 auto-degrade sets `settings.api.serve_ui = False` in place before `ui_exposed` and `admin_exposed` are derived from it (`messagefoundry/__main__.py`, the flip and the two derivations in one ladder), so the engine calls one instance exposed for 11.7.1 and not exposed for 6.3.3 in a single boot โ€” but `require_mfa` defaults on and `security_loosenings()` still names the explicit opt-out on every boot; re-key `admin_exposed` on the `instance_exposed` predicate already present in the file, fix two `exposure_desc` else-branches, and settle the refuse-on-upgrade fork against `docs/CONFIGURATION.md:1439`. +> **OWNER RULING 2026-08-04 โ€” WARN FIRST, with a DATED flip to refuse.** The fork at Proposed point 3 +> is settled: the corrected `admin_exposed` derivation ships as a **warning**, not as an immediate +> refusal. +> +> The fix itself is unchanged โ€” re-key `admin_exposed` off a `serve_ui`-independent predicate +> (`instance_exposed` already exists in this file) and correct the two `exposure_desc` else-branches. +> What is settled is the *upgrade behaviour*: because this makes a **currently-starting configuration +> refuse**, against this repo's own rule at `docs/CONFIGURATION.md:1439`, the refusal is deferred. +> +> **The flip must carry a DATE, in the warning text and in this item.** A deferred refusal with no date +> is a refusal that never happens โ€” it becomes a warning everyone learns to scroll past, which is how +> the inert control this item is about came to exist in the first place. Scheduling it is the point; +> "flip it later" is not a plan. +> +> The gap is real and measured (arm C starts rc=0 with single-factor admin on production PHI), but it +> has been latent for some time. Breaking a running production instance on upgrade, with no notice, +> trades one silent failure for a loud one nobody scheduled. + **Cluster:** Security & Compliance. **Priority:** P1. **Verdict:** build. **Severity:** medium. **What:** the `serve` startup ladder derives its admin-exposure predicate from a field an earlier arm has already mutated. In [`messagefoundry/__main__.py`](../messagefoundry/__main__.py): @@ -3615,6 +3633,22 @@ What is NOT settled is the mechanism. Two independent passes reached different a > ๐Ÿšง **Status OPEN (filed 2026-08-01).** Value **9/10** ยท Difficulty **3/10** ยท _quick win_. `_partition` ([pipeline/dryrun.py:112](../messagefoundry/pipeline/dryrun.py)) narrows with `items = result if isinstance(result, list) else [result]`. A **`list`** of `Send`s works; a **tuple** or **set** does not โ€” the container itself becomes the single item, matches none of the three `isinstance` filters, and yields `([], [], [])`. **Verified live:** `_partition((send, send))[0] == []`. The Handler ran, returned deliveries, and **nothing is delivered and nothing errors** โ€” the message finalizes `FILTERED` (every handler ran but delivered nothing), which is indistinguishable from a handler deliberately declining it. That is an **accept-and-drop**, the one thing CLAUDE.md ยง12 forbids outright. +> **OWNER RULING 2026-08-04 โ€” RAISE, do not widen.** The fork this item reserved is settled: a +> Handler returning a tuple or set of `Send`s must **raise**, so the message lands `ERROR`/dead-letter. +> It must NOT be widened to accept any iterable. +> +> **The reasoning, because it decides how the fix is built.** Widening would start *delivering* messages +> the engine drops today โ€” every live Handler returning a tuple would begin flowing on upgrade, silently, +> including PHI currently being dropped. An accept-and-drop is what CLAUDE.md ยง12 forbids; converting it +> into an accept-and-**send** nobody asked for is the worse of the two failures. A loud dead-letter is +> recoverable and visible; a quiet new delivery path is neither. +> +> โš ๏ธ `tests/test_sandbox_codec.py::test_partition_parity_table` pins the current `[0,0,0]` behaviour +> deliberately and its docstring argues *against* normalising โ€” that rationale is **superseded by this +> ruling**, so the test is rewritten and its reasoning replaced, not extended around. `pipeline/_sandbox_codec.py` +> preserves the container shape on purpose, so in-process and subprocess modes must agree on the new +> behaviour or the fix creates a mode-dependent disposition. + **Cluster:** Correctness / data loss. **Priority:** P1. **Verdict:** build (small). **Severity:** high (silent PHI non-delivery, no operator signal), medium (likelihood: `return (Send(...), Send(...))` is a natural idiom and a one-character difference from the working form). **Why it is not merely cosmetic:** the disposition is not `ERROR` and not `UNROUTED` โ€” it is `FILTERED`, a *legitimate* outcome. So the count-and-log invariant is satisfied on paper (the message is counted and logged) while the operator is told the handler chose to drop it. Nothing in the store, the console, or an alert distinguishes this from intent. From 5463287f5b8fc78aa757494dbbbf15b70bef6170 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Tue, 4 Aug 2026 09:55:51 -0500 Subject: [PATCH 2/4] =?UTF-8?q?docs(backlog):=20correct=20the=20anchor=20i?= =?UTF-8?q?n=20the=20#326=20ruling=20=E2=80=94=20the=20rule=20is=20at=20:1?= =?UTF-8?q?475,=20not=20:1439?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #326 ruling justified deferring the refusal by citing "this repo's own rule at docs/CONFIGURATION.md:1439". A sibling session checked the anchor and it is wrong. At HEAD, :1439 sits in the [security] desugaring section, whose rule is "no shipped refusal is LOOSENED" (the No-loosen rule, ADR 0092 ยง5). That is about not weakening an existing refusal -- the OPPOSITE direction from adding a new one that breaks a starting config. Cited as written, the ruling rested on a rule that says nothing about its case. The real rule is at :1475 and the quoted text was right all along: "a new refusal fires only on a new opt-in". I inherited the wrong line number from #326's own Proposed point 3 without re-checking it -- the same defect this session catalogued five times in other people's work, in a ruling other sessions are about to build from. The correction also surfaces something the implementer needs. The real rule's stated reasoning IS this case -- "a refusal by default would stop working dev/staging/prod deployments from booting on upgrade over something they cannot change" -- and the house pattern it names (require_memory_encryption_declaration, ADR 0152 rung 2; allowed_client_networks, ADR 0151) is a refusal gated behind an OPT-IN FLAG, not merely a dated flip. The owner's ruling stands as given; whether the eventual refusal also sits behind an opt-in is a build detail that should follow those two precedents rather than be invented at the keyboard. Recorded as a dated correction inside the ruling rather than a silent edit, because the wrong anchor was already pushed and a reader who saw it should be able to see that it moved and why. backlog_status_check.py --min-items 277: OK, 278 items. --- docs/BACKLOG.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 203ca443..710e8035 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -2843,7 +2843,23 @@ No test covers it. `tests/test_scan_tokens_source.py:559-583` (`test_absolute_ho > The fix itself is unchanged โ€” re-key `admin_exposed` off a `serve_ui`-independent predicate > (`instance_exposed` already exists in this file) and correct the two `exposure_desc` else-branches. > What is settled is the *upgrade behaviour*: because this makes a **currently-starting configuration -> refuse**, against this repo's own rule at `docs/CONFIGURATION.md:1439`, the refusal is deferred. +> refuse**, against this repo's own rule โ€” *"a new refusal fires only on a new opt-in"* โ€” the refusal +> is deferred. +> +> โš ๏ธ **Anchor corrected 2026-08-04: that rule is at `docs/CONFIGURATION.md:1475`, not `:1439`.** #326's +> own Proposed point 3 cites `:1439`; at HEAD that line sits in the `[security]` desugaring section, +> whose rule is *"no shipped refusal is **loosened**"* (the No-loosen rule, ADR 0092 ยง5) โ€” which is +> about not WEAKENING an existing refusal, the opposite direction from adding one. The quoted text was +> right and the line number was wrong; this ruling inherited the wrong anchor from the item without +> re-checking it, and a sibling session caught it. +> +> **The real rule reads stronger than "warn first", and the implementer should know it.** Its stated +> reasoning is this exact case โ€” *"a refusal by default would stop working dev/staging/prod deployments +> from booting on upgrade over something they cannot change"* โ€” and the house pattern it names +> (`require_memory_encryption_declaration`, ADR 0152 rung 2; `allowed_client_networks`, ADR 0151) is a +> refusal gated behind an **opt-in flag**, not merely a dated flip. The owner ruled warn-first with a +> dated flip; whether the eventual refusal also sits behind an opt-in is a build detail that should +> follow those two precedents rather than be invented. > > **The flip must carry a DATE, in the warning text and in this item.** A deferred refusal with no date > is a refusal that never happens โ€” it becomes a warning everyone learns to scroll past, which is how From 1d8f81c44c5a012821eeffe6899c2c0284804e7b Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Tue, 4 Aug 2026 09:58:05 -0500 Subject: [PATCH 3/4] docs(backlog): correct the #326 anchors again, and record the mechanism question Second correction to the same citation, because the first was still imprecise. Both catches came from sibling sessions reading the line verbatim; neither came from me. WHAT :1439 ACTUALLY IS. Read at HEAD it is "...[ai].production) is **rejected at load** with a" -- mid-sentence in the [security] desugaring section, about rejecting MOVED LEGACY KEYS. It is not a rule about upgrade behaviour in either direction. My first correction called it the No-loosen rule; that rule is real but sits at :1445, six lines later, and concerns not WEAKENING an existing refusal -- still the opposite direction from adding one. I corrected a wrong anchor to a different wrong description of the same line, which is the same error one layer down. THE THREE REAL ANCHORS, each verified verbatim rather than inherited: :1475 "a new refusal fires only on a new opt-in" :88, :1020 "The refuse/warn split is [security].enforcement, not the production tier" THE SECOND FIND CHANGES THE BUILD, and is the more valuable half. [security].enforcement is the repo's FIRST-CLASS refuse/warn split -- refuse under enforce, warn under warn -- and every sibling gate uses it (require_managed_identity, security_notifications_required). A bespoke dated flip for #326 alone would stand up a second, parallel mechanism for the same decision: two ways to answer one question, and no rule about which applies. The lane building #326 proposed a reading that satisfies both, now recorded and flagged to the owner: warn irrespective of enforcement until the dated flip, then obey [security].enforcement like its siblings. That preserves the deferral the owner asked for and lands the gate on the existing mechanism rather than beside it. The owner's ruling stands and is not reopened. What was open is only HOW the eventual refusal is expressed -- and that is a question the ruling never claimed to settle. Worth stating plainly, since this ledger is read as authority: a wrong anchor in a RULING carries more weight than the same wrong anchor in the item it came from, because a ruling is what other sessions build against without re-deriving. That is the whole reason both corrections are dated notes rather than silent edits. backlog_status_check.py --min-items 277: OK, 278 items. --- docs/BACKLOG.md | 42 +++++++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 710e8035..904c0aef 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -2846,20 +2846,36 @@ No test covers it. `tests/test_scan_tokens_source.py:559-583` (`test_absolute_ho > refuse**, against this repo's own rule โ€” *"a new refusal fires only on a new opt-in"* โ€” the refusal > is deferred. > -> โš ๏ธ **Anchor corrected 2026-08-04: that rule is at `docs/CONFIGURATION.md:1475`, not `:1439`.** #326's -> own Proposed point 3 cites `:1439`; at HEAD that line sits in the `[security]` desugaring section, -> whose rule is *"no shipped refusal is **loosened**"* (the No-loosen rule, ADR 0092 ยง5) โ€” which is -> about not WEAKENING an existing refusal, the opposite direction from adding one. The quoted text was -> right and the line number was wrong; this ruling inherited the wrong anchor from the item without -> re-checking it, and a sibling session caught it. +> โš ๏ธ **Anchors corrected 2026-08-04, in two passes โ€” the second because the first was still imprecise.** +> #326's own Proposed point 3 cites `docs/CONFIGURATION.md:1439`. Read verbatim at HEAD, that line is +> *"โ€ฆ`[ai].production`) is **rejected at load** with a"* โ€” mid-sentence in the `[security]` desugaring +> section, about rejecting **moved legacy keys**. It is not a rule about upgrade behaviour in either +> direction. (My first correction called it the No-loosen rule; that rule is real but sits at `:1445`, +> six lines later, and concerns not *weakening* an existing refusal โ€” still the opposite direction.) +> The quoted text was right and the anchor was wrong; this ruling inherited it from the item without +> re-checking, and two sibling sessions caught it. > -> **The real rule reads stronger than "warn first", and the implementer should know it.** Its stated -> reasoning is this exact case โ€” *"a refusal by default would stop working dev/staging/prod deployments -> from booting on upgrade over something they cannot change"* โ€” and the house pattern it names -> (`require_memory_encryption_declaration`, ADR 0152 rung 2; `allowed_client_networks`, ADR 0151) is a -> refusal gated behind an **opt-in flag**, not merely a dated flip. The owner ruled warn-first with a -> dated flip; whether the eventual refusal also sits behind an opt-in is a build detail that should -> follow those two precedents rather than be invented. +> **The three anchors that are real, verified verbatim at HEAD:** +> - `:1475` โ€” *"a new refusal fires only on a new opt-in"* (`require_memory_encryption_declaration`, +> ADR 0152 rung 2). Its stated reasoning is this exact case. +> - `:88` and `:1020` โ€” *"**The refuse/warn split is `[security].enforcement`, not the production +> tier**"*, on `require_managed_identity` and `security_notifications_required`. +> +> โš ๏ธ **THE MECHANISM IS A BUILD QUESTION THIS RULING DOES NOT SETTLE โ€” and the repo already has one.** +> `[security].enforcement` is the first-class refuse/warn split, and every sibling gate uses it: refuse +> under `enforcement = enforce`, warn under `warn`. A bespoke dated flip for #326 alone would stand up a +> **second, parallel mechanism for the same decision** โ€” which is how a codebase ends up with two ways +> to answer one question and no rule about which applies. +> +> A reading that satisfies both, proposed by the lane building this and flagged to the owner: ship the +> corrected `admin_exposed` as a warning **irrespective of `enforcement`** until the dated flip, after +> which it **obeys `[security].enforcement` like its siblings**. That gets the deferral the owner asked +> for and lands the gate on the existing mechanism rather than beside it. +> +> The owner's ruling โ€” warn first, with a dated flip โ€” stands as given and is not reopened by this note. +> What is open is only *how* the eventual refusal is expressed, and the precedents to follow rather than +> invent are `require_memory_encryption_declaration` (ADR 0152 rung 2) and `allowed_client_networks` +> (ADR 0151), both of which gate the refusal behind an opt-in. > > **The flip must carry a DATE, in the warning text and in this item.** A deferred refusal with no date > is a refusal that never happens โ€” it becomes a warning everyone learns to scroll past, which is how From 2cb825ebd0d9c754e9f07965dac660591c63b4f0 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Tue, 4 Aug 2026 10:14:13 -0500 Subject: [PATCH 4/4] =?UTF-8?q?docs(backlog):=20reverse=20both=20rulings?= =?UTF-8?q?=20=E2=80=94=20the=20premise=20they=20rested=20on=20does=20not?= =?UTF-8?q?=20exist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner-confirmed 2026-08-04: MessageFoundry is a NOT-DEPLOYED beta with ZERO production instances. Nobody is running it. Published to PyPI is not deployed. That makes an entire class of argument vacuous, and both rulings I recorded were built on it. Reversed, as dated supersessions rather than edits, because the wrong versions were pushed and two lanes were already building against them. #341: RAISE -> WIDEN. The first ruling said widening "would start delivering messages the engine drops today -- every live Handler returning a tuple would begin flowing on upgrade, including PHI currently being dropped." That requires live Handlers to exist. None do. The cost it priced has no referent. Three arguments for widening that never touched deployment, and would have carried it regardless -- all three surfaced by the lane doing recon, not by me: 1. _handler_names (dryrun.py:96-99) already accepts a tuple/set/generator from a ROUTER. Only the HANDLER path rejects it. Widening removes an internal inconsistency; raising entrenches a split with no stated rationale. 2. `return ()` is a documented filter idiom (lens.py:678, SHALL'd at ADR 0108:60). It composes under widen; under raise it needs a carve-out. 3. It does what the author plainly meant. #326: WARN-FIRST -> REFUSE OUTRIGHT. The first ruling deferred the refusal because the fix "makes a currently-starting configuration refuse on upgrade", citing CONFIGURATION.md:1475. That rule exists in as many words to protect working dev/staging/prod deployments from booting on upgrade. There are none. Worth recording plainly: a sibling session and I then spent an afternoon carefully distinguishing a bespoke dated flip from the house [security].enforcement split from the opt-in-flag pattern. BOTH MECHANISMS WERE SOLVING A PROBLEM THAT DOES NOT EXIST. The analysis is preserved in the item for whoever revisits it if an adopter goes live; it is not a live design question now. Also recorded, verified at HEAD and independent of the rulings: - #328: api/app.py is IN scope. The multisession plan said DROP it, but every [integrity] setting reaches the Engine ONLY via create_managed_app -- audit_verify_on_start at api/app.py:5467. Building to the plan's scope would ship a dead setting: configurable, documented, never read. - #341's ADR 0087 citation: AC-11 is at :232, and :238 is AC-12 (code-set tables). A builder following the wrong range rewrites the wrong SHALL. The premise is now in project memory (mf-not-deployed-beta), written by a sibling session that hit the same gap the same day. It is not derivable from the tree -- I searched README, POSITIONING, SUPPORT-POLICY, EARLY-ADOPTER-GUIDE and the backlog and found no statement of it; the nearest signal is the value rubric's 9 = "a named adopter is waiting", which is only consistent with it. backlog_status_check.py --min-items 277: OK, 278 items. --- docs/BACKLOG.md | 110 ++++++++++++++++++++++-------------------------- 1 file changed, 51 insertions(+), 59 deletions(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 904c0aef..cb40d25e 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -2836,55 +2836,25 @@ No test covers it. `tests/test_scan_tokens_source.py:559-583` (`test_absolute_ho > ๐Ÿ”ข **Filed 2026-08-01 โ€” not started.** Value **6/10** ยท Difficulty **3/10** ยท _quick win_. ASVS 6.3.3's admin-MFA refusal and #189's dual-control warning are both inert in the topology the runbook recommends โ€” the ADR 0143 auto-degrade sets `settings.api.serve_ui = False` in place before `ui_exposed` and `admin_exposed` are derived from it (`messagefoundry/__main__.py`, the flip and the two derivations in one ladder), so the engine calls one instance exposed for 11.7.1 and not exposed for 6.3.3 in a single boot โ€” but `require_mfa` defaults on and `security_loosenings()` still names the explicit opt-out on every boot; re-key `admin_exposed` on the `instance_exposed` predicate already present in the file, fix two `exposure_desc` else-branches, and settle the refuse-on-upgrade fork against `docs/CONFIGURATION.md:1439`. -> **OWNER RULING 2026-08-04 โ€” WARN FIRST, with a DATED flip to refuse.** The fork at Proposed point 3 -> is settled: the corrected `admin_exposed` derivation ships as a **warning**, not as an immediate -> refusal. +> **OWNER RULING 2026-08-04 โ€” REFUSE OUTRIGHT. Supersedes an earlier ruling on this item that said +> WARN-FIRST with a dated flip.** No warn-first, no dated flip, no opt-in flag. The corrected +> `admin_exposed` gate refuses, as it was written to. > -> The fix itself is unchanged โ€” re-key `admin_exposed` off a `serve_ui`-independent predicate -> (`instance_exposed` already exists in this file) and correct the two `exposure_desc` else-branches. -> What is settled is the *upgrade behaviour*: because this makes a **currently-starting configuration -> refuse**, against this repo's own rule โ€” *"a new refusal fires only on a new opt-in"* โ€” the refusal -> is deferred. +> โš ๏ธ **Why the first ruling was reversed.** It deferred the refusal because the fix *"makes a currently- +> starting configuration refuse on upgrade"*, citing `docs/CONFIGURATION.md:1475` โ€” *"a new refusal fires +> only on a new opt-in"*. That rule exists **in as many words** to protect *working dev/staging/prod +> deployments from booting on upgrade*. **There are none: MessageFoundry is a not-deployed beta with zero +> production instances** (owner-confirmed 2026-08-04). Nothing starts today that would stop starting. > -> โš ๏ธ **Anchors corrected 2026-08-04, in two passes โ€” the second because the first was still imprecise.** -> #326's own Proposed point 3 cites `docs/CONFIGURATION.md:1439`. Read verbatim at HEAD, that line is -> *"โ€ฆ`[ai].production`) is **rejected at load** with a"* โ€” mid-sentence in the `[security]` desugaring -> section, about rejecting **moved legacy keys**. It is not a rule about upgrade behaviour in either -> direction. (My first correction called it the No-loosen rule; that rule is real but sits at `:1445`, -> six lines later, and concerns not *weakening* an existing refusal โ€” still the opposite direction.) -> The quoted text was right and the anchor was wrong; this ruling inherited it from the item without -> re-checking, and two sibling sessions caught it. +> **An afternoon was then spent distinguishing two mechanisms that were both solving this non-problem** โ€” +> a bespoke dated flip versus the house `[security].enforcement` split (`:88`, `:1020`) versus the opt-in +> flag pattern (`:1475`, ADR 0152 rung 2 / ADR 0151). That analysis is preserved below for whoever +> revisits it **if an adopter ever goes live**; it is not a live design question now. When there is no +> installed base, the cost of a breaking change is zero and the simple correct end state wins. > -> **The three anchors that are real, verified verbatim at HEAD:** -> - `:1475` โ€” *"a new refusal fires only on a new opt-in"* (`require_memory_encryption_declaration`, -> ADR 0152 rung 2). Its stated reasoning is this exact case. -> - `:88` and `:1020` โ€” *"**The refuse/warn split is `[security].enforcement`, not the production -> tier**"*, on `require_managed_identity` and `security_notifications_required`. -> -> โš ๏ธ **THE MECHANISM IS A BUILD QUESTION THIS RULING DOES NOT SETTLE โ€” and the repo already has one.** -> `[security].enforcement` is the first-class refuse/warn split, and every sibling gate uses it: refuse -> under `enforcement = enforce`, warn under `warn`. A bespoke dated flip for #326 alone would stand up a -> **second, parallel mechanism for the same decision** โ€” which is how a codebase ends up with two ways -> to answer one question and no rule about which applies. -> -> A reading that satisfies both, proposed by the lane building this and flagged to the owner: ship the -> corrected `admin_exposed` as a warning **irrespective of `enforcement`** until the dated flip, after -> which it **obeys `[security].enforcement` like its siblings**. That gets the deferral the owner asked -> for and lands the gate on the existing mechanism rather than beside it. -> -> The owner's ruling โ€” warn first, with a dated flip โ€” stands as given and is not reopened by this note. -> What is open is only *how* the eventual refusal is expressed, and the precedents to follow rather than -> invent are `require_memory_encryption_declaration` (ADR 0152 rung 2) and `allowed_client_networks` -> (ADR 0151), both of which gate the refusal behind an opt-in. -> -> **The flip must carry a DATE, in the warning text and in this item.** A deferred refusal with no date -> is a refusal that never happens โ€” it becomes a warning everyone learns to scroll past, which is how -> the inert control this item is about came to exist in the first place. Scheduling it is the point; -> "flip it later" is not a plan. -> -> The gap is real and measured (arm C starts rc=0 with single-factor admin on production PHI), but it -> has been latent for some time. Breaking a running production instance on upgrade, with no notice, -> trades one silent failure for a loud one nobody scheduled. +> **The fix itself is unchanged:** re-key `admin_exposed` off the `serve_ui`-independent predicate that +> already exists in that file, and correct both `exposure_desc` else-branches (`__main__.py:1883` and +> `approvals_exposure_desc` at `:1939`). **Cluster:** Security & Compliance. **Priority:** P1. **Verdict:** build. **Severity:** medium. @@ -2993,6 +2963,14 @@ The two nearest-looking guards are neither: `tests/test_scaffold.py:51-52` asser > ๐Ÿ”ข **Filed 2026-08-01 โ€” not started.** Value **6/10** ยท Difficulty **3/10** ยท _quick win_. Both shipped verification surfaces call `verify_audit_chain()` bare (`messagefoundry/__main__.py:3596`, `pipeline/engine.py:860`) and the `audit-verify` subparser declares only `--service-config` and `--db` (`__main__.py:571-578`), so a truncated keyed chain โ€” the residue the anchor exists to catch โ€” reports CLEAN with no way for an operator to supply one; the remainder is a new `audit-anchor` subcommand, an `--expected-anchor` flag into the already-present `expected_anchor=` keyword, and an `[integrity]` key for the startup path, with no change to the comparison logic and no store migration. _(was 5/10 ยท 3/10.)_ +> โš ๏ธ **AMENDED 2026-08-04 โ€” `api/app.py` is IN scope; the multisession plan was wrong to drop it.** +> `SCHEDULABLE-BACKLOG-MULTISESSION-PLAN.md` scoped this item as "CLI + settings" and said **DROP +> `api/app.py`**. Verified against HEAD: every `[integrity]` setting reaches the Engine **only** through +> `create_managed_app` โ€” `audit_verify_on_start=integ.audit_verify_on_start` at +> `messagefoundry/api/app.py:5467`, fed from `__main__.py`. Building to the plan's scope would ship a +> **dead setting**: configurable, documented, and never read. Caught by the lane doing recon before +> building, which is the only reason it was caught at all. + **Cluster:** Security / Audit integrity. **Priority:** P2. **Verdict:** build. **Severity:** medium. **What:** the audit hash chain links each row to its predecessor, so deleting the **newest** rows leaves a shorter prefix that still verifies. The store layer already solves this. `verify_audit_chain` takes an optional out-of-band anchor and compares it in constant time ([`store/store.py:7475-7486`](../messagefoundry/store/store.py)), returning `"audit log diverges from recorded anchor โ€ฆ โ€” truncated or rewritten"`; the producing side, `audit_anchor() -> tuple[int, str]`, is implemented on **all three** backends ([`store/store.py:7387`](../messagefoundry/store/store.py), [`store/sqlserver.py:8591`](../messagefoundry/store/sqlserver.py), [`store/postgres.py:5676`](../messagefoundry/store/postgres.py)). Its docstring states the contract plainly: @@ -3665,21 +3643,35 @@ What is NOT settled is the mechanism. Two independent passes reached different a > ๐Ÿšง **Status OPEN (filed 2026-08-01).** Value **9/10** ยท Difficulty **3/10** ยท _quick win_. `_partition` ([pipeline/dryrun.py:112](../messagefoundry/pipeline/dryrun.py)) narrows with `items = result if isinstance(result, list) else [result]`. A **`list`** of `Send`s works; a **tuple** or **set** does not โ€” the container itself becomes the single item, matches none of the three `isinstance` filters, and yields `([], [], [])`. **Verified live:** `_partition((send, send))[0] == []`. The Handler ran, returned deliveries, and **nothing is delivered and nothing errors** โ€” the message finalizes `FILTERED` (every handler ran but delivered nothing), which is indistinguishable from a handler deliberately declining it. That is an **accept-and-drop**, the one thing CLAUDE.md ยง12 forbids outright. -> **OWNER RULING 2026-08-04 โ€” RAISE, do not widen.** The fork this item reserved is settled: a -> Handler returning a tuple or set of `Send`s must **raise**, so the message lands `ERROR`/dead-letter. -> It must NOT be widened to accept any iterable. +> **OWNER RULING 2026-08-04 โ€” WIDEN. Supersedes an earlier ruling on this item that said RAISE.** +> A Handler returning a tuple, set or generator of `Send`s is **accepted**, like a list. It does not raise. +> +> โš ๏ธ **Why the first ruling was reversed, because the reasoning is the point.** It rested on: *"widening +> would start delivering messages the engine drops today โ€” every live Handler returning a tuple would +> begin flowing on upgrade, including PHI currently being dropped."* That argument requires **live +> Handlers to exist**. **MessageFoundry is a not-deployed beta with zero production instances** +> (owner-confirmed 2026-08-04). There is no installed base, so the cost the first ruling priced is +> **vacuous**. The same defect this backlog keeps recording โ€” a conclusion resting on a premise nobody +> checked โ€” committed in a ruling other sessions were building from. +> +> **Three arguments for widening that never touch deployment, and would have carried it anyway:** +> 1. **It removes an internal inconsistency.** `_handler_names` (`pipeline/dryrun.py:96-99`) already does +> `return [result] if isinstance(result, str) else list(result)` โ€” so a **Router** returning a tuple, +> set or generator works **today**. Only the **Handler** path rejects it. Widening makes the two agree; +> raising entrenches a split with no stated rationale. +> 2. **`return ()` is a documented filter idiom** (`lens.py:678`, SHALL'd at ADR 0108:60). It composes +> naturally under widen; under raise it needs a carve-out for the empty case. +> 3. **It does what the author plainly meant.** A Handler that returns a container of `Send`s intended +> them to be sent. > -> **The reasoning, because it decides how the fix is built.** Widening would start *delivering* messages -> the engine drops today โ€” every live Handler returning a tuple would begin flowing on upgrade, silently, -> including PHI currently being dropped. An accept-and-drop is what CLAUDE.md ยง12 forbids; converting it -> into an accept-and-**send** nobody asked for is the worse of the two failures. A loud dead-letter is -> recoverable and visible; a quiet new delivery path is neither. +> **Neutral either way, so not a tiebreaker:** both options need the subprocess-codec fix below, and both +> falsify ADR 0108:37's *"No engine runtime change"*. > -> โš ๏ธ `tests/test_sandbox_codec.py::test_partition_parity_table` pins the current `[0,0,0]` behaviour -> deliberately and its docstring argues *against* normalising โ€” that rationale is **superseded by this -> ruling**, so the test is rewritten and its reasoning replaced, not extended around. `pipeline/_sandbox_codec.py` -> preserves the container shape on purpose, so in-process and subprocess modes must agree on the new -> behaviour or the fix creates a mode-dependent disposition. +> โš ๏ธ **Build constraint, unchanged by the reversal.** `pipeline/_sandbox_codec.py` preserves the container +> shape on purpose, and `tests/test_sandbox_codec.py::test_partition_parity_table` pins the current +> `[0,0,0]` for both modes. In-process and subprocess must agree on the new behaviour or the fix creates a +> **mode-dependent disposition**, which is worse than the bug. That test is rewritten under this ruling and +> its docstring rationale replaced, not extended around. **Cluster:** Correctness / data loss. **Priority:** P1. **Verdict:** build (small). **Severity:** high (silent PHI non-delivery, no operator signal), medium (likelihood: `return (Send(...), Send(...))` is a natural idiom and a one-character difference from the working form).