diff --git a/docs/AI.md b/docs/AI.md
index b71f47d0..5251853e 100644
--- a/docs/AI.md
+++ b/docs/AI.md
@@ -175,9 +175,11 @@ stdout); on error it prints `{"error": "..."}`. It prints **config only, never m
## IDE gating behavior
The IDE assistant ([ide/src/chat.ts](../ide/src/chat.ts)) resolves the policy **before** every
-request: it first calls `GET /ai/policy` (authoritative); on any error it falls back to the local
-`messagefoundry ai-policy` CLI; if that also fails it uses a conservative built-in default
-(`byo` / `code_only` / `prod`, `assist_permitted: null`) so the safe assistant still works offline.
+request: it first calls `GET /ai/policy` (authoritative, and cached on success); on any error it falls
+back to that cached authoritative policy, then to the local `messagefoundry ai-policy` CLI; if none of
+those can positively confirm a policy it uses a fail-closed built-in default (`mode: unverified`),
+which **disables** assistance rather than re-enabling BYO — a central *off* must not be bypassable by
+taking the engine offline (SEC-022).
Then it applies the effective policy:
@@ -186,14 +188,33 @@ Then it applies the effective policy:
| `mode == off` | **Disabled.** "AI assistance is turned off by your MessageFoundry policy." |
| `mode == managed_claude` / `managed_claude_baa` | **Disabled.** This IDE version can't service a managed provider; it does **not** silently fall back to BYO (that would violate operator intent). |
| `mode == byo` and `assist_permitted == false` | **Disabled.** "Your role does not include the `ai:assist` permission." |
-| `mode == byo` and `assist_permitted` is `true` **or** `null` | **Enabled** (proceeds as today). |
+| `mode == byo` and `assist_permitted` is `true` **or** `null` | **Enabled** — *unless* an authoritative `false` was previously observed; see the sticky-deny rule below. |
+| `mode == unverified` (nothing could confirm a policy) | **Disabled.** Fail-closed; see above. |
-**The tokenless-IDE / `assist_permitted == null` trust note.** Under BYO, `null` (RBAC not evaluable
-offline) is **allowed**. This is safe by construction: BYO sends only **code-only** context to the
-developer's own provider — it never sees the engine or any message data, so there is no PHI to
-protect with RBAC at this stage. The central *off* switch is still honored because `mode` is read
+**The `assist_permitted == null` trust note.** Under BYO, `null` (RBAC not evaluable) is **allowed**.
+This is safe by construction: BYO sends only **code-only** context to the developer's own provider —
+it never sees the engine or any message data, so there is no PHI to protect with RBAC at this stage.
+The central *off* switch is honored regardless, because `mode` is identity-independent and is read
straight from the policy, token or not.
+**The IDE's gate read is authenticated (BACKLOG #330).** `assist_permitted` is computed from the
+acting identity, so a tokenless caller can only ever be told `null` and the deny row above could never
+fire. `resolveAiPolicy` therefore attaches the cached bearer — never prompting for one, and never over
+plain `http://` to a non-loopback host. Two things this does **not** change: the engine endpoint stays
+tokenless-*readable* (the `GET /ai/policy` section above is unchanged and still true), and the status
+bar's **separate**, timer-driven read of the same route stays **tokenless** — it wants only the
+identity-independent `environment`, and a bearer on that timer would keep refreshing the session's
+idle clock and make the engine's 30-minute idle timeout unreachable (CWE-613).
+
+**The sticky-deny rule (ADR 0035 AC-7).** Because `null` means "could not be evaluated" rather than
+"permitted", a fresh `null` must not *upgrade* assistance a central policy switched off: an
+authoritative `assist_permitted: false` the IDE has already observed is **retained** over a later
+`null`, so under BYO that combination resolves to **Disabled**. The rule is deliberately one-way — a
+cached `true` is *not* sticky, since fabricating a permit from stale state is the fail-open direction
+— and any evaluable `true`/`false` replaces the cached value outright, so signing in is the escape
+hatch. Anything that is not the literal `true`/`false`, **including a response that omits the field**,
+counts as "not evaluated" and never as a permit.
+
`messagefoundry.showAiPolicy` (command **"MessageFoundry: Show AI Policy"**) displays the current
resolved policy in the IDE.
diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md
index 59a79d9d..8ecc7b56 100644
--- a/docs/BACKLOG.md
+++ b/docs/BACKLOG.md
@@ -2433,7 +2433,7 @@ def route_demo_oru(msg):
## 233. Steps view move-drop logic implemented twice (model + webview)
-> 🔢 **Filed 2026-07-30 — not started.** Value **6/10** · Difficulty **3/10** · _quick win_. Prerequisite for #237; option (c), a differential test across both implementations, can land first on its own.
+> ✅ **SHIPPED 2026-08-04 — option (c) ONLY, by owner ruling: the divergence class is now GATED by a differential test, not eliminated.** Value **6/10** · Difficulty **3/10** · _quick win_. `ide/src/test/suite/steps-mirror.test.ts` loads the real `ide/media/stepsWebview.js` under jsdom (a new `ide/` devDependency, lock re-locked in the same commit) and asserts all ten mirrors against their `stepsModel` counterparts on **every** `ide` CI leg — 2,000 seeded generated row sets for the five pure row-array mirrors, and four hand-authored adversarial cases across all ordered (drag, target) pairs and five pointer fractions for the four DOM-bound ones. It found exactly **one** live divergence and closed it: `canDropRow` accepted a read-only `code` row as a drop target while the webview refused it, contradicting the model's own stated contract. **NOT built: options (a) and (b).** Both implementations still exist, so what closes is the *silent* half of the divergence class, not the duplication; #237's "sequenced behind #233" dependency is met by the gate rather than by de-duplication. **Stale anchors:** the `stepsModel.ts:1767` / `:1861` / `:1531` and `stepsView.ts:917` line numbers in the table and prose below are as-filed on 2026-07-30 and have since moved to `:1779` / `:1873` / `:1540` and `:957`.
**Cluster:** IDE & Authoring. **Priority:** P2. **Verdict:** build (de-duplicate). **Severity:** medium — a silent-divergence class, not a visible bug.
@@ -2457,7 +2457,7 @@ The webview cannot import from `src/` (it is loaded as a plain script into a `de
## 234. Steps view projection refreshes on save only
-> 🔢 **Filed 2026-07-30 — not started. Re-framed 2026-07-30 to match the instruction that filed it.** Value **4/10** · Difficulty **3/10** · _fill-in_. This was originally recorded as "revisit — do not treat as a bug", which contradicted the owner's actual words: *"Put that fix on the backlog too."* It is a **fix**, gated on an ADR amendment — not a question about whether to act. The engineering caveat that motivated the softer framing is preserved below and is unchanged.
+> 🔢 **Filed 2026-07-30. PARTLY LANDED 2026-08-04 — the race half is fixed; the save-gate relaxation this item was filed for is STILL OPEN. Re-framed 2026-07-30 to match the instruction that filed it.** Value **4/10** · Difficulty **3/10** · _fill-in_. This was originally recorded as "revisit — do not treat as a bug", which contradicted the owner's actual words: *"Put that fix on the backlog too."* It is a **fix**, gated on an ADR amendment — not a question about whether to act. **Landed:** a user save arriving while a `lens rewrite` held the single edit slot was **discarded**, leaving the view on a pre-save projection with no signal until the next save; it is now deferred to slot release and re-projected exactly once ([ADR 0076](adr/0076-typed-action-vocabulary-action-list-lens.md) Amendment C — written **PROPOSED, not ratified**; owner ratification still needed). **Still open:** whether a *bounded relaxation* of the save gate is safe. Argue it against the corrected premise, not the old one: `render()` pipes `document.getText()` to `lens parse -` over stdin, so rows are projected from the **live buffer**, not from disk — the "stale disk content" justification the gate's own comment carried was false. The surviving reasons are re-shelling Python per keystroke and the fact that a re-projection replaces the entire webview HTML. `RERENDER_DEBOUNCE_MS` is now at `stepsView.ts:91`, not `:89` as the text below says. The engineering caveat that motivated the softer framing is preserved below and is unchanged.
**Cluster:** IDE & Authoring. **Priority:** P3. **Verdict:** **build (ADR-first)** — owner asked for the fix; the save-gate it touches is a deliberate ADR 0076 §5 guardrail, so the amendment lands before the change. **Severity:** low (UX latency).
@@ -3095,7 +3095,7 @@ That distinction matters concretely for the ASVS record. The scorecard's absence
## 330. The IDE's `ai:assist` gate can never fire
-> 🔢 **Filed 2026-08-01 — not started.** Value **5/10** · Difficulty **3/10** · _fill-in_. ADR 0035's SEC-022 `ai:assist` half was never wired — `resolveAiPolicy` omits `getJson`'s token argument (`ide/src/aiPolicy.ts:78`, against the header-when-present at `ide/src/engineClient.ts:141`) so the engine can only ever answer `null` and `docs/AI.md:188` publishes a deny row no code path produces — but no PHI is at risk, the brokered path is server-gated, and the `mode` half still covers the central-off case; TypeScript in one module, ordered so the unconditional cache write at `aiPolicy.ts:79` is guarded before the bearer lands, with the status-bar reader left tokenless or the CWE-613 idle clock becomes unreachable.
+> ✅ **FIXED 2026-08-04 — ADR 0035 AC-7/AC-8 added, ADR 0110 amended.** Both defects are closed, in the load-bearing order. **(1) The guard landed first:** the write to `LAST_POLICY_KEY` now goes through a pure `mergeAuthoritativePolicy` (`ide/src/aiPolicyModel.ts`, zero imports so it is asserted node-side on every CI leg), so an answer that does not carry an evaluable `assist_permitted` can no longer overwrite a cached `false`. "Not evaluable" is deliberately wider than the literal `null`: `AiPolicyWire` is a compile-time claim `JSON.parse` does not enforce, so a 200 that OMITS the field arrives as `undefined` — which a `=== null` guard would let through, and which is not `false` either, so the cache would be poisoned past recovery. The bit is narrowed at the boundary (`evaluatedPermission`) on both authoritative paths, the engine read and the CLI fallback. The retention is one-way by design — a cached `true` is **not** sticky (fabricating a permit is the fail-open direction), an evaluable `true`/`false` always wins outright, and `mode` always comes fresh so a central `off`→`byo` re-enable still propagates. **(2) Then the bearer:** `resolveAiPolicy` attaches the cached token via `peekToken` (never `ensureToken` — a chat turn must not pop a sign-in modal) behind the SEC-005 `assertTargetAllowed` gate, so the engine can resolve the identity-dependent `assist_permitted` and the `ai:assist` deny branch can fire at all. The two orderings are not equivalent: attaching the bearer first would open a window in which an authenticated-but-degrading read poisons the cache. **`statusBar.ts`'s `/ai/policy` read stays TOKENLESS** — the two readers are now the named constants `ENVIRONMENT_PLAN` (`authenticated: false`, timer-driven, wants the identity-independent `environment`) and `ASSIST_GATE_PLAN` (`authenticated: true`, user-initiated only), same route and opposite answer, both asserted in CI so a later reader cannot "unify" them into the CWE-613 bug. 20 new tests (`ai-policy-model.test.ts`, `ai-policy.test.ts`, `engine-doctor.test.ts`, `engine-client.test.ts`), each falsified against a planted defect. **Residuals, deliberately not closed here:** (a) nothing constructs `EngineStatusBar`, so the status bar's tokenlessness is asserted on the plan CONSTANT, not on `readEnvironment`'s use of it — rewiring that call site would type-check and stay green (recorded in ADR 0035 AC-8); (b) the cache is one global key while the bearer is keyed per engine URL, so a deny observed against one engine also suppresses assistance against another (fail-closed, recorded in AC-7); and (c) the bearer is looked up under `engineUrl()`, but sign-in happens against the status-bar/promote target, which is `environments()[0].url` whenever `messagefoundry.environments` is configured — so for a user whose only session is against a named environment URL the read is still unattributed and the gate still cannot fire for them. Retargeting `resolveAiPolicy` changes WHICH engine the policy is read from, a behaviour change this item does not ask for; it needs its own number.
**Cluster:** Security & Compliance / IDE & Authoring. **Priority:** P2. **Verdict:** build. **Severity:** medium.
@@ -3612,7 +3612,7 @@ What is NOT settled is the mechanism. Two independent passes reached different a
---
## 341. Handler returning a tuple or set of Sends delivers nothing, silently
-> 🚧 **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.
+> ✅ **Status CLOSED (built 2026-08-04) — WIDEN, not raise.** `_partition` no longer narrows on `isinstance(result, list)`: a Handler may return **any non-`str` iterable** of `Send`/`SetState`/`SetMeta` — list, tuple, set or generator — and it partitions element-wise. **The body's "Fix direction (not yet decided) … failing loud is probably right" is settled the other way**, by owner ruling. THE acceptance criterion holds: `return []` and `return ()` still **filter** (deliver nothing, raise nothing), and a value that is not a container — a bare `int`, a `Message` returned by mistake — still drops silently rather than newly raising, because the gate is `isinstance(…, Iterable)` and never a duck-typed `list(result)` (`Message` has `__getitem__(path: str)` and no `__iter__`, so `list()` would raise out of the handler). **The body's "Fixing `_partition` fixes both modes at once" is FALSE and was the most dangerous sentence in this item** — acting on it would have shipped a MODE-DEPENDENT disposition (in-process delivers, `[sandbox].mode=subprocess` still drops), worse than the bug it closes. One shared rule (`wiring.handler_result_items`) is now applied in three places: the parent's `_partition`, `_sandbox_codec.enc_result`, and `_sandbox_worker` **inside** `with run_contexts(…)` — the last because a generator Handler's body runs lazily, and materialising it at describe time would execute it with no run context, so a `code_set(…)` inside one would raise under subprocess while working under off. **Two author-visible facts the body does not carry.** (1) A **`set` delivers but has no defined fan-out order** — `Send` is hashed on its fields and `str` hashing is seeded per process, so order differs between processes (parent vs sandbox child) and across a crash re-run, i.e. a set gives up FIFO order between sibling `Send`s to one outbound; mode parity is over the delivered **multiset** plus an *ordered* container's order, and the docs steer authors to a list/tuple. (2) A **generator Handler is not execution-traced** — its body runs after the ADR 0072 tracer detaches, so that invocation reports no lines and no sends, now declared as `"lazy_result": true` rather than left to read as an inert handler; **this change opened that gap** (before it, a generator delivered nothing, so the trace's `[]` was exact). ADR 0072 §6 gate 1 is split by level and amended accordingly; ADR 0087's parity bullet + AC-11 rescoped; ADR 0108's §2 invariant and §7 tuple rationale corrected (its refusal **stands**, on conservative scope — nothing 0108 built changed); `checks.py`'s `accepts=` advisory widened to recognize `return ()`, which `lens.py` already did. **`pipeline/dryrun.py:112`, the anchor cited below, no longer holds that line.**
> **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.
diff --git a/docs/CONNECTIONS.md b/docs/CONNECTIONS.md
index 0342d437..cd4219f8 100644
--- a/docs/CONNECTIONS.md
+++ b/docs/CONNECTIONS.md
@@ -202,10 +202,27 @@ real transform) is also fine as a **single module** — the shipped `IB_ACME_ADT
non-trivial transform logic.
A **router fans out** by returning multiple handler names (`return ["to_a", "to_b"]`); a **single
-handler fans out** by returning multiple `Send`s (`return [Send("OB_A", msg), Send("OB_B", msg)]`).
+handler fans out** by returning multiple `Send`s (`return [Send("OB_A", msg), Send("OB_B", msg)]`) —
+a list is the idiom shown throughout these docs, but **any non-`str` iterable** delivers the same
+`Send`s (a tuple, a set, or a generator that `yield`s them). An **empty** one (`return []` /
+`return ()`) is the filter: nothing is delivered and the message is logged `FILTERED`.
Namespace router/handler names uniquely (e.g. by site/partner) — `messagefoundry check` flags a
duplicate name (across **any** of these files) and an inbound that binds a router that doesn't exist.
+> **Prefer a list, tuple or generator — they have an order; a `set` does not.** Fan-out is delivered
+> in iteration order, and a `set`'s iteration order is not defined: it varies from process to process
+> (`Send` hashes on its fields, and string hashing is seeded per process). Two `Send`s to the **same**
+> outbound therefore queue in an arbitrary relative order, and a re-run after a crash — a different
+> process — can queue them in a different one, so a `set` gives up both FIFO order between siblings and
+> the identical-output-on-re-run property the staged pipeline leans on (CLAUDE.md §2). Which `Send`s
+> are delivered is unaffected. **Use an ordered container whenever order matters.**
+>
+> A **generator** Handler delivers exactly like a list, but its body runs *after* the execution tracer
+> behind `dryrun --trace` (and the Test Bench that reads it) has detached. That invocation's trace
+> record therefore carries no executed lines and no sends, marked `"lazy_result": true` so the omission
+> is declared rather than read as a handler that did nothing; the run's message-level `sends` are still
+> exact. Return a list or tuple if you want the handler's body traced line-by-line.
+
> **Transforms & HL7 escaping.** Writing a **component/subcomponent** (`msg["PID-5.1"] = value`)
> stores `value` as a literal: HL7 delimiters in it (`^ ~ & |`) are **escaped** so they stay data
> (`"O^Brien"` remains one component, not two). To build *multiple* components, write the whole
diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md
index 0906c9a1..a755c92c 100644
--- a/docs/USER-GUIDE.md
+++ b/docs/USER-GUIDE.md
@@ -377,7 +377,7 @@ The Router name (`"adt_router"`) is what an inbound Connection binds to: `inboun
### 2. Write a Handler (`@handler`)
-A Handler receives the message from a Router, then **filters → transforms → returns `Send`(s)**. Return `None` to filter the message out (logged `FILTERED`); return one `Send` or a list to fan out to multiple outbound connections.
+A Handler receives the message from a Router, then **filters → transforms → returns `Send`(s)**. Return `None` to filter the message out (logged `FILTERED`); return one `Send`, or any non-`str` iterable of them, to fan out to multiple outbound connections.
From [samples/config/adt.py](../samples/config/adt.py):
@@ -392,7 +392,9 @@ def archive(msg):
return Send("FILE-OUT_Test_ADT", msg)
```
-The `Send` target (`"FILE-OUT_Test_ADT"`) names an `outbound(...)` Connection declared in the same config. To fan out, return a list — e.g. [samples/results_relay/results_relay.py](../samples/results_relay/results_relay.py) ends with `return [Send(OB_EHR, msg), Send(FILE_ARCHIVE, msg)]`.
+The `Send` target (`"FILE-OUT_Test_ADT"`) names an `outbound(...)` Connection declared in the same config. To fan out, return a container of `Send`s — e.g. [samples/results_relay/results_relay.py](../samples/results_relay/results_relay.py) ends with `return [Send(OB_EHR, msg), Send(FILE_ARCHIVE, msg)]`. A **list** is the idiom used throughout these docs, but a tuple, a set, or a generator that `yield`s its `Send`s all deliver the same `Send`s; an **empty** container (`return []` / `return ()`) is the filter, exactly like `return None`.
+
+> **Two caveats on the less common shapes.** Fan-out is delivered in iteration order, and a **`set`** has no defined iteration order — it varies per process, so sibling `Send`s to the same outbound queue in an arbitrary order that a crash re-run can change. Use a list or tuple when order matters. And a **generator** Handler's body runs after the `dryrun --trace` execution tracer has detached, so its trace record shows no executed lines and no sends and is marked `"lazy_result": true` (the run's message-level `sends` are still exact) — return a list or tuple if you want it traced line-by-line. Both are documented in full in [CONNECTIONS.md](CONNECTIONS.md).
### 3. The `Message` operations you'll use
diff --git a/docs/adr/0035-ide-extension-workspace-trust-and-scope.md b/docs/adr/0035-ide-extension-workspace-trust-and-scope.md
index 65b2fbca..5240bae1 100644
--- a/docs/adr/0035-ide-extension-workspace-trust-and-scope.md
+++ b/docs/adr/0035-ide-extension-workspace-trust-and-scope.md
@@ -2,7 +2,7 @@
- **Status:** Accepted
- **Date:** 2026-06-26
-- **Related:** ADR 0007 (GUI-manageable connections.toml) · ADR 0024 (AI policy) · CLAUDE.md §9 (PHI/HIPAA), §10 (Console) · SEC-004, SEC-005, SEC-022
+- **Related:** ADR 0007 (GUI-manageable connections.toml) · docs/AI.md (the AI policy model) · ADR 0135 (the engine-brokered path) · CLAUDE.md §9 (PHI/HIPAA), §10 (Console) · SEC-004, SEC-005, SEC-022
---
@@ -74,6 +74,48 @@ online-permitted BYO assistant all keep working.
- **AC-6** — WHEN the engine policy is read successfully, THE EXTENSION SHALL cache it so a
previously-seen central "off" is not overridable by going offline.
→ `ide/src/test/suite/ai-policy.test.ts` (`pickOfflinePolicy` cached-wins case)
+- **AC-7** — WHEN the engine's answer to `GET /ai/policy` does NOT carry an evaluable
+ `assist_permitted` (the literal `true` or `false`) AND a cached authoritative policy holds
+ `assist_permitted: false`, THE EXTENSION SHALL retain the `false` in both the returned and the cached
+ policy, and SHALL NOT retain a cached `true` the same way.
+ → `ide/src/test/suite/ai-policy-model.test.ts` (the pure rule, node-side on every leg) +
+ `ide/src/test/suite/ai-policy.test.ts` (the cache write)
+
The asymmetry is the decision: `assist_permitted` is identity-dependent, so a non-answer means
+ "could not be evaluated", and a degraded read must not *upgrade* assistance a central policy switched
+ off. A cached `true` is deliberately **not** sticky — a non-answer under BYO is allowed by design
+ (docs/AI.md, *"the `assist_permitted == null` trust note"*), so carrying a permit forward would
+ fabricate one, which is the fail-open direction.
+
**"Not evaluable" is wider than `null` on purpose.** `AiPolicyWire` is a compile-time claim about
+ a response and `JSON.parse` does not enforce it, so a 200 that merely OMITS the field arrives as
+ `undefined`. A guard keyed on the literal `null` would let that through — and because `undefined` is
+ not `false` either, the cache would be poisoned so no later answer could restore the deny. The bit is
+ therefore narrowed at the boundary (`evaluatedPermission`), and only `true`/`false` count as answers.
+
**Accepted consequences**, stated as decisions rather than surprises: (a) the deny is one-way
+ until an evaluable answer replaces it, so a user granted `ai:assist` later, who holds no valid
+ session, sees assistance disabled until the engine answers `true` for them — the escape hatch is
+ signing in, as there is no "clear cached policy" command; and (b) the cache is a **single global
+ key**, not keyed per engine URL as the bearer is, so a deny observed against one engine also
+ suppresses assistance against another until an evaluable answer arrives from it. Both are the
+ fail-CLOSED direction, which is why they are accepted here rather than treated as defects.
+- **AC-8** — WHEN `resolveAiPolicy` reads the authoritative policy, THE EXTENSION SHALL attach the
+ cached bearer (never prompting for one) so `assist_permitted` is resolvable for the acting user, and
+ SHALL NOT attach it to a non-loopback plain-`http://` target. The status bar's periodic `/ai/policy`
+ environment read SHALL be expressed as an `authenticated: false` plan constant.
+ → `ide/src/test/suite/ai-policy.test.ts` (the bearer reaches the request; `peekToken` by identity) +
+ `ide/src/test/suite/engine-client.test.ts` (the `Authorization` header, both polarities) +
+ `ide/src/test/suite/engine-doctor.test.ts` (`ASSIST_GATE_PLAN` / `ENVIRONMENT_PLAN`)
+
The two `/ai/policy` readers give opposite answers about the bearer *on purpose*: the status
+ bar's read is timer-driven and wants the identity-independent `environment`, where a bearer would
+ defeat the engine's idle timeout (CWE-613, ADR 0110 §2); this one is user-initiated and wants the
+ identity-dependent `assist_permitted`, which no tokenless caller can ever receive. Both are expressed
+ as CI-asserted plan constants rather than call-site arguments.
+
**Known gap in the evidence, recorded rather than papered over.** The third clause is deliberately
+ written about the CONSTANT, because that is all the tests pin. No suite constructs `EngineStatusBar`,
+ so nothing asserts that `readEnvironment` actually *uses* `ENVIRONMENT_PLAN` — rewiring that call site
+ to send a bearer would type-check and leave every test green. The constant is load-bearing only given
+ the call site reads it (`runProbe` attaches a bearer iff `entry.authenticated`), which is verified by
+ reading. Closing it needs an injectable-fetch seam on `EngineStatusBar` asserting `token === undefined`;
+ that is a test-infrastructure change beyond BACKLOG #330 and wants its own item.
## Options considered
diff --git a/docs/adr/0072-traced-dryrun-mode.md b/docs/adr/0072-traced-dryrun-mode.md
index 037f12de..165e1dbd 100644
--- a/docs/adr/0072-traced-dryrun-mode.md
+++ b/docs/adr/0072-traced-dryrun-mode.md
@@ -1,6 +1,6 @@
# ADR 0072 — Traced dry-run mode: `dryrun --trace json` for the interactive live-debug loop
-**Status:** Accepted (2026-07-06). Ratified by the owner — the engine lane (MULTISESSION-PLAN-7 **L5**) may build. The traced mode is **additive + opt-in**; nothing here changes the shipped `dryrun` default. The §6 test gates are the acceptance criteria (byte-identical run · live-lookup identical · coverage-intact · redacted-by-default).
+**Status:** Accepted (2026-07-06). Ratified by the owner — the engine lane (MULTISESSION-PLAN-7 **L5**) may build. The traced mode is **additive + opt-in**; nothing here changes the shipped `dryrun` default. The §6 test gates are the acceptance criteria (byte-identical run · live-lookup identical · coverage-intact · redacted-by-default). **Amended 2026-08-04 (BACKLOG #341)** — gate 1 stated a single byte-identical equality; the repo reads it at two levels (message-level and per-invocation), and widening `_partition` to fan out any non-`str` iterable made a **generator Handler** deliver where it previously delivered nothing, which the per-invocation mirror cannot observe. Gate 1 in §6 is split by level and given an explicit generator carve-out, and §3 gains the `lazy_result` field that declares it. The message-level guarantee — the one the ADR exists to make — is **unchanged and still absolute**.
**Deciders:** owner + IDE/DX working group
**Related:** BACKLOG **#92** (interactive live-debug loop — the primary consumer, v2), BACKLOG **#84** (Test Bench profiling/coverage — the second consumer), BACKLOG **#48** (Insert Element palette — sibling DX lane), ADR 0004 (payload-agnostic ingress — `RawMessage` vs `Message` in a handler), ADR 0010/0043 (`db_lookup`/`fhir_lookup` — the sanctioned non-pure reads that raise in dry-run), CLAUDE.md §9 (PHI handling — `dryrun` output can contain full bodies). Plan: [`docs/releases/MULTISESSION-PLAN-7.md`](../releases/MULTISESSION-PLAN-7.md) (L5 builds this; L6/L7 consume it).
**Code references** are `origin/main @ 0f0ba08`; line numbers are approximate — locate exactly at implementation time.
@@ -43,6 +43,7 @@ Emitted as JSON (streamed — see §5). One object per Router/Handler invocation
"disposition": "PROCESSED" | "ROUTED" | "UNROUTED" | "FILTERED" | "ERROR",
"sends": [ { "outbound": "" }, ... ], // handler only
"routed_to": [ "", ... ], // router only
+ "lazy_result": true, // OPTIONAL, absent unless true — see below
"annotations": [
{ "line": , "kind": "live_lookup_skipped",
"call": "db_lookup" | "fhir_lookup" }
@@ -50,6 +51,8 @@ Emitted as JSON (streamed — see §5). One object per Router/Handler invocation
}
```
+- **`lazy_result` (added 2026-08-04, BACKLOG #341).** A **generator** Router/Handler returns without running its body; the body runs when the engine materialises the generator, which is *after* this tracer has restored `sys.settrace`. Such an invocation therefore has **no line events** and its `sends`/`routed_to` are **not measurable** — the tracer must not drain a one-shot iterator to find out, because that would leave the real run nothing to materialise and turn the traced run into a 0-delivery run (gate 1, §6). The invocation is flagged `"lazy_result": true` so a consumer renders *"this body was not traced"* rather than an apparently inert handler; the **message-level** `sends`/`handlers` are computed by the real run and stay exact. Return a list or tuple to get a line-by-line trace.
+
- **Value-capture timing.** `sys.settrace` `"line"` events fire on line *entry*, so a value assigned on line *N* is only observable once the tracer reaches line *N+1*. The tracer therefore reports a **locals-diff per line** (`assigned` = locals that changed since the previous line event), and attributes each change to the line that produced it. This is deterministic and needs no AST rewrite; an AST-assisted refinement is a possible v2 (§7), out of scope here.
- **`msg` mutations.** Field writes (`msg[...] = …` / `msg.set(...)`) surface as a change to the `msg` object; the tracer records the **path + new value** written on that line (not the whole message), so the IDE can annotate `msg["PID-5.1"] ▸ "SMITH"`. Whole-payload before/after stays the Test Bench's job (L4), not the trace's.
- **Scope.** Events are captured **only** for frames inside the config module's Router/Handler (by `co_filename` + the def's line range); calls into engine/library code return `None` from the trace function (not traced) — the annotation surface is the author's own code, nothing else.
@@ -79,7 +82,10 @@ The sanctioned non-pure reads (ADR 0010/0043) are **unavailable in dry-run and r
**Costs / risks:** a `sys.settrace` pass slows the traced handler (acceptable — dry-run is a dev-time preview, not the hot path). The trace function is correctness-sensitive (prior-tracer restore, thread-locality) — hence the mandatory §5 rules and the §6 test gates.
**Test gates (L5 `tests/`):**
-1. **Byte-identical:** a `--trace` run's `disposition` + `sends`/`routed_to` equal the non-traced run's for the same sample.
+1. **Byte-identical (amended 2026-08-04 — stated by level):**
+ - **1a — message level (absolute, no exceptions).** A `--trace` run's top-level `disposition` + `sends` + `handlers` equal the non-traced run's for the same sample. This is the guarantee the ADR exists to make: installing the tracer must never change what the engine would do. It holds for **every** Router/Handler shape, generators included.
+ - **1b — per-invocation mirror (best-effort, degradation declared).** Each invocation's `sends`/`routed_to` mirror what that Router/Handler returned. **Carve-out:** where the Router/Handler is a **generator**, its body has not run at record time and the tracer refuses to consume the one-shot iterator (draining it is what would break 1a), so its `sends`/`routed_to` are `[]` and its `events` are `[]`. That invocation SHALL carry `"lazy_result": true` (§3), and 1a SHALL still hold — the under-report is **declared, bounded, and never a mis-report**: the trace may say less than the run did, never something different. → `tests/test_dryrun_trace.py::test_gate_1a_a_generator_handler_traces_byte_identically_at_message_level`, `tests/test_dryrun_trace.py::test_gate_1b_a_generator_invocation_declares_lazy_result` — the pair pins both halves, so the documented under-report cannot silently become a mis-report, and deleting the `_sends_from` iterator guard reddens 1a.
+ - *Why this is an amendment and not a discovered bug:* before BACKLOG #341 a generator Handler delivered **nothing**, so reporting no sends was exact and 1a and 1b agreed trivially. Widening `_partition` to fan out any non-`str` iterable made a generator Handler deliver — a change to the engine, made by that item, which opened the gap on the Handler half. The Router half has had it since this ADR shipped.
2. **Live-lookup identical:** a handler hitting an unstubbed `db_lookup` yields identical disposition/Sends **with and without** `--trace`, plus a `live_lookup_skipped` annotation.
3. **Coverage-intact:** a traced run executed **under `pytest-cov`** leaves the outer coverage data intact (proves the prior tracer was restored).
4. **PHI-redacted-by-default:** values are `REDACTED` without the show-PHI opt-in.
diff --git a/docs/adr/0076-typed-action-vocabulary-action-list-lens.md b/docs/adr/0076-typed-action-vocabulary-action-list-lens.md
index fbfdd3d4..2ac018dc 100644
--- a/docs/adr/0076-typed-action-vocabulary-action-list-lens.md
+++ b/docs/adr/0076-typed-action-vocabulary-action-list-lens.md
@@ -290,8 +290,10 @@ the comment merged into an unrelated row. **The build lands failing tests for (1
`delete_row` would begin failing with "internal: could not locate the statement" on **every** action
that has a leading comment. Attachment ("a statement travels with its leading comment block") is a
separate, larger item and is gated on BACKLOG #233 — `blockExtent` / `walkMove` / `resolveDrop` are
- implemented twice (`ide/src/stepsModel.ts:1767` vs `ide/media/stepsWebview.js:68`) with no
- differential test.
+ implemented twice (`blockExtent` in `ide/src/stepsModel.ts` vs `ide/media/stepsWebview.js:68`) with no
+ differential test. *(2026-08-04: the "no differential test" half is closed —
+ `ide/src/test/suite/steps-mirror.test.ts` is that test. The duplication itself stands; the owner chose
+ the differential gate over de-duplication.)*
- **No inline/trailing-comment extraction.** Verified working today: `set_params` on
`msg.set("PID-3.1", "X") # noqa: E501` preserves the pragma exactly, and interior comments in a
multi-line call are absorbed into the action row's span. A note kind must not touch either.
@@ -539,3 +541,116 @@ Acceptance Criteria bucket. Promote to the block above if and when the owner acc
- **AC-D5** — Descended rows SHALL either carry live values (requiring an accepted ADR 0072 amendment
widening the tracer's frame scope) or SHALL render an explicit "not traced" state distinguishable from
PHI redaction — never a redacted placeholder that can never resolve.
+
+## Amendment C (2026-08-04) — the update-loop guard DEFERS a save-triggered re-projection instead of dropping it (BACKLOG #234)
+
+> **Status of this amendment: PROPOSED — not ratified.** It is written and built because BACKLOG #234
+> requires the guardrail it touches to be re-argued in a dated amendment in the same change, not because
+> the owner has ruled on it; ratification is an owner decision, and until it lands this section follows
+> Amendment B's convention (its acceptance criteria sit under a distinct heading, outside this ADR's
+> counted Acceptance Criteria bucket).
+>
+> **What it claims:** it **strengthens** the §5 "sync on save only" guardrail and does **not** relax it.
+> The projection still syncs on save and on save only. What changes is what happens to a save that
+> arrives while a `lens rewrite` holds the single edit slot: it is now **deferred to slot release**
+> instead of being **silently discarded**. Nothing here widens the sync trigger, adds a keystroke path,
+> or touches the #225 live-value save gate.
+
+### C.1 The defect
+
+`EditLoopGuard.shouldReactToDocumentChange()` returns `false` while an edit is in flight — the correct
+answer for the `WorkspaceEdit` the provider itself is applying, which must not feed back into a re-render
+that fights the webview (the update loop §5's guardrail set exists to break).
+
+The provider's save subscription consumed that answer as an unconditional **return**. But the guard
+cannot distinguish *our own* `WorkspaceEdit` from a *user* save that merely happened to land inside an
+in-flight rewrite — and on that second case the early return **dropped the save**. The view would then
+keep rendering a projection of the pre-save buffer with no signal, until the user saved again. On the
+shipped code this would surface on first deployment as "I saved and the Steps view did not update";
+there are no deployments today, which is why there is still time to fix it properly rather than
+document it.
+
+### C.2 The change
+
+- `EditLoopGuard` gains `noteSuppressedChange()` / `takeSuppressedChange()` — a single clear-on-read
+ boolean, mirroring the existing `queue()` / `takePending()` shape. One boolean, not a queue: a
+ re-projection reads the whole buffer, so any number of suppressed saves owe exactly **one** refresh.
+- A new pure `releaseEdit(guard, onRefreshOwed?)` is **the only sanctioned way to release the slot**:
+ it calls `endEdit()` and then, only if a change was suppressed, invokes the callback. `drainEdits`
+ releases through it in its `finally`, including on the unexpected-rejection path.
+- The provider records the debt in the save subscription's guard-rejected branch and pays it through the
+ **same 250 ms debounced `render()`** a real save uses — so a deferred refresh coalesces with a
+ subsequent save exactly like two rapid saves do, rather than adding a second, differently-timed render.
+- That channel is a pure `RerenderDebouncer` (in `stepsModel.ts`, timer functions injected) with two
+ operations: `schedule()` and **`cancel()`**. **A re-projection DISCHARGES an owed refresh** — it reads
+ the whole current buffer, so the run starting now already satisfies whatever was owed — and `render()`
+ therefore begins by discharging both routes, synchronously, before its `lens parse` await (so a save
+ landing mid-render is recorded afresh and still honoured):
+ - `rerender.cancel()`, for the release-then-force order. Three of the four release sites
+ (`applyStructural`, `applyPickedEdit`, `applyUndoRedo`) release the slot and then FORCE a full
+ re-projection a few lines later; without the cancel a suppressed save produces **two** — the forced
+ one, then the armed one ~250 ms afterwards, replacing the whole webview HTML again.
+ - `guard.takeSuppressedChange()`, for the force-then-release order. `drainEdits`' unexpected-rejection
+ handler renders to revert the optimistic webview change *inside* the drain, and only then does the
+ `finally` release; nothing is armed yet, so a cancel cannot reach that path.
+ The paths that return BEFORE any render (a `lens rewrite` refusal, a disposed panel) reach neither
+ discharge, which is exactly where the deferral is the only route.
+- A source-scan test asserts `ide/src/stepsView.ts` contains no bare `guard.endEdit()`, so a future
+ fourth release site cannot silently reintroduce the drop, and that `render()` opens with both
+ discharges.
+
+### C.3 Guardrail accounting (what is NOT changed)
+
+- **"Sync on save only" stands.** No keystroke, `onDidChangeTextDocument`, or timer path is added. The
+ deferred refresh is a *save* that already happened; it is being honoured late, not invented.
+- **"One editor at a time" stands.** The slot semantics are untouched; `releaseEdit` releases exactly
+ when `endEdit()` did.
+- **The update-loop guard stands.** `shouldReactToDocumentChange()` still returns `false` in flight, so
+ our own `WorkspaceEdit` still cannot trigger a re-render. The deferral fires *after* the slot frees,
+ which is precisely when a re-render is safe.
+- **`clearPending()` is unchanged and NOT folded in.** Dropping a queued *param edit* on a structural op
+ (the orphaned-queue rule, §5 v2) and deferring a *document refresh* are different rules with different
+ reasons; both release sites keep their existing `clearPending()` / `takePending()` behaviour.
+- **The #225 live-value save gate is untouched** — an explicit non-goal of BACKLOG #234.
+
+### C.4 A correction this amendment depends on
+
+The comment in `ide/src/stepsView.ts` that justified the save gate claimed "`lens parse` reads the file
+from disk, so re-projecting on every keystroke would slice the current (dirty) buffer against line ranges
+computed from stale disk content". **That premise is false**, and is corrected in the same change:
+`render()` pipes `document.getText()` to `lens parse -` over stdin and slices that same snapshot for the
+view models, exactly as this ADR's 2026-07-10 addendum states ("the rows are projected from the **live
+buffer**"). The disk read belongs to the live-value **trace**, which is separately save-gated by #225.
+
+This matters beyond tidiness: BACKLOG #234's *other* half asks whether a bounded relaxation of the save
+gate is safe, and that question was about to be argued against a premise that does not hold. A
+compensating control must not rest on a false premise (CLAUDE.md §11). The real, surviving reasons for
+the gate are re-shelling Python per keystroke and the fact that each re-projection replaces the entire
+webview HTML — which would destroy focus, selection and any half-typed input mid-word.
+
+**Deliberately not decided here.** Whether to relax the gate to a debounced re-projection on *change* is
+BACKLOG #234's remaining half. It stays open, and it should be decided against the corrected premise
+above rather than the false one. This amendment lands the race fix **first**, on purpose: the dropped
+refresh is a defect under the current gate and would widen materially under any relaxation.
+
+## Acceptance Criteria (Amendment C — proposed, not ratified; deliberately outside the counted block)
+
+*(Same convention as Amendment B: kept under a distinct heading so unratified criteria do not merge into
+this ADR's accepted Acceptance Criteria bucket. Promote to the block above if and when the owner
+accepts. The criteria are nonetheless **built and tested** — the amendment lands with its gate.)*
+
+- **AC-C1** — WHILE an edit holds the single edit slot, WHEN a document save for the projected document
+ is observed, THE SYSTEM SHALL record it and SHALL NOT re-project immediately → guard unit test refs.
+- **AC-C2** — WHEN the edit slot is released after one or more suppressed saves, THE SYSTEM SHALL run
+ exactly ONE re-projection, in EITHER order relative to a forced one: the deferred run goes through the
+ same debounced channel a direct save uses, and any `render()` discharges the owed refresh on entry —
+ cancelling the armed channel (release-then-force) and taking the guard's debt (force-then-release)
+ → `releaseEdit` / `drainEdits` / `RerenderDebouncer` test refs, including a kept-in-tree falsification
+ showing a render that does not discharge yields two.
+- **AC-C3** — WHERE no save was suppressed, releasing the slot SHALL NOT trigger any additional
+ re-projection → negative test ref.
+- **AC-C4** — WHEN `apply` rejects unexpectedly during a drain, THE SYSTEM SHALL still release the slot
+ AND still run an owed re-projection → rejected-apply test ref.
+- **AC-C5** — THE SYSTEM SHALL release the edit slot only through `releaseEdit`; a bare `endEdit()` call
+ in the provider SHALL fail a source-scan test, as SHALL a `render()` whose first statement is not the
+ debounce cancel → inventory test refs.
diff --git a/docs/adr/0087-sandbox-subprocess-isolation.md b/docs/adr/0087-sandbox-subprocess-isolation.md
index f691f23c..af098ebd 100644
--- a/docs/adr/0087-sandbox-subprocess-isolation.md
+++ b/docs/adr/0087-sandbox-subprocess-isolation.md
@@ -1,6 +1,6 @@
# 0087 — Router/Handler subprocess isolation
-- **Status:** Accepted
+- **Status:** Accepted; **Amended (2026-08-04)** — the transform-result parity rule changed shape. The child now materialises a container return with `_partition`'s **own** rule instead of reproducing its exact input container, so a tuple/set/generator **delivers** in both modes (BACKLOG #341). AC-11 and the "Result parity" bullet below are rewritten accordingly; the isolation boundary and the codec grammar are untouched.
- **Date:** 2026-07-10
- **Related:** [ADR 0009](0009-run-scoped-context-providers.md) (RunContext providers) · [ADR 0010](0010-handler-callable-db-lookup.md) / [ADR 0043](0043-fhir-read-lookup.md) (`db_lookup`/`fhir_lookup`) · [ADR 0072](0072-traced-dryrun-mode.md) (tracer seam it composes with) · [ADR 0036](0036-windows-config-source-trust.md) / [ADR 0041](0041-load-path-attestation-and-change-attribution.md) (config-source trust) · CLAUDE.md §2 (reliability/purity, count-and-log) · CLAUDE.md §4 (layering) · BACKLOG #197 · ASVS 15.2.5 / `docs/security/ASVS-L3-REMEDIATION-PLAN.md` WP-L3-17
@@ -146,9 +146,29 @@ target, no `pickle` import left to mis-suppress:
registry. `mode=off` draws no such line either — see the residuals.
- **Result parity.** The child materialises a router result with `_handler_names`' own logic (so a
documented-supported **generator Router**, which is unpicklable, now works under `mode=subprocess`)
- and reproduces `_partition`'s *exact* input container for a transform result — including describing
- an item `_partition` would ignore rather than omitting it, so a tuple/set/int return still drops and
- a `Send` **subclass** still delivers, byte-identically to `mode=off`.
+ and — since the 2026-08-04 amendment — a transform result with `_partition`'s own logic, the shared
+ `wiring.handler_result_items` rule. A **container** return (list, tuple, set, generator) is described
+ element-wise, so both modes deliver the **same `Send`s, into the same three partitions**; anything
+ that rule does not recognise as a container stays a single item, and an item `_partition` would ignore
+ is **described rather than omitted**, so it still drops and a `Send` **subclass** still delivers,
+ byte-identically to `mode=off`. The materialization runs inside the child's `with run_contexts(...)`,
+ so a **generator Handler's** lazily-executed body sees the same run-scoped providers (`code_set`,
+ `state_get`, …) it sees under `mode=off` — materialising it later, at describe time, would make those
+ raise under `mode=subprocess` only.
+ - **CAUTION — parity is over the delivered set, not the order, for an unordered container.** An *ordered*
+ container (list, tuple, generator) delivers in its own order under either mode. A **`set`** has no
+ defined iteration order — `Send` is a frozen dataclass hashed on its fields and `str` hashing is
+ seeded per process — so the child, being a **different process**, materialises it in a different
+ order than the parent would (measured: a six-element set iterated in a different order in **all
+ four** independent process pairs probed). Fan-out order from a
+ `set` is therefore unspecified in *both* modes and is **not** a mode-parity obligation; only the
+ multiset is. This is a property of `set`, not of the sandbox: the same non-reproducibility appears
+ across a crash re-run at `mode=off`. See `wiring.handler_result_items` for the full statement and
+ `docs/CONNECTIONS.md` for the author-facing steer toward ordered containers.
+ - **Residual (mode-independent, recorded in [ADR 0072](0072-traced-dryrun-mode.md) §6 gate 1 —
+ do not restate it here):** a generator Handler is not execution-traced and its per-invocation
+ `sends` are empty. It reproduces at `mode=off`, so it is not a sandbox residual; it is noted here
+ only because this bullet is where a reader meets generator Handlers.
- **`Send` carries encoded text, never a live `Message`.** The sole parent-side consumer already
reduces it to a `str`, so the parent's `Send(...)` rebuild is a provable no-op for ADR 0104's
copy-on-Send choke point instead of taking a second snapshot.
@@ -231,10 +251,19 @@ target, no `pickle` import left to mis-suppress:
`tests/test_sandbox_codec.py::test_hostile_frames_fail_closed[name_mismatch]`
- **AC-11** — WHERE `[sandbox].mode=subprocess`, THE SYSTEM SHALL route a **generator** Router
identically to `mode=off` (it previously dead-lettered every message), and SHALL preserve
- `_partition` parity for every other return shape — a `Send` subclass still delivers, a tuple/set/int
- still drops.
+ `_partition` parity for **every** return shape — where, since the 2026-08-04 amendment, a
+ tuple/set/generator of `Send`s **delivers** in both modes (BACKLOG #341), a `Send` subclass still
+ delivers, and a non-iterable unrecognized value (a bare `int`, a `__reduce__` gadget) still drops.
+ Parity is over the **multiset** of items in each of the three partitions, plus their **order for an
+ ordered container**; a `set` return has no defined iteration order in either mode, so its fan-out
+ order is explicitly **not** covered by this SHALL (see the Result-parity bullet).
+ WHERE the Handler is a **generator**, its body SHALL execute inside the child's run context, so a
+ run-scoped accessor within it resolves as it does under `mode=off` rather than raising.
→ `tests/test_sandbox.py::test_generator_router_routes_under_mode_subprocess`,
- `tests/test_sandbox_codec.py::test_partition_parity_table`
+ `tests/test_sandbox_codec.py::test_partition_parity_table`,
+ `tests/test_sandbox.py::test_a_generator_handler_delivers_under_mode_subprocess`,
+ `tests/test_sandbox.py::test_a_generator_handlers_body_runs_inside_the_childs_run_context`,
+ `tests/test_sandbox_codec.py::test_handler_result_items_treats_a_str_as_a_single_value`
- **AC-12** — WHERE the engine publishes code-set tables, THE SYSTEM SHALL serve **those** tables to a
sandboxed Router/Handler — including after a transparent respawn that follows a `codesets/` edit
made without a `/config/reload` — and the per-dispatch frame SHALL carry no code-set bytes.
diff --git a/docs/adr/0108-steps-view-accumulator-send-fan-out-copy-on-send-authoring.md b/docs/adr/0108-steps-view-accumulator-send-fan-out-copy-on-send-authoring.md
index c78bedda..379c6d31 100644
--- a/docs/adr/0108-steps-view-accumulator-send-fan-out-copy-on-send-authoring.md
+++ b/docs/adr/0108-steps-view-accumulator-send-fan-out-copy-on-send-authoring.md
@@ -1,6 +1,6 @@
# ADR 0108 — Steps-view accumulator Send fan-out (copy-on-Send authoring)
-**Status:** Accepted (2026-07-14) — owner-directed ("do it") after two model iterations; built + adversarially verified this session (engine `lens.py` + `ide/`).
+**Status:** Accepted (2026-07-14) — owner-directed ("do it") after two model iterations; built + adversarially verified this session (engine `lens.py` + `ide/`). **Amended 2026-08-04** — §2's "no engine runtime change" invariant and one §7 rationale both rested on `_partition` narrowing on `isinstance(result, list)`. BACKLOG #341 changed that function: it now accepts any non-`str` iterable. Both statements are corrected in place below. **Nothing this ADR built changed** — the recognizer, the rewrites, the byte-stability contract and every §6 acceptance criterion stand as written.
**Deciders:** owner + IDE/DX
**Related:** [ADR 0076](0076-typed-action-vocabulary-action-list-lens.md) (the action-list lens + row-scoped-splice contract §5/§6 this extends), [ADR 0089](0089-recognition-first-lens-native-idioms.md) (recognition-first / honest degradation §4), [ADR 0104](0104-copy-on-send-outbound-message-model-recognition-first-handler-message-type-and-hl7-field-picker.md) (copy-on-Send — the snapshot-at-construction model this lets an analyst *author*), [ADR 0106](0106-steps-view-add-dropdown-vocabulary-expansion-adr-0076-phase-b.md) (the Add palette this repoints the **Send** item within; §6 byte-scoping exceptions), [ADR 0103](0103-steps-view-row-context-menu.md) (the insert-after-on-send suppression rule this refines), BACKLOG **#222** (Steps view), **#26** (the declined visual-authoring line + its structured-Steps-view carve-out — native `.py` stays the only artifact).
**Code references** are this branch (`send-fanout`); locate by symbol, not absolute line.
@@ -34,7 +34,7 @@ def route_adt(msg):
- Deleting every append leaves `sends = []; return sends` — an empty accumulator = **FILTERED** at runtime, honest, with **no coupled name-scrub**.
- The three legacy returned forms (`return Send(...)`, `return [Send, ...]`, `return []`) stay **byte-identically** recognized for the estate; the palette simply stops *authoring* the return form.
-**Invariant (unchanged, #26).** Native `.py` is the only artifact and execution path. Every op emits Python the lens re-recognizes (byte-stable codegen), never a declarative logic engine. **No engine runtime change** — `dryrun.py::_partition` already delivers a NAME-built list identically to a returned list (it keys on `isinstance(result, list)` and never inspects the collector name); this is a pure recognizer + rewrite + view change.
+**Invariant (unchanged, #26).** Native `.py` is the only artifact and execution path. Every op emits Python the lens re-recognizes (byte-stable codegen), never a declarative logic engine. **No engine runtime change *for this ADR*** — `dryrun.py::_partition` already delivers a NAME-built list identically to a returned list, because it materialises whatever container it is handed and never inspects the collector name; this is a pure recognizer + rewrite + view change. *(Amended 2026-08-04: as originally written this sentence attributed that behaviour to `_partition` keying on `isinstance(result, list)`. BACKLOG #341 replaced that narrowing with a shared any-non-`str`-iterable rule — an engine runtime change to `_partition`, made by that item, not this one. The accumulator claim is unaffected: a `list` was and remains delivered element-wise.)*
## 3. Recognizer
@@ -69,6 +69,8 @@ def route_adt(msg):
A multi-agent review (10 confirmed findings, 0 refuted) hardened the build before commit: the `Send` import was injected at a stale index when a top-level import trailed the edited handler (→ `_leading_import_end`); a destination containing `"` emitted a non-canonical escaped literal ruff reflows (→ `_str_lit` honors ruff's single-quote escape-avoidance); syntactic recognition mis-showed an append into a non-returned/rebound/closure list as a delivering send and mis-tagged its scaffold (→ the delivering-accumulator gate + demotion); the convert gate keyed on return **count** not top-level terminality (→ would move a nested guard's fan-out branch); a non-empty tuple return would flip 0→N deliveries on convert (→ refused); and a nested anchor could place an append inside a loop/if at the wrong indent (→ top-level placement). Each fix carries a regression test.
+*Amended 2026-08-04:* the tuple item's **premise** is gone — since BACKLOG #341 a non-empty tuple return **delivers**, so converting one up would be delivery-neutral, not a 0→N flip. **The refusal stands** on conservative scope: the palette never authors that form, and enabling the rewrite carries its own byte-stability obligations that belong to a Steps-view item. The §6 SHALL and its regression test are unchanged; only this rationale is corrected.
+
## 8. Declined / out of scope (v1)
- **Nested per-iteration authored fan-out** (an append the ops place *inside* a loop): recognized when hand-written, never authored by the ops (they place top-level, one delivery per message); reorder via move, or hand-edit.
diff --git a/docs/adr/0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md b/docs/adr/0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md
index e766f1ad..f3f2618d 100644
--- a/docs/adr/0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md
+++ b/docs/adr/0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md
@@ -221,6 +221,41 @@ then it cannot warn about the states that do block an action: `unreachable`/`for
- **#26 (no visual/declarative authoring) is untouched — #26-clean:** this surface authors nothing, projects to no `.py`,
and executes no logic. No PySide6. No "channel"/"route" element.
+## Amendment — 2026-08-04 (BACKLOG #330)
+
+`/ai/policy` now has **two readers, with deliberately opposite probe plans**. This ADR is the record of
+authority for the probe-plan vocabulary (`ProbePlanEntry`, `PROBE_ENDPOINTS`, `POLICY_ROUTE`,
+`POLL_PLAN`, `VERIFY_PLAN`), so the second reader is recorded here rather than only in ADR 0035.
+
+- **`ENVIRONMENT_PLAN` (`authenticated: false`)** — the status bar's environment read, unchanged in
+ behaviour. It was previously a literal `undefined` token argument at the `readEnvironment` call site;
+ it is now driven by a named constant, so its tokenlessness is data CI asserts rather than an argument
+ a later tidy-up could "fix".
+- **`ASSIST_GATE_PLAN` (`authenticated: true`)** — `aiPolicy.ts`'s gate read of the same route. It wants
+ `assist_permitted`, which the engine computes from the acting identity and answers as `null` to any
+ caller it cannot attribute, so a tokenless read left ADR 0035's `ai:assist` half unable to fire at all.
+
+**Nothing in §2 or §5 is relaxed.** §2's prohibition on a bearer from the timer stands verbatim and
+still governs the status bar; §5's *"the environment is **read** (tokenless `/ai/policy`) and displayed,
+never set"* bullet is unchanged and describes `ENVIRONMENT_PLAN` exactly. The new reader is **not** on a
+timer: its only callers are user-initiated (a chat turn, and the *Show AI Policy* command), so it sits
+under **`VERIFY_PLAN`'s** rationale — a click or an activation is real user activity, so refreshing the
+idle clock there is honest rather than a forgery — and not under `POLL_PLAN`'s.
+
+Consequences for the acceptance criteria above, both verified rather than assumed:
+
+- **AC-3 still holds verbatim.** It quantifies over `POLL_PLAN`, which is untouched; and
+ `ENVIRONMENT_PLAN`, the other constant on the timer path, is `authenticated: false` and is now
+ asserted to be, which it was not before.
+- **AC-6 still holds as written.** `PROBE_ENDPOINTS` is unchanged — `/ai/policy` was already on the
+ allowlist — so no new route is probed by anything.
+
+The vocabulary itself is now **shared** rather than private to the status bar: `engineStatusModel.ts`
+is the IDE's probe-plan module, and `aiPolicy.ts` imports from it. That is the point of recording this
+here — without it, the next reader finds an `authenticated: true` plan for `/ai/policy` inside a module
+whose header forbids a bearer on the poll, and reasonably reads it as the exact bug this ADR exists to
+prevent.
+
## Alternatives considered
- **A webview "Engine Doctor" panel** — rejected: opens from the same command dispatch (fixes a missed click no better);
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 4b8f91f2..f19f8ee5 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -104,11 +104,11 @@ what is withheld and what you can request.
| [0069](0069-durable-write-throughput-lever.md) | Durable-write is not the throughput wall — engine feed concurrency is — the store absorbs ~27k commits/s (measured commit-storm) while the pipeline feeds only ~750/s (~36× gap); rejects every durable-write-tier lever (app-side group-commit, faster log storage, bigger pool — each ~0) and reaffirms ADR 0055's `DELAYED_DURABILITY`/`commit_delay` rulings; directs effort to engine-side feed concurrency (pooled claiming + higher stage concurrency + engine per-message CPU); commit-depth reduction is secondary and largely invariant-blocked | Proposed (2026-07-03) |
| [0070](0070-t17-infra-fault-bound.md) | Bounding a persistent pooled T17 infra fault. The `StageDispatcher`'s machinery-fault branch released the head and returned a fixed-backoff RETRY, while `release_claimed` *undid* the claim's poison-guard increment (`attempts = MAX(attempts-1, 0)`) and left `next_attempt_at` past-due — so a persistent infra fault re-claimed at the ~0.25 s sweep cadence forever (an escalation-less ~4×/s spin; the G6 ceiling never fires; the head silently head-of-line-blocks its lane). **Fix A:** re-pend the head with exponential-capped backoff, releasing `items[i:]` (never `items[1:]`, which strands the head INFLIGHT and breaks FIFO). **Fix B:** `[pipeline].infra_fault_policy`, default `stop` — count consecutive zero-progress infra faults and STOP the lane with an alert (`retry_forever` opt-in). A T17 head is **never** auto-dead-lettered: the message is not at fault, and the signal cannot distinguish a long outage from a deterministic bug — a human makes that call | Accepted (2026-07-04; implemented) |
| [0071](0071-cut-executor-round-trips-b5.md) | Cutting per-message executor round-trips: the async-marshaling feed wall (build-plan B5) — the concrete lever ADR 0069 deferred. The 2026-07-04 py-spy profile names the ~107 msg/s pooled ceiling as **per-completion executor→loop marshaling + the Windows Proactor self-pipe wakeup**, and (adversarially adjudicated + micro-benched 2026-07-04) it is a **blocking-driver (SQL Server / aioodbc per-statement) wall** — Postgres (asyncpg loop-native, ~2 crossings/msg) and SQLite (loop-affine handoff lock) keep the async path **by construction**. Decision: on SQL Server, **thread-hop fusion** — run each off-loop CPU stage + its handoff on one dedicated-executor hop via a dedicated *synchronous* pyodbc source, so a multi-statement handoff marshals back once (micro-bench: 12→2 crossings/msg, commits/msg identical ⇒ no transaction fusion, so 0069's fence is not hit). Cheaper/coalesced wakeup secondary (deferred); free-threading (ADR 0053) tertiary. **Throughput GO/NO-GO is the SQL-Server leg** (SQLite proved mechanism-only, throughput sign-unstable); null ⇒ escalate to ADR 0053 | Proposed (2026-07-04) |
-| [0072](0072-traced-dryrun-mode.md) | Traced dry-run mode — `dryrun --trace json`: a `sys.settrace` pass around the Router/Handler call emits a line-addressable `(line, event, value)` sequence + disposition + Sends, the data source for the **#92** interactive live-debug loop (v2 inline values) and **#84** profiling/coverage. Additive + **preview-only** (a traced run is byte-identical to a non-traced one — no dispatch/logic change); values are **PHI-redacted by default** + streamed-never-persisted (reuse the `--show-phi` gate); `db_lookup`/`fhir_lookup` stay `ERROR` (annotated `live_lookup_skipped`, handler does **not** resume); prior tracer restored in `finally` (coverage.py-safe), frame-scoped, thread-local. Stdlib-only, no runtime dep | Accepted (2026-07-06) — building (L5) |
+| [0072](0072-traced-dryrun-mode.md) | Traced dry-run mode — `dryrun --trace json`: a `sys.settrace` pass around the Router/Handler call emits a line-addressable `(line, event, value)` sequence + disposition + Sends, the data source for the **#92** interactive live-debug loop (v2 inline values) and **#84** profiling/coverage. Additive + **preview-only** (a traced run is byte-identical to a non-traced one — no dispatch/logic change); values are **PHI-redacted by default** + streamed-never-persisted (reuse the `--show-phi` gate); `db_lookup`/`fhir_lookup` stay `ERROR` (annotated `live_lookup_skipped`, handler does **not** resume); prior tracer restored in `finally` (coverage.py-safe), frame-scoped, thread-local. Stdlib-only, no runtime dep | Accepted (2026-07-06) — building (L5); **amended 2026-08-04** (BACKLOG #341 — the byte-identical gate is now stated by level: message-level absolute, per-invocation best-effort with a declared `lazy_result` carve-out for a generator Router/Handler) |
| [0073](0073-ownership-scoped-recovery-single-consumer-lanes.md) | Ownership-scoped recovery + single delivery consumer per outbound lane — the N-active-shards-on-one-unified-store reliability runtime (builds ADR 0063's deferred primitive): `reset_stale_inflight(owned=OwnedLanes)` scopes startup/DR crash recovery to a shard's config-graph lanes (channel_id for ingress/routed/response, destination_name for outbound; empty set matches nothing; residual-`IN`/`ANY` predicate keeps the WS-B ready-index seek); deterministic rendezvous (sha256 HRW) outbound-lane ownership over the pinned shard universe, gated at the wake boundary + pooled lane provider + per-lane spawn (predicate, not set — a reload-dropped lane keeps exactly its owner); `--shard`+`[cluster]` refused fail-closed; shard-set-changing reloads refused (fleet restart required); owner-only outbound controls/purge (409 names the owner; `/connections` rows carry `owner_shard`); sharded-only non-owned-lane buildup/stall watchdog (hung-owner paging). N-active stays gated on the clean 4-engine no-loss bench before SYSTEM-REQUIREMENTS calls it supported | Accepted (2026-07-06) — built |
| [0074](0074-adopter-capacity-estimator.md) | Adopter-run capacity estimator (BACKLOG #96) — productize the **built** `harness/load/` rate-walk + zero-loss reconcile as a supported `messagefoundry capacity` command an adopter points at *their* box/store/config to answer "does this carry my ~36 msg/s hospital with headroom?"; reports the **per-interface** no-loss ceiling + engine-wide aggregate + a **backend-aware limiting-factor** label + provision-at-≤50%-of-ceiling guidance. Hard requirements: **isolated throwaway store** (refuse to run against a non-isolated/production store — count-and-log intact), **synthetic PHI-free** payloads only (ADR 0030), backend-aware labels (SQLite knob rankings do **not** transfer to server backends — the B12 lesson), explicit harness-ceiling caveats (~450/s ACK per driver, ~135–144/s delivered per sink, poller-zero ⇒ sub-ceiling knee). v1 = rate-walk + limiting-factor labels; deeper per-stage diagnostics deferred. A productization of throughput-campaign evidence (PR #768), not a new measurement effort | Accepted (2026-07-07) — ratified. ⛔ **BUILD GATED (2026-07-14)**: a validity re-check vs STEP-4 Arm 0 found **14 confirmed blockers** in the *measurement* method (the named "only success gate" over-reports by **3–5.5×**; the poller-zero failure mode *satisfies* it; the estimand is intake not delivery; the aggregate-is-the-sum rule is measured-false; the ceiling is instant-partner). **Premise + hard requirements + the fail-closed guard layer still hold and remain buildable.** See the ADR's 2026-07-14 Amendment |
| [0075](0075-per-hop-sql-statement-batching.md) | Per-hop SQL statement batching (`[pipeline].batch_handoff_statements`, default-ON (emergency off-switch), fail-closed, SQL-Server-only) — the last [ADR 0069](0069-durable-write-throughput-lever.md)-named feed lever ("batching SQL statements per executor hop"): fold a multi-statement handoff **body** (guard-DELETE + inserts + finalize applock + `messages.status` UPDATE + event, from the SAME shared `(sql,params)` builders that keep the async/sync twins in lockstep) into 1–2 `pyodbc.execute()` batches, cutting **network round-trips + aioodbc executor crossings — NOT transactions** (`commits/msg` stays 2.000; the ADR 0069 cross-lane/commit fence is not hit). Attacks the serial-RT co-bottleneck the [ADR 0071](0071-cut-executor-round-trips-b5.md) B5 fusion NO-GO could not (the ~11 ms inter-box store RTT × ~4–5 RT/msg) and works on the **default async path**. Microbench (adversarially re-reviewed, counts VERIFIED honest): per-hop drop 27–50%, but the **≥40% figure is CONDITIONAL on the applock-rc-fold** — under the strict interpretation 27–33% clears nothing, so the microbench JUSTIFIES a live-rig e2e A/B, it does not substitute for it. Content-vs-infra error attribution + a golden-SQL/living RT-count CI gate are load-bearing. **Promoted default-ON 2026-07-08** (Bench B distance-insurance A/B: harmless-near + helps-far, green SS correctness precondition) — flag retained only as an emergency off-switch | Accepted (2026-07-07) — promoted default-ON 2026-07-08 |
-| [0076](0076-typed-action-vocabulary-action-list-lens.md) | Typed action vocabulary + structured action-list lens over Python Handlers (BACKLOG #222, under the #26 amendment) — phase 1: `messagefoundry/actions.py`, pure typed helpers mirroring Corepoint's action classes over the existing `Message` API (control flow stays native Python; no flow wrappers); phase 2: static-only `lens parse --json` (stdlib `ast`, never imports/executes config) + a VS Code `CustomTextEditorProvider` rendering any *parseable* Handler as a Corepoint-style action-list view — typed rows for the bounded structural grammar, in-place read-only `code` rows for anything else (coverage invariant: rows exactly partition the def body — never drop/reorder/synthesize) and whole-file refusal only on parse failure; phase 3 (bake + owner-go gated): row-scoped line-splice rewrites (byte-stable outside the edited row). The `.py` stays the **only artifact and execution path** — no interpreter, no declarative artifact, no canvas; InterSystems guardrails adopted (sync-on-save, one-editor-at-a-time, degrade-to-text-editor); live values reuse the ADR 0072 stream + `--show-phi` gate unchanged; stdlib-only, no new runtime dep (libcst deferred) | Accepted (2026-07-10) — **Amendment A ACCEPTED 2026-07-30 (owner-ratified, in force):** a `note` row kind so comment-only rows stop projecting as opaque `code`, **superseding ADR 0106 §5 (L)**, and reconciling the §3 enum to the kinds the parser already emits; build = BACKLOG #248. **Amendment B ⛔ DECLINED 2026-07-30 (owner ruling — too risky):** ADR 0089 Phase D "helper descent" is **not adopted and not to be built** — duplicate-call-site aliasing is unsolved in any ADR, and the yield is unmeasured and possibly negative; spec retained for auditability, reopening needs a new amendment. Better lever, not declined: recognize `msg["X"] = v` |
+| [0076](0076-typed-action-vocabulary-action-list-lens.md) | Typed action vocabulary + structured action-list lens over Python Handlers (BACKLOG #222, under the #26 amendment) — phase 1: `messagefoundry/actions.py`, pure typed helpers mirroring Corepoint's action classes over the existing `Message` API (control flow stays native Python; no flow wrappers); phase 2: static-only `lens parse --json` (stdlib `ast`, never imports/executes config) + a VS Code `CustomTextEditorProvider` rendering any *parseable* Handler as a Corepoint-style action-list view — typed rows for the bounded structural grammar, in-place read-only `code` rows for anything else (coverage invariant: rows exactly partition the def body — never drop/reorder/synthesize) and whole-file refusal only on parse failure; phase 3 (bake + owner-go gated): row-scoped line-splice rewrites (byte-stable outside the edited row). The `.py` stays the **only artifact and execution path** — no interpreter, no declarative artifact, no canvas; InterSystems guardrails adopted (sync-on-save, one-editor-at-a-time, degrade-to-text-editor); live values reuse the ADR 0072 stream + `--show-phi` gate unchanged; stdlib-only, no new runtime dep (libcst deferred) | Accepted (2026-07-10) — **Amendment A ACCEPTED 2026-07-30 (owner-ratified, in force):** a `note` row kind so comment-only rows stop projecting as opaque `code`, **superseding ADR 0106 §5 (L)**, and reconciling the §3 enum to the kinds the parser already emits; build = BACKLOG #248. **Amendment B ⛔ DECLINED 2026-07-30 (owner ruling — too risky):** ADR 0089 Phase D "helper descent" is **not adopted and not to be built** — duplicate-call-site aliasing is unsolved in any ADR, and the yield is unmeasured and possibly negative; spec retained for auditability, reopening needs a new amendment. Better lever, not declined: recognize `msg["X"] = v`. **Amendment C PROPOSED 2026-08-04 (built, awaiting owner ratification):** the update-loop guard DEFERS a save-triggered re-projection to slot release instead of dropping it (BACKLOG #234) — claims to STRENGTHEN the §5 "sync on save only" guardrail, not relax it; also corrects the false premise the save gate's own comment rested on (`lens parse` reads the LIVE buffer over stdin, not disk). Whether to relax the gate itself is #234's other half and is explicitly not decided |
| [0077](0077-action-bound-step-up.md) | Action-bound step-up re-verification for durable-takeover operations (ASVS 2.2.4 / BACKLOG #187) — a fresh re-authentication bound to the *specific* privileged action, not merely to a recent login, so a hijacked live session cannot silently perform a durable takeover | Accepted |
| [0078](0078-certificate-revocation-posture.md) | Certificate revocation posture (OCSP/CRL, ASVS 12.1.4, BACKLOG #201) — **enforced start-time refusal + delegated proxy**, NOT in-engine OCSP (stdlib `ssl` has no OCSP/CRL fetch; a hand-rolled responder fetch fights on-prem offline-by-default). Refines [ADR 0002](0002-phase2-transport-security-and-strong-auth.md)'s *documented* revocation residual into an **enforced** control: `serve` REFUSES to start an in-process, off-loopback `[api]` TLS bind (`tls_cert_file` set + non-loopback `host`) UNLESS revocation is *proven in front* — a declared TLS-terminating proxy (`tls_terminated_upstream` + `trusted_proxies`, which does its own OCSP-must-staple/CRL) OR the operator opt-out `MEFOR_TLS_REVOCATION_ATTESTED=1`. Secure default = refuse; the loopback default + proxy-terminated paths start **byte-identically** (the pure `config/tls_policy.py:in_process_tls_revocation_refused` predicate short-circuits). Compensating controls: the SQL-Server SChannel path already does OS-managed revocation, and `pipeline/cert_expiry.py` alerts on expiring certs (steering short-lived certs). **Amendment 2026-07-12 (BACKLOG #201 residual):** extends the SAME posture-keyed refusal to the OUTBOUND verifying-TLS connectors — pure `revocation_hop_disposition(*, is_phi, production, is_loopback_hop, proxy_proven, attested)` + `RevocationHopGuard` in `config/tls_policy.py`, wired into MLLP-over-TLS egress, the REST/SOAP/FHIR https paths (`refuse_unrevoked_verified_hop`), and the Postgres asyncpg store hop (`_refuse_store_revocation`); per-connection `tls_revocation_attested` + the blanket `MEFOR_TLS_REVOCATION_ATTESTED` env are the opt-outs. Composes with #200 (fires only on a VERIFYING hop — no double-refusal). Still out of scope: SQL-Server/SChannel (already OS-managed), DICOM-SCU/FTPS, the FhirLookup read path. Flips the ASVS 12.1.4 row from documented-residual to enforced-delegation | Accepted (2026-07-10; amended 2026-07-12) — built |
| [0079](0079-kerberos-idp-session-coordination.md) | Kerberos/AD engine-session lifetime coordinated with the directory (IdP) — terminate engine sessions when the directory revokes or disables the account, rather than letting a local session outlive its AD principal. **Amendment 2026-07-21:** mechanism 1's preferred input — the Kerberos ticket `endtime` — is **unobtainable** via pyspnego 0.12.1 (no expiry on the public `ContextProxy`; `SSPIProxy.step()` discards sspilib's `AcceptContextResult.expiry`), so on the Kerberos/LDAPS path it would degrade to a second local constant dressed as directory data. ASVS 7.1.3 therefore **closed by ACCEPTANCE** (signed register row, theme 3) and this ADR's Proposed→Accepted trigger is **NOT fired**; mechanism 1 ships only where the datum genuinely exists — the federated `id_token.exp` session cap. Mechanism 2 (background re-validation loop) stays deferred. **Amendment 2026-07-22: mechanism 2 is BUILT.** The deferral's stated cost was void — the candidate set derives from the existing `list_users()` + `list_sessions()`, so **no `sessions` schema change on any backend** (provenance columns were only ever mechanism 1's need). Recorded narrowing: `require_step_up` performs **no** directory bind (it compares the stored `reauth_at`), so the step-up surface is protected by inability to REFRESH — leaving a ≤`step_up_max_age_seconds` residual — while **bulk/raw PHI reads** (`require_phi_read`, 120/min) and **connection start/stop** (`require_paced`) survived to the 12 h cap. Adds three properties the design did not name: **two-strike** before revoking (the lookup returns one `None` for disabled ∪ deleted ∪ wrong-search-base), **all-or-nothing passes** (planned in `auth/reconcile.py` before any write), and a **mass-revoke circuit breaker** — a bad search base answers "not found" for everyone, so a pass exceeding **both** `ad_session_revoke_max` (5) **and** `ad_session_revoke_max_fraction` (0.34) aborts + alerts. AND, not OR: the floor alone signs out a 5-person site, the proportion alone fires on a 3-of-3 offboarding. Group re-diff rides the pass free (demotions no longer wait for a login); channel scope deliberately excluded. Default OFF (`ad_session_recheck_seconds = 0`) | Accepted (mechanism 2 built 2026-07-22; mech 1 Kerberos path closed by acceptance, federated path shipped in ADR 0142) |
@@ -118,7 +118,7 @@ what is withheld and what you can request.
| [0083](0083-mtls-client-certificate-identity.md) | mTLS client-certificate identity (BACKLOG #200) — a **verified** peer cert's subject/SAN maps to a MessageFoundry principal via an explicit deny-by-default allow-list (`[api].tls_client_cert_identities`), rooted in `CERT_REQUIRED` and namespace-qualified against spoofing. An **attested service-to-service** identity (no MFA/session/step-up). **Activated** (PLAN-9 Wave 3): a fork-free scope-populating shim (`api/tls_client_cert.py`) surfaces the verified peer cert post-handshake, and a fenced cert-only dependency `require_service_cert` gates one non-interactive route (`GET /service/identity`) — never a bearer/PHI/step-up route (refuses PHI-view perms at build), so a cert-identity can never bypass step-up | Accepted (2026-07-10) — owner ratified; model+resolver built (#200), **activated** PLAN-9 Wave 3 |
| [0084](0084-accepts-router-seam.md) | `accepts=` Router-stage seam — let a Handler declare a **pure** router-time applicability predicate (`Callable[[Message \| RawMessage], bool]`) so the Router declines it **before** a routed row is materialized, recovering the `2` transactions per self-filtering handler the ADR 0051 `txn/msg = 3 + 2H + 2N` model charges (the ADT hub's `wasted == 32`, ~63% of its durable writes). Purity enforced by construction (router stage already makes `db_lookup`/`fhir_lookup` raise — ADR 0010/0043); additive + default-identical (`accepts=None` = today). **Crux:** an all-declined message finalizes **`UNROUTED`** not `FILTERED` — count-and-log intact (still `RECEIVED` at ingress, still a final logged disposition, never accept-and-drop), only per-destination FILTERED granularity lost (optional `message_events` declined-handler mitigation). Ships an **advisory** `accepts-candidate` lint (flags a `@handler` opening with a guard-filter `if …: return []`). Spec + lint stub only, no engine build (build = BACKLOG #213) | **Accepted (2026-07-11, owner-ratified)** — `FILTERED → UNROUTED` accepted; `message_events` declined-handler mitigation deferred from v1, gated behind the #63 verbosity gate when built |
| [0085](0085-direct-hisp-smime-connector.md) | Direct-Project S/MIME-over-SMTP outbound connector (DIRECT-HISP, BACKLOG #157) — PR1 **outbound send only**: a new `ConnectorType.DIRECT` + `DirectDestination` that **SIGNs then ENCRYPTs** the Handler body via core `cryptography` `serialization.pkcs7` (no new dep — `endesive` rejected, `dnspython` deferred) and submits it as `application/pkcs7-mime; smime-type=enveloped-data` over the reused EMAIL STARTTLS/`refuse_cleartext_credentials` posture. All signing key+cert / per-partner recipient cert / trust anchor loaded + cross-validated at construction (fail loud): key↔cert public-key match + one-level `verify_directly_issued_by` chain check. New fail-closed `[egress].allowed_direct` host gate (kept separate from `allowed_smtp`). Inbound Direct mail / MDN / DNS-CERT discovery / IHE XDR **deferred** to later phases | Accepted (2026-07-10) — PR1 outbound-only, later phases deferred |
-| [0087](0087-sandbox-subprocess-isolation.md) | Router/Handler subprocess isolation (SANDBOX, BACKLOG #197, ASVS 15.2.5 / WP-L3-17) — an **opt-in** `[sandbox]` section: `mode=off` (default) runs Routers/Handlers in-process **byte-identically, zero overhead**; `mode=subprocess` runs each inbound's Router/Handler in a **persistent per-inbound worker child** (`pipeline/sandbox.py` + `_sandbox_worker.py` + `_sandbox_codec.py`; stdlib-only, no new dep — RestrictedPython rejected), never a per-message fork. The OS-process boundary denies admin code reach to the parent's DEK/audit-chain/sockets (the child loads only the message *graph*); on top: a forbidden-import guard (socket/store/crypto), a parent-enforced wall-clock cap (+ POSIX `RLIMIT_CPU`/`RLIMIT_AS`), and a **fail-closed** refusal of the live `db_lookup`/`fhir_lookup` bridges. Interposed at the `route_only`/`transform_one` seam (the in-process `mode=off` path composes with the ADR 0072 tracer; `mode=subprocess` bypasses it); engine-side handler/outbound-name validation stays engine-side; a denial → `ERROR`/dead-letter **post-ACK** (no NAK, never dropped). **MFW2 amendment (BACKLOG #339):** the IPC pipe originally pickled, so a Handler's `__reduce__` executed in the engine parent and the boundary was bypassable outright; both legs now use a closed-tag, non-executing codec. Consistent with the [0144](0144-security-lint-gate-over-admin-authored-router-handler-config.md) row below, **15.2.5 stays Partial** — this is the *address-space* half (the child still reaches `os`/`subprocess` and the host); OS-level default-deny confinement is [ADR 0147](0147-hardened-runtime-isolation-for-router-handler-code-ipc-brokered-sandbox-extends-adr-0087.md), still Proposed. **Residuals:** default-off; live-lookup forward-over-IPC deferred; load-time top-level exec not sandboxed (unchanged safe-source DACL gate); confinement is address-space only; worker kill does not reap a grandchild (#342) | Accepted (2026-07-10) — opt-in subprocess isolation built (#197) |
+| [0087](0087-sandbox-subprocess-isolation.md) | Router/Handler subprocess isolation (SANDBOX, BACKLOG #197, ASVS 15.2.5 / WP-L3-17) — an **opt-in** `[sandbox]` section: `mode=off` (default) runs Routers/Handlers in-process **byte-identically, zero overhead**; `mode=subprocess` runs each inbound's Router/Handler in a **persistent per-inbound worker child** (`pipeline/sandbox.py` + `_sandbox_worker.py` + `_sandbox_codec.py`; stdlib-only, no new dep — RestrictedPython rejected), never a per-message fork. The OS-process boundary denies admin code reach to the parent's DEK/audit-chain/sockets (the child loads only the message *graph*); on top: a forbidden-import guard (socket/store/crypto), a parent-enforced wall-clock cap (+ POSIX `RLIMIT_CPU`/`RLIMIT_AS`), and a **fail-closed** refusal of the live `db_lookup`/`fhir_lookup` bridges. Interposed at the `route_only`/`transform_one` seam (the in-process `mode=off` path composes with the ADR 0072 tracer; `mode=subprocess` bypasses it); engine-side handler/outbound-name validation stays engine-side; a denial → `ERROR`/dead-letter **post-ACK** (no NAK, never dropped). **MFW2 amendment (BACKLOG #339):** the IPC pipe originally pickled, so a Handler's `__reduce__` executed in the engine parent and the boundary was bypassable outright; both legs now use a closed-tag, non-executing codec. Consistent with the [0144](0144-security-lint-gate-over-admin-authored-router-handler-config.md) row below, **15.2.5 stays Partial** — this is the *address-space* half (the child still reaches `os`/`subprocess` and the host); OS-level default-deny confinement is [ADR 0147](0147-hardened-runtime-isolation-for-router-handler-code-ipc-brokered-sandbox-extends-adr-0087.md), still Proposed. **Residuals:** default-off; live-lookup forward-over-IPC deferred; load-time top-level exec not sandboxed (unchanged safe-source DACL gate); confinement is address-space only; worker kill does not reap a grandchild (#342) | Accepted (2026-07-10) — opt-in subprocess isolation built (#197); **amended 2026-08-04** (BACKLOG #341 changed the transform-result parity rule's shape: the child materialises a container return with `_partition`'s own rule, and parity is scoped to the delivered set + an *ordered* container's order — a `set` has no defined order in either mode) |
| [0088](0088-apiclient-service-cli-extraction.md) | Extract a Qt-free / FastAPI-free `apiclient/` engine-client library + a `messagefoundry service {install,start,stop,status}` CLI (BACKLOG #103, reusable-core half) — `apiclient/client.py` is the verbatim former `console/client.py` body (deps: `httpx` + lazy `truststore` + the pure `api/` pydantic models), the canonical client shared by the console, the headless load/acceptance harness, and future clients; `service.py` is the verbatim former `console/service_control.py` (stdlib-only Windows SCM control), surfaced on the CLI. `console/client.py` + `console/service_control.py` become thin re-export shims (no behaviour change); harness client imports repoint to `messagefoundry.apiclient`, Qt-widget imports stay on `console/`. Explicitly the reusable-core half of #103 — the console is **kept**; deleting `console/`, rehoming the Qt widgets, and dropping the `[console]` extra remain **deferred**. Does **not** supersede [ADR 0032](0032-console-desktop-launch.md); no new dependency | Accepted (2026-07-10) — built (PLAN-9 Wave 3) |
| [0086](0086-deterministic-corepoint-import.md) | Deterministic Corepoint action-list import → code-first Handlers (BACKLOG #105) — the **inverse** of ADR 0076's typed vocabulary + lens: a pure, stdlib-only engine importer (`messagefoundry/corepoint_import.py`) + a `messagefoundry import corepoint --out ` CLI that parses a Corepoint action-list export and emits one code-first `@router`/`@handler` module per channel, calling the ADR 0076 vocabulary + `Send`. The `.py` stays the only artifact/execution path (#26). **Amended 2026-07-24 (§2(a′)/(b′)/(c′)): the input schema is VALIDATED and it is XML** — root ``, logic at `/`, a recursive control-flow tree whose `@Data` is rich-text markup that must be stripped before any statement classifies; `` is a section label (never an action) and `@Disabled` is preserved as a comment, never live. Parsed through defusedxml (`forbid_dtd`/`forbid_entities`/`forbid_external`); `parse_any()` sniffs XML vs. the superseded synthetic JSON model. The mapping is the inverse of ADR 0076 §2 and deliberately narrow — a field path is never guessed, and control flow is emitted as real Python with inert placeholder conditions. Unmapped verbs emit in-place `# TODO: Corepoint …` + best-effort `msg.set` stub (count-and-log — never dropped). Untrusted export values ride across as `json.dumps`-escaped literals (no code injection). Correctness gate: emitted modules pass `messagefoundry check` + round-trip through `lens parse`. Optional `ide/` wrapper deferred | Accepted (2026-07-10), amended 2026-07-24 — owner ratified; engine importer + CLI built, schema validated (#105) |
| [0089](0089-recognition-first-lens-native-idioms.md) | Recognition-first lens (Phase A, BACKLOG #222) -- recognize the native Message API (msg.set / msg.field-copy / msg.delete_segments) as editable set_field/copy_field/delete_segment rows, so a Handler authored in the native API (not the ADR 0076 actions.py wrappers) renders as editable Steps without being rewritten. Extends the byte-stable rewrite to EDIT and INSERT those native forms import-free (insert_row exempt from the editable-kind guard; inserts indent to the anchor code line). Roadmap phases A-E; a production-estate scan measured ~66% opaque rows before, ~42% editable after Phase A | Accepted (2026-07-13) — owner-ratified; Phase A built + adopted |
@@ -139,7 +139,7 @@ what is withheld and what you can request.
| [0105](0105-streaming-very-large-hl7-attachments-detach-the-opaque-document-from-the-transformable-skeleton.md) | Streaming very-large HL7 attachments — **detach the opaque document from the transformable skeleton** (BACKLOG #149, Phase 0 substrate). A base64 PDF in `OBX-5.5` that pushes the frame past the 16 MiB cap is lifted at ingress into a **content-addressed, chunked, in-store attachment** (each chunk `mfenc` AES-GCM-sealed — bounded plaintext window per seal), leaving a small `mfdoc:v1:ref::` **live handle** (a live sibling of the `mfdoc:v1:pruned:` tombstone, unified with #94's opaque-pointer contract); the small skeleton rides the existing ingress→routed→outbound stages unchanged, and delivery **re-attaches** the handle — splicing the verbatim value back into `OBX-5.5` (no decode/re-encode) — and streams the frame inline MLLP MDM. **Owner rulings:** (1) Epic's MLLP does not cap frame size → inline MLLP, no FHIR-Binary; (2) pure pass-through, **doc-mutating transforms a non-goal** on streaming feeds; (3) **Approach B — store the `OBX-5.5` value VERBATIM** (byte-for-byte, no decode/re-encode → trivially re-run-pure); (4) 3-backend parity before go-live. **Substrate generalizes the `shared_body` refcount+GC** (`attachment`/`attachment_chunk` tables, `put_attachment`/`read_attachment`/`attachment_incref`/`attachment_decref`, content-addressed + dedup) + a NEW startup **orphan/incomplete-attachment sweep** (reclaims refcount-0 AND orphaned chunks so no PHI at rest, wired where `reset_stale_inflight` runs) + the key-rotation re-encrypt sweep extended to **re-seal chunks** + a `supports_streaming_attachments` capability flag (SQLite True; SS/PG raise until Phase 4). **Ingress detach (Phase 1a) + delivery re-attach (Phase 1b) now built** — the full round-trip: an over-threshold `OBX-5.5` is detached VERBATIM at ingress and re-materialized (spliced back byte-for-byte) at the terminal egress via the pure `reattach_documents_in_hl7` (injected async reader) on both the single-item and batch delivery paths; hydration is fail-loud (a missing/GC'd attachment → retryable `DeliveryError`, never a handle on the wire) and a **pure read** (never decref — retry-idempotent, fan-out-safe). The outbound MLLP send is uncapped so the large frame streams inline. **Phase 3a (retention) now built:** a `message_attachment` join table persists the message→attachment linkage (populated atomically with the ingress incref) and `purge_message_bodies` **decrefs + deletes** those rows in the body-purge transaction — ordered so a crash-re-run is a no-op (no double-decref / underflow / premature GC of a shared attachment), **closing the Phase-1b over-retention gap** (a purged-but-referenced document is now reclaimed at its last referrer). **Phase 4 (SQL Server + Postgres substrate parity) now built** — the whole Phase-0→3a substrate (schema, put/read/incref/decref/sweep, ingress two-object commit, retention decref + dead-row split, key-rotation re-seal) is implemented on both server backends at byte-for-byte behavioral parity with the SQLite reference (dialect + txn-model adapted only), `supports_streaming_attachments` flipped True, with SS/PG parity tests on the CI legs — **go-live parity met (the production store is SQL Server)**. **Remaining:** Phase 3b (read-surface migration — raw view / content search / retention strip made attachment-aware) is the only phase left. Extends [ADR 0001](0001-staged-pipeline-architecture.md)/[ADR 0028](0028-base64-binary-carriage-codec.md) | Accepted (2026-07-12) — Phase 0 substrate + Phase 1a ingress detach + Phase 1b delivery re-attach + Phase 3a linkage/retention decref + Phase 4 SS+PG parity built (`stream_threshold_bytes`/`max_message_bytes`/in-flight budget, deferred-ACK, two-object commit, verbatim splice-back, inline MLLP, `message_attachment` decref, streaming on all three backends); Phase 3b (operator read surfaces) the only remainder |
| [0106](0106-steps-view-add-dropdown-vocabulary-expansion-adr-0076-phase-b.md) | Steps-view **authoring palette** (ADR 0076 Phase B) — grow the Steps view's **Add** menu from 3 items to a grouped **~22-item palette** across four groups: **Transform** (16, incl. new pure helpers `trim_field`/`substring_field`/`pad_field`/`replace_literal`/`date_diff_field`/`arith_field`), **Translate & lookup** (Code Lookup via a **named code-set picker** — ADR 0033 data, not an inline dict — + live `db_lookup`/`fhir_lookup`), **Structure & flow** (If/Else If/Else, For Each, **Send**, **Filter**, **Raise Error**, Comment — making control/send rows *insertable*, not just recognized), **Diagnostics** (`log_note`/`checkpoint` in a separate `diagnostics.py`, `logger.debug`-only + **redact-by-default**). Core engine work: idempotent **import injection** + a multi-line **statement-template insert** through the audited paste machinery, with two sanctioned byte-scoping exceptions (import injection, clause-append). The adversarial pass caught + fixed real bugs (For Each would emit crashing `msg.segments("OBX")` and read it back false-green → arity-hardened; Filter's `return []` collides with a dynamic-destination Send → distinct `filtered:true` discriminant; regex `matches` → `test:{expr}` escape-hatch only; `code_lookup` `assign_to` dead-bind gate). **Block deferred → #231.** OUT: Loop/Call/ChooseFrom/Try-Catch, regex ops, inline-dict lookups, DB/web writes, clock reads. 3 phases: engine helpers + recognizer-safety → lens insert/recognize → IDE surface (owner lane). Extends [ADR 0076](0076-typed-action-vocabulary-action-list-lens.md)/[ADR 0089](0089-recognition-first-lens-native-idioms.md); sibling to [ADR 0103](0103-steps-view-row-context-menu.md) | Accepted (2026-07-12; palette shipped #1013/#1022) — the Steps-view Add palette is built (owner's `ide/` lane, #222) |
| [0107](0107-phase-4-is-closed-transaction-reduction-is-a-measured-dead-end.md) | **Phase 4 is CLOSED — transaction reduction is a measured dead end.** The P0 falsifier [ADR 0099](0099-phase-4-group-commit-amortize-the-per-event-transaction-cost.md) pre-registered has run and returned **ABANDON**. Inline stage-fusion ([ADR 0057](0057-inline-step-a-fast-path.md)) **works** — it cut `committed_txns/msg` **10.47 → 7.49** (−28.5%; manipulation check passed, disarmed-arm trap avoided at `H=D=dests=1`) — and **buys nothing**: throughput moved **−0.56%**, inside the pre-registered NULL band and below either arm's replicate spread. **That null IS the verdict** (pre-registered primary A/B). **Arm E** adds the number worth remembering: sweeping H∈{1,2,4,8} on the unmodified split path, a **~3× swing in committed transactions (9.89→29.20/msg) moves throughput only −11.7%** → **elasticity −0.115** — the txn→throughput coupling is real but **far too weak to be a lever**. ⚠️ **CORRECTION (same-day, adversarial verify):** an earlier draft claimed arm E proves *"F2 cannot clear the bar at any shape"* and that F2's ceiling *"lands inside B5's rejected band"* — **both FALSE.** F2's arm-E ceiling at H=8 is **+13.2%**, which is **ABOVE** the +8% PROCEED bar and above B5's +6.5…+10% band; even net of the *measured* H=1 give-back (−4.49 pts) it is **+8.75%**. **The data does NOT exclude F2 clearing the bar at high H** — and **F2 cannot be measured without being built** (the fusion gate is `len(names)==1`, so inline fusion is H=1-only *by construction*). We therefore **decline on cost/risk/evidence, NOT on a proof of impossibility**: fusion buys nothing at the only measurable shape; the deratings that would sink it are argued, not measured; ADR 0071 B5 is the precedent (6× fewer round-trips → +6.5…+10%, NO-GO); and F2 is a large permanent 3-backend surface. Decisions: no F2/F3; **ADR 0057 ⛔ DO NOT PROMOTE, default-OFF permanently**; state the conclusion precisely (**NOT** "the wall is per-message" — transactions *do* matter, just far too weakly); **F1 survives on latency/cleanliness merit only, never a throughput claim**; **do NOT open a fifth store-side falsifier** (four negative: C5/C6/C7/P0). **Frontier: the ENGINE side has never been ATTRIBUTED** — note shardcert already publishes per-shard PIDs for an external per-PID CPU capture that nobody has ever taken | Accepted (2026-07-13) — closes options; authorizes no build |
-| [0108](0108-steps-view-accumulator-send-fan-out-copy-on-send-authoring.md) | **Steps-view accumulator Send fan-out** — author multi-destination, copy-on-Send fan-out ([ADR 0104](0104-copy-on-send-outbound-message-model-recognition-first-handler-message-type-and-hl7-field-picker.md)) as first-class mid-body **actions**, never a `Send`-in-a-`return`. The owner rejected both `return Send(...)` and the named-sends `return [a, b]` form: a send should read as an action, not the function's return. **Decision:** recognize + author the **accumulator idiom** `sends = []; sends.append(Send("OB", msg)); ...transform...; sends.append(Send("OB2", msg)); return sends` — each `sends.append(Send(...))` is an editable `send` row (additive `appended:true`) positioned AT the append; the `sends = []` init + bare `return sends` footer are managed **read-only scaffold** (`scaffold:"collector_init"/"return_collector"`, kind stays `code`); deleting every append leaves an empty accumulator = **FILTERED**, no name-scrub. The three legacy returned forms stay **byte-identical**; the palette's **Send** item repoints `template:"send"` → `op:"insert_send"`, plus a per-row **+ dest** `add_destination` button. **No engine runtime change** — `dryrun._partition` already delivers a NAME-built list identically (keys on `isinstance(result, list)`); pure recognizer + rewrite + view. **Honesty gate:** an append is a send row only where its collector is a *clean delivering accumulator* (single top-level `[]` init, top-level `return NAME`, bound nowhere else); a discarded/aliased/rebound/closure-local append degrades to a read-only `code` row. IDE keys insert-after suppression on a new `isReturnRow` (append allows after; return/footer suppresses) across the six position sites + the CSP mirror. Adversarial pass folded in 10 fixes (stale import index → `_leading_import_end`; ruff quote escape; delivering-accumulator gate; nested-guard convert; tuple 0→N; nested-anchor placement). Extends [ADR 0076](0076-typed-action-vocabulary-action-list-lens.md)/[ADR 0089](0089-recognition-first-lens-native-idioms.md); repoints the [ADR 0106](0106-steps-view-add-dropdown-vocabulary-expansion-adr-0076-phase-b.md) Send item; #26-clean | Accepted (2026-07-14) — owner-directed; engine + IDE built, adversarially verified |
+| [0108](0108-steps-view-accumulator-send-fan-out-copy-on-send-authoring.md) | **Steps-view accumulator Send fan-out** — author multi-destination, copy-on-Send fan-out ([ADR 0104](0104-copy-on-send-outbound-message-model-recognition-first-handler-message-type-and-hl7-field-picker.md)) as first-class mid-body **actions**, never a `Send`-in-a-`return`. The owner rejected both `return Send(...)` and the named-sends `return [a, b]` form: a send should read as an action, not the function's return. **Decision:** recognize + author the **accumulator idiom** `sends = []; sends.append(Send("OB", msg)); ...transform...; sends.append(Send("OB2", msg)); return sends` — each `sends.append(Send(...))` is an editable `send` row (additive `appended:true`) positioned AT the append; the `sends = []` init + bare `return sends` footer are managed **read-only scaffold** (`scaffold:"collector_init"/"return_collector"`, kind stays `code`); deleting every append leaves an empty accumulator = **FILTERED**, no name-scrub. The three legacy returned forms stay **byte-identical**; the palette's **Send** item repoints `template:"send"` → `op:"insert_send"`, plus a per-row **+ dest** `add_destination` button. **No engine runtime change** for this ADR — pure recognizer + rewrite + view (see the ADR's §2 invariant for the `_partition` behaviour it rests on, amended 2026-08-04 by BACKLOG #341; do not restate it here). **Honesty gate:** an append is a send row only where its collector is a *clean delivering accumulator* (single top-level `[]` init, top-level `return NAME`, bound nowhere else); a discarded/aliased/rebound/closure-local append degrades to a read-only `code` row. IDE keys insert-after suppression on a new `isReturnRow` (append allows after; return/footer suppresses) across the six position sites + the CSP mirror. Adversarial pass folded in 10 fixes (stale import index → `_leading_import_end`; ruff quote escape; delivering-accumulator gate; nested-guard convert; non-empty-tuple convert refused; nested-anchor placement) — see the ADR's §7 for each rationale. Extends [ADR 0076](0076-typed-action-vocabulary-action-list-lens.md)/[ADR 0089](0089-recognition-first-lens-native-idioms.md); repoints the [ADR 0106](0106-steps-view-add-dropdown-vocabulary-expansion-adr-0076-phase-b.md) Send item; #26-clean | Accepted (2026-07-14) — owner-directed; engine + IDE built, adversarially verified; **amended 2026-08-04** (two `_partition` rationales corrected by BACKLOG #341 — nothing built changed) |
| [0109](0109-at-rest-encryption-fail-closed-on-an-undeclared-phi-posture.md) | At-rest encryption fail-closed on an **undeclared PHI posture** — default an undeclared/underived `[ai].data_class` to **PHI** so a keyless store refuses to start (reusing the built `__main__.py:980-1004` PHI serve gate) unless the operator declares `data_class=synthetic`, configures a key, or sets the audited `[store].allow_unencrypted_phi`. Makes cleartext-at-rest an explicit, audited opt-out at every posture (prod is already fail-closed); keyless→keyed reads stay back-compat. Closes [Secure Build Scorecard](../Secure_Build_Scorecard_MEFOR.md) gap #4 (→ B+ → A−). Layer-2 first-run auto-key deferred to a follow-on ADR. **Tier S2×P2 ⇒ T3.** Extends [ADR 0002](0002-phase2-transport-security-and-strong-auth.md); audited-escape precedent [ADR 0036](0036-windows-config-source-trust.md) | **Rejected (2026-07-14)** — premise refuted on code review: `serve` already fails closed for every PHI posture via `require_posture()` (env mandatory; `staging`/`prod`→PHI; custom-env-undeclared raises); cleartext only for declared/derived synthetic (no real PHI). No code shipped; scorecard gap #4 corrected |
| [0110](0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md) | **IDE engine-link doctor — the status bar tells the truth about the promote target** (BACKLOG #232). The `MEFOR: ` item painted a green check whenever *anything* answered `GET /health`: `classifyProbe` folded **every** HTTP status — **including a 401** — into `"reachable"`, and the probe **discarded the body**. `AuthSettings.enabled` defaults True, so a plain `serve` = engine up + IDE holding **no session** + every authenticated call 401ing + **a confident green check** (a live probe of the owner's engine returned `{"status":"ok","version":null}` — `/health` discloses `version` only to an authenticated caller, WP-L3-07). The click's menu diagnosed nothing, and its `Open engine URL in browser` opened the **bare** engine URL — a live **404** (there is no `/` route). **Decision:** green means *"the IDE can USE this engine"*, not *"a socket answered"* — a closed link-state union (`unreachable{code}`/`foreign`/`signedOut`/`unverified`/`blocked{reason}`/`drifted`/`ok`) in the vscode-free model; the tokenless poll reads the `/health` body (`version: null` ⇒ **signedOut**; a version present ⇒ **`unverified`, NEVER green** — `optional_identity` applies no RBAC and no must-change gate, and `/auth/me` is must-change-**exempt**, so neither endpoint can prove a session is usable); a green check is **EARNED** only by a **user-initiated** deep probe of a non-exempt protected route (`GET /config/provenance` — `monitoring:read`, no step-up, yields `drift` free) and it **DECAYS**. **The periodic poll MUST stay TOKENLESS** — `identity_for_token(..., activity=True)` refreshes the session idle clock, so a bearer on a 15s timer would make the engine's 30-min idle timeout unreachable forever (CWE-613). Surface is **native chrome, NOT a webview**: a `MarkdownString` hover (the primary diagnosis — renders at the item, needs no command dispatch), a state-gated QuickPick (`title:`, never a `placeHolder:`), a `$(sync~spin)` flip at the click site, and the extension's **first engine `LogOutputChannel`** (URLs/status/errno/duration/verdict — **never a body, never a token**). **Boundary made executable:** the IDE renders and repairs the **LINK**, never the **WORKLOAD** — container poverty + the model emitting **command ids, never data** + **two frozen CI allowlists** (an `EngineLink` field list with no connection/message/queue/count/rate field, and a probe-endpoint list of only `/health`, `/ai/policy`, `/config/provenance`; `/messages`/`/connections`/`/stats` break the build). Exposes `engineSignIn`/`engineSignOut` (already-written flows that were unreachable; `signIn()` inherits ADR 0035's pre-prompt `assertTargetAllowed()` refusal). **Declined:** a webview panel; a bearer on the poll; a "Reload config" button (`require_step_up` + 300s window while `withAuth` never retries a 403); inline change-password/MFA (deep-link to `/ui/login` — the Console owns credentials); "Start local engine" (a terminal cwd'd at a worktree forks a brand-new store + bootstrap admin). Extends [ADR 0100](0100-ide-native-surface-polish-and-open-to-messagefoundry-startup-experience-backlog-221.md) (supersedes its "opens engine settings" record) / [ADR 0035](0035-ide-extension-workspace-trust-and-scope.md); bounded by [ADR 0065](0065-web-ops-dashboard.md); #26-clean | Accepted (2026-07-14) — owner-directed; built + verified against the live engine (IDE v0.0.28) |
| [0111](0111-not-deployed-connections.md) | **Connection present but not deployed** (BACKLOG #233) — a first-class `deployed: bool = True` on **both** connection models so a config can carry a real, reviewed, dark connection (retired partner, superseded duplicate send, a relay pulled from prod) that is **never wired, never started, never queued to, and whose `env()` is never resolved**. Removes DEGRADED-on-**every**-boot, which is indistinguishable from a real regression — a permanent alarm is a disabled alarm. **Key finding:** `auto_start=False` already dodges `resolve_env_settings` on the *cold serve* path (both boot gates return before `_source_config`/`_dest_config`), **but `_build_check_connectors` (`wiring_runner.py:5181`/`:5186`) loops EVERY inbound and EVERY outbound with NO gate** — so `messagefoundry check` (the **required** commit gate), every reload, every promote and every `connection upsert` still explode, and one unresolvable connection blocks edits to *every other* connection in the file. **Honoring the flag there IS the feature.** `deployed=False` **WINS over `auto_start`** (deploying is a *config* change, not a runtime action → `start`/`restart` 409). **Three-way distinction that must never be conflated:** **SIMULATED** (#15 — built, receives rows, suppresses egress, finalizes `PROCESSED`) vs **PARKED** ([ADR 0048](0048-third-tier-disaster-recovery-standby.md)/[ADR 0095](0095-connection-lifecycle-scheduler-and-credential-fault-stop.md) — rows **RETAINED**, queued, retried) vs **NOT DEPLOYED** (no row is ever created). Enforced at the **`transform_one` Send-materialization seam** (`pipeline/dryrun.py:403-416`) — structurally mirroring [ADR 0084](0084-accepts-router-seam.md)'s `accepts=` seam for the router half — which covers the split, [ADR 0057](0057-inline-step-a-fast-path.md) inline and [ADR 0071](0071-cut-executor-round-trips-b5.md) fused paths plus dry-run/`check`/Test Bench in **one** edit; plus a **separate** 409 guard on the [ADR 0090](0090-resend-a-stored-message-to-an-alternate-outbound-connection.md) resend/edit-resend path, which inserts an outbound row directly and bypasses `transform_one` entirely. **Count-and-log (CLAUDE.md §12) preserved** by a per-destination `message_events` row **added to `_AUDIT_FLOOR_EVENTS`** (else the #63 verbosity gate evaporates it at `errors`/`off`, re-creating the accept-and-drop) + a **7th `MessageStatus.NOT_DEPLOYED`** used only when **every** selected destination was declined — because the finalizer decides `FILTERED` **by absence**, merely dropping the target would silently report "the handler filtered this" with **zero code changes and zero test failures** (the trap). **At-least-once ([ADR 0001](0001-staged-pipeline-architecture.md)) preserved:** the decline is keyed on the **REGISTRY FLAG** (a property of the graph), never on live runner state, so a re-run re-derives an identical delivery set. Connection **stays in `Registry.outbound`** (the orphan sweep keys on the registry — removing it would dead-letter already-queued rows). No DDL, no migration, no schema-hash change. **Not built here:** the `ide/` form controls (three TS field enumerations — deferred to avoid colliding with in-flight [ADR 0106](0106-steps-view-add-dropdown-vocabulary-expansion-adr-0076-phase-b.md) work; the flag works via hand-edited TOML + code-first without them) and **BACKLOG #234** (`_SCALAR_FIELDS` is a 12-key whitelist with no passthrough → a GUI save silently strips `priority`/`schedule`/`shard`/`metadata`/`batch`/`stall`/`dead_letter_days`; a **pre-existing** data-loss bug, filed only). Extends [ADR 0007](0007-gui-manageable-connections-toml.md) | Accepted (2026-07-14) — owner-directed; built (#233) |
diff --git a/docs/testing/master-test-plan/12-vs-code-ide-extension.md b/docs/testing/master-test-plan/12-vs-code-ide-extension.md
index 4785507b..daa0734d 100644
--- a/docs/testing/master-test-plan/12-vs-code-ide-extension.md
+++ b/docs/testing/master-test-plan/12-vs-code-ide-extension.md
@@ -62,15 +62,16 @@ actually fail a merge.
| Evidence | What it proves |
|---|---|
| `.github/workflows/ci.yml:263-317` — `ide build (ubuntu-latest \| windows-latest)` | `npm ci` from `ide/package-lock.json`, `tsc --noEmit` strict type-check, esbuild bundle on both OSes; `npm run test:unit` on every leg; `npm test` (headless VS Code) on the Windows leg only |
-| `ide/package.json:822` `test:unit` | 474 of 560 tests run with no Extension Host; a hand-maintained `--ignore` list excludes 8 files (86 tests) whose module-under-test transitively imports `vscode` |
+| `ide/package.json:822` `test:unit` | 487 of 580 tests run with no Extension Host; a hand-maintained `--ignore` list excludes 8 files (93 tests) whose module-under-test transitively imports `vscode` |
| `src/test/suite/extension.test.ts` | The extension activates with no workspace; every contributed command is registered; one non-interactive command (`showAiPolicy`) executes end to end |
| `src/test/suite/settings-scope.test.ts:23-90` | ADR 0035 AC-2/AC-3 as a **family invariant**: every declared setting is classified, anything whose name matches `/url\|endpoint\|host\|python\|exec\|command\|token\|credential/i` must be `scope: "machine"`, and `capabilities.untrustedWorkspaces.supported === "limited"` |
| `src/test/suite/pythonpath.test.ts` | ADR 0035 AC-1 — `resolvePythonPath` (`cli.ts:25-46`) never prefers a workspace `.venv` when untrusted; an explicit `pythonPath` is honoured verbatim; win32 + posix layouts |
| `src/test/suite/engine-target.test.ts` | ADR 0035 AC-4 — `assertTargetAllowed` (`engineTarget.ts:29-44`) refuses plain `http://` to a non-loopback host; loopback http allowed; unparseable URL fails safe |
-| `src/test/suite/ai-policy.test.ts` | ADR 0035 AC-5/AC-6 — no cache + no CLI policy ⇒ disabled `"unverified"`; a cached authoritative `off` survives going offline (`aiPolicy.ts:60-68`) |
-| `src/test/suite/engine-doctor.test.ts` | ADR 0110 AC-3/AC-6/AC-8 — the `EngineLink` field allowlist and `PROBE_ENDPOINTS = ["/ai/policy","/config/provenance","/health"]` (`engineStatusModel.ts:124`) are frozen; every `POLL_PLAN` entry is `authenticated:false`; a command link planted in a 403 detail cannot reach the trusted hover |
+| `src/test/suite/ai-policy.test.ts` | ADR 0035 AC-5/AC-6 — no cache + no CLI policy ⇒ disabled `"unverified"`; a cached authoritative `off` survives going offline (`aiPolicy.ts:78-86`). ADR 0035 AC-7/AC-8 — `resolveAiPolicy`'s fetch-and-cache path via an injected `AiPolicyIo`: the bearer reaches the request, `DEFAULT_AI_POLICY_IO.readToken` **is** `peekToken` by identity (never `ensureToken`), SEC-005 withholds it from a non-loopback `http://` target, and a degraded answer does not overwrite a cached deny in the STORE |
+| `src/test/suite/ai-policy-model.test.ts` | ADR 0035 AC-7 — `mergeAuthoritativePolicy`, the only assertion of the merge rule that runs **node-side on every leg** (zero-import module, so not on the `--ignore` list). The deny is sticky over a non-evaluable answer and reaches `assistantState`; a cached permit is **not** sticky; `mode` always comes fresh; "non-evaluable" covers an omitted/non-boolean field, not just `null` |
+| `src/test/suite/engine-doctor.test.ts` | ADR 0110 AC-3/AC-6/AC-8 — the `EngineLink` field allowlist and `PROBE_ENDPOINTS = ["/ai/policy","/config/provenance","/health"]` (`engineStatusModel.ts:131`) are frozen; every `POLL_PLAN` entry is `authenticated:false`; `ENVIRONMENT_PLAN` is `authenticated:false` and `ASSIST_GATE_PLAN` is `authenticated:true` on the **same** route (BACKLOG #330 — asserted so the two cannot be "unified"); a command link planted in a 403 detail cannot reach the trusted hover |
| `src/test/suite/engine-status.test.ts` | ADR 0110 AC-1/AC-2/AC-5 — `classifyHealth` can never return `ok`; `version:null` vs version-present verdicts; an earned verdict decays; glyph/hover rendering |
-| `src/test/suite/engine-client.test.ts` | ADR 0110 AC-4 — an unanswered request rejects tagged `MF_TIMEOUT`; `ECONNREFUSED` is distinct; hung vs dead render differently |
+| `src/test/suite/engine-client.test.ts` | ADR 0110 AC-4 — an unanswered request rejects tagged `MF_TIMEOUT`; `ECONNREFUSED` is distinct; hung vs dead render differently. ADR 0035 AC-8 — against a real loopback server, a token becomes `Authorization: Bearer ` and a tokenless call sends **no** `Authorization` header at all (the status bar's read depends on the latter) |
| `src/test/suite/engine-control.test.ts` | ADR 0112 AC-3/AC-6 — the serve argv is `serve --config ` with no `--db`/`--env`/`--port`; the preflight classifier picks the right remedy; `runDirHasEngine` fork guard; `planActions` gating |
| `src/test/suite/engine-setup.test.ts` | Every button on the guided setup page resolves to a known `CMD` id (the webview cannot smuggle an arbitrary command id), ids are unique, `CMD.startEngine` only in the dev-tone section |
| `src/test/suite/connection-merge.test.ts` | Non-rendered `connections.toml` keys survive an edit; a cleared rendered field deletes the key; retry merges field-wise; clone direction-flip drops inapplicable keys; name-collision refusal; `planSave` is the single merge policy for both writers |
diff --git a/docs/testing/master-test-plan/13-steps-editor.md b/docs/testing/master-test-plan/13-steps-editor.md
index 1a20ec10..ad4025d1 100644
--- a/docs/testing/master-test-plan/13-steps-editor.md
+++ b/docs/testing/master-test-plan/13-steps-editor.md
@@ -49,8 +49,9 @@
| `tests/test_actions.py` (44 tests) | All 15 vocabulary verbs incl. every `ValueError` guard; ADR 0076 gate 5 purity — `actions.py` top-level imports do no I/O (`:369`), `actions.py` + `lens.py` add no runtime dependency (`:375`) |
| `tests/test_diagnostics.py` (3 tests) | `log_note` redacts operands unless the dev `_reveal` flag is set; a bad template never raises; `checkpoint` logs segment ids only |
| `ide/src/test/suite/steps.test.ts` (~20 cases) | Row→view-model kind/title/params mapping; `code`-row verbatim passthrough; parse-error/no-handler/null-parse → text fallback; HTML escaping of HL7-derived params; `buildLensTraceArgs` never emits `--show-phi`; `traceRowValues` redacted by default; live-value line-containment mapping; the BACKLOG #225 dirty-buffer skip incl. a pre-fix regression demo |
-| `ide/src/test/suite/steps-edit.test.ts` (1,842 lines, ~95 cases) | Edit→`lens rewrite` spec mapping; editable vs read-only rows; literal-only param editing; field-picker button placement; rewrite-result parsing; one-edit-at-a-time + queue/coalesce + orphaned-queue discard; F7 `expect_src` wiring from the *projected* source; every structural op spec; toolbar Add defaults; ADR 0103 before/after + `contextMenuEnablement` matrix + the server-rendered menu template; `canDropRow`/`resolveDrop`/`insertionBarAnchor`/`walkMove`/`blockExtent`/`captureBlock`/`blockLabel` |
+| `ide/src/test/suite/steps-edit.test.ts` (2,186 lines, 121 cases) | Edit→`lens rewrite` spec mapping; editable vs read-only rows; literal-only param editing; field-picker button placement; rewrite-result parsing; one-edit-at-a-time + queue/coalesce + orphaned-queue discard; F7 `expect_src` wiring from the *projected* source; every structural op spec; toolbar Add defaults; ADR 0103 before/after + `contextMenuEnablement` matrix + the server-rendered menu template; `canDropRow`/`resolveDrop`/`insertionBarAnchor`/`walkMove`/`blockExtent`/`captureBlock`/`blockLabel`; the BACKLOG #234 suppressed-save deferral (guard debt, `releaseEdit`, `drainEdits`) and the `RerenderDebouncer` one-re-projection-per-release property with a fake clock |
| `ide/src/test/suite/steps-addmenu.test.ts` (295 lines, ~20 cases) | `ADD_MENU_CATALOG` spans the four ADR 0106 groups; every item op is a supported lens op; Add→Send emits `insert_send`; `ADD_MENU_BY_ID` allowlist; `STRUCTURAL_OPS` forces re-projection; `buildAddMenuRequest` mappings; ADR 0108 `isReturnRow` + `add_destination` |
+| `ide/src/test/suite/steps-mirror.test.ts` (1,492 lines, 50 cases; BACKLOG #233) | The webview↔model MIRROR parity gate. Loads the real `ide/media/stepsWebview.js` under **jsdom** with a recording `acquireVsCodeApi` double and reaches its mirrors through the opt-in `window.__mfStepsTestExports` hook, then asserts each against its `stepsModel` counterpart. **Two populations, deliberately, because the nine computation mirrors split in two** (the tenth, `stepsCtxRows`, IS the serialization boundary and is pinned directly). (i) The five that take a plain ROW ARRAY (`blockExtent`, `captureBlock`+`clipLabel`, `buildDropSlots`, `walkMove`) are swept over **2,000 seeded generated row sets** (`mulberry32`, seed printed on any failure so it reproduces) against ONE loaded page — they never touch the DOM, so a document per set would buy nothing. (ii) The four that are DOM-bound (`canDrop`, `resolveDrop`, `barAnchor`, and `scopeLabel`, which reads `.textContent` off the rendered header) take `` elements and a `getBoundingClientRect`, so they run over **four hand-authored adversarial cases** (nesting 0–4, if/elif/else, nested `for`, an empty-bodied header, `raise`, code rows, the ADR 0108 scaffold pair, an appended send, a `return []` filter, a multi-line row, titles carrying `& < > " '`) × ALL ordered (drag, target) pairs × pointer fractions **0.1/0.4/0.5/0.6/0.9**. 0.4 and 0.6 are the load-bearing ones: they straddle the 1/3 and 2/3 tri-zone thresholds, and 0.1/0.5/0.9 alone cannot see a threshold moving to 1/2 (verified by falsification — every failure landed at 0.4). Covers **at least** STEPS-06 (script loads under jsdom, one `alive` ping, no error diagnostic; its second clause stays manual), STEPS-07/08 (population i), STEPS-09 (population ii), STEPS-10 (context-menu enablement read back off the real server-rendered template) and STEPS-12 (the top-level-function inventory guard). It ALSO pins the row serialization boundary itself (`renderRowHtml` → dataset → `stepsCtxRows`), the code-row drag interception, and the hook's inertness when the opt-in flag is unset. Node-side, imports no `vscode`, so it runs on **every** `ide` leg — not only the Windows Extension Host one. STEPS-11 is satisfied by the seeded-divergence falsifications recorded in the PR, not by a permanently mutated copy in the tree |
| `ide/src/test/suite/hl7scope.test.ts` + `completion-scope.test.ts` | ADR 0104 AC-9 — trigger→structure resolution, Z-segment + sample union, rank-never-remove, visibly distinct scope miss; decorator/type extraction; `occurrence=`/`repetition=` kwarg context |
| `.github/workflows/ci.yml` `test` job (`:41`, matrix at `:374-376`) | The full **413-case** `lens`/`actions`/`diagnostics` pytest suite (verified by collection on this checkout) runs on `ubuntu-latest`, `windows-2022`, `windows-2025` (py3.14) as a **required** check — so CRLF, BOM and Windows-path behaviour of the engine half are genuinely exercised on a required leg |
| `.github/workflows/ci.yml` `ide` job (`:263-320`) | `tsc --noEmit`, esbuild bundle, `npm run test:unit` (the Steps model suites) on both legs, and `npm test` (`@vscode/test-electron`, headless VS Code) on the Windows leg |
@@ -62,15 +63,15 @@
| Risk | Failure mode | Blast radius | Detected today? | Priority |
|---|---|---|---|---|
| Row-contract drift Python→TypeScript | `lens.py` renames a field or changes the partition; the IDE keeps parsing the *frozen* fixture snapshot, so every `ide` test stays green while the live view mis-projects rows. A mis-projected line range means a byte-stable edit splices into the **wrong statement** | Silent wrong transform → wrong clinical data on every message through that Handler | **No.** Verified on this checkout: all 7 committed fixtures are stale — `suite` is missing from all of them, `label`/`operand` from `adt.json` and `IB_RADIOLOGY_SR.json`. `suite` is load-bearing for drag/drop scoping (`stepsModel.ts:29-32`) | P0 |
-| Webview mirror divergence | `ide/media/stepsWebview.js` re-implements 10 pure model functions (`blockExtent:68`, `captureBlock:83`, `buildDropSlots:100`, `walkMove:126`, `clipLabel:309` — the `blockLabel` mirror, `canDrop:384`, `scopeLabel:397`, `resolveDrop:404`, `barAnchor:433`, menu enablement ~`:590`) and is explicitly **not** unit-tested (`steps-edit.test.ts:954-956`: "verified manually"). A diverged mirror computes a wrong move/drop target; the engine then applies it byte-stably and it re-parses clean | Moving a `msg.set` out of an `if` guard, or into the wrong branch, is a semantic change the byte-stability gates structurally cannot see | **No.** ADR 0108's acceptance requires "the model and the CSP-isolated mirror in agreement" with nothing enforcing it. `buildDropSlots` is not even exported from `stepsModel.ts` (`:1672`), so no test *could* compare it today | P0 |
+| Webview mirror divergence | `ide/media/stepsWebview.js` re-implements 10 pure model functions (`blockExtent:68`, `captureBlock:83`, `buildDropSlots:100`, `walkMove:126`, `clipLabel:309` — the `blockLabel` mirror, `canDrop:384`, `scopeLabel:397`, `resolveDrop:404`, `barAnchor:433`, menu enablement ~`:590`) and, until BACKLOG #233, **was** explicitly not unit-tested (`steps-edit.test.ts` said so in as many words: "verified manually"). A diverged mirror computes a wrong move/drop target; the engine then applies it byte-stably and it re-parses clean | Moving a `msg.set` out of an `if` guard, or into the wrong branch, is a semantic change the byte-stability gates structurally cannot see | **Yes, as of BACKLOG #233** — `ide/src/test/suite/steps-mirror.test.ts` loads the webview script under jsdom and asserts every mirror against its model counterpart on every `ide` leg. Both of this row's original grounds are now spent: `buildDropSlots` **is** exported from `stepsModel.ts`, and the drop/clipboard mirrors are **no longer** "verified manually" (`steps-edit.test.ts` now points at the parity suite; what genuinely stays manual is the menu's positioning/dismissal/keyboard wiring, STEPS-76). The suite found exactly **one** live divergence — the model's `canDropRow` accepted a read-only `code` row as a drop target while the webview refused it, contradicting the model's own stated contract — and it is now closed. ADR 0108's "model and mirror in agreement" acceptance line is a gate rather than a claim | P0 |
| Engine change never triggers the IDE tests | The `ide` job's PR path filter is `^(ide/\|\.github/workflows/ci\.yml)` (`ci.yml:448`). A PR touching `messagefoundry/lens.py` — the exact contract the Steps view consumes — does not run it at all. And `ci-gate` deliberately does **not** `needs: ide` (`ci.yml:265`), so even a red `ide` leg cannot block a merge | The whole analyst-facing surface can regress green. (It does re-run on push-to-main, `ci.yml:410` — after the merge, when it can no longer block anything) | **No** | P0 |
| Zero action rows in the tested corpus | Census on this checkout: `lens parse` over all 12 `samples/config` handlers yields **12 code rows, 12 send rows, 4 control rows and 0 action/lookup/diagnostic rows**. ADR 0076 gate 1's named corpus therefore proves nothing about the action-row, param-edit or Add-palette surface — the part an analyst actually uses. The IDE fixtures inherit the same hole | Every projection/edit path for the editable surface is only ever tested against ad-hoc inline strings written by whoever wrote the test — no shared, reviewed adversarial corpus | Partially (inline strings in `test_lens_native/palette/fanout`) — but no corpus-level partition/byte-stability/ruff/`check` sweep over action rows | P1 |
| `{"expr": …}` splice writes arbitrary, unnormalized Python | `_validated_expr` (`lens.py:1869`) checks only "parses as one expression" and "is exactly one call argument". **Verified on this checkout:** `set_field(msg, "PID-3", __import__("os").popen("whoami").read())` is accepted and written into the Handler body. **Also newly verified:** an expr is spliced **verbatim**, so `foo( 1,2 )` produces output that **fails `ruff format --check`** and **fails `ruff check --select F` (F821 undefined name)** — a direct breach of ADR 0076 gate 3 ("emitted code is first-class") that no existing test covers | Handlers execute in the engine process. ADR 0144's lint runs only inside `messagefoundry check`, never on the rewrite path, and the Steps view gives no in-editor signal — while pitching a form field at an analyst who does not know Python. It also silently breaks the purity invariant the at-least-once contract depends on | **No** on all three counts | P1 |
| False completeness: helper-body writes invisible and unmarked | ADR 0089 Phase D (helper descent) is unbuilt — `_msh(msg)` renders as an opaque `code` row. ADR 0104 AC-10's **"unmodeled code present"** marker does not exist: grep for `unmodeled` across `ide/`, `messagefoundry/`, `tests/` returns only the ADR and `docs/research/message-model-eval.md`; the named test is absent | An analyst edits a PID mapping in the Steps view, sees no other write to that field, saves — and a helper's later write silently overrides it. Wrong clinical data, no failing test, no operator signal | **No** | P1 |
-| The provider is entirely untested | No test file references `stepsView.ts`, `StepsEditorProvider` or `registerSteps`. Untested: save-only re-projection + 250 ms debounce (`stepsView.ts:839-856`, `:89`), fallback-to-text on refusal (`:281`), the 3-second script handshake toast (`:340-349`), exec-gate degradation (`:216`), `applyUndoRedo` (`:499`), `applyPickedEdit`'s drain-not-clear rule (`:472-483`), `applyStructural`'s `clearPending`-before-`endEdit` (`:452`), `retainContextWhenHidden` + `supportsMultipleEditorsPerDocument:false` (`:1127`) | Every ADR 0076 §6 IDE guardrail lives in this one file. A regression means the view writes on a keystroke, races itself, or shows a stale projection | **No** | P1 |
-| Undo/redo not asserted end to end | Each op is one `WorkspaceEdit` (`stepsView.ts:380-386`, `:433-439`), so N Steps ops should be exactly N undo steps returning the file byte-for-byte | A coalesced or partial undo leaves a half-edited Handler that still parses and still passes every byte-stability gate. The operator believes they reverted; they shipped a partial transform | **No** | P1 |
-| Steps view ↔ split text editor race | The engine F7 guard (`lens.py:1533`, tested at `test_lens_rewrite_v2.py:822`) and the IDE `expect_src` wiring (`steps-edit.test.ts:515-595`) are each tested **in isolation**, never interleaved. The code comment at `stepsView.ts:355-358` records the exact defect class: if `expect_src` is ever recomputed from the same buffer sent as stdin, the guard becomes a tautology | The named ADR 0076 §6 failure mode: a stale-coordinate edit splices into the wrong statement | **No** | P1 |
-| Steps-authored destination never validated against the graph | `insert_send`/`add_destination` reject only an **empty** string (`lens.py:2294` — verified: `Send("OB_TYPO_DOES_NOT_EXIST", msg)` is accepted). The IDE destination picker degrades to free text when the graph can't be read (`stepsView.ts:98-106`) | An analyst ships a fan-out leg that silently never delivers. `checks.py:_check_send_target` (`:235-262`) would flag it as *advisory, non-blocking* — and no test proves the Steps path surfaces a Problems entry | **No** | P1 |
+| The provider is entirely untested | No test file references `stepsView.ts`, `StepsEditorProvider` or `registerSteps`. Untested: save-only re-projection + 250 ms debounce (`stepsView.ts:884-898`, `:91`), fallback-to-text on refusal (`:300`), the 3-second script handshake toast (`:364-373`), exec-gate degradation (`:218`), `applyUndoRedo` (`:533`), `applyPickedEdit`'s drain-not-clear rule (`:505-516`), `applyStructural`'s `clearPending`-before-`releaseEdit` (`:482`), `retainContextWhenHidden` + `supportsMultipleEditorsPerDocument:false` (`:1167`) | Every ADR 0076 §6 IDE guardrail lives in this one file. A regression means the view writes on a keystroke, races itself, or shows a stale projection | **No** | P1 |
+| Undo/redo not asserted end to end | Each op is one `WorkspaceEdit` (`stepsView.ts:404-410`, `:463-469`), so N Steps ops should be exactly N undo steps returning the file byte-for-byte | A coalesced or partial undo leaves a half-edited Handler that still parses and still passes every byte-stability gate. The operator believes they reverted; they shipped a partial transform | **No** | P1 |
+| Steps view ↔ split text editor race | The engine F7 guard (`lens.py:1533`, tested at `test_lens_rewrite_v2.py:822`) and the IDE `expect_src` wiring (`steps-edit.test.ts:515-595`) are each tested **in isolation**, never interleaved. The code comment at `stepsView.ts:379-382` records the exact defect class: if `expect_src` is ever recomputed from the same buffer sent as stdin, the guard becomes a tautology | The named ADR 0076 §6 failure mode: a stale-coordinate edit splices into the wrong statement | **No** | P1 |
+| Steps-authored destination never validated against the graph | `insert_send`/`add_destination` reject only an **empty** string (`lens.py:2294` — verified: `Send("OB_TYPO_DOES_NOT_EXIST", msg)` is accepted). The IDE destination picker degrades to free text when the graph can't be read (`stepsView.ts:100-108`) | An analyst ships a fan-out leg that silently never delivers. `checks.py:_check_send_target` (`:235-262`) would flag it as *advisory, non-blocking* — and no test proves the Steps path surfaces a Problems entry | **No** | P1 |
| Doc↔code drift on the palette | Nothing in code or CI references `docs/STEPS-PALETTE.md`. It is already wrong at line 3 ("The **Steps view** (`/ui`, …)") — grep over `messagefoundry_webconsole/` for `steps`/`lens` returns nothing, and FEATURE-COVERAGE-PLAN.md `:1517` states outright "the web console has no Steps view" | The only user-facing description of the 27-item vocabulary misdirects analysts and reviewers about what code a step actually writes | **No** | P1 |
| `docs/FEATURE-MAP.md` omits the whole subsystem | §11 "Surfaces — VS Code IDE" (`:175-186`) lists no Steps view, no action vocabulary, no `lens` CLI, no #222/ADR 0076 — while BACKLOG #222 (`:6689`) marks all three phases SHIPPED | A shipped analyst-facing subsystem is invisible in the project's status source of truth, so it is never scoped into release gates, coverage audits or support policy | **No** | P1 |
| Architectural guardrail is a convention, not a gate | Nothing asserts that no engine package imports `messagefoundry/lens.py`. Today it holds — grep confirms only `__main__.py:2777` and `:2801` (both lazy) plus `tests/` | A declarative logic **execution** path would begin exactly by importing the row contract into `pipeline/`. If it is ever crossed, no test fires | **No** | P1 |
@@ -78,8 +79,8 @@
| ADR 0104 AC-8 field-picker round-trip unproven | `ide/src/hl7Picker.ts` (171 lines, `pickHl7Path` at `:163`) has **no test file** — grep for `hl7Picker`/`pickHl7Path` across `ide/src/test/` returns nothing. AC-8's named test does not exist | An offered path that does not round-trip byte-identically silently corrupts a field write the moment the analyst clicks it | **No** | P1 |
| Gate-3 claim overstated | ADR 0076 gate 3 claims rewritten files pass `mypy --strict` and `messagefoundry check` on the samples corpus. No test runs mypy on rewritten output; the sole `check` spot-check is `test_lens_rewrite_v2.py:852` on `adt.py` only, and it relies on that file's `# type: ignore`. The ruff gates `pytest.skip` when ruff is absent (`test_lens_rewrite_v2.py:85-91`) | An emitted form that re-parses but fails strict typing (the class the bare-tuple refusal exists to catch) ships as "first-class output" with the gate asserting nothing — and the gate can silently vanish | **No** | P2 |
| PHI into the general log via a form field | `log_note` redacts operands but emits the **template verbatim** (`diagnostics.py:43`); the palette inserts an empty template the analyst fills in place (`stepsModel.ts:1001`). `diagnostics._reveal` (`:33`) has no environment clamp — the module docstring calls clamping "a wiring concern for the caller" and no caller does it | PHI at DEBUG in the general application log, authored through a form field by a non-programmer, with CLAUDE.md §9 unenforced on this path | **No** | P2 |
-| Live-value sample picker offers "All files" | `stepsView.ts:251` filters `{ "HL7 messages": ["hl7"], "All files": ["*"] }`; `scopeFor` then reads the pick with `fs.readFileSync` (`:193`). Redaction bounds exposure to segment ids and `buildLensTraceArgs` structurally cannot emit `--show-phi`, but no test exercises the "operator picked a non-synthetic file" path | The PHI posture rests on "the picker defaults to `messageSetsDir`". Nothing enforces or warns | **No** | P2 |
-| No projection budget for large Handlers | Measured on this checkout: a 5,000-statement Handler parses in **0.081 s** and rewrites in **0.155 s** (fine) but yields **5,001 rows / 965,409 bytes** of row JSON, and `buildHandlerViewModels`/`renderHandlersHtml` render every row of every handler with no virtualization (`stepsView.ts:311`, `:339`). `ide/src/cli.ts:162`/`:194` cap child stdout at 64 MB | A large ported Handler makes the view unusable or silently truncated with no notice — on exactly the migrated estates ADR 0089 targets | **No** | P2 |
+| Live-value sample picker offers "All files" | `stepsView.ts:253` filters `{ "HL7 messages": ["hl7"], "All files": ["*"] }`; `scopeFor` then reads the pick with `fs.readFileSync` (`:195`). Redaction bounds exposure to segment ids and `buildLensTraceArgs` structurally cannot emit `--show-phi`, but no test exercises the "operator picked a non-synthetic file" path | The PHI posture rests on "the picker defaults to `messageSetsDir`". Nothing enforces or warns | **No** | P2 |
+| No projection budget for large Handlers | Measured on this checkout: a 5,000-statement Handler parses in **0.081 s** and rewrites in **0.155 s** (fine) but yields **5,001 rows / 965,409 bytes** of row JSON, and `buildHandlerViewModels`/`renderHandlersHtml` render every row of every handler with no virtualization (`stepsView.ts:335`, `:363`). `ide/src/cli.ts:162`/`:194` cap child stdout at 64 MB | A large ported Handler makes the view unusable or silently truncated with no notice — on exactly the migrated estates ADR 0089 targets | **No** | P2 |
| No git-diff-cleanliness assertion on a working-tree file | Byte-stability is proven against in-memory oracles only; nothing performs a Steps edit on a checked-in file and asserts `git diff` is exactly the intended hunk | Diffable, reviewable config is the stated rationale for the whole #26 decline. One stray whitespace/EOL flip per edit destroys review value on every Steps-authored PR | **No** | P2 |
| No VSIX build/signature/attestation/publish | grep for `vsce`/`vsix`/`marketplace` over `.github/workflows/` returns nothing; the only path is manual `npm run package` (`ide/README.md:123`, which still names `messagefoundry-0.0.1.vsix` while `ide/package.json:5` says `0.0.34`) | The Steps editor reaches users only through a hand-built, unsigned, unattested VSIX. A missing `media/` asset ships with no gate — and no provenance for a surface that writes executable Handler code | **No** | P1 |
| Dead coordinate-critical near-duplicate | `stepsModel.ts:261 splitLines` (splits on `\r?\n` only) sits beside `:272 physicalLines` (the correct `\r\n\|\r\|\n` mirror of `lens._physical_lines`, `lens.py:3490`). Only `physicalLines` is used in the build path (`:482`) | A future caller reaching for the wrong one desyncs IDE line slicing from AST coordinates on a CR-only file; F7 then compares mismatched text and either refuses everything or mis-splices | **No** | P2 |
@@ -99,10 +100,10 @@
| STEPS-03 | `suite` id semantics hold for every row | Functional | pytest | container-CI | n/a | T | P0 | For every row of every corpus handler: `suite` equals the enclosing block header's line number as a string (the `def` line at nesting 0); two rows share a `suite` iff they are AST siblings in the same statement list; no row omits `suite` |
| STEPS-04 | A single CI job carries both Python 3.14 and Node 24 | Functional | CI-leg | container-CI | n/a | T | P0 | A named job (`steps-contract`) installs both toolchains and runs STEPS-01/02/03/05..12; it appears in `ci-gate`'s `needs:` list (`ci.yml:1386`) and is configured as a required context in branch protection |
| STEPS-05 | Older/partial contract degrades safely in the view model | Negative/Security | ide-mocha | container-CI | n/a | T | P1 | With `suite`, `label`, `operand`, `literal_params`, `appended` and `scaffold` each individually absent, `buildHandlerViewModels` returns the same row count and order, throws nothing, and (for absent `suite`) `canDropRow` returns `false` for every pair — never an unscoped drop |
-| STEPS-06 | `stepsWebview.js` loads under jsdom and completes its handshake | Functional | ide-mocha | container-CI | n/a | T | P0 | Loaded under jsdom with a stub `acquireVsCodeApi`, the script runs to completion with no thrown error and posts exactly one `{command:"stepsDiag", level:"ping", text:"alive"}`; a second load against the retained `window.__mfStepsVscode` does not re-acquire |
-| STEPS-07 | Mirror parity — `blockExtent` / `captureBlock` / `blockLabel` | Functional | ide-mocha | container-CI | n/a | T | P0 | Over ≥2,000 generated row sets (nesting 0–4, mixed kinds, control headers, returns, scaffold rows), the jsdom-loaded webview functions return values deep-equal to `stepsModel.blockExtent` (`:1767`), `captureBlock` (`:1803`), `blockLabel` (`:1839`) for identical inputs — noting the webview's `blockLabel` mirror is named `clipLabel` (`stepsWebview.js:309`). Any divergence fails |
-| STEPS-08 | Mirror parity — `buildDropSlots` / `walkMove` | Functional | ide-mocha | container-CI | n/a | T | P0 | Same corpus; webview `buildDropSlots` (`:100`) and `walkMove` (`:126`) deep-equal `stepsModel` `buildDropSlots` (`:1672`) and `walkMove` (`:1861`). Requires `buildDropSlots` to be exported from `stepsModel.ts` — the export is part of the deliverable |
-| STEPS-09 | Mirror parity — `canDrop` / `scopeLabel` / `resolveDrop` / `barAnchor` | Functional | ide-mocha | container-CI | n/a | T | P0 | Same corpus, all ordered (drag, target) pairs; webview `canDrop` (`:384`), `scopeLabel` (`:397`), `resolveDrop` (`:404`), `barAnchor` (`:433`) deep-equal `canDropRow` (`:1509`), `scopeLabel` (`:1594`), `resolveDrop` (`:1531`), `insertionBarAnchor` (`:1629`) |
+| STEPS-06 | `stepsWebview.js` loads under jsdom and completes its handshake | Functional | ide-mocha | container-CI | n/a | T | P0 | Loaded under jsdom with a stub `acquireVsCodeApi`, the script runs to completion with no thrown error and posts exactly one `{command:"stepsDiag", level:"ping", text:"alive"}`. **The second clause — a second load against the retained `window.__mfStepsVscode` does not re-acquire — is NOT automated and is covered by STEPS-76's checklist instead** (recorded 2026-08-04): VS Code's retain-context reload gives the script a fresh realm holding a retained `window`, which one jsdom document cannot reproduce — re-running a classic script in the SAME global throws on its own top-level `const`, testing the harness rather than the product |
+| STEPS-07 | Mirror parity — `blockExtent` / `captureBlock` / `blockLabel` | Functional | ide-mocha | container-CI | n/a | T | P0 | Over ≥2,000 **seeded generated** row sets (nesting 0–4, mixed kinds, control headers with and without bodies, elif/else continuations, returns, appended sends, scaffold rows, multi-line rows), the jsdom-loaded webview functions return values deep-equal to `stepsModel.blockExtent`, `captureBlock`, `blockLabel` for identical inputs — noting the webview's `blockLabel` mirror is named `clipLabel` (`stepsWebview.js:309`). The generator is deterministic and the seed is printed with any divergence, so a failure reproduces exactly. Any divergence fails |
+| STEPS-08 | Mirror parity — `buildDropSlots` / `walkMove` | Functional | ide-mocha | container-CI | n/a | T | P0 | Same ≥2,000 generated row sets; webview `buildDropSlots` (`:100`) and `walkMove` (`:126`) deep-equal `stepsModel` `buildDropSlots` and `walkMove`. Requires `buildDropSlots` to be exported from `stepsModel.ts` — the export is part of the deliverable |
+| STEPS-09 | Mirror parity — `canDrop` / `scopeLabel` / `resolveDrop` / `barAnchor` | Functional | ide-mocha | container-CI | n/a | T | P0 | These four are **DOM-bound** — they take `` elements and a `getBoundingClientRect`, so they cannot be driven from a generated row array and are swept over the four hand-authored adversarial cases instead, at **all ordered (drag, target) pairs × pointer fractions 0.1/0.4/0.5/0.6/0.9** (0.4 and 0.6 straddle the 1/3 and 2/3 tri-zone thresholds; without them a threshold drift to 1/2 is invisible). Webview `canDrop` (`:384`), `scopeLabel` (`:397`), `resolveDrop` (`:404`), `barAnchor` (`:433`) deep-equal `canDropRow`, `scopeLabel`, `resolveDrop`, `insertionBarAnchor`. A per-row `getBoundingClientRect` stub is mandatory (jsdom has no layout, and the webview falls back to a constant 0.5 fraction on a zero-height box) and the suite asserts the stub is what makes the fractions discriminate |
| STEPS-10 | Mirror parity — context-menu enablement | Functional | ide-mocha | container-CI | n/a | T | P0 | For every row context in the corpus, the webview's enablement computation (~`:590`) yields the same enabled/disabled set as `contextMenuEnablement` (`stepsModel.ts:1178`) |
| STEPS-11 | Seeded divergence is caught | Negative/Security | ide-mocha | container-CI | n/a | T | P0 | A deliberately mutated copy of one mirrored function (e.g. `blockExtent` returning `mj-1`) makes STEPS-07..10 fail with a diff naming the function and the failing input. Demonstrated once in the test suite as a self-check, then reverted |
| STEPS-12 | Mirror inventory guard | Negative/Security | ide-mocha | container-CI | n/a | T | P1 | A source scan of `ide/media/stepsWebview.js` enumerates every top-level `function (` and asserts each name is either in an explicit "not-a-mirror" allowlist (DOM/wiring helpers) or has a parity assertion in STEPS-07..10. Adding an 11th mirror without a parity test fails |
@@ -129,15 +130,15 @@
| STEPS-33 | Unparseable module → whole-file refusal → text fallback | Functional | ide-mocha + ide-electron | dev-PC | n/a | T | P1 | `lens parse` on a `SyntaxError` module exits non-zero with a `{"error": …}` body; `shouldFallBackToText` (`stepsModel.ts:500`) returns `fallback:true`; opening it as Steps in a real Extension Host reopens it as the default text editor and shows the notice, leaving the file unmodified |
| STEPS-34 | Read-only rows refuse every op, including the newer ones | Negative/Security | pytest | container-CI | n/a | T | P1 | For a `code` row, a `control` row, a `collector_init` scaffold row and a `return_collector` scaffold row: each of the 11 `_SUPPORTED_OPS` targeting it is refused with `LensRewriteError` and zero source change |
| STEPS-35 | Re-projection happens on save only, debounced | Functional | ide-electron | dev-PC | n/a | T | P1 | Typing 20 characters into the underlying document triggers **zero** `lens parse` invocations (counted through a stubbed CLI boundary); one save triggers exactly one after ~250 ms; three saves within 250 ms coalesce to one |
-| STEPS-36 | One Steps editor per document | Functional | ide-electron | dev-PC | n/a | T | P1 | `vscode.openWith` twice on the same URI yields one Steps webview (`supportsMultipleEditorsPerDocument:false`, `stepsView.ts:1127`); the second call focuses the existing one |
+| STEPS-36 | One Steps editor per document | Functional | ide-electron | dev-PC | n/a | T | P1 | `vscode.openWith` twice on the same URI yields one Steps webview (`supportsMultipleEditorsPerDocument:false`, `stepsView.ts:1167`); the second call focuses the existing one |
| STEPS-37 | Every write is a `WorkspaceEdit`; no out-of-band file write | Negative/Security | ide-mocha + ide-electron | container-CI | n/a | T | P1 | A source scan finds no `fs.write*`/`writeFile`/`appendFile` in `ide/src/stepsView.ts` or `ide/media/stepsWebview.js`; in the Extension Host, an unsaved Steps edit leaves the on-disk mtime and bytes unchanged while `document.isDirty` is true |
| STEPS-38 | Undo/redo fidelity across a mixed op sequence | Functional | ide-electron | dev-PC | n/a | T | P1 | Apply insert → move → delete → param-edit (4 ops). Four `undo` commands return `document.getText()` **byte-identical** to the original; four `redo` return it byte-identical to the post-edit form. Not 3, not 5 |
| STEPS-39 | Concurrent Steps + split text editor is refused, not mis-spliced | Functional | ide-electron | dev-PC | n/a | T | P1 | Open Steps + a split text editor on the same file; insert a line above the target row in the text view; then trigger a Steps row edit. `lens rewrite` refuses on `expect_src` mismatch, an error toast appears, the view re-projects, and the file's target statement is unchanged. Repeat with the edit *below* the target row (must still succeed) |
| STEPS-40 | Live values are skipped while the buffer is dirty | PHI | ide-electron | dev-PC | n/a | T | P1 | With a dirty buffer, no `dryrun --trace` child is spawned and no row shows a live-value marker; after save, markers reattach to the correct rows (line-containment verified against the saved text) |
| STEPS-41 | Untrusted workspace degrades legibly | Negative/Security | ide-electron | dev-PC | n/a | T | P1 | With workspace trust off, `isExecGated()` is true, no child process is spawned for `lens parse`/`rewrite`/`graph`/`dryrun`, and the Steps view shows an explicit "workspace not trusted" state — not a blank page, not a silent empty row list |
-| STEPS-42 | Script-handshake failure surfaces | Functional | ide-electron | dev-PC | n/a | T | P2 | With the webview script deliberately blocked, the 3-second timer (`stepsView.ts:340-349`) fires exactly one error message naming "View as Code"; with the script loading normally, it never fires |
+| STEPS-42 | Script-handshake failure surfaces | Functional | ide-electron | dev-PC | n/a | T | P2 | With the webview script deliberately blocked, the 3-second timer (`stepsView.ts:364-373`) fires exactly one error message naming "View as Code"; with the script loading normally, it never fires |
| STEPS-43 | Closing and reopening re-derives rows solely from the `.py` | Negative/Security | ide-electron | dev-PC | n/a | T | P1 | Close the Steps editor, mutate the `.py` on disk out of band, reopen as Steps: the projection reflects the on-disk file exactly. No `globalState`/`workspaceState` key holds rows (asserted by enumerating both stores) |
-| STEPS-44 | A raced typed edit is drained, never dropped | Functional | ide-mocha | container-CI | n/a | T | P1 | With a field-picker edit in flight, a queued typed edit is applied after it settles (`applyPickedEdit` calls `takePending`, not `clearPending` — `stepsView.ts:472-483`); with a structural op in flight, the pending queue is cleared before `endEdit` (`:452`) and nothing drains in between |
+| STEPS-44 | A raced typed edit is drained, never dropped | Functional | ide-mocha | container-CI | n/a | T | P1 | With a field-picker edit in flight, a queued typed edit is applied after it settles (`applyPickedEdit` calls `takePending`, not `clearPending` — `stepsView.ts:505-516`); with a structural op in flight, the pending queue is cleared before the slot is released (`guard.clearPending()` at `:482`, then `releaseEdit`) and nothing drains in between |
| STEPS-45 | A Steps-authored bad destination reaches the Problems panel | Functional | pytest + ide-electron | dev-PC | n/a | T | P1 | pytest: `messagefoundry validate --json` over a config dir whose Handler sends to `OB_TYPO_DOES_NOT_EXIST` reports a dangling literal target (`checks.py:226 _check_send_target`). ide-electron: after Add→Send with a typed free-text destination and a save, a Problems diagnostic naming the unknown outbound appears within one validate cycle |
| STEPS-46 | Steps is opt-in, never the default `.py` editor | Negative/Security | ide-mocha + ide-electron | dev-PC | n/a | T | P1 | `ide/package.json:549-556` declares `messagefoundry.stepsEditor` with `"priority": "option"` (asserted as a manifest test); opening a Handler `.py` normally yields the text editor; the "View as Steps" CodeLens appears only on a `@handler` in a config file; "Reopen With → Python" is always reachable from a Steps editor |
| STEPS-47 | Import-graph guardrail: `lens.py` is CLI-only | Negative/Security | pytest | container-CI | n/a | T | P1 | An AST scan of `messagefoundry/**/*.py` finds `messagefoundry.lens` imported **only** from `messagefoundry/__main__.py` (lazily, inside `_lens_parse`/`_lens_rewrite`). Any import from `pipeline/`, `store/`, `transports/`, `config/`, `api/` or `messagefoundry/__init__.py` fails the test with an explicit "#26 carve-out breach" message |
@@ -146,7 +147,7 @@
| STEPS-50 | The Steps view is never the artifact of record | Negative/Security | ide-mocha | container-CI | n/a | T | P1 | A source assertion over `ide/src/stepsView.ts` + `ide/media/stepsWebview.js`: no `fs` write API, and every `vscode.setState`/`getState` payload is confined to a declared shape of `{clipboard, selection}` (no `rows`, no `handlers`, no projection). A new persisted field fails the test |
| STEPS-51 | The palette contains nothing non-executable (BACKLOG #231 line) | Negative/Security | ide-mocha + pytest | container-CI | n/a | T | P1 | Every `ADD_MENU_CATALOG` item's `op` ∈ `lens._SUPPORTED_OPS` (asserted against the engine's real set, exported to a manifest — not the hardcoded copy in `steps-addmenu.test.ts`), **and** every item's generated form re-parses to a non-`code`, non-decorative row. A decorative/grouping item with no executable projection fails |
| STEPS-52 | Routers have no Steps view, at the provider level too | Negative/Security | pytest + ide-electron | dev-PC | n/a | T | P1 | pytest (extends `test_lens_parse.py:435`): a module containing only `@router` defs parses to `[]` handlers. ide-electron: opening that module as Steps falls back to text with the "no handler" notice; no router row, no router palette |
-| STEPS-53 | No declarative field-mapping surface | Negative/Security | ide-mocha | container-CI | n/a | T | P1 | No catalog item, message command or persisted structure represents a source→destination mapping table; the HL7 field picker's only effect is to splice a **path string argument** into an existing call (`stepsView.ts:619-630` → `applyPickedEdit` → `set_params`). A commit adding a mapping-table message type or artifact fails the message-command allowlist assertion |
+| STEPS-53 | No declarative field-mapping surface | Negative/Security | ide-mocha | container-CI | n/a | T | P1 | No catalog item, message command or persisted structure represents a source→destination mapping table; the HL7 field picker's only effect is to splice a **path string argument** into an existing call (`stepsView.ts:654-665` → `applyPickedEdit` → `set_params`). A commit adding a mapping-table message type or artifact fails the message-command allowlist assertion |
| STEPS-54 | `docs/STEPS-PALETTE.md` ↔ `ADD_MENU_CATALOG` are 1:1 | Functional | ide-mocha | container-CI | n/a | T | P1 | A node test parses the four markdown tables and asserts: 27 rows total, group counts 14/3/8/2 matching the headings, and every row's **Item** label maps to exactly one catalog id with the same `group`. An added/renamed/removed catalog item fails |
| STEPS-55 | Every documented "Generates" form round-trips | Functional | pytest | container-CI | n/a | T | P1 | For each of the 27 documented generated forms, inserting that literal source into a handler and re-parsing yields the row kind/action the doc implies (e.g. `msg.add_repetition("", "")` → `action`/`add_repetition`). A doc form the lens does not recognise fails |
| STEPS-56 | `STEPS-PALETTE.md` names the correct surface | Functional | ide-mocha | container-CI | n/a | T | P1 | Line 3 no longer claims the Steps view is at `/ui`; a doc assertion requires the phrase identifying it as the VS Code custom editor `messagefoundry.stepsEditor`. Corroborated by: grep for `steps`/`lens` over `messagefoundry_webconsole/` returns nothing, and FEATURE-COVERAGE-PLAN.md `:1517` states "the web console has no Steps view" |
@@ -155,7 +156,7 @@
| STEPS-59 | `diagnostics._reveal` is environment-clamped | PHI | pytest | container-CI | n/a | T | P2 | With `active_environment` resolving to `staging` or `prod`, enabling `diagnostics._reveal` either raises or is a no-op (operands stay `TRACE_REDACTED`). Today `diagnostics.py:33` documents clamping as "a wiring concern for the caller" and no caller does it |
| STEPS-60 | Live-value trace never carries `--show-phi`, in the real argv | PHI | ide-electron | dev-PC | n/a | T | P1 | The argv of the spawned child in a real Extension Host run contains `dryrun`, `--trace`, `json` and never `--show-phi` (complements the pure assertion in `steps.test.ts` on `buildLensTraceArgs`, `stepsModel.ts:674`); every rendered live value is the redacted placeholder |
| STEPS-61 | Live-value output is never persisted | PHI | ide-electron | dev-PC | n/a | T | P1 | After a live-value run, no new file appears under the workspace, the extension storage path, or the global storage path; `globalState`/`workspaceState` gain no key containing trace data. Verified by directory + state snapshot diff |
-| STEPS-62 | A sample picked outside `messageSetsDir` still leaks nothing | PHI | ide-electron | dev-PC | n/a | T | P2 | With the "All files" filter used to pick a **synthetic** `.hl7` outside `messageSetsDir`, `scopeFor` (`stepsView.ts:185-204`) contributes only segment ids to the picker scope — no field value reaches the scope, the rows, the HTML, or any persisted state (asserted by scanning the rendered HTML for every field value in the file) |
+| STEPS-62 | A sample picked outside `messageSetsDir` still leaks nothing | PHI | ide-electron | dev-PC | n/a | T | P2 | With the "All files" filter used to pick a **synthetic** `.hl7` outside `messageSetsDir`, `scopeFor` (`stepsView.ts:187-206`) contributes only segment ids to the picker scope — no field value reaches the scope, the rows, the HTML, or any persisted state (asserted by scanning the rendered HTML for every field value in the file) |
| STEPS-63 | Steps test evidence carries no message bodies | PHI | CI-leg | container-CI | n/a | T | P1 | The Steps legs never redirect `lens parse`/`lens rewrite`/`dryrun`/`generate` stdout into a CI log line, a job summary, or an uploaded artifact; all corpus data is synthetic and committed as such. A grep of the job log for `PID\|` / `MSH\|` finds no message body |
| STEPS-64 | Projection performance budget on a large Handler | Performance | pytest | container-CI | n/a | T | P2 | A generated 5,000-statement Handler: `lens parse` ≤ 0.5 s and `lens rewrite` ≤ 1.0 s wall clock on the CI runner (measured baseline on this checkout: 0.081 s / 0.155 s), and the emitted JSON is ≤ 2 MB (measured baseline: 965,409 bytes for 5,001 rows) |
| STEPS-65 | Large projections are capped or virtualized with an explicit notice | Performance | ide-mocha | container-CI | n/a | T | P2 | Above a declared row threshold, `renderHandlersHtml` either virtualizes or emits an explicit "too large to project — open as code" notice; the rendered HTML for a 5,001-row contract is bounded and the notice text is present. Silent truncation fails |
@@ -169,11 +170,11 @@
| STEPS-73 | Field-picker splice is byte-stable and scope-correct | Functional | ide-electron | dev-PC | n/a | T | P2 | Picking a path on an `action` row produces exactly one `set_params` edit changing only that argument; a scoped pick on a handler with `accepts_types` ranks the declared type's segments first and never removes the All-segments escape |
| STEPS-74 | Deep nesting and wide fan-out survive every op | Functional | pytest | container-CI | n/a | T | P2 | A corpus handler nested 6 levels deep with a 5-destination accumulator fan-out: every op at every nesting level is either applied byte-stably (verified against the independent oracle) or refused with zero change; `suite` ids remain unique and correct after each |
| STEPS-75 | Analyst comprehension trial on a representative Handler | Usability | manual | dev-PC | n/a | C | P1 | A non-Python HL7 interface analyst, given an unfamiliar anonymized ported Handler, correctly states what it does and makes one correct field-mapping edit that passes `messagefoundry check` — without opening the text editor. Recorded as pass/fail with the analyst's stated confusions. **C — the outcome is recorded, not gated:** exit criterion 14 blocks on the trial being *run*, not on its result, so it cannot fail a release. It becomes a **T** row the day the owner records a pass threshold. The core product claim; no automated proxy exists |
-| STEPS-76 | Drag-and-drop and context-menu behaviour in a live webview | Usability | manual | dev-PC | n/a | T | P1 | A human confirms: the insertion bar lands where the statement lands; the tri-zone control-header drop (before / into body / after block) matches the resulting code; the cross-suite scope label is correct; a drop on a read-only `code` row is refused; the context menu clamps to the viewport, flips submenus at the right edge, reveals submenus mutually exclusively, and dismisses on Escape/outside-click/scroll/resize/blur |
+| STEPS-76 | Drag-and-drop and context-menu behaviour in a live webview | Usability | manual | dev-PC | n/a | T | P1 | A human confirms: the insertion bar lands where the statement lands; the tri-zone control-header drop (before / into body / after block) matches the resulting code; the cross-suite scope label is correct; a drop on a read-only `code` row is refused; the context menu clamps to the viewport, flips submenus at the right edge, reveals submenus mutually exclusively, and dismisses on Escape/outside-click/scroll/resize/blur; **and (added 2026-08-04, from STEPS-06's un-automatable second clause) that a retain-context reload of the Steps panel does not re-acquire the VS Code API — one `alive` ping, no double-acquire error, the toolbar still live** |
| STEPS-77 | Visual states an eyeball must confirm | Usability | manual | dev-PC | n/a | T | P2 | Muted read-only scaffold rows (`sends = []` / `return sends`), the `[blank]` placeholder on an empty editable input, greyed ↑/↓ at suite edges, the row selection focus ring, and the redacted `▸ ⋯` live-value placeholder all render as specified |
| STEPS-78 | Clean-machine VSIX install and first Steps open | Compat | manual | dev-PC | n/a | T | P2 | On a machine with no prior extension state: `code --install-extension `, open a Handler, "View as Steps" — rows render, the toolbar enables, and one edit applies. No missing-asset error in the webview console |
| STEPS-79 | Coverage-lift scan on the external estate | Usability | external | dev-PC | n/a | C | P2 | Re-running ADR 0089 §5's repeatable AST scan over the external 87-file / 486-function config repository reports the recognized-statement percentage and the residual `code`-row percentage per handler. **C — it publishes a number, and no threshold exists to fail against**; it becomes a **T** row when the owner records a minimum recognized-statement percentage. Needs a corpus that is not in this repo |
-| STEPS-80 | Multi-author: a `git pull` rewrites the `.py` under an open Steps editor | Functional | ide-electron | dev-PC | n/a | T | P1 | With Steps open on a corpus Handler and the buffer **clean**, a second author's commit is applied to the working tree out of band (`git pull` / `git checkout `) so statements shift and one target statement changes text. Then: (a) the view re-projects from the new on-disk text within one 250 ms debounce (`stepsView.ts:89`, `:839-856`) and no row keeps a pre-pull coordinate; (b) a row edit posted from the **pre-pull** projection is refused on `expect_src` mismatch (`lens.py:1533`) with the file byte-unchanged — never spliced into the pulled text; (c) with the buffer **dirty** at pull time, the on-disk change does not silently overwrite the projection: the view either re-projects from the buffer or shows the stale-projection notice, and no `WorkspaceEdit` is applied against stale coordinates. Complements STEPS-39 (same-machine split editor) and STEPS-43 (out-of-band change while the editor is *closed*) — neither covers a live editor under a concurrent author |
+| STEPS-80 | Multi-author: a `git pull` rewrites the `.py` under an open Steps editor | Functional | ide-electron | dev-PC | n/a | T | P1 | With Steps open on a corpus Handler and the buffer **clean**, a second author's commit is applied to the working tree out of band (`git pull` / `git checkout `) so statements shift and one target statement changes text. Then: (a) the view re-projects from the new on-disk text within one 250 ms debounce (`stepsView.ts:91`, `:884-898`) and no row keeps a pre-pull coordinate; (b) a row edit posted from the **pre-pull** projection is refused on `expect_src` mismatch (`lens.py:1533`) with the file byte-unchanged — never spliced into the pulled text; (c) with the buffer **dirty** at pull time, the on-disk change does not silently overwrite the projection: the view either re-projects from the buffer or shows the stale-projection notice, and no `WorkspaceEdit` is applied against stale coordinates. Complements STEPS-39 (same-machine split editor) and STEPS-43 (out-of-band change while the editor is *closed*) — neither covers a live editor under a concurrent author |
**Row count: 80 (STEPS-01 … STEPS-80). Class: T 78, C 2 (STEPS-75, STEPS-79), A 0. P0: 12 (all T). P1: 51. P2: 17.**
@@ -197,20 +198,20 @@
#### S2 — STEPS-06..12: the jsdom mirror-parity suite
-**Preconditions.** `jsdom` added to `ide/package.json` devDependencies and `ide/package-lock.json` re-locked (DEP-1 applies to the lockfile). `buildDropSlots` exported from `ide/src/stepsModel.ts:1672`.
+**Preconditions.** `jsdom` added to `ide/package.json` devDependencies and `ide/package-lock.json` re-locked (DEP-1 applies to the lockfile). `buildDropSlots` exported from `ide/src/stepsModel.ts`.
**Steps.**
-1. Build a row-set generator producing `RowDropContext[]` with: nesting 0–4, kinds `action`/`lookup`/`control`/`send`/`code`/`diagnostic`, control headers with and without bodies, `appended` sends, `collector_init`/`return_collector` scaffold rows, and `suite` ids consistent with the nesting.
-2. Load `ide/media/stepsWebview.js` under jsdom with `window.acquireVsCodeApi` stubbed to a recording double and `document.querySelectorAll('li.row')` backed by a synthetic DOM built from the generated rows.
-3. Extract the webview's mirrored functions from the loaded script's scope (expose them behind a test-only `window.__mfStepsTestExports` hook set inside the existing IIFE — a hook, not a second implementation).
-4. For each generated row set, call each mirrored function and its `stepsModel` counterpart with identical inputs and `assert.deepStrictEqual`.
-5. Repeat over all ordered (drag, target) pairs for `canDrop`/`resolveDrop`/`barAnchor`.
+1. Build a **seeded** row-set generator producing `RowDropContext[]` with: nesting 0–4, kinds `action`/`lookup`/`control`/`send`/`code`/`diagnostic`, control headers with and without bodies, elif/else continuations, `appended` sends, `collector_init`/`return_collector` scaffold rows, and `suite` ids consistent with the nesting. Deterministic (`mulberry32`) so the seed alone reproduces a failing set.
+2. Load `ide/media/stepsWebview.js` under jsdom with `window.acquireVsCodeApi` stubbed to a recording double and `document.querySelectorAll('li.row')` backed by a synthetic DOM built from the rendered rows. **Note (recorded 2026-08-04):** only the DOM-bound mirrors need this per case. The five row-array mirrors are pure, so the ≥2,000-set sweep runs against ONE loaded page; the render → DOM → read-back boundary they skip is pinned separately by the `stepsCtxRows`-vs-view-models comparison over the hand-authored cases.
+3. Extract the webview's mirrored functions from the loaded script's scope behind a test-only `window.__mfStepsTestExports` hook — a hook handing out the SAME function objects the page uses, never a second implementation. **Placement (corrected 2026-08-04):** there is no enclosing IIFE to put it in. `ide/media/stepsWebview.js` is a standalone CLASSIC script loaded via the `