dev
← All ADRs

Package Environment Aggregation

ACCEPTEDEngine2026-06-01

Context

autograph packages can require operator-provided configuration (secrets, preferences, endpoints). For example:

  • autograph.pkg.agent needs LLM credentials (provider, token, models)
  • A future autograph.pkg.python might need a Python version or registry URL
  • A future autograph.pkg.database might need connection strings

The system must support multiple environments (dev, staging, prod) with clear separation of concerns and a predictable workflow.

Requirements

  1. Discoverability - After installing deps, the operator must know exactly what variables need to be filled
  2. Aggregation - Package env templates must be collected generically (no hardcoding)
  3. Idempotency - Re-running install must NOT overwrite values already filled by the operator
  4. Fail-fast - Build must fail clearly if required env vars are missing
  5. Multi-environment - Support multiple env profiles (dev, staging, prod) cleanly
  6. Separation - Clear distinction between: project meta-config, package templates, operator-filled values, and generated output
  7. Genericity - Works for any package without modifying the engine
  8. Freshness - The system detects when source configs changed and rebuild is needed
  9. Safety - Destructive operations use backup/restore to prevent accidental data loss
  • ADR package-context-resolution: structural paths from root env:, secrets from dotenv
  • ADR package-interface-contract: packages declare interface via augr.pkg.yml
  • ADR lifecycle-tasks: defines overall lifecycle stages

Options considered

Option A - Single env/.env with appended sections

All package templates appended to one file with section headers.

  • Pro: Single file, simple dotenv
  • Con: Grows large. Hard to attribute ownership. Idempotency complex (detect existing sections). No multi-env support.

Option B - Per-environment directory with env.d/ drop-ins + build step (chosen)

Layered structure: meta-config at root, per-env directories with drop-in files, aggregated into a single generated file at build time.

  • Pro: Multi-env native. Clear ownership (one file per package). Idempotent (existing files untouched). Build step validates completeness. Drop-in pattern well-known (conf.d/).
  • Con: More complex directory structure. Requires build step. Operator must know workflow.

Option C - Per-package env files loaded directly by compose

Separate files, compose uses env_file: to load them.

  • Pro: No build step.
  • Con: Task dotenv: doesn't support globs. Compose must list each file. No multi-env without duplication.

Key constraint: Task dotenv limitation

Task's dotenv: directive does not support template resolution in paths (./env/{{.VAR}}/... fails) and does not support globs. This forces us to use fixed-path generated files rather than per-env paths in the dotenv declaration. The ideal architecture would use ./env/<ENV>/.env.packages directly, but Task's limitation requires an intermediate fixed-name file at env/ root.

Decision

Option B - Per-environment directory with env.d/ drop-ins + build step.

Consequences

Complete lifecycle (symmetric)

┌─────────────────────────────────────────────────────────────────────┐
│ CONSTRUCT                           │ DECONSTRUCT                   │
├─────────────────────────────────────┼───────────────────────────────┤
│                                     │                               │
│ setup                               │ teardown                      │
│  ├── git submodule init             │  ├── deinit submodules        │
│  └── create env/ from env.template/ │  └── remove env/ entirely     │
│                                     │                               │
│ augr:install                        │ augr:uninstall                │
│  ├── west resolve packages          │  ├── remove resolved deps     │
│  ├── scan package env templates     │  └── backup + remove env.d/   │
│  └── copy templates → env.d/        │                               │
│                                     │                               │
│  ⟶ MANUAL: fill env/<ENV>/env.d/    │                               │
│                                     │                               │
│ augr:build                          │ augr:clean                    │
│  ├── aggregate env.d/ → .env.project│  └── remove .env.project      │
│  ├── validate completeness          │                               │
│  └── (operator runs augr:up next)   │                               │
│                                     │                               │
│ augr:up                             │ augr:down                     │
│  ├── build Docker images            │  └── stop + remove containers │
│  └── start containers               │                               │
│                                     │                               │
│ (app interaction - future ADR)      │                               │
│                                     │                               │
├─────────────────────────────────────┼───────────────────────────────┤
│ SAFETY                                                              │
├─────────────────────────────────────────────────────────────────────┤
│ augr:env:backup   - move env/<ENV>/ to env/<ENV>.backup/            │
│ augr:env:restore  - restore env/<ENV>.backup/ to env/<ENV>/         │
│ augr:env:purge    - permanently delete backups                      │
│ teardown          - full reset (removes env/ entirely)              │
└─────────────────────────────────────────────────────────────────────┘

Note on augr:build → augr:up separation: Task's dotenv: loads files at parse time (before any task runs). After augr:build generates .env.project, the operator must invoke augr:up as a separate command so that Task re-parses and loads the newly generated file. This two-step is a consequence of Task's architecture, not a design choice.

Directory structure

env/
├── .env.augr                   # Engine meta-config (created by setup)
│                               # Contains: AUGR_ENV, AUGR_CONTAINER_CLI
│                               # Fixed name, loaded first by dotenv
│
├── .env.project                # Build output (created by augr:build)
│                               # Aggregation of all env.d/ package configs
│                               # Fixed name, loaded second by dotenv
│
├── dev/                        # Active environment (AUGR_ENV=dev)
│   ├── env.d/                  # Package configs (operator fills these)
│   │   ├── agent.env           # From pkg.agent/env.template/.env
│   │   ├── database.env        # From pkg.database/env.template/.env
│   │   └── ...
│   ├── state/                  # Runtime state (volumes, caches, logs)
│   │   └── ...
│   └── shared/                 # Shared resources (build caches, etc.)
│       └── ...
│
├── dev.backup/                 # Backup of dev/ (created by augr:env:backup)
│
├── staging/                    # Another environment (same structure)
│   └── ...
│
└── .gitignore

Naming rationale:

  • .env.augr - the autograph engine configuration (meta, always present)
  • .env.project - the project's full runtime configuration (generated from packages)

dotenv loading (root Taskfile)

dotenv:
  - './env/.env.augr'           # Engine meta-config (always exists after setup)
  - './env/.env.project'        # Aggregated package config (exists after augr:build)

Both are fixed paths - no template resolution needed. dotenv: silently ignores missing files, so the Taskfile works at every lifecycle stage.

env/.env.augr - Engine meta-configuration

Created by setup from workspace/env.template/.env.augr. The template is versioned in the repo. The actual file in env/ is NOT committed (env/ is gitignored entirely). The operator regenerates it by re-running setup.

# Active environment profile
AUGR_ENV=dev

# Container runtime
AUGR_CONTAINER_CLI=docker

env/.env.project - Build output

Created by augr:build. Contains all variables from env/<ENV>/env.d/*.env concatenated.

Ephemeral - regenerated on every build. Never edited manually.

env/ gitignore strategy

# env/.gitignore
# Everything in env/ is gitignored.
# The directory structure is recreated by setup from env.template/.
*
!.gitignore

The env/ directory is never committed. Its structure is reproducible:

  • setup creates it from workspace/env.template/ (which IS versioned)
  • augr:install populates env.d/ from package templates
  • augr:build generates .env.project

This ensures no accidental secret commits and full reproducibility.

Package env template convention

Each package that needs operator configuration:

autograph.pkg.<name>/
├── augr.pkg.yml              # Declares: env_template: env.template/.env
└── env.template/
    └── .env                  # Documented template with empty values

Variable naming:

  • AUGR_ADE_* - agent/LLM config (pkg.agent)
  • AUGR_PKG_<NAME>_* - package-specific

augr:install behavior

  1. Resolve packages (west init + update)
  2. Read AUGR_ENV from env/.env.augr
  3. Create env/<ENV>/env.d/ if not exists
  4. For each resolved package with env_template in augr.pkg.yml:
    • Target: env/<ENV>/env.d/<package-name>.env
    • If target does NOT exist: copy template, display "New: please fill in"
    • If target exists: compare with template, warn if new vars were added upstream
  5. Display summary of files needing operator attention

augr:build behavior

Freshness checks (implemented as internal: true tasks for reuse):

  • env/.env.project exists
  • env/.env.project is newer than all env/<ENV>/env.d/*.env
  • env/.env.project is newer than env/.env.augr

If stale or missing:

  1. Read AUGR_ENV from env/.env.augr
  2. Concatenate all env/<ENV>/env.d/*.env
  3. Write to env/.env.project
  4. Validate: scan for empty required values → fail with message listing what's missing

Note: Infrastructure paths (AUGR_PATH_WORKSPACE, etc.) are NOT in .env.project. They are resolved by the root Taskfile's vars: section (using ROOT_DIR from git rev-parse) and exported via env:. This is documented in ADR package-context-resolution.

Implementation note: Freshness checks and regeneration should be internal: true tasks for reuse. The entire env lifecycle could live in a dedicated Taskfile.env.yaml, testable independently.

augr:up behavior

After augr:build has generated .env.project:

  1. Task re-parses (new invocation) → dotenv loads .env.project
  2. All package vars + infra paths are in process env
  3. Build Docker images (compose reads ${VAR:?} from process env)
  4. Start containers

Backup / Restore / Purge

Safe-delete protocol for environment data:

augr:env:backup:

  • Moves env/<ENV>/ to env/<ENV>.backup/
  • If env/<ENV>.backup/ already exists: fail (must purge old backup first)
  • Confirms action to operator

augr:env:restore:

  • If env/<ENV>/ already exists: fail (must uninstall current env first)
  • Moves env/<ENV>.backup/ back to env/<ENV>/
  • Removes .env.project (stale after restore - forces rebuild)

augr:env:purge:

  • Permanently deletes env/<ENV>.backup/ (or all *.backup/ dirs)
  • Requires confirmation (destructive)

teardown:

  • Removes entire env/ directory (including backups)
  • Deinits submodules
  • Nuclear option - returns to post-clone state

Compose env loading

Compose reads from process env (inherited from Task's dotenv). No env_file: in compose. Compose uses ${VAR:?} for fail-fast on required vars.

Scope exclusion

App-level interaction with containers (serve, shell, dev servers) is out of scope. Conventions for running services will be defined in a future ADR.

Implementation note

The env lifecycle (install/build/backup/restore/purge) has enough complexity to warrant its own dedicated Taskfile (e.g., Taskfile.env.yaml) that can be tested independently from the rest of the system.