Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 29 additions & 8 deletions docs/AI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.

Expand Down
8 changes: 4 additions & 4 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2433,7 +2433,7 @@ def route_demo_oru(msg):

## 233. Steps view move-drop logic implemented twice (model + webview)

> 🔢 **Filed 2026-07-30 — not started.** Value **6/10** · Difficulty **3/10** · _quick win_. Prerequisite for #237; option (c), a differential test across both implementations, can land first on its own.
> ✅ **SHIPPED 2026-08-04 — option (c) ONLY, by owner ruling: the divergence class is now GATED by a differential test, not eliminated.** Value **6/10** · Difficulty **3/10** · _quick win_. `ide/src/test/suite/steps-mirror.test.ts` loads the real `ide/media/stepsWebview.js` under jsdom (a new `ide/` devDependency, lock re-locked in the same commit) and asserts all ten mirrors against their `stepsModel` counterparts on **every** `ide` CI leg — 2,000 seeded generated row sets for the five pure row-array mirrors, and four hand-authored adversarial cases across all ordered (drag, target) pairs and five pointer fractions for the four DOM-bound ones. It found exactly **one** live divergence and closed it: `canDropRow` accepted a read-only `code` row as a drop target while the webview refused it, contradicting the model's own stated contract. **NOT built: options (a) and (b).** Both implementations still exist, so what closes is the *silent* half of the divergence class, not the duplication; #237's "sequenced behind #233" dependency is met by the gate rather than by de-duplication. **Stale anchors:** the `stepsModel.ts:1767` / `:1861` / `:1531` and `stepsView.ts:917` line numbers in the table and prose below are as-filed on 2026-07-30 and have since moved to `:1779` / `:1873` / `:1540` and `:957`.

**Cluster:** IDE & Authoring. **Priority:** P2. **Verdict:** build (de-duplicate). **Severity:** medium — a silent-divergence class, not a visible bug.

Expand All @@ -2457,7 +2457,7 @@ The webview cannot import from `src/` (it is loaded as a plain script into a `de

## 234. Steps view projection refreshes on save only

> 🔢 **Filed 2026-07-30 — not started. Re-framed 2026-07-30 to match the instruction that filed it.** Value **4/10** · Difficulty **3/10** · _fill-in_. This was originally recorded as "revisit — do not treat as a bug", which contradicted the owner's actual words: *"Put that fix on the backlog too."* It is a **fix**, gated on an ADR amendment — not a question about whether to act. The engineering caveat that motivated the softer framing is preserved below and is unchanged.
> 🔢 **Filed 2026-07-30. PARTLY LANDED 2026-08-04 — the race half is fixed; the save-gate relaxation this item was filed for is STILL OPEN. Re-framed 2026-07-30 to match the instruction that filed it.** Value **4/10** · Difficulty **3/10** · _fill-in_. This was originally recorded as "revisit — do not treat as a bug", which contradicted the owner's actual words: *"Put that fix on the backlog too."* It is a **fix**, gated on an ADR amendment — not a question about whether to act. **Landed:** a user save arriving while a `lens rewrite` held the single edit slot was **discarded**, leaving the view on a pre-save projection with no signal until the next save; it is now deferred to slot release and re-projected exactly once ([ADR 0076](adr/0076-typed-action-vocabulary-action-list-lens.md) Amendment C — written **PROPOSED, not ratified**; owner ratification still needed). **Still open:** whether a *bounded relaxation* of the save gate is safe. Argue it against the corrected premise, not the old one: `render()` pipes `document.getText()` to `lens parse -` over stdin, so rows are projected from the **live buffer**, not from disk — the "stale disk content" justification the gate's own comment carried was false. The surviving reasons are re-shelling Python per keystroke and the fact that a re-projection replaces the entire webview HTML. `RERENDER_DEBOUNCE_MS` is now at `stepsView.ts:91`, not `:89` as the text below says. The engineering caveat that motivated the softer framing is preserved below and is unchanged.

**Cluster:** IDE & Authoring. **Priority:** P3. **Verdict:** **build (ADR-first)** — owner asked for the fix; the save-gate it touches is a deliberate ADR 0076 §5 guardrail, so the amendment lands before the change. **Severity:** low (UX latency).

Expand Down Expand Up @@ -3095,7 +3095,7 @@ That distinction matters concretely for the ASVS record. The scorecard's absence

## 330. The IDE's `ai:assist` gate can never fire

> 🔢 **Filed 2026-08-01 — not started.** Value **5/10** · Difficulty **3/10** · _fill-in_. ADR 0035's SEC-022 `ai:assist` half was never wired — `resolveAiPolicy` omits `getJson`'s token argument (`ide/src/aiPolicy.ts:78`, against the header-when-present at `ide/src/engineClient.ts:141`) so the engine can only ever answer `null` and `docs/AI.md:188` publishes a deny row no code path produces — but no PHI is at risk, the brokered path is server-gated, and the `mode` half still covers the central-off case; TypeScript in one module, ordered so the unconditional cache write at `aiPolicy.ts:79` is guarded before the bearer lands, with the status-bar reader left tokenless or the CWE-613 idle clock becomes unreachable.
> ✅ **FIXED 2026-08-04 — ADR 0035 AC-7/AC-8 added, ADR 0110 amended.** Both defects are closed, in the load-bearing order. **(1) The guard landed first:** the write to `LAST_POLICY_KEY` now goes through a pure `mergeAuthoritativePolicy` (`ide/src/aiPolicyModel.ts`, zero imports so it is asserted node-side on every CI leg), so an answer that does not carry an evaluable `assist_permitted` can no longer overwrite a cached `false`. "Not evaluable" is deliberately wider than the literal `null`: `AiPolicyWire` is a compile-time claim `JSON.parse` does not enforce, so a 200 that OMITS the field arrives as `undefined` — which a `=== null` guard would let through, and which is not `false` either, so the cache would be poisoned past recovery. The bit is narrowed at the boundary (`evaluatedPermission`) on both authoritative paths, the engine read and the CLI fallback. The retention is one-way by design — a cached `true` is **not** sticky (fabricating a permit is the fail-open direction), an evaluable `true`/`false` always wins outright, and `mode` always comes fresh so a central `off`→`byo` re-enable still propagates. **(2) Then the bearer:** `resolveAiPolicy` attaches the cached token via `peekToken` (never `ensureToken` — a chat turn must not pop a sign-in modal) behind the SEC-005 `assertTargetAllowed` gate, so the engine can resolve the identity-dependent `assist_permitted` and the `ai:assist` deny branch can fire at all. The two orderings are not equivalent: attaching the bearer first would open a window in which an authenticated-but-degrading read poisons the cache. **`statusBar.ts`'s `/ai/policy` read stays TOKENLESS** — the two readers are now the named constants `ENVIRONMENT_PLAN` (`authenticated: false`, timer-driven, wants the identity-independent `environment`) and `ASSIST_GATE_PLAN` (`authenticated: true`, user-initiated only), same route and opposite answer, both asserted in CI so a later reader cannot "unify" them into the CWE-613 bug. 20 new tests (`ai-policy-model.test.ts`, `ai-policy.test.ts`, `engine-doctor.test.ts`, `engine-client.test.ts`), each falsified against a planted defect. **Residuals, deliberately not closed here:** (a) nothing constructs `EngineStatusBar`, so the status bar's tokenlessness is asserted on the plan CONSTANT, not on `readEnvironment`'s use of it — rewiring that call site would type-check and stay green (recorded in ADR 0035 AC-8); (b) the cache is one global key while the bearer is keyed per engine URL, so a deny observed against one engine also suppresses assistance against another (fail-closed, recorded in AC-7); and (c) the bearer is looked up under `engineUrl()`, but sign-in happens against the status-bar/promote target, which is `environments()[0].url` whenever `messagefoundry.environments` is configured — so for a user whose only session is against a named environment URL the read is still unattributed and the gate still cannot fire for them. Retargeting `resolveAiPolicy` changes WHICH engine the policy is read from, a behaviour change this item does not ask for; it needs its own number.

**Cluster:** Security & Compliance / IDE & Authoring. **Priority:** P2. **Verdict:** build. **Severity:** medium.

Expand Down Expand Up @@ -3612,7 +3612,7 @@ What is NOT settled is the mechanism. Two independent passes reached different a
---
## 341. Handler returning a tuple or set of Sends delivers nothing, silently

> 🚧 **Status OPEN (filed 2026-08-01).** Value **9/10** · Difficulty **3/10** · _quick win_. `_partition` ([pipeline/dryrun.py:112](../messagefoundry/pipeline/dryrun.py)) narrows with `items = result if isinstance(result, list) else [result]`. A **`list`** of `Send`s works; a **tuple** or **set** does not — the container itself becomes the single item, matches none of the three `isinstance` filters, and yields `([], [], [])`. **Verified live:** `_partition((send, send))[0] == []`. The Handler ran, returned deliveries, and **nothing is delivered and nothing errors** — the message finalizes `FILTERED` (every handler ran but delivered nothing), which is indistinguishable from a handler deliberately declining it. That is an **accept-and-drop**, the one thing CLAUDE.md §12 forbids outright.
> ✅ **Status CLOSED (built 2026-08-04) — WIDEN, not raise.** `_partition` no longer narrows on `isinstance(result, list)`: a Handler may return **any non-`str` iterable** of `Send`/`SetState`/`SetMeta` — list, tuple, set or generator — and it partitions element-wise. **The body's "Fix direction (not yet decided) … failing loud is probably right" is settled the other way**, by owner ruling. THE acceptance criterion holds: `return []` and `return ()` still **filter** (deliver nothing, raise nothing), and a value that is not a container — a bare `int`, a `Message` returned by mistake — still drops silently rather than newly raising, because the gate is `isinstance(…, Iterable)` and never a duck-typed `list(result)` (`Message` has `__getitem__(path: str)` and no `__iter__`, so `list()` would raise out of the handler). **The body's "Fixing `_partition` fixes both modes at once" is FALSE and was the most dangerous sentence in this item** — acting on it would have shipped a MODE-DEPENDENT disposition (in-process delivers, `[sandbox].mode=subprocess` still drops), worse than the bug it closes. One shared rule (`wiring.handler_result_items`) is now applied in three places: the parent's `_partition`, `_sandbox_codec.enc_result`, and `_sandbox_worker` **inside** `with run_contexts(…)` — the last because a generator Handler's body runs lazily, and materialising it at describe time would execute it with no run context, so a `code_set(…)` inside one would raise under subprocess while working under off. **Two author-visible facts the body does not carry.** (1) A **`set` delivers but has no defined fan-out order** — `Send` is hashed on its fields and `str` hashing is seeded per process, so order differs between processes (parent vs sandbox child) and across a crash re-run, i.e. a set gives up FIFO order between sibling `Send`s to one outbound; mode parity is over the delivered **multiset** plus an *ordered* container's order, and the docs steer authors to a list/tuple. (2) A **generator Handler is not execution-traced** — its body runs after the ADR 0072 tracer detaches, so that invocation reports no lines and no sends, now declared as `"lazy_result": true` rather than left to read as an inert handler; **this change opened that gap** (before it, a generator delivered nothing, so the trace's `[]` was exact). ADR 0072 §6 gate 1 is split by level and amended accordingly; ADR 0087's parity bullet + AC-11 rescoped; ADR 0108's §2 invariant and §7 tuple rationale corrected (its refusal **stands**, on conservative scope — nothing 0108 built changed); `checks.py`'s `accepts=` advisory widened to recognize `return ()`, which `lens.py` already did. **`pipeline/dryrun.py:112`, the anchor cited below, no longer holds that line.**

> **OWNER RULING 2026-08-04 — WIDEN. Supersedes an earlier ruling on this item that said RAISE.**
> A Handler returning a tuple, set or generator of `Send`s is **accepted**, like a list. It does not raise.
Expand Down
19 changes: 18 additions & 1 deletion docs/CONNECTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading