Status: accepted · Version 1.1 · Filed 2026-04-19
SPEC-019 v1.1 — Environment Resolution Contract (HOST_ENV + PRISM_ENV + MODE_PROFILES)
Status: draft | v1.1 | Supersedes: v1.0 | Related: ADR-18, ADR-21 (supersedes 19), ADR-22 (supersedes 20), project_host_contract memory
v1.1 change log
- Renamed PRISM_MODE value
personal -> local throughout (aligns with dev conventions: .env.local / .env.lan / .env.cloud). Same semantic, better name.
- All other content unchanged from v1.0.
Purpose
Define how Prism reads configuration at install and runtime across three modes (local / lan / cloud), making the codebase environmentally aware (“soft”) without mode-specific control flow in application code. Every config value’s source is knowable at runtime via provenance logging.
Scope
In-scope:
- Install-time: host probing, mode selection, env-block write to editor MCP configs.
- Runtime: env read, tiered resolution, provenance logging at backend startup.
- Backend, MCP server, installer, and docker-compose files all consume the same resolver surface.
Out-of-scope:
- Secrets management (use OS keychain / vault).
- Per-project config overrides (future).
- Feature flags.
The two buckets
HOST_ENV — discovered facts about the host
Never chosen. Probed by install.py once; written to every editor MCP env block; read by runtime (no platform.system() calls in runtime code per the host-contract memory).
| Var | Meaning | Probe method |
|---|
| PRISM_HOST_OS | Darwin / Linux / Windows | platform.system() at install time |
| PRISM_HOST_FQDN | Full hostname | socket.getfqdn() |
| PRISM_HOST_LAN_IP | Primary LAN IPv4 | socket.gethostbyname_ex() filtered for RFC1918 |
| PRISM_HOST_USER_HOME | User home dir | Path.home() |
| PRISM_APP_SUPPORT | Per-OS app-support dir | mcp/host_config.py at install |
PRISM_ENV — Prism’s wiring, chosen per mode
Selected at install from prompts + MODE_PROFILES defaults; written to env; read by runtime.
The mode knob:
- PRISM_MODE: local | lan | cloud (the one switch everything keys off)
Backend wiring:
| Var | Local default | LAN default | Cloud default |
|---|
| PRISM_API_URL | http://127.0.0.1:8765 | http://$:41765 | https://$ |
| PRISM_API_KEY | auto-minted | minted on server install | minted on install |
| PRISM_BIND_ADDR | 127.0.0.1 | 0.0.0.0 | 0.0.0.0 |
| PRISM_BACKEND_PORT | 8765 | 41765 | 443 |
| PRISM_PG_PORT | 5433 | 45432 | n/a |
| PRISM_NEO4J_HTTP_PORT | 7474 | 47474 | n/a |
| PRISM_NEO4J_BOLT_PORT | 7687 | 47687 | n/a |
| PRISM_ALLOWED_ORIGINS | http://127.0.0.1:* | http://$:* | $ (https required) |
Install context:
- PRISM_ROOT, PROJECT_ROOT, PRISM_GITHUB_USER, PRISM_FS_MCP_NAME, LOG_DIR, LOG_LEVEL
Resolution order (tiered — first tier to answer wins)
- System facts - socket/platform/filesystem calls.
- Environment variables - PRISM_* read from process env.
- Config files - .env, .env., backend/app/config (pydantic BaseSettings).
- MODE_PROFILES - module-level constant in backend/app/config/env.py. Per-mode fallback dict. Last resort.
MODE_PROFILES (the in-code fallback)
MODE_PROFILES: dict[str, dict[str, Any]] = {
"local": {
"PRISM_BIND_ADDR": "127.0.0.1",
"PRISM_BACKEND_PORT": 8765,
"PRISM_PG_PORT": 5433,
"PRISM_NEO4J_HTTP_PORT": 7474,
"PRISM_NEO4J_BOLT_PORT": 7687,
"PRISM_ALLOWED_ORIGINS": ["http://127.0.0.1:*"],
},
"lan": {
"PRISM_BIND_ADDR": "0.0.0.0",
"PRISM_BACKEND_PORT": 41765,
"PRISM_PG_PORT": 45432,
"PRISM_NEO4J_HTTP_PORT": 47474,
"PRISM_NEO4J_BOLT_PORT": 47687,
# ALLOWED_ORIGINS derived from HOST_FQDN at resolve-time
},
"cloud": {
"PRISM_BIND_ADDR": "0.0.0.0",
"PRISM_BACKEND_PORT": 443,
# ALLOWED_ORIGINS must be explicit, https-only. Refuse otherwise.
},
}
Provenance logging (mandatory)
At backend startup, after resolving each key:
CORS allowed_origins=['http://127.0.0.1:*'] (source: profile:local)
PRISM_API_URL=http://server1.home.lan:41765 (source: env)
PRISM_HOST_OS=Darwin (source: system:platform.system())
Backend integration surface
# backend/app/config/env.py
class HostEnv: ...
class PrismEnv: ...
def resolve() -> tuple[HostEnv, PrismEnv]: ...
Called once at startup. Result injected into FastAPI app state. Routers/services consume request.app.state.env.prism.allowed_origins — no mode-branching in application code.
install.py integration
- Probe HOST_ENV (tier-1).
- Prompt user for PRISM_MODE + override knobs (tier-2 user intent).
- Merge into MODE_PROFILES[mode] baseline (tier-4).
- Validate cloud mode has PRISM_WEB_URL https://.
- Validate lan mode’s PRISM_API_URL is FQDN-shaped (ADR-18).
- Write flat env block to all 4 editor MCP configs.
Migration path (physical-artifact renames)
The personal -> local rename cascades to implementation artifacts. Execute during SPEC-019 implementation, not as a separate PR:
- docker-compose.personal.yml -> docker-compose.local.yml
- .env.personal -> .env.local (already exists as backend/.env.local for dev; reconcile)
- prism-personal-backend / -postgres / -neo4j container names -> prism-local-*
- NEO4J_AUTH neo4j/prism_personal -> neo4j/prism_local
- install/backend.py
BackendMode = Literal["personal","lan","cloud"] -> Literal["local","lan","cloud"]
- install/backend.py
DEFAULT_PERSONAL_URL, resolve_personal(), start_personal(), COMPOSE_PERSONAL, DEFAULT_CREDENTIALS (all personal symbols) rename to local
- Existing running local stack: teardown during TODO #70 cutover; fresh up with new names.
Test requirements
Smoke battery mcp/smoke_env_resolve.py:
- Mode-profile baseline: resolve on empty env -> MODE_PROFILES values for each mode.
- Tier precedence: env overrides config overrides profile.
- HOST_ENV probes return expected shape on Darwin/Linux/Windows.
- ADR-18 FQDN enforcement: lan bare-hostname PRISM_API_URL is rejected at install.
- Cloud https enforcement: cloud plain-http PRISM_ALLOWED_ORIGINS is rejected at install.
- Provenance log contains one line per resolved key at startup.
CORS smoke mcp/smoke_cors.py:
- Local mode, same-origin fetch: allowed.
- Local mode, cross-origin from not-allowed port: denied (no ACAO header).
- Lan mode, cross-origin from allowed Mac FQDN: allowed.
- Cloud mode, http:// origin: startup refuses to boot backend.
Migration path from current code
- File SPEC-019 v1.0. (done, superseded)
- File SPEC-019 v1.1 (this). (done)
- File ADR-19 + ADR-20 (done, superseded) + ADR-21 + ADR-22 (done).
- TODO #72 (localhost audit) executes first.
- Implement backend/app/config/env.py with HostEnv + PrismEnv.
- Mount CORSMiddleware always in backend/app/main.py.
- Rename personal -> local across compose files, container names, install.py symbols.
- Update install.py for MODE_PROFILES baseline + FQDN/https validation at tier 2.
- Update docker-compose files so every port/bind reads from env.
- Drop legacy PRISMGR_API_URL fallback in mcp/client.py + smoke scripts.
- Smoke batteries (env_resolve + cors) green before merge.
Exit criteria
- PRISM_MODE values are
local, lan, cloud — no personal references remain in code, compose files, container names, or docs.
- Zero
platform.system() calls in backend or mcp/ runtime code.
- Zero hardcoded ports in docker-compose files.
- CORSMiddleware mounted in main.py; allowlist from resolved env.
- Provenance log at startup, one line per resolved key.
- install.py rejects lan bare-hostname URL + cloud http URL.
- mcp/smoke_env_resolve.py green across local/lan/cloud.
- mcp/smoke_cors.py green across local/lan.