Skip to main content
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).
VarMeaningProbe method
PRISM_HOST_OSDarwin / Linux / Windowsplatform.system() at install time
PRISM_HOST_FQDNFull hostnamesocket.getfqdn()
PRISM_HOST_LAN_IPPrimary LAN IPv4socket.gethostbyname_ex() filtered for RFC1918
PRISM_HOST_USER_HOMEUser home dirPath.home()
PRISM_APP_SUPPORTPer-OS app-support dirmcp/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:
VarLocal defaultLAN defaultCloud default
PRISM_API_URLhttp://127.0.0.1:8765http://$:41765https://$
PRISM_API_KEYauto-mintedminted on server installminted on install
PRISM_BIND_ADDR127.0.0.10.0.0.00.0.0.0
PRISM_BACKEND_PORT876541765443
PRISM_PG_PORT543345432n/a
PRISM_NEO4J_HTTP_PORT747447474n/a
PRISM_NEO4J_BOLT_PORT768747687n/a
PRISM_ALLOWED_ORIGINShttp://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)

  1. System facts - socket/platform/filesystem calls.
  2. Environment variables - PRISM_* read from process env.
  3. Config files - .env, .env., backend/app/config (pydantic BaseSettings).
  4. 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

  1. Probe HOST_ENV (tier-1).
  2. Prompt user for PRISM_MODE + override knobs (tier-2 user intent).
  3. Merge into MODE_PROFILES[mode] baseline (tier-4).
  4. Validate cloud mode has PRISM_WEB_URL https://.
  5. Validate lan mode’s PRISM_API_URL is FQDN-shaped (ADR-18).
  6. 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:
  1. Mode-profile baseline: resolve on empty env -> MODE_PROFILES values for each mode.
  2. Tier precedence: env overrides config overrides profile.
  3. HOST_ENV probes return expected shape on Darwin/Linux/Windows.
  4. ADR-18 FQDN enforcement: lan bare-hostname PRISM_API_URL is rejected at install.
  5. Cloud https enforcement: cloud plain-http PRISM_ALLOWED_ORIGINS is rejected at install.
  6. Provenance log contains one line per resolved key at startup.
CORS smoke mcp/smoke_cors.py:
  1. Local mode, same-origin fetch: allowed.
  2. Local mode, cross-origin from not-allowed port: denied (no ACAO header).
  3. Lan mode, cross-origin from allowed Mac FQDN: allowed.
  4. Cloud mode, http:// origin: startup refuses to boot backend.

Migration path from current code

  1. File SPEC-019 v1.0. (done, superseded)
  2. File SPEC-019 v1.1 (this). (done)
  3. File ADR-19 + ADR-20 (done, superseded) + ADR-21 + ADR-22 (done).
  4. TODO #72 (localhost audit) executes first.
  5. Implement backend/app/config/env.py with HostEnv + PrismEnv.
  6. Mount CORSMiddleware always in backend/app/main.py.
  7. Rename personal -> local across compose files, container names, install.py symbols.
  8. Update install.py for MODE_PROFILES baseline + FQDN/https validation at tier 2.
  9. Update docker-compose files so every port/bind reads from env.
  10. Drop legacy PRISMGR_API_URL fallback in mcp/client.py + smoke scripts.
  11. 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.
Last modified on April 22, 2026