diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ddf65bc..4a2cb0a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,50 @@ All notable changes to MessageFoundry are documented here. The format follows ## [Unreleased] +### Added +- **`messagefoundry audit-anchor`, and `audit-verify --expected-anchor` / `--expected-anchor-file` to + check one back.** The audit hash chain links each row to its predecessor, so deleting the *newest* + rows leaves a shorter chain that still walks cleanly — `audit-verify` on its own reports OK after a + tail-truncation, which is the shape an attacker hiding what they just did leaves behind. The store + could always compare against an external anchor; nothing exposed it, so the capability was + unreachable. `audit-anchor` prints `COUNT:HEAD` (a row count plus a digest — no PHI, no secret, safe + to hold in a ticket or an object store); passing it back reports `truncated or rewritten` when the + live chain differs. + **Know what it is before you build a job on it: an EXACT point-in-time seal**, comparing the count + *and* the head hash. The head half is not redundant — an attacker who cuts the newest rows and forges + the same number of replacements restores the count and leaves a chain that walks cleanly, so the head + is the only thing that differs. The cost of that detection is that a chain which merely **grew** also + reports `truncated or rewritten`. So it seals a chain **at rest across a gap in custody**: quiesce the + engine, anchor, hold the value off-box, re-verify while the chain is still quiesced — around a + maintenance window, a database move, a backup/restore, a hand-off. Anchoring and immediately + re-verifying compares a value to itself; re-checking a held anchor against a **running** engine alarms + on every ordinary boot. For continuous coverage of a live engine the off-box log forward / tee remains + the control, and `[integrity].audit_verify_on_start` is unchanged — it is a bare walk and stays blind + to a truncated tail. ([BACKLOG #328](docs/BACKLOG.md)) + ### Changed +- **A PHI instance reached through a declared reverse proxy with `[security].require_mfa` explicitly + off would refuse to start on first deployment, where it previously would not have.** The + MFA-at-exposure gate derived "is this instance exposed?" from `[api].serve_ui`, a field the ADR 0143 + console degrade arms rewrite **in place** earlier in the same startup. On the topology the runbooks + recommend — a loopback bind behind a declared TLS terminator, with the web console left at its + default — the auto-degrade cleared that flag first, so the gate evaluated "not exposed" and the + refusal was unreachable, while the ASVS 11.7.1 arm in the same startup classified the identical boot + as exposed. The gate now reads a single console-independent predicate (an off-loopback bind **or** + `[api].tls_terminated_upstream`), so it also fires when the console is auto-degraded, when + `serve_web_console = false` disables it outright, and when the console package is simply not + installed: the surface authenticating with one factor is the JSON operator API, which the proxy + serves either way. The `#189` dual-control advisory reads the same predicate and gains the same reach + (still warn-only). + **Who this would bite:** a deploying site that has explicitly set `require_mfa = false` on a + PHI-carrying environment behind a declared TLS terminator, under `enforcement = enforce`. **Two + remedies, both existing:** set `[security].require_mfa = true`, or set the already-shipped + acknowledgment `[security].allow_single_factor_admin_when_exposed = true`, which downgrades the + refusal to a loud audited warning. A plain loopback bind with nothing declared is **not** exposed and + is byte-identical. An **undeclared** proxy (`web_console_public_address` set, no + `tls_terminated_upstream`) deliberately still does not refuse — exposure there would be an inference — + but it no longer passes in silence: a new warning names single-factor admin directly on a PHI instance + with `require_mfa` off. ([BACKLOG #326](docs/BACKLOG.md), [ADR 0140](docs/adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md) amendment) - **BREAKING — an `[[alerts.rules]]` block that routes to an unconfigured transport now refuses at startup instead of being silently ignored.** `notifier_from_settings` returned early when **no** transport was configured, *before* the loop that cross-checks each rule's `transports` against the 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 da09dba2..4ba20f79 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -2431,7 +2431,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. @@ -2455,7 +2455,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). @@ -2864,7 +2864,7 @@ No test covers it. `tests/test_scan_tokens_source.py:559-583` (`test_absolute_ho ## 326. MFA-at-exposure refusal reads `serve_ui` after it is flipped off -> 🔢 **Filed 2026-08-01 — not started.** Value **6/10** · Difficulty **3/10** · _quick win_. ASVS 6.3.3's admin-MFA refusal and #189's dual-control warning are both inert in the topology the runbook recommends — the ADR 0143 auto-degrade sets `settings.api.serve_ui = False` in place before `ui_exposed` and `admin_exposed` are derived from it (`messagefoundry/__main__.py`, the flip and the two derivations in one ladder), so the engine calls one instance exposed for 11.7.1 and not exposed for 6.3.3 in a single boot — but `require_mfa` defaults on and `security_loosenings()` still names the explicit opt-out on every boot; re-key `admin_exposed` on the `instance_exposed` predicate already present in the file, fix two `exposure_desc` else-branches, and settle the refuse-on-upgrade fork against `docs/CONFIGURATION.md:1439`. +> ✅ **SHIPPED 2026-08-04.** `admin_exposed` is now `instance_exposed` — an off-loopback bind **or** `[api].tls_terminated_upstream` — defined ONCE above its first consumer, from two fields no earlier arm reassigns, and shared with the ASVS 11.7.1 arm that already used it. It reads no console flag, so the ADR 0143 in-place `serve_ui = False` degrades can no longer clear an exposure refusal: the MFA-at-exposure refusal and the #189 dual-control advisory now reach a declared-proxy instance whose console is auto-degraded, explicitly disabled, or absent (arms C/D in `tests/test_cli.py`, a real-gate row in `tests/test_checks_gate_parity.py`, a shape guard in `tests/test_security_doc_drift.py`). Both `exposure_desc` else-branches name the proxy instead of `[api].serve_ui`. **Built to REFUSE, per the owner ruling of 2026-08-04 — the WARN-FIRST blockquote below is SUPERSEDED** and is being amended by a separate session, so do not read it as the shipped behaviour: there is no warning-first phase, no dated flip and no new opt-in, the refusal rides the existing `[security].enforcement` split, and the pre-existing `allow_single_factor_admin_when_exposed` acknowledgment is unchanged (with more postures to act on). A plain loopback bind with nothing declared is byte-identical. The UNDECLARED-proxy residual is deliberately still not refused — nothing was declared, so exposure would be an inference — but it is no longer silent: the ADR 0068 §8 heuristic was **measured** not to cover it (it is about the /ui cookie, and the ADR 0143 auto-degrade suppresses it in the same posture), so a dedicated arm now warns, naming single-factor admin. **Two stale claims in the body below are corrected here rather than rewritten:** the arm table's arm-A string is now `admin interface reached through a declared reverse proxy ([api].tls_terminated_upstream)`, and the `docs/CONFIGURATION.md:1437`/`:1439` citations are wrong anchors — the opt-in scoping rule lives on the `require_memory_encryption_declaration` row and the `enforcement` refuse/warn split at `:88`/`:1020`. **Two residuals are left OPEN for the owner**, recorded in the [ADR 0140](adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md) amendment: the `[auth] enabled = false` startup arm still keys on the bind alone (same two-answers-in-one-startup shape, one arm over, and it needs its own hoist plus its own adjudication), and the vault-only `OFF-LOOPBACK-DEPLOYMENT.md` runbook still carries blind-spot wording this fix invalidates. > **OWNER RULING 2026-08-04 — REFUSE OUTRIGHT. Supersedes an earlier ruling on this item that said > WARN-FIRST with a dated flip.** No warn-first, no dated flip, no opt-in flag. The corrected @@ -2991,7 +2991,7 @@ The two nearest-looking guards are neither: `tests/test_scaffold.py:51-52` asser ## 328. `audit-verify` cannot detect a truncated audit tail -> 🔢 **Filed 2026-08-01 — not started.** Value **6/10** · Difficulty **3/10** · _quick win_. Both shipped verification surfaces call `verify_audit_chain()` bare (`messagefoundry/__main__.py:3596`, `pipeline/engine.py:860`) and the `audit-verify` subparser declares only `--service-config` and `--db` (`__main__.py:571-578`), so a truncated keyed chain — the residue the anchor exists to catch — reports CLEAN with no way for an operator to supply one; the remainder is a new `audit-anchor` subcommand, an `--expected-anchor` flag into the already-present `expected_anchor=` keyword, and an `[integrity]` key for the startup path, with no change to the comparison logic and no store migration. _(was 5/10 · 3/10.)_ +> 🚧 **Status OPEN — Proposed 1-2 SHIPPED 2026-08-04, Proposed 3 DEFERRED.** `messagefoundry audit-anchor` (`--service-config` / `--db` / `--json`, with the same SQLite missing-DB refusal as its verify twin, so a typo'd path cannot mint an empty database and print an anchor OF NOTHING) prints `COUNT:HEAD`, and `audit-verify --expected-anchor COUNT:HEAD` / `--expected-anchor-file PATH` feeds it into the already-present `expected_anchor=` keyword — no comparison-logic change and no store migration, as filed. `docs/FEATURE-MAP.md`'s hand-maintained CLI count moved 30 to 31 with it. **Proposed 3 — the `[integrity]` startup-anchor key — is NOT built, which is why this stays OPEN.** The reason is measured, and pinned by `test_an_anchor_goes_stale_on_the_next_appended_row`: the shipped comparator is an EXACT point-in-time seal (row count *and* head hash), so a stored anchor consumed by the startup auto-verify would fire a false `integrity_drift` on essentially every restart, because any running instance writes audit rows. It needs a seal-on-stop / check-on-start design (or a monotonic-prefix comparator) before it is worth wiring, and the plumbing is a THREE-file edit — `config/settings.py`, `pipeline/engine.py`, and `api/app.py`'s `create_managed_app`, which is the only route an `[integrity]` key reaches the Engine by, and which the multi-session plan had scope-dropped. `[integrity].audit_verify_on_start` therefore remains a bare walk and still cannot see a truncated tail; that limit is now stated on its own `docs/CONFIGURATION.md` row and in ADR 0014 §16.4.2. The SQL Server and Postgres `audit_anchor` CLI tests are written and collect cleanly but have **never executed locally** (no Docker daemon) — they are CI-verified only. _(was 5/10 · 3/10.)_ > ⚠️ **AMENDED 2026-08-04 — `api/app.py` is IN scope; the multisession plan was wrong to drop it.** > `SCHEDULABLE-BACKLOG-MULTISESSION-PLAN.md` scoped this item as "CLI + settings" and said **DROP @@ -3093,7 +3093,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. @@ -3610,7 +3610,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/CONFIGURATION.md b/docs/CONFIGURATION.md index c41c4efd..5e421d55 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -715,7 +715,7 @@ three built-in environment names (`dev`, `staging`, `prod`) derive PHI. The audi | `app_log_days` | int | `0` | past N days, **delete** application **log files** (`.log`/`.txt`, one level) from the configured `[logging].log_dir` (#120). The supervisor (NSSM `AppRotateBytes`) rotates the daily logs by **size** but never by **age**, so the log dir grows unbounded; this bounds it (by file mtime, so the currently-written file is never eligible). `0` = keep. **No-op unless `[logging].log_dir` is set.** Metadata only — file content is never read. While `app_log_compress_days` is on, the same window also ages out the `*.log.gz`/`*.txt.gz` archives that setting produces — so compressing a log doesn't make it immortal; with compression off the eligible set is exactly what it was | | `app_log_compress_days` | int | `0` | past N days, **gzip** application **log files** (`.log`/`.txt`, one level — the same selection as `app_log_days`, by mtime, so the currently-written file is never eligible) in `[logging].log_dir` to `.gz` (#119). The log stays readable (`gzip -d`) at a fraction of the disk, so a long-running box keeps far more history for the same footprint. Each file is **free-space prechecked** (`shutil.disk_usage` must show room for the source **plus** its archive plus a `max(10%, 1 MiB)` margin — short, and the file is **skipped and logged**, never attempted) and each written archive is **integrity-validated** — staged to an **exclusively created, randomly named** temp file beside it (`tempfile.mkstemp`: `O_CREAT\|O_EXCL`, so it never truncates an existing file, never follows a symlink, and never collides with a sibling engine shard compressing the same directory), `fsync`ed, re-read **off disk**, decompressed and compared **byte-for-byte** against the original, renamed into place, and then **validated again at `.gz` itself** — and it is that last check, on the bytes actually sitting where the log used to be, that authorizes removing the original. Any failure leaves the original **in place**, does not count it as compressed, and logs it; an existing `.gz` is never clobbered. The archive inherits the source's mtime, so `app_log_days` still ages it out. Files over 64 MiB are skipped (the codec is in-memory), and so is a file whose archive would not be **smaller** than it (an empty or already-compressed log — compressing must never *cost* disk). Names/counts/sizes are logged, **never file content**. `0` = never compress. **No-op unless `[logging].log_dir` is set.** Set it **shorter** than `app_log_days` — a longer window compresses nothing, since the delete sweep runs first | | `search_preset_days` | int | `0` | past N days, **delete** saved-search presets (ADR 0136) neither used nor edited since the cutoff. The stored `criteria` is the operator's own content/`field_value` needle — **PHI-shaped by construction**, encrypted at rest — so it needs a window like any other PHI tier (ASVS 14.2.7). The whole **row** is deleted, not blanked: a preset's entire payload *is* its criteria. **Keys on last-USED** (BACKLOG #306) — the cutoff is compared against the *later* of `updated_at` (written by a save) and `last_used_at` (written by a recall), so a preset you run daily but never re-save is **kept**. A preset last touched before the `last_used_at` column existed has it NULL and ages out on `updated_at` alone. `0` = keep forever (the default) | -| `audit_days` | int | `0` | **reserved / not enforced — keep-forever by design.** The rationale rests on the **audit-retention requirement** (45 CFR 164.316(b)(2)(i) six-year documentation retention; every framework floor is far below it — CIS Control 8.10 is 90 days, PCI DSS 4.0 §10.5.1 is 12 months, NIST SP 800-53 AU-11 defers to organizational policy), **not** on chain-breakage. *Corrected 2026-07-30:* this row used to argue "deleting rows would break the chain". That is true only of deleting the **oldest** rows, and it is exactly **inverted** for the threat that motivates audit retention — `verify_audit_chain`'s own docstring (`store/store.py:7385`) records that deleting the **newest** rows is *not* caught by the walk alone, because the surviving prefix still chains cleanly. An attacker hiding what they just did truncates the newest rows. **And the anchor that would catch it is not reachable from the shipped CLI** (`__main__.py:3418` calls `verify_audit_chain()` with no `expected_anchor`, and no flag supplies one), so `audit-verify` returns CLEAN after a tail-truncation; as shipped, truncation detection depends on the off-box tee, not on anything runnable locally. Do **not** restore the chain-breakage argument, and do **not** upgrade this to "the anchor detects it" without also shipping a way to pass one. Archive-first pruning is a tracked follow-up. **This row is the source of record for the chain-truncation reasoning**; `docs/PHI.md` §8 carries the retention-window inventory, and wherever its §8 still withholds pruning on the ground that "deleting rows would break the chain", that rationale is **superseded here** — do not read it as the authority | +| `audit_days` | int | `0` | **reserved / not enforced — keep-forever by design.** The rationale rests on the **audit-retention requirement** (45 CFR 164.316(b)(2)(i) six-year documentation retention; every framework floor is far below it — CIS Control 8.10 is 90 days, PCI DSS 4.0 §10.5.1 is 12 months, NIST SP 800-53 AU-11 defers to organizational policy), **not** on chain-breakage. *Corrected 2026-07-30:* this row used to argue "deleting rows would break the chain". That is true only of deleting the **oldest** rows, and it is exactly **inverted** for the threat that motivates audit retention — `MessageStore.verify_audit_chain`'s own docstring (`store/store.py:7535-7537`, and its `audit_anchor` sibling at `:7513-7518`) records that deleting the **newest** rows is *not* caught by the walk alone, because the surviving prefix still chains cleanly. An attacker hiding what they just did truncates the newest rows. *Updated 2026-08-04 ([BACKLOG #328](BACKLOG.md)):* **the anchor is now reachable from the CLI**, which is the condition this row set for changing this paragraph. `messagefoundry audit-anchor` prints `COUNT:HEAD`, and `messagefoundry audit-verify --expected-anchor COUNT:HEAD` (or `--expected-anchor-file PATH`) passes it back into `verify_audit_chain(expected_anchor=...)` (`_audit_verify`, `__main__.py:3727`). **A bare `audit-verify` is still clean after a tail-truncation** — that has not changed and is not a bug; the walk cannot see it, which is the entire reason the anchor exists. **Read the anchor's semantics before relying on it: it is an EXACT point-in-time seal, not a monotonic-prefix check.** It compares the row count *and* the head hash, so an anchor taken before any subsequent audit row reports `truncated or rewritten` on a chain that merely **grew**. It therefore seals a chain **at rest between two offline checks** — quiesce the engine, anchor, hold the value off-box, re-verify while the chain is still quiesced (across a maintenance window, a DB move, a backup/restore, a custodian hand-off) — and nothing else: anchoring and immediately re-verifying compares a value to itself, and re-checking a held anchor against a **running** engine alarms every boot. Do not write a compliance job that stores one anchor and re-checks it against a running engine; for a running engine the off-box tee is still the control. Do **not** restore the chain-breakage argument. Archive-first pruning is a tracked follow-up. **This row is the source of record for the chain-truncation reasoning**; `docs/PHI.md` §8 carries the retention-window inventory, and wherever its §8 still withholds pruning on the ground that "deleting rows would break the chain", that rationale is **superseded here** — do not read it as the authority | | `max_db_mb` | int | `0` | advisory only: warn (WARNING log + an `AlertSink` `storage_threshold` event) when the database exceeds this — measured as the **SQLite file + `-wal`/`-shm`**, `SUM(size)` over `sys.database_files` on **SQL Server**, and `pg_database_size()` on **Postgres**. Never auto-deletes. `0` = off | | `purge_interval_seconds` | float | `3600` | how often the purge/maintenance loop runs a pass | | `max_pass_seconds` | float | `0` | maximum wall-clock seconds **one maintenance pass** may spend (#121, [ADR 0137](adr/0137-time-boxed-retention-maintenance-pass-between-phase-cap.md)). A **between-phase soft cap**: `run_once` checks elapsed monotonic time before each phase and, once this is reached, **skips the remaining phases** (marking the pass `capped`) so a long pass can't run unbounded into the next maintenance window — the skipped tail re-runs next interval, and a skipped WAL-checkpoint/VACUUM does **not** advance its last-run marker. Checked only *between* phases, never inside one, so a running VACUUM is non-interruptible. `0` = off (the default — no cap); ~`14400` (4 h, the Corepoint off-peak ceiling) is the recommended value when enabled | @@ -1378,7 +1378,7 @@ an **editable** install (`pip install -e .` — no RECORD baseline) is a **no-op |---|---|---|---| | `enabled` | bool | `true` | run startup attestation at all. On by default (alert-only is harmless); a **no-op** off an editable install. Set `false` only to suppress the check entirely (e.g. an unusual packaging where RECORD is known-stale) — you then lose the in-place-tamper tripwire. | | `fail_closed_on_drift` | bool | `false` | when `true`, drift makes `serve` **refuse to start** (after recording the audit row + alerting). Default `false` = **alert-only**: a legitimate reviewed in-place security hotfix (the documented vendored-parser patch contingency) would itself trip a RECORD mismatch, so fail-closed-by-default would brick a legitimate patch. Opt in for hard enforcement on a locked-down instance. | -| `audit_verify_on_start` | bool | `false` | when `true`, the engine **re-walks the `audit_log` hash chain once at startup** (#190). **Alert-only by construction:** a broken chain logs a WARNING and fires the `AlertSink` but **never** crashes startup — a refuse-to-start on a tripped tamper alarm would be a self-inflicted DoS. Default `false` (opt in): on a very large `audit_log` the full re-walk adds startup latency, so it is not on by default. **Read the two limits below before citing this as tamper detection.** | +| `audit_verify_on_start` | bool | `false` | when `true`, the engine **re-walks the `audit_log` hash chain once at startup** (#190). **Alert-only by construction:** a broken chain logs a WARNING and fires the `AlertSink` but **never** crashes startup — a refuse-to-start on a tripped tamper alarm would be a self-inflicted DoS. Default `false` (opt in): on a very large `audit_log` the full re-walk adds startup latency, so it is not on by default. **It is a bare walk: it passes no anchor, so it is blind to a truncated tail** (below). Nothing here consumes an anchor — that is `messagefoundry audit-verify --expected-anchor`, run by an operator against a quiesced chain. **Read the two limits below before citing this as tamper detection.** | **The chain is *tamper-evident* only when the store is keyed.** With no store encryption key the cipher is `IdentityCipher`, whose `audit_mac_key()` returns `None` — "no DEK → no derived key → the audit chain @@ -1396,11 +1396,35 @@ deployment does get the keyed chain. The unkeyed chain is what a **synthetic** i you record "tamper-evident audit log" in a risk register. **And the walk does not catch a truncated tail.** `verify_audit_chain` detects modified or deleted -**older** rows, but deleting the **newest** rows leaves a prefix that still chains cleanly — and the -shipped `messagefoundry audit-verify` passes no `expected_anchor`, so it returns CLEAN after a -tail-truncation. An attacker hiding what they just did truncates the newest rows. Truncation detection -depends on the off-box log forward / tee, not on anything runnable locally. The full reasoning is the -[`[retention]`](#retention) `audit_days` row, which is the source of record for it. +**older** rows, but deleting the **newest** rows leaves a prefix that still chains cleanly, so a bare +walk returns CLEAN after a tail-truncation. An attacker hiding what they just did truncates the newest +rows. `audit_verify_on_start` is a bare walk and is therefore blind to exactly that. + +**What closes it is an anchor, and it is an operator command, not a startup setting** +([BACKLOG #328](BACKLOG.md)). `messagefoundry audit-anchor` prints `COUNT:HEAD`; passing it back as +`messagefoundry audit-verify --expected-anchor COUNT:HEAD` (or `--expected-anchor-file PATH`) compares +the live chain against it and reports `truncated or rewritten` when they differ. The anchor is a row +count plus a digest — no PHI, no secret — so it is safe to hold in a ticket or an object store, which +is what makes it an *external* witness. + +**The anchor is an EXACT point-in-time seal.** It compares the count **and** the head hash, so an +anchor taken before any subsequent audit row reports `truncated or rewritten` on a chain that merely +**grew**. The head half is not redundant with the count: an attacker who cuts the newest rows and +forges the same number of replacements restores the count *and* leaves a chain that walks cleanly, so +the head hash is the only thing that differs. The sharp edge and that detection are the same check. + +**So it seals a chain AT REST between two offline checks — that is the whole workflow, and it is the +only one with detection power.** Anchoring and immediately re-verifying compares a value to itself and +proves nothing; re-checking a held anchor against a **running** engine alarms on every ordinary boot, +because a running engine writes audit rows. What sits between those two useless readings is a real +control: **stop or quiesce the engine, take the anchor, hold it somewhere the engine's operator cannot +rewrite, and re-verify while the chain is still quiesced** — across a maintenance window, a database +move, a backup/restore, or a hand-off between custodians. Anything that happened to the DB in that gap +is what the anchor detects. Do not build a periodic job against a live engine on it. + +The full reasoning is the [`[retention]`](#retention) `audit_days` row, which is the source of record +for it. **For continuous coverage of a running engine, an off-box log forward / tee remains the answer** +— the anchor does not replace it and cannot, because the anchor is a seal on a stationary object. ### `[engine]` **Not implemented.** There is **no `EngineSettings` model**, so an `[engine]` block in @@ -1469,10 +1493,10 @@ and a PHI weakening under **strict enforcement** (`enforcement = enforce`, the d | `encrypt_stored_data` | bool | `true` | PHI encrypted at rest (key from the environment) | | `allow_unencrypted_phi` | bool | `false` | audited escape: start a PHI instance with **no** key | | `allow_unencrypted_phi_under_strict_enforcement` | bool | `false` | the **second acknowledgment** required to start a PHI instance keyless under strict enforcement ([ADR 0140](adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md)). Under `enforcement = enforce`, `allow_unencrypted_phi = true` on its own is **not** enough — `serve` still refuses to start (exit 2) unless this is also set, so the highest-risk posture (real PHI + strict enforcement) is never one flag away from plaintext at rest. Under `enforcement = warn` the single `allow_unencrypted_phi` flag still governs. With both set the instance starts with PHI bodies, summary/metadata and the error columns **unencrypted at rest**, and the startup AUDIT line names **both** flags. A **loosening** — `security_loosenings()` reports it, so it is never silent | -| `allow_single_factor_admin_when_exposed` | bool | `false` | permit **single-factor admin on an exposed PHI instance** (ADR 0140). With `require_sign_in` on, `require_mfa` explicitly off, and the operator surface exposed — a **non-loopback bind**, or the console reached through a declared TLS-terminating proxy **with `serve_web_console` explicitly `true`** — a PHI instance under `enforcement = enforce` **refuses to start** (exit 2) — the Administrator role would authenticate with a single factor over the network. Setting this permits that start; it is recorded in a WARNING-level AUDIT line and the ordinary exposure warning still prints. A **loosening** — `security_loosenings()` reports it. **Blind spot — do not treat this refusal as your MFA control.** The proxy arm keys on the console actually being *served*, and a **default-on** console auto-degrades to JSON-only on an exposed instance (ADR 0143, flipped in place at startup), so the topology this document recommends — loopback bind behind a declared terminator, `serve_web_console` left at its default — does **not** trip the refusal even on a PHI instance under `enforce` with `require_mfa = false`. The JSON operator API is still reachable off-box through that proxy with single-factor admin. Set `require_mfa = true` there regardless; the gate will not catch you. **Prefer `require_mfa = true` — and know its scope.** Under the shipped `require_mfa_scope = "every_local_account"` it requires a second factor from **every** local account, *not* only Administrators, so a non-interactive **local** bearer-token service account becomes MFA-pending and cannot enrol unattended. **There are two remedies, not three.** Either make it a **directory (AD/Kerberos) principal** — those are out of scope under either value, their factor delegated to the directory — or set `require_mfa_scope = "administrators"` (itself reported as a loosening, and it leaves every local Administrator in scope) — see that row below. **mTLS is *not* the third.** A `[api].tls_client_cert_identities` mapping does grant a cert-identity that never meets the MFA gate, but that plane is admitted on exactly **one** route (`GET /service/identity`, `require_service_cert`) and carries no session, so an account "moved to mTLS" can read back its own identity and nothing else — it cannot replay, purge, poll status, or do any work a service account exists for. The `[api].tls_client_cert_identities` row above is the authority on that reach. Directory identities being out of scope also means an AD-only deployment is safe **for its AD users**; its local bootstrap admin and any local service accounts are still in scope | +| `allow_single_factor_admin_when_exposed` | bool | `false` | permit **single-factor admin on an exposed PHI instance** (ADR 0140). With `require_sign_in` on, `require_mfa` explicitly off, and the instance exposed — a **non-loopback bind**, **or** a declared TLS-terminating proxy (`[api].tls_terminated_upstream`) — a PHI instance under `enforcement = enforce` **refuses to start** (exit 2) — the Administrator role would authenticate with a single factor over the network. Setting this permits that start; it is recorded in a WARNING-level AUDIT line and the ordinary exposure warning still prints. A **loosening** — `security_loosenings()` reports it. **The exposure test does not consult the browser console** ([BACKLOG #326](BACKLOG.md); ADR 0140 amendment). It did, and that made the arm miss the topology this document recommends: a loopback bind behind a declared terminator with `serve_web_console` left at its default, where the ADR 0143 auto-degrade clears the console flag in place before the gate reads it. The exposed surface that authenticates with one factor is the **JSON operator API**, which the proxy serves whether or not `/ui` is mounted, so the predicate is the bind-and-proxy posture alone and the refusal fires on at least: an off-loopback bind; a declared proxy with the console left default-on; and a declared proxy with `serve_web_console = false`. **This refusal is the one exception to the "a new refusal fires only on a new opt-in" scoping rule** stated three rows below on `require_memory_encryption_declaration` — by owner ruling of 2026-08-04, recorded in the [ADR 0140](adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md) amendment, which is the single source for why. Nothing new gates it. **One residual is deliberately left open:** an **undeclared** proxy — `web_console_public_address` set with no `tls_terminated_upstream` — does not count as exposed here, because nothing was declared, so exposure would be an *inference*, and an inference must not refuse. It **warns** instead, on its own dedicated arm: on a PHI instance with `require_mfa` explicitly off, startup prints that if that origin is served by an undeclared proxy the Administrator role is single-factor over the network and this refusal cannot see it. Do **not** read the ADR 0068 §8 undeclared-proxy warning as that control — it is about the `/ui` session cookie and HSTS, and it is suppressed entirely when the ADR 0143 auto-degrade clears the console flag, which the same `web_console_public_address` triggers. **Prefer `require_mfa = true` — and know its scope.** Under the shipped `require_mfa_scope = "every_local_account"` it requires a second factor from **every** local account, *not* only Administrators, so a non-interactive **local** bearer-token service account becomes MFA-pending and cannot enrol unattended. **There are two remedies, not three.** Either make it a **directory (AD/Kerberos) principal** — those are out of scope under either value, their factor delegated to the directory — or set `require_mfa_scope = "administrators"` (itself reported as a loosening, and it leaves every local Administrator in scope) — see that row below. **mTLS is *not* the third.** A `[api].tls_client_cert_identities` mapping does grant a cert-identity that never meets the MFA gate, but that plane is admitted on exactly **one** route (`GET /service/identity`, `require_service_cert`) and carries no session, so an account "moved to mTLS" can read back its own identity and nothing else — it cannot replay, purge, poll status, or do any work a service account exists for. The `[api].tls_client_cert_identities` row above is the authority on that reach. Directory identities being out of scope also means an AD-only deployment is safe **for its AD users**; its local bootstrap admin and any local service accounts are still in scope | | `allow_unverified_alert_smtp_tls` | bool | `false` | the **acknowledgment** required to start an enforcing PHI instance whose `[alerts]` SMTP hop does not authenticate the relay — i.e. `[alerts].email_use_tls = false` (cleartext) or `[alerts].email_tls_verify = false` (encrypted but accepts any certificate) ([#323](BACKLOG.md)). Covers BOTH shapes deliberately: cleartext is strictly worse than unauthenticated TLS, so gating only the second would hand an operator a bypass onto the worse posture. Without it `serve` refuses to start (exit 2); with it the start is permitted and named in a WARNING-level `AUDIT:` line. An **acknowledgment switch rather than the clamped `MEFOR_ALLOW_INSECURE_TLS` escape** the connectors use, because this cell is constructed outside the `active_hop_posture` scope where that clamp would be inert. A **loosening** — `security_loosenings()` reports it, so it is never silent | | `memory_encryption_operator_declared` | bool | `false` | **`[BUILT]` ([ADR 0152](adr/0152-in-use-data-protection-for-phi-platform-memory-encryption-attestation-asvs-11-7-1.md) rung 2, ASVS 11.7.1):** the operator's **declaration** that this host provides hardware memory encryption (AMD SEV-SNP / Intel TDX), so PHI is protected in RAM **while it is being processed**. The engine cannot verify it — a local CPU flag is emitted by the OS whose integrity the requirement protects against — so this records **who took responsibility**, the same discipline as `MEFOR_TLS_REVOCATION_ATTESTED`. It is deliberately **not** called "attested": in confidential computing that word means a CPU-signed quote verified against the silicon vendor's root PKI (ADR 0152 rung 3, **not built**). An **exposed** PHI instance without it **warns and starts** — on every environment, at both `enforcement` settings; it refuses only if `require_memory_encryption_declaration` is also set. A **positive platform read-out does not substitute for it** (a read-out must never relax a control). **Loopback and synthetic instances are byte-identical** (never consulted). If the platform read-out positively contradicts this, the contradiction is **warned at start and reported** as `memory_encryption_readout_contradicts_declaration` on `GET /security/posture` — but **never refused** (the read-out is a self-report, not evidence, and has known false negatives: driver not loaded, container without the device node mapped, Azure CVM paravisor). **Setting this does not make the instance ASVS 11.7.1-compliant** — see the read-out note below the table. Env: `MEFOR_SECURITY_MEMORY_ENCRYPTION_OPERATOR_DECLARED` | -| `require_memory_encryption_declaration` | bool | `false` | **`[BUILT]` (ADR 0152 rung 2):** turn the row-12 warning above into a **refusal** — an **exposed** PHI instance with no `memory_encryption_operator_declared` then **refuses to start** under `enforcement=enforce` (and still warns under `warn`). **Opt-in by design, and the default is load-bearing:** the property is a **host** property that no operator can satisfy on Windows (the read-out is always `null` there), and "exposed" includes the recommended loopback-behind-proxy topology, so a refusal by default would stop working dev/staging/prod deployments from booting on upgrade over something they cannot change. Same scoping rule as `[security].allowed_client_networks`' companion refusal (ADR 0151): a new refusal fires only on a new opt-in. Set it in an estate that has standardized on confidential-computing hosts and wants a missing declaration to be fatal. Env: `MEFOR_SECURITY_REQUIRE_MEMORY_ENCRYPTION_DECLARATION` | +| `require_memory_encryption_declaration` | bool | `false` | **`[BUILT]` (ADR 0152 rung 2):** turn the row-12 warning above into a **refusal** — an **exposed** PHI instance with no `memory_encryption_operator_declared` then **refuses to start** under `enforcement=enforce` (and still warns under `warn`). **Opt-in by design, and the default is load-bearing:** the property is a **host** property that no operator can satisfy on Windows (the read-out is always `null` there), and "exposed" includes the recommended loopback-behind-proxy topology, so a refusal by default would stop working dev/staging/prod deployments from booting on upgrade over something they cannot change. Same scoping rule as `[security].allowed_client_networks`' companion refusal (ADR 0151): a new refusal fires only on a new opt-in. **One exception exists, and it is recorded:** the `allow_single_factor_admin_when_exposed` refusal three rows above was corrected under BACKLOG #326 and fires with no new opt-in gating it — see that row and the [ADR 0140](adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md) amendment for the reasoning; do not generalise it. Set it in an estate that has standardized on confidential-computing hosts and wants a missing declaration to be fatal. Env: `MEFOR_SECURITY_REQUIRE_MEMORY_ENCRYPTION_DECLARATION` | | `organization_domains` | list[str] | `[]` | **`[BUILT]` (ASVS 3.7.3):** domains that count as **inside** your organization. The console interposes a "you are leaving this site" page, with a cancel, before any navigation to a destination **not** covered here. ASVS asks about destinations outside the application's **control**, and control is *organisational* rather than topological — your own AD FS is a different host, a different origin, and squarely yours — so this is a declared domain list, **not** a same-origin test. Matched on a **label boundary**: `hospital.example` covers `adfs.hospital.example` and **not** `evilhospital.example` (a bare suffix test would admit the lookalike, which is the failure that makes an interstitial worse than none). **Empty is the STRICT position, not the lax one:** with nothing declared, *every* absolute `http(s)` destination is treated as external and gets the page — including your own IdP. Declaring your domains here is the correct fix for that, **not** `external_link_allowlist`. Entries are bare domains: a URL, scheme or `*` wildcard is refused at config load, because all three look right and match nothing. Env: `MEFOR_SECURITY_ORGANIZATION_DOMAINS` | | `external_link_interstitial` | bool | `true` | **`[BUILT]` (ASVS 3.7.3):** show the "you are leaving this site" page at all. Setting it `false` means the console navigates off-site with **no notification and no cancel** — that is the control itself, so this is a posture decision rather than a convenience one, and `serve` prints a warning naming it at every start. The federated sign-in leg is affected: with the interstitial on and the IdP outside `organization_domains`, `GET /ui/oidc/start` renders the page and the flow is minted only on confirm (`POST`), which also closes the standing hole where any external page could begin a sign-in by linking to the start leg. Env: `MEFOR_SECURITY_EXTERNAL_LINK_INTERSTITIAL` | | `external_link_allowlist` | list[str] | `[]` | **`[BUILT]` (ASVS 3.7.3) — ⚠️ THE AUDITED ESCAPE, AND IT LOWERS SECURITY.** Destinations listed here are navigated to with **no notification and no cancel**, which is precisely what the requirement asks for. It exists for legitimate high-volume external destinations an operator does not want to declare as their own domain. Same label-boundary matching. Non-empty makes `serve` print a warning **naming every entry individually** — never a count, because "3 destinations exempted" is the shape of message that lets an entry nobody intended sit in a list for a year. **Prefer `organization_domains`**: declaring a domain you control is a statement about scope; allowlisting one you do not is a waiver. Env: `MEFOR_SECURITY_EXTERNAL_LINK_ALLOWLIST` | 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/EARLY-ADOPTER-GUIDE.md b/docs/EARLY-ADOPTER-GUIDE.md index fc61acf1..93afcd69 100644 --- a/docs/EARLY-ADOPTER-GUIDE.md +++ b/docs/EARLY-ADOPTER-GUIDE.md @@ -408,6 +408,19 @@ Full references: **[SECURITY.md](SECURITY.md)**, **[PHI.md](PHI.md)**, and **[DE surface in `last_error`/`detail`). - [ ] Run **`messagefoundry audit-verify`** periodically (the audit log is tamper-*evident*, not tamper-*proof*), and set `[retention]` windows — they are **off by default (kept forever)**. +- [ ] **Seal the audit DB across any gap in custody, with an anchor.** A bare `audit-verify` is clean + after the *newest* rows are deleted — the surviving prefix still chains — so on its own it is + blind to the attack it is run for. **`messagefoundry audit-anchor`** prints `COUNT:HEAD` (no PHI, + no secret) and **`messagefoundry audit-verify --expected-anchor COUNT:HEAD`** (or + `--expected-anchor-file`) checks it back. It is an **exact point-in-time seal**, and that fixes + how to use it: **stop or quiesce the engine, take the anchor, hold it somewhere the engine's + operator cannot rewrite, and re-verify while the chain is still quiesced** — around a maintenance + window, a database move, a backup/restore, or a hand-off between custodians. Whatever happened to + the DB in that gap is what it detects. **Two ways to get a useless answer:** taking the anchor and + verifying it in the same breath compares a value to itself, and re-checking a held anchor against + a **running** engine reports `truncated or rewritten` on every ordinary boot, because a running + engine writes audit rows. This is **not** a periodic control against a live engine — for that, an + off-box log forward / tee is still the answer, and it is the stronger one regardless. --- diff --git a/docs/FEATURE-MAP.md b/docs/FEATURE-MAP.md index 0fd101e5..471a6638 100644 --- a/docs/FEATURE-MAP.md +++ b/docs/FEATURE-MAP.md @@ -226,7 +226,7 @@ The PySide6 **desktop console was retired** and `messagefoundry/console/` delete | Environments + deferred `env()` values (`environments/.toml`) | ✅ | | | Env-aware promote (dry-run pre-flight) | ✅ | | | Config reload (`POST /config/reload`, allow-list-confined + audited) | ✅ | #85/#101 | -| CLI — 30 subcommands | ✅ | Run/author (`serve`, `supervise`, `init`, `import`, `validate`, `graph`, `dryrun`, `check`, `impact`, `connection`, `codeset`, `alert`, `security`, `generate`, `lens`, `hl7schema`, `hl7structures`, `adr-analyze`, `ai-policy`) + operate (`backup`, `restore-verify`, `rotate-key`, `rekey-audit`, `audit-verify`, `gen-key`, `protect-key`, `cert`, `verify`, `support-bundle`, `service`) — `_DISPATCH` in `__main__.py` is the registry | +| CLI — 31 subcommands | ✅ | Run/author (`serve`, `supervise`, `init`, `import`, `validate`, `graph`, `dryrun`, `check`, `impact`, `connection`, `codeset`, `alert`, `security`, `generate`, `lens`, `hl7schema`, `hl7structures`, `adr-analyze`, `ai-policy`) + operate (`backup`, `restore-verify`, `rotate-key`, `rekey-audit`, `audit-verify`, `audit-anchor`, `gen-key`, `protect-key`, `cert`, `verify`, `support-bundle`, `service`) — `_DISPATCH` in `__main__.py` is the registry | | Synthetic HL7 generators (ADT, …) | ✅ | `messagefoundry generate`; corpus git-ignored | | Windows service via NSSM | ✅ | docs/SERVICE.md | diff --git a/docs/REMOTE-CONSOLE.md b/docs/REMOTE-CONSOLE.md index eea33fc2..028b9566 100644 --- a/docs/REMOTE-CONSOLE.md +++ b/docs/REMOTE-CONSOLE.md @@ -179,11 +179,19 @@ Auth is on by default; remote users sign in with local accounts (± TOTP MFA) or `[security].allow_single_factor_admin_when_exposed` is not set ([`__main__.py`](../messagefoundry/__main__.py), the `admin_exposed` block). Either switch turns the refusal into a loud, audited warning that starts. A non-PHI instance is silent. - It also has a **blind spot**: the proxy arm keys on the console actually being *served*, and a - default-on console auto-degrades to JSON-only when exposed (§3) — so the recommended - loopback-behind-a-terminator topology does **not** trip it, while the JSON operator API is still - reachable off-box. Don't treat the refusal as your MFA control; see the - `allow_single_factor_admin_when_exposed` row in [`CONFIGURATION.md`](CONFIGURATION.md) and + **"Exposed" here is the bind-and-proxy posture, not the console**: an off-loopback bind, **or** + `[api].tls_terminated_upstream` — whether or not `/ui` ends up mounted. So the recommended + loopback-behind-a-terminator topology in §3 **does** trip it, including when the default-on console + auto-degrades to JSON-only, and when `serve_web_console = false` disables the console outright: the + single-factor surface being protected is the JSON operator API. (This is a correction — + [BACKLOG #326](BACKLOG.md); the arm used to read the console flag, which the §3 auto-degrade clears + first, and would have missed exactly that topology on first deployment.) An **undeclared** proxy — + a set `[security].web_console_public_address` with no `tls_terminated_upstream` — is outside the + predicate and does **not** refuse: nothing was declared, so exposure would be an inference. It gets + its own **warning** instead, naming single-factor admin explicitly, on a PHI instance with + `require_mfa` off. That is a distinct arm — **not** the ADR 0068 §8 undeclared-proxy warning, which + is about the `/ui` cookie and HSTS and is suppressed by §3's auto-degrade in the same posture. See + the `allow_single_factor_admin_when_exposed` row in [`CONFIGURATION.md`](CONFIGURATION.md) and [`SECURITY-LOOSENING.md`](SECURITY-LOOSENING.md). - Under the shipped `require_mfa_scope = "every_local_account"`, a non-interactive **local** bearer-token service account becomes MFA-pending and cannot enrol unattended. Settle this **before** diff --git a/docs/SECURITY-LOOSENING.md b/docs/SECURITY-LOOSENING.md index 444a78f6..c7a77878 100644 --- a/docs/SECURITY-LOOSENING.md +++ b/docs/SECURITY-LOOSENING.md @@ -223,6 +223,17 @@ trail. factor over the network. This ack **downgrades that refusal to a loud, audited warning** (the same warn-and-start `enforcement = warn` takes, but scoped to this one control), so the instance boots single-factor while staying at `enforce`. +- **Scope correction ([BACKLOG #326](BACKLOG.md)):** "a declared reverse proxy" above means exactly + `[api].tls_terminated_upstream` — the bind-and-proxy posture, **independent of the browser console**. The + shipped predicate additionally required the console to be *served*, which the ADR 0143 auto-degrade had + already turned off, so a loopback-behind-a-declared-proxy instance would not have reached this refusal at + all on first deployment and this ack would have had nothing to lift there. The wording in this section was + already the intended scope; the code now matches it, and the ack itself is unchanged. An **undeclared** + proxy (a set `web_console_public_address` with no `tls_terminated_upstream`) stays outside the predicate + — nothing was declared, so exposure there is an inference, and an inference must not refuse. It has its + own startup **warning**, which names single-factor admin directly on a PHI instance with `require_mfa` + off; read that arm, not the ADR 0068 §8 undeclared-proxy warning, as the control for this case (§8 is + about the `/ui` cookie and HSTS, and the ADR 0143 auto-degrade suppresses it in the same posture). - **When acceptable:** a production exposure where the second factor is supplied by a **compensating control outside MessageFoundry** — an authenticating reverse proxy / mTLS admin gateway, or AD/Kerberos MFA delegated to the directory (this flag gates only local Administrator accounts). diff --git a/docs/SECURITY.md b/docs/SECURITY.md index f408bcb6..c4ea2e95 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1080,8 +1080,8 @@ one-to-one — that is why the bind/exposure posture occupies two rows and the A | PHI-read volume, per actor | `identity.user_id` | > 120 reads (`phi_read_rate_limit_per_actor`) per 60 s (`phi_read_rate_limit_window_seconds`); the global dimension `phi_read_rate_limit_global` defaults to `0` = **off** | **THROTTLE** 429 + `Retry-After: 10`, WARNING-logged, charged at **admission** before any store work | on, 120 / 60 s | `[auth].phi_read_rate_limit_enabled` | | Admin-write rate, per actor | `identity.user_id` × request method | **non-GET only**; > 12 writes (`admin_write_rate_limit_per_actor`) per 1.0 s (`admin_write_rate_limit_window_seconds`); no global dimension (`glob=0`) | **THROTTLE** 429 + `Retry-After: 1`, WARNING-logged. **JSON API only** — no `/ui` route charges it at this release | on, 12 writes / 1.0 s | `[auth].admin_write_rate_limit_enabled` | | Serve-hop security posture | declared data class (`[ai].data_class`, or derived from `[ai].environment`) × `[security].enforcement` × (`api.is_loopback` **or** `exposure_protected`), via `phi_read_hop_disposition` | disposition is REFUSE — a **PHI** instance under `enforcement = enforce` whose serve hop is neither loopback, nor in-process TLS, nor a declared TLS-terminating proxy. Setting `[security].enforcement = warn` turns the refusal into WARN-and-serve; a non-PHI declared data class removes it entirely | **DENY** 403 (PHI-free message) on every **JSON-API** PHI-read route (`require_phi_read`, plus the step-up bulk routes), **before** any identity work. **Not applied on the `/ui` browse routes** — `enforce_phi_read_hop` has no console call site, so those get the per-actor budget only (pinned by `test_the_ui_phi_browse_gap_is_disclosed`) | ALLOW on loopback | `[security].enforcement`, `[ai].data_class`/`environment`, `[api].tls_cert_file`, `tls_terminated_upstream` + `trusted_proxies` | -| Bind / exposure posture — refusing arms | `[api].host` loopback-ness, `tls_terminated_upstream`, `trusted_proxies`, `public_origin`, `serve_ui`; derived `ui_exposed`, `admin_exposed`; `[security].enforcement`; declared data class | auth off on a non-loopback bind; `/ui` exposed without the required origin/TLS declarations; `admin_exposed` + PHI + `enforcing` + `require_mfa` explicitly opted out | **DENY at startup** — `serve` prints an error and exits **2**. The refuse/warn dial is `[security].enforcement` (default `enforce`), **not** `production`: the auth-off and `/ui`-exposure arms refuse **unconditionally**, and the `require_mfa` arm refuses when the declared data class is PHI **and** enforcement is `enforce` — which includes the non-production `dev` and `staging` environments, both of which derive PHI — and warns otherwise. `[security].allow_single_factor_admin_when_exposed = true` downgrades that one arm to permitted-but-audited. The same attributes force the session cookie's `Secure` flag + HSTS, and permit WebAuthn `rp_id` derivation from the request URL **only** on a loopback bind with no proxy declared | loopback, nothing declared | `[api].*`, `[security].enforcement`, `[security].allow_single_factor_admin_when_exposed`, `[ai].data_class`/`environment` | -| Bind / exposure posture — dual-control arm | `admin_exposed` × `[approvals].enabled` × declared data class | `admin_exposed` **and** PHI **and** `[approvals].enabled` off — high-value actions complete on one caller's authority | **LOG** — a startup **WARNING only, on every instance including production**; `serve` does **not** refuse. The refuse arm is an explicit unresolved owner fork recorded in `__main__.py`, not a shipped control | approvals off | `[approvals].enabled` | +| Bind / exposure posture — refusing arms | `[api].host` loopback-ness, `tls_terminated_upstream`, `trusted_proxies`, `public_origin`; derived `instance_exposed` (loopback-ness **or** a declared terminator) and `admin_exposed`, plus `ui_exposed` for the `/ui` arms only; `[security].enforcement`; declared data class | auth off on a non-loopback bind; `/ui` exposed without the required origin/TLS declarations; `admin_exposed` + PHI + `enforcing` + `require_mfa` explicitly opted out | **DENY at startup** — `serve` prints an error and exits **2**. The refuse/warn dial is `[security].enforcement` (default `enforce`), **not** `production`: the auth-off and `/ui`-exposure arms refuse **unconditionally**, and the `require_mfa` arm refuses when the declared data class is PHI **and** enforcement is `enforce` — which includes the non-production `dev` and `staging` environments, both of which derive PHI — and warns otherwise. `[security].allow_single_factor_admin_when_exposed = true` downgrades that one arm to permitted-but-audited. **`admin_exposed` is `instance_exposed`, and reads no console flag** (BACKLOG #326): the ADR 0143 degrade arms rewrite `serve_ui` in place earlier in the same startup, so deriving an exposure decision from it made this arm and the dual-control arm below miss a declared-proxy instance whose console had been degraded or disabled — while the ASVS 11.7.1 arm called that same boot exposed. The same attributes force the session cookie's `Secure` flag + HSTS, and permit WebAuthn `rp_id` derivation from the request URL **only** on a loopback bind with no proxy declared | loopback, nothing declared | `[api].*`, `[security].enforcement`, `[security].allow_single_factor_admin_when_exposed`, `[ai].data_class`/`environment` | +| Bind / exposure posture — dual-control arm | `admin_exposed` (= `instance_exposed`: an off-loopback bind **or** a declared TLS terminator — never the console flag, BACKLOG #326) × `[approvals].enabled` × declared data class | `admin_exposed` **and** PHI **and** `[approvals].enabled` off — high-value actions complete on one caller's authority | **LOG** — a startup **WARNING only, on every instance including production**; `serve` does **not** refuse. The refuse arm is an explicit unresolved owner fork recorded in `__main__.py`, not a shipped control | approvals off | `[approvals].enabled` | | Pending federated-login flows, per client IP | the `client_ip` recorded on each staged flow | ≥ **16** pending flows from this address (`DEFAULT_PER_IP_CAP`, no knob), or ≥ `oidc_flow_cache_max` (**512**) engine-wide; 300 s TTL; **reject-when-full, never evict** (evict-oldest would turn a start-leg flood into a login DoS) | **DENY** the start leg — `FlowCacheFullError` → **303** to `/ui/login?e=rate_limited`, WARNING-logged, deliberately **never** audited so a flood cannot amplify into `audit_log` growth | 16 / 512 / 300 s | `[auth].oidc_flow_cache_max`, `oidc_flow_ttl_seconds` | | `Sec-Fetch-Mode` on the federated sign-in legs | the browser fetch-metadata header on `GET /ui/sso`, `POST /ui/oidc/start`, `GET /ui/oidc/callback` | header **present** and not `navigate` (absent = allowed, for non-browser clients). Distinct from the `Sec-Fetch-Site` row below: a different header, a different surface, and `assert_same_origin` deliberately does **not** run on the callback leg, whose `Sec-Fetch-Site` is legitimately cross-site | **DENY** — 303 → `/ui/login?e=sso_failed`\|`oidc_failed`, plus an **audited** `auth.login_failed` row carrying the closed-set slug `non_navigation_fetch`. Evaluated **after** the login limiter, so the audit write is itself rate-bounded | on | (no knob) | | Instance environment posture × claimed AI data scope | `[ai].derived_posture()` (from `[ai].environment` / `data_class` / `production`; an unresolved posture defaults to the **strictest** ceiling) re-resolved server-side through `resolve_effective_policy` on every `POST /ai/chat` | the effective mode is not `managed_endpoint`, or the request's `data_scope` exceeds the server-enforced ceiling (the engine-broker MVP enforces `code_only` regardless of what the caller claims) | **DENY** — **409** on the mode mismatch, **403** on scope excess; each audited `ai.assist` with PHI-safe metadata only | `mode = byo`, `data_scope = code_only` | `[ai].mode`, `[ai].data_scope`, `[ai].environment`/`data_class`/`production` | @@ -1623,7 +1623,18 @@ inherited from another caller. It is surfaced on `GET /audit` and in the `audit: **Tamper-evidence (AUDIT-INTEGRITY).** Each `audit_log` row carries a `row_hash` that chains the previous row's hash with this row's content (SHA-256), so deleting, editing, or reordering any row is -detectable. Verify the chain with `messagefoundry audit-verify` (exit 0 = intact). Rows written +detectable. Verify the chain with `messagefoundry audit-verify` — exit 0 means at least that no +surviving row was edited or reordered. It does **not** mean nothing was removed: deleting the *newest* +rows leaves a prefix that still chains cleanly, so a bare verify is clean after a tail-truncation. For +that, snapshot `messagefoundry audit-anchor` (`COUNT:HEAD`) and pass it back as `messagefoundry +audit-verify --expected-anchor`. It is an exact point-in-time seal, which fixes what it is for: it +seals a chain **at rest across a gap** — quiesce the engine, anchor, hold the value off-box, re-verify +while the chain is still quiesced (a maintenance window, a DB move, a backup/restore, a custodian +hand-off). Anchoring and re-verifying in one breath compares a value to itself, and a held anchor +re-checked against a **running** engine alarms on every boot, because a running engine writes audit +rows; for continuous coverage the off-box tee is still the control ([BACKLOG #328](BACKLOG.md); the +`[retention].audit_days` row in [`CONFIGURATION.md`](CONFIGURATION.md) is the source of record). Rows +written before the feature are chained on first start. The `client` address is folded **inside** the chained payload — deliberately, since attribution an attacker could rewrite without breaking tamper-evidence would be worse than none — as a **conditional trailing element**, appended only when non-`NULL`. A 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/0014-alerting-rules-engine.md b/docs/adr/0014-alerting-rules-engine.md index 19374ebd..eb5ee67e 100644 --- a/docs/adr/0014-alerting-rules-engine.md +++ b/docs/adr/0014-alerting-rules-engine.md @@ -226,7 +226,11 @@ install. All three shipped defaults are unchanged and byte-identical. `[ai].data_class = "phi"` + a store key + the one-time `messagefoundry rekey_audit_chain` migration, after which keying is automatic. **Residual (accepted):** lever (B) depends on store encryption being on (off by default), so 16.4.2 is **Partial** until the WP #243 needle-mover turns encryption on — - tamper-evident today, forgery-evident once keyed. + tamper-evident today, forgery-evident once keyed. **A second residual on lever (A), added 2026-08-04 + ([BACKLOG #328](../BACKLOG.md)):** it is a *bare* walk — it passes no anchor — so it cannot see a + truncated tail (the surviving prefix still chains cleanly), whatever the keying state. The operator + lever for that is `messagefoundry audit-verify --expected-anchor`, run against a quiesced chain; there + is no startup setting that consumes an anchor. Do not read (A) as covering deletion of the newest rows. Each control is **A → Partial (accepted) / B → Pass** — none reaches an unconditional loopback (Posture-A) Pass; the exposed-deployment Pass is the instructed configuration above. This ADR does not 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/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md b/docs/adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md index 56cc993e..09687316 100644 --- a/docs/adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md +++ b/docs/adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md @@ -163,3 +163,82 @@ ack keeps its name). At the default (`enforce` × PHI) behaviour is **byte-ident non-production warn-and-start), so the two acks remain the **surgical stay-at-`enforce`, lift-exactly-one- control** alternative. The four floor items that stay hard-refused and the unconditional ePHI audit are unchanged. (This supersedes the original ack name in §Decision item 2 and the switch-2 discussion above.) + +## Amendment (2026-08-04) — the exposure predicate no longer reads the console flag (BACKLOG #326) + +The single-factor-admin-at-exposure carve-out above is keyed on `admin_exposed`. As shipped that was +`not settings.api.is_loopback or ui_exposed`, and `ui_exposed` is derived from `settings.api.serve_ui` +— a field the two [ADR 0143](0143-web-console-on-by-default-disableable-with-loopback-secure-context-browser-hardening.md) +degrade arms rewrite **in place** earlier in the same `serve` call (soft-degrade when the console wheel +is absent; auto-degrade when a default-on console meets an exposed bind). By the time this gate read it, +`serve_ui` answered "is `/ui` mounted?" — a presentation fact — rather than "is the admin interface +reachable from the network?", the exposure fact the carve-out is about. + +The consequence would have been visible on first deployment of the topology the runbooks **recommend**: +a loopback bind behind a declared TLS terminator, with `[security].serve_web_console` left at its +default. The auto-degrade clears the console flag, so `admin_exposed` evaluated `False`, so a production +PHI instance under `enforcement = enforce` with `[security].require_mfa = false` would have started +clean — with the JSON operator API reachable off-box through that proxy on a single admin factor, and +with `allow_single_factor_admin_when_exposed` having nothing to lift. The same boot was simultaneously +classified **exposed** by the ASVS 11.7.1 arm ([ADR 0152](0152-in-use-data-protection-for-phi-platform-memory-encryption-attestation-asvs-11-7-1.md)), +which already used the console-independent predicate. One startup, two contradictory answers to "is this +instance exposed?". + +**Corrected keying.** `instance_exposed = not settings.api.is_loopback or +settings.api.tls_terminated_upstream` is now defined **once**, above the first consumer, and both the +MFA-at-exposure arm (`admin_exposed = instance_exposed`) and the ASVS 11.7.1 arm read that one +definition. Neither reads `serve_ui`. `ui_exposed` survives, scoped to what it actually describes: the +`/ui`-specific origin/TLS refusals and the browser-console advisories. + +**What changes.** The refusal now fires on at least three postures it did not reach before: a declared +proxy with the console default-on (auto-degraded), a declared proxy with `serve_web_console = false`, +and a declared proxy where the console wheel is simply not installed. An off-loopback bind behaved +correctly before and is unchanged. A plain loopback bind with nothing declared is **not** exposed and +is byte-identical — the property the loopback-quiet tests pin. + +**This is a tightening, and it is deliberate.** A configuration that starts today would refuse on first +deployment of the corrected build. Per the owner ruling of 2026-08-04 it ships as a real refusal: no +warning-first phase, no dated flip, and **no new opt-in gating it**. There are no deployments to protect +from an upgrade, so the simple correct end state is what gets built. The refusal continues to ride the +existing `[security].enforcement` refuse/warn split like every sibling gate. + +**The acknowledgment is unchanged.** `[security].allow_single_factor_admin_when_exposed` still lifts this +one refusal to permitted-but-audited, with its WARNING-level `AUDIT:` line and its entry in +`security_loosenings()`. The ruling forbade a *new* opt-in gating the corrected refusal; it did not +retire this one. Its practical reach grows, because it now has the postures above to act on. + +**Residual, deliberately left open — an UNDECLARED proxy is still not "exposed", and it now has its own +warning.** A set `[api].public_origin` (`[security].web_console_public_address`) with no +`tls_terminated_upstream` on a loopback bind does not satisfy `instance_exposed`, so this refusal does +not fire there. That is a choice: nothing has been declared, so exposure would be an *inference*, and +the ruling that tightened this gate was about a **declared** proxy. Promoting an inference to a refusal +is a different decision, and was not authorised here. + +An earlier draft of this amendment justified leaving it open by asserting the case "still warns", +pointing at the ADR 0068 §8 undeclared-proxy heuristic. **That premise was false, and measurement is +what showed it**, so it is corrected here rather than quietly dropped. §8's warning is about the `/ui` +session cookie and HSTS — it says nothing about admin factors — and it is gated on +`settings.api.serve_ui`, which the ADR 0143 auto-degrade clears in place for exactly this input (a +default-on console plus a set `public_origin`). On the commonest shape of the posture it therefore did +not print at all: the residual was not "warned rather than refused", it was **silent**. A compensating +control must not rest on a false premise, so the control was built rather than the sentence softened — +a dedicated arm now warns, naming single-factor admin, when `public_origin` is set, the instance is not +`instance_exposed`, the declared data class is PHI, and `require_mfa` was explicitly opted out. A truly +signal-less undeclared proxy — no `public_origin` either — remains undetectable in-engine and +runbook-only, as ADR 0068 §8 records. + +**A second residual, NOT closed here — the auth-disabled refusal still keys on the bind alone.** The +much earlier startup arm for `[auth] enabled = false` reads `not settings.api.is_loopback`, several +hundred lines above where `instance_exposed` is defined, and this change does not touch it. So a PHI +instance with authentication entirely off, on a loopback bind behind a declared terminator, would still +start on first deployment — and the loosening text it prints still reads "loopback-only; a non-loopback +bind refuses", which the corrected exposure model contradicts. That is the same +two-answers-in-one-startup shape this amendment corrects, one arm over. It was left alone deliberately: +the owner ruling named `admin_exposed`, and re-keying the auth-off arm needs its own hoist (it runs +long before the current definition) plus its own adjudication, because it would convert that topology +from starting to refusing. **Recorded here as an open item for the owner** so it is not rediscovered as +a surprise. + +**Also amended:** [ADR 0143](0143-web-console-on-by-default-disableable-with-loopback-secure-context-browser-hardening.md) +carries a cross-reference noting that its in-place `serve_ui = False` flips no longer feed any exposure +predicate. diff --git a/docs/adr/0143-web-console-on-by-default-disableable-with-loopback-secure-context-browser-hardening.md b/docs/adr/0143-web-console-on-by-default-disableable-with-loopback-secure-context-browser-hardening.md index 023560c8..c8d714b3 100644 --- a/docs/adr/0143-web-console-on-by-default-disableable-with-loopback-secure-context-browser-hardening.md +++ b/docs/adr/0143-web-console-on-by-default-disableable-with-loopback-secure-context-browser-hardening.md @@ -172,3 +172,23 @@ Posture-A ASVS re-score is owner-gated and handled separately. - [x] `security_loosenings()` untouched (the switch is not a loosening); `docs/SECURITY-LOOSENING.md` reframed + switch-table default flipped. - [x] Docs (`CONFIGURATION.md`, `SECURITY.md`, `PHI.md`, `OFF-LOOPBACK-DEPLOYMENT.md`) + `ide/src/securityEditor.ts` flip "off by default" → on by default. - [ ] Posture-A ASVS re-score (3.3.1/3.3.3 stay Partial on loopback; auto-TLS deferred) — **owner-gated, separate**. + +## Cross-reference (2026-08-04) — the in-place `serve_ui = False` flips feed no exposure predicate (BACKLOG #326) + +Both degrade arms decided here — the package-absent soft-degrade and the exposed-bind auto-degrade — +rewrite `settings.api.serve_ui` **in place** during `serve`, deliberately, so the JSON-only decision +threads through the remaining gates and `create_managed_app`. That is unchanged. + +What changed is downstream: the MFA-at-exposure and #189 dual-control arms used to derive +`admin_exposed` from that already-rewritten field, so an auto-degrade silently cleared an exposure +refusal it was never meant to touch. Those arms now read a single console-independent +`instance_exposed` (an off-loopback bind **or** `[api].tls_terminated_upstream`). See the +[ADR 0140](0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md) +amendment of the same date. + +Consequence for this ADR: an in-place flip decided here is now purely a **presentation** decision — it +determines whether `/ui` is mounted, and nothing else. `ui_exposed` remains, scoped to the +`/ui`-specific origin/TLS refusals and browser-console advisories that legitimately depend on the +console being served. This ADR mentioned neither `require_mfa` nor `admin_exposed`, which is how the +interaction came to exist without ever being adjudicated; recording it here is what puts it on the +record. 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 `