MessageFoundry ships an AI coding assistant in the VS Code IDE, and a centrally-governed, environment-aware policy that controls it across the full range from OFF to PHI-safe. The policy is set by whoever operates the install (ops/admin), not by the individual developer, so a central "off" — or a cap on what data the assistant may see — is honored on every workstation that talks to the engine.
Carries PHI implications. This document covers the policy model and its enforcement. The hard PHI guarantee (the MVP assistant only ever sends code, never message bodies) is restated in PHI.md; the RBAC permission that gates it is in SECURITY.md.
Scope — product feature, not the dev process. This governs the AI assistant the shipped product offers operators. The maintainers' own discipline for using Claude Code to build MessageFoundry — risk-tiered guardrails, the daily loop, provenance — is a distinct, complementary standard:
Secure_AI_Development_Standards.md. The two share the word "AI" and nothing else.
Status (MVP). The policy model + config + RBAC + the engine policy endpoint + the CLI + gating of the existing provider-agnostic, bring-your-own IDE chat assistant are built. One engine broker IS built:
managed_endpoint(ADR 0135) brokers a singlecode_onlyprompt to a customer-managed / self-hosted LLM overPOST /ai/chat, audited per use; it never reachesphiscope.managed_claude/managed_claude_baaare accepted as policy values but the IDE cannot service them, and thedeidentified/phiscopes are not reachable in the MVP (see Future direction).
The policy is two independent axes, then clamped by the instance's production posture (decoupled from the environment name, ADR 0017):
-
mode— what kind of AI, on an OFF→PHI-safe spectrum:modeMeaning offNo AI assistance at all. byoBring-your-own provider, configured in the IDE; the engine never sees the traffic. Code-only by construction (PHI-safe). managed_endpointBUILT (ADR 0135) — the engine brokers one code_onlyprompt to a customer-managed / self-hosted LLM overPOST /ai/chat, audited per use, behind a fail-closed SSRF allowlist. Never reachesphiscope.managed_claudeEngine-brokered managed provider. Future — not serviceable by this IDE version. managed_claude_baaEngine-brokered managed provider under a BAA + zero-data-retention connection — the only mode that can reach phiscope. Future.Dev-process analogue (§4.5).
managed_claude_baais the product / runtime path for PHI to reach an LLM under a BAA. Its build-time counterpart — when real PHI may enter the AI assistant used to develop MessageFoundry, under a signed BAA + zero-data-retention agreement (operator-enabled, minimum-necessary, audited) — is §4.5 ofSecure_AI_Development_Standards.md. Same control (BAA + ZDR), different surface; both default to no PHI. -
data_scope— the most sensitive data the assistant may be given, least→most sensitive:data_scopeOrder Meaning code_only0 Graph names + the active editor's code. The only scope the MVP ever sends. synthetic1 Plus synthetic (generated) HL7 — never real patient data. deidentified2 De-identified message data. Requires the (unbuilt) de-id framework — never reached today. phi3 Real message bodies / PHI. Reachable only under managed_claude_baa. -
production— the instance's posture flag (abool, decoupled from the environment name). Sets a ceiling ondata_scope(never onmode):productiondata_scopeceilingfalse(non-production)synthetictrue(production)phiifmode == managed_claude_baa, elsecode_onlyPosture is derived from the built-in environment names when unset (
dev/staging→ non-production,prod→ production); a custom env name (e.g.test,poc) sets[ai].production(and[ai].data_class) explicitly. When the posture can't be resolved, the policy clamps to the strictest ceiling, so an un-tuned install never accidentally widens scope.
resolve_effective_policy(mode, data_scope, production)
(config/ai_policy.py) is a pure function that returns the
effective policy after applying, in order:
- Posture ceiling —
data_scopeis lowered to the production-posture ceiling (above) if the request exceeds it. phihard rule —phisurvives only undermanaged_claude_baa; otherwise it falls back tocode_only.deidentifiedhard rule —deidentifiedalways falls back tocode_onlytoday, because no de-identification is wired into the AI-assist path. (A de-id framework,messagefoundry/anon// ADR 0030, exists to build PHI-free test datasets; it does not de-identify message bodies flowing to the assistant — see PHI.md §9.)offnormalization — whenmode == off,data_scopeis irrelevant and resolves tocode_only.
mode is never clamped by posture — only data_scope is. Every clamp is recorded in a
human-readable reason so an operator can see why the effective policy differs from what was
configured. Representative results:
Configured (mode, data_scope, production) |
Effective data_scope |
Why |
|---|---|---|
byo, code_only, true |
code_only |
no clamp (this is the default) |
byo, phi, true |
code_only |
production ceiling for non-BAA mode |
managed_claude_baa, phi, true |
phi |
the full PHI-safe end — no clamp |
managed_claude_baa, deidentified, true |
code_only |
no AI-path de-id wired |
managed_claude_baa, synthetic, true |
synthetic |
under both ceiling and the phi rule |
byo, phi, false |
synthetic |
non-production ceiling |
byo, deidentified, false |
synthetic |
ceiling reached before the de-id rule |
off, phi, true |
code_only |
AI off → scope irrelevant |
Set in messagefoundry.toml, with the usual MEFOR_AI_* env overrides
(CONFIGURATION.md). Precedence stays CLI > env > TOML > default.
| Key | Type | Default | Notes |
|---|---|---|---|
mode |
enum | byo |
off · byo · managed_endpoint (built, ADR 0135) · managed_claude · managed_claude_baa |
data_scope |
enum | code_only |
code_only · synthetic · deidentified · phi |
environment |
str | — | free-form active-environment name (ADR 0017); selects environments/<name>.toml + current_environment(). Required for serve (no default). |
data_class |
enum | derived | synthetic · phi — does this instance carry real PHI (drives the at-rest/egress advisories). Derived from a built-in name (dev→synthetic, staging/prod→phi) when unset; required for a custom name. |
production |
bool | derived | production-tier posture (drives the AI ceiling + prod DEBUG refusal), decoupled from the name. Derived (dev/staging→false, prod→true) when unset; required for a custom name. |
provider |
str | claude |
names the provider the broker addresses; recorded in the per-use audit. Does not select a request shape (the broker builds one wire shape unconditionally). Validated at config load — only a serviceable provider is accepted (BACKLOG #95) |
model |
str | claude-opus-4-8 |
forward-compat, unused in MVP |
baa_attested |
bool | false |
forward-compat, unused in MVP |
endpoint |
str | — | forward-compat, unused in MVP |
# messagefoundry.toml
[ai]
mode = "byo"
data_scope = "code_only"
environment = "prod"Env keys follow MEFOR_AI_<KEY> — e.g. MEFOR_AI_MODE, MEFOR_AI_DATA_SCOPE,
MEFOR_AI_ENVIRONMENT.
A new permission ai:assist (auth/permissions.py)
governs whether an identity may use the assistant. It is granted to the Coding role (and to
Administrator, which holds every permission). Operator, Viewer, and the other roles do not
get it. See SECURITY.md.
Both surfaces emit the same snake_case JSON (single source of truth):
{ "mode": "byo", "data_scope": "code_only", "environment": "prod", "assist_permitted": true, "reason": null }mode / data_scope / environment are the effective (clamped) values. reason is the clamp
note (or null). assist_permitted is the identity-dependent bit:
assist_permitted |
Meaning |
|---|---|
true |
the caller holds ai:assist (or is the system identity). |
false |
the caller is authenticated but lacks ai:assist. |
null |
RBAC could not be evaluated — no/invalid token under enabled auth (offline CLI always returns null). |
Returns the effective policy (api/app.py). It deliberately does
not require a permission: the install policy (mode/scope/environment) is non-sensitive operational
config, and must be readable so a central off is honored even by a tokenless client. The
identity-dependent part is carried only in assist_permitted (null when RBAC can't be evaluated).
Policy reads are not audited in the MVP — per-use egress auditing arrives with the future
broker.
The offline fallback (main.py): it loads
messagefoundry.toml from the working directory (or --service-config <path>), resolves the
effective policy, and prints the same JSON to stdout — except assist_permitted is always
null (RBAC is not evaluable offline). --json prints only the JSON object (the IDE parses
stdout); on error it prints {"error": "..."}. It prints config only, never message data.
The IDE assistant (ide/src/chat.ts) resolves the policy before every
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:
| Effective state | Behavior |
|---|---|
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 — 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 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.
In the MVP the assistant only ever attaches code_only context — the graph's connection/router/
handler names and the active editor's code (capped by the messagefoundry.ai.contextCharLimit VS
Code setting, default 8000 chars; oversized files are cut on a line boundary and a marker is
appended so the truncation is never silent), nothing more. No message bodies, no patient data, are
ever sent — regardless of mode, provider, or that limit. Scopes above code_only (synthetic,
deidentified, phi) are not wired into the IDE; the resolver caps them and the chat path carries
an explicit guard against attaching anything beyond code. See PHI.md.
- Engine-brokered managed providers (
managed_claude,managed_claude_baa) are P1/P2. The engine — not the IDE — will broker the provider connection, so egress is centrally controlled and per-use auditable.managed_claude_baaover a BAA + zero-data-retention connection is the only path by whichphiscope ever becomes reachable. - Runtime de-identification (the
deidentifiedscope) is roadmap only — the AI-assist path has no message de-identification wired in today, so the scope falls back tocode_only. (A de-id framework,messagefoundry/anon// ADR 0030, exists to build PHI-free test datasets; it does not de-identify message bodies for the live assistant.) See PHI.md §9. - The
provider/model/baa_attested/endpointconfig keys are accepted but unused today; they exist so the broker can consume them without a config migration.