Operate

Environment Model

How root env requirements, declared sources, policy values, and execution env layers fit together in one deterministic model.

learnmaintainersintermediatestable2026-04-29

Why this page exists

Environment handling in Ota is now more than one small field in the contract.

A repo can declare root env requirements, explicit file-backed sources, context-wide execution defaults, task-local overrides, mode-specific overrides, and Ota-owned injected values, all without losing determinism.

  • root env.vars says which values are part of repo truth
  • root env.sources says which files ota may read values from
  • workspace policy and org policy can satisfy declared env requirements without inventing new ones
  • execution contexts and tasks can still add execution-only env on top
  • the winner is explainable through ota env, ota doctor, receipts, and JSON output

The model in one view

  • env.vars is the requirement layer: required, secret, default, allowed, and PATH composition
  • env.vars.<NAME>.default lets the contract supply a non-secret fallback directly, so repos that standardize on ota run or ota up often do not need a separate .env file for simple defaults
  • env.sources is the explicit file-loading layer: ota only reads declared source files
  • execution.contexts.<name>.env is the context-wide execution default layer
  • tasks.<name>.env is the task override layer
  • tasks.<name>.execution.modes.<mode>.env is the selected mode override layer
  • tasks.<name>.env_bindings is the service-derived execution layer for env values that must follow declared service endpoint truth across native and container paths
  • OTA_WORKSPACE and ota-derived cache env are injected execution helpers, not repo requirements
One contract, multiple env layersyaml
execution:  default_context: app  contexts:    app:      backend: container      lifecycle: ephemeral      env:        NODE_ENV: development      attachments:        isolated_paths:          - .npm env:  vars:    WEB_ORIGIN:      default: "http://127.0.0.1:3000"    API_BASE_URL:      default: "http://127.0.0.1:8080"    REQUEST_TIMEOUT_SECONDS:      default: "15"    DATABASE_URL:      required: true      secret: true    RELEASE_CHANNEL:      default: stable      allowed:        - stable        - canary  sources:    - kind: dotenv      path: .env.local    - kind: properties      path: config/app.properties tasks:  build:    env:      CI: "true"    command:      exe: npm      args: [run, build]

Contract defaults can replace simple dotenv defaults

When a value is not secret and the repo should have one stable fallback, keep that truth in env.vars.<NAME>.default instead of scattering it across starter .env files.

This works especially well for app feature flags, local URLs, and timeout knobs when the repo is normally started through ota run or ota up.

  • use contract defaults for non-secret values that should stay reviewable in ota.yaml
  • keep secrets in policy, the shell, or declared env.sources, not in contract defaults
  • app runtimes still receive the resolved values through the normal process environment, so Node and similar stacks do not need their own dotenv loader for those defaults
Replace simple .env defaults with contract defaultsyaml
env:  vars:    WEB_ORIGIN:      default: "http://127.0.0.1:3000"    API_BASE_URL:      default: "http://127.0.0.1:8080"    REQUEST_TIMEOUT_SECONDS:      default: "15"    ANALYTICS_WRITE_KEY:      required: true      secret: true  sources:    - kind: dotenv      path: .env.local tasks:  dev:    run: pnpm dev

Winner order

Root env resolution and execution env injection are related, but they are not the same layer.

  • Repo commands resolve declared env vars in this order: task env, org policy env, process env, declared env sources in order, then contract default
  • Workspace commands add one higher policy layer: task env, workspace policy env, org policy env, process env, declared env sources in order, then contract default
  • Execution env injection then applies context env, task env, and selected mode env for the task that is actually running
  • Explicit task or mode env still wins over Ota-derived fallback execution env

Declared source kinds

Source kinds are curated on purpose. Ota supports the common deterministic formats directly and rejects open-ended parser plugins or runtime auto-detection.

  • shipped kinds are dotenv, properties, json, yaml, and toml
  • path is always relative to the contract directory
  • must_exist: true makes the file itself part of readiness
  • nested structured sources flatten through . before env-key normalization
  • arrays, object leaves, and null values are explicit errors
  • two keys that normalize to the same env name inside one source are a hard collision error
Curated source examplesyaml
env:  sources:    - kind: dotenv      path: .env.local    - kind: properties      path: src/main/resources/application.properties    - kind: json      path: appsettings.json    - kind: yaml      path: config/app.yaml    - kind: toml      path: config/env.toml

Execution-only env

Execution env exists so one plane can carry its own tool paths and runtime defaults without repeating the same values on every task.

  • put shared plane defaults in execution.contexts.<name>.env
  • put one-task overrides in tasks.<name>.env
  • put mode-specific overrides in tasks.<name>.execution.modes.<mode>.env only when backend selection changes the correct value
  • put service-derived task env in tasks.<name>.env_bindings when a value like DB_HOST or DATABASE_URL should come from declared services endpoints instead of hard-coded backend-specific hostnames
  • use from_service.password_env for real service URL credentials; literal from_service.password is only for disposable local/dev credentials
  • ota injects OTA_WORKSPACE automatically so container or remote workspace roots do not need to be hardcoded
  • ota can also derive fallback cache env for known attachment pairs such as .m2, .npm, .pnpm-store, .gradle, .pip-cache, and .pypoetry-cache
Context env plus explicit overrideyaml
execution:  contexts:    application:      backend: container      lifecycle: persistent      env:        JAVA_TOOL_OPTIONS: -Dfile.encoding=UTF-8      attachments:        isolated_paths:          - .m2 tasks:  test:    context: application    env:      MAVEN_OPTS: -Dmaven.repo.local=${OTA_WORKSPACE}/custom-m2/repository    run: mvn test

Detect versus runtime

Detect and init can help author the contract, but runtime stays explicit.

  • detector-led init can infer curated env.sources from known standard files such as .env.local, .env, src/main/resources/application.properties, src/main/resources/application.yml, src/main/resources/application.yaml, appsettings.json, and appsettings.Development.json
  • those inferred entries carry provenance and confidence like other detect/init output
  • runtime commands do not auto-discover those files later
  • ota run, ota up, ota env, and ota doctor only load the source files that are already declared in env.sources

How to use it

  • start with env.vars when the repo needs one value to be real, reviewable, and enforceable
  • add env.sources only when the repo intentionally depends on source files for those values
  • use tasks.<name>.requirements.env when one workflow path should require a declared env var without turning it into repo-global truth
  • use context env when one execution plane needs a shared default
  • use task or mode env only for the smaller override boundary they actually own
  • use tasks.<name>.env_bindings when task env should be derived from declared service endpoints
  • declare both the generated env value and the referenced password_env as secret: true when a service binding includes credentials
  • use ota env --task <name> to prove the winner before a task surprises you
  • use ota doctor when a source is missing, malformed, colliding, or structurally invalid