Volume 3 — Integration Automation and CI Orchestration — contents
  1. Foreword
  2. Chapter 1 — What Integration-Level Automation Is
  3. Chapter 2 — Why Most CI YAML Grows Monstrously
  4. Chapter 3 — The Contract: Six Typed Configuration Categories
  5. Chapter 4 — Specifications
  6. Chapter 5 — Landscape Files
  7. Chapter 6 — Topology Files
  8. Chapter 7 — Runtime Files vs Environment Variables
  9. Chapter 8 — Data Profiles
  10. Chapter 9 — Credential Vocabularies
  11. Chapter 10 — Credential Resolution
  12. Chapter 11 — Credentials in Flight
  13. Chapter 12 — Credentials Across Environments
  14. Chapter 13 — The Development-Environment Ladder
  15. Chapter 14 — Configuration Profiles as a Discipline
  16. Chapter 15 — The Local-Dev Tax and How Typed Configs Cancel It
  17. Chapter 16 — Reproducing CI Failures Locally
  18. Chapter 17 — Profile Promotion
  19. Chapter 18 — The taskplus Task Model
  20. Chapter 19 — Built-in Task Types
  21. Chapter 20 — Workflow YAML Grammar
  22. Chapter 21 — Passing Information to Jobs
  23. Chapter 22 — Flow Control as Data
  24. Chapter 23 — Extending the Task Model
  25. Chapter 24 — Wiring to a Real CI Vendor
  26. Chapter 25 — Bridges to Other Layers
  27. Chapter 26 — Case Study: Porting a Real Pipeline
  28. Chapter 27 — Where This Approach Is Not the Right Fit

Chapter 15 — The Local-Dev Tax and How Typed Configs Cancel It

The .env.local file is a confession. Every team that has one is admitting that their pipeline depends on configuration the codebase doesn't name.

The previous chapter built the proposed profile mechanism. This chapter applies it to a specific operational symptom: the local-dev tax — the accumulated friction every developer pays on a fresh checkout to get the project actually running. Every piece of that friction is a missing typed configuration. This chapter walks through the common pieces, names what each one is hiding, and shows the migration path under the chapter-14 profile system.

The end state the chapter describes is the boring fresh checkout: clone the repository, run one command, the test suite passes. No tribal knowledge, no README's "you might also need to..." section, no half-day onboarding ritual. Just code, profile, results.

The shape of the tax

A fresh checkout of a moderately-mature Python project typically exposes a developer to seven or eight distinct sources of local-dev tax. Each has its own recognisable name and its own characteristic failure mode.

.env.local files

The most common form. A file (committed or not) named .env, .env.local, .env.example that contains a list of export NAME=value lines. The application reads them via a library (python-dotenv, direnv's shell hook, the developer's manual source .env.local).

# .env.example
DATABASE_URL=postgresql://localhost:5432/dev
REDIS_URL=redis://localhost:6379/0
SECRET_KEY=changeme
LOG_LEVEL=DEBUG
GITHUB_TOKEN=
SLACK_WEBHOOK_URL=

What the file is hiding:

  • A landscape entry for the local-dev database (localhost:5432).
  • A landscape entry for the local-dev Redis.
  • A credential for the database (the password, currently empty because the local Postgres is set up with trust auth).
  • A runtime configuration for the log level.
  • A credential reference for GITHUB_TOKEN (with no resolution discipline — the developer is expected to paste their PAT into the file).
  • An infrastructure-service entry for Slack notifications.

The migration: each piece of content moves into the appropriate typed configuration file from chapters 4–9. The file disappears.

direnv and .envrc

A more sophisticated cousin of .env.local. The developer installs direnv; when they cd into the project directory, a .envrc script auto-runs and exports a set of environment variables for the shell:

# .envrc
export DATABASE_URL=postgresql://localhost:5432/dev
export REDIS_URL=redis://localhost:6379/0
source_up_if_exists  # cascade up the directory tree
use python 3.11      # auto-activate the project's Python

The same content as .env.local, plus a Python-version selection on top. The migration is the same: every export becomes a typed configuration entry. The Python version is a runtime configuration field (not currently shipped in the runtime YAML schema, but a natural addition).

.tool-versions (asdf, mise)

A file declaring which versions of which tools the project uses:

# .tool-versions
python 3.11.7
nodejs 20.10.0
poetry 1.7.1

Tool-version selection is legitimately a local-dev concern — it is about how the developer's machine is set up, not about how the orchestration runs. Most teams should keep this file checked in.

What it is not hiding: nothing significant. A .tool-versions file is closer to a pyproject.toml's Python-version constraint than to an environment-variable export — it informs the developer of the expected toolchain rather than parameterising the orchestration.

The migration in this case is partial: keep the file as a toolchain manifest; do not let it grow into an environment-variable substitute. A .tool-versions file with five tool versions is fine. A .tool-versions file with DATABASE_URL in it is the same .env.local mistake in fresh costume.

make setup

An imperative setup script the developer runs once after checkout:

# Makefile
setup:
	./scripts/install-system-deps.sh
	poetry install
	./scripts/create-local-db.sh
	./scripts/seed-test-data.sh
	./scripts/generate-ssh-keys.sh
	cp .env.example .env.local
	@echo "Now edit .env.local with your GitHub token"

What make setup is hiding:

  • A specification for the local environment (which system packages, which Python version, which database, which seed data).
  • A landscape entry that says "the local-dev landscape includes these databases, these mock services."
  • A credential bootstrap (the SSH keys, the .env.local template).
  • A README the developer is expected to read after the imperative script completes.

The migration: the system-package and Python-version steps stay imperative (they're machine-setup concerns). Everything else becomes typed configuration plus the chapter-14 profile machinery.

The README's "you might also need to..." section

The plaintext resting-place of every workaround the team has accumulated:

Fresh checkout? You'll need: — Python 3.11 (use pyenv or asdf) — Postgres running on port 5432 (we use Postgres.app on macOS; Linux folks use Docker) — A GitHub PAT with read:repo scope — An entry in /etc/hosts for dev.local — Trust the local SSL cert (instructions below) — Run make setup (might fail on Apple Silicon; if so, see issue #234) — Edit .env.local to add your PAT — Sign in to AWS via SSO (token expires daily) — Connect the VPN (only required if you're testing the payments service)

This section is the manifest of tribal knowledge. Every line is a piece of state the codebase requires but doesn't name. A new hire works through it for a half-day; a returning developer works through it on each major OS upgrade; a senior engineer swears at it when the seventh dependency in the list has shifted without anyone updating the README.

Each line is a missing typed configuration:

Line What it really is
Python 3.11 (use pyenv) Toolchain manifest (.tool-versions)
Postgres on port 5432 Landscape entry: a local-dev staging-db infrastructure service
GitHub PAT with read:repo Credential entry: local-github-pat, scope tag environment:local-dev
/etc/hosts entry for dev.local Specification: the local-dev environment requires this name resolution; provisioning step ensures it
Trust local SSL cert Specification: the local-dev environment requires this cert to be in the system trust store
make setup (might fail on Apple Silicon) Specification: machine-setup imperative; bug to fix in issue #234
Edit .env.local Migrating already; remove this step
AWS SSO sign-in (daily) Credential resolution: the local-dev AWS credential reference resolves to a fetched-on-demand AWS SSO token
VPN if testing payments Topology: the payments topology requires VPN connectivity; document the dependency in the topology file

Once each line has a typed home, the README's section empties out. The remaining entries are the genuine machine-setup imperatives (toolchain, system packages) — short, stable, and honest about being one-time work.

The cumulative cost

Every piece of local-dev tax has the same general failure mode: it works on the developer who set it up; it fails on the next developer.

The cumulative cost across a team:

  • Onboarding time. A new hire spends a half-day to a full day getting the project actually running. Multiply by team growth rate.
  • OS-upgrade churn. When macOS releases a new major version, every developer pays the local-dev tax again because some part of make setup doesn't work on the new OS.
  • Debugging time. When a test fails locally but passes in CI, the developer's first few hours of debugging are spent ruling out local-dev environmental differences. When the difference is in the README's tribal knowledge, the bug is only reproducible by people who set up their environment the same way.
  • CI ↔ local divergence. As the README grows, local-dev diverges from CI. Eventually some classes of failure can only be reproduced in CI; the developer has to push commits to iterate. The inner-loop velocity collapses.

The discipline of this chapter cancels each of these costs by moving the configuration into the codebase. Once the local-dev profile names every piece of state the project depends on, a fresh checkout is clone + ccai profile activate local-dev.

The migration walk-through

The walk-through that follows takes the fictitious project from the section above through migration. Each step adds one typed configuration; each step is independently committable; each step makes the README's section shorter.

Step 1 — Inventory

Walk every file that participates in local-dev configuration: .env.local, .envrc, the README, the Makefile, the project's docker-compose.yaml, every setup-*.sh. Catalogue every distinct configuration value. Group them by category:

Value Source Category
DATABASE_URL=postgresql://localhost:5432/dev .env.local Data Profile + Credential + Landscape
REDIS_URL=redis://localhost:6379/0 .env.local Data Profile + Landscape
SECRET_KEY=changeme .env.local Credential
LOG_LEVEL=DEBUG .env.local Runtime
GITHUB_TOKEN=... .env.local Credential
SLACK_WEBHOOK_URL=... .env.local Credential + Landscape (infrastructure service)
python 3.11 .tool-versions Toolchain manifest (stays)
Postgres on port 5432 README Landscape (infrastructure service)
/etc/hosts for dev.local README Specification
Local SSL cert trusted README Specification
AWS SSO daily token README Credential
VPN for payments README Topology

The inventory is the chapter's most important step. An inventory that misses something will leave the README with a residual line, which is the same kind of tribal knowledge the discipline aims to eliminate.

Step 2 — Author the local-dev configurations

For each typed configuration, author the file:

config/landscape/landscape-local-dev.yaml — the local-dev landscape:

environment:
    label: local-dev

apod:
    clients:
        - deviceType: network/client-localhost
          name: developer-laptop
          host: 127.0.0.1
          role: runner
          credentials:
              - local-runner
          features:
              parallelism_max: 4
          skip: false

infrastructure:
    services:
        - serviceType: database/postgres
          name: staging-db          # same identifier as production
          host: 127.0.0.1
          port: 5432
          database: dev
          credentials:
              - local-postgres

        - serviceType: cache/redis
          name: staging-redis        # same identifier as production
          host: 127.0.0.1
          port: 6379
          credentials:
              - local-redis

        - serviceType: web/webhook
          name: slack-notifications  # same identifier as production
          endpoint: ${PROMPT:Slack webhook URL}
          credentials:
              - local-slack-webhook

config/dataprofiles/dataprofiles-local-dev.yaml — the profiles:

dataprofiles:
    - identifier: staging-db-rw       # same identifier as production
      category: basic-tcp
      host: 127.0.0.1
      port: 5432
      dbtype: postgres
      dbname: dev
      credential: local-postgres

    - identifier: staging-redis        # same identifier as production
      category: basic-tcp
      host: 127.0.0.1
      port: 6379
      dbtype: redis
      credential: local-redis

config/credentials/credentials.yaml — local-dev section appended:

credentials:
    # ... existing CI / staging / prod entries ...

    - identifier: local-postgres
      categories: [basic, environment:local-dev]
      username: dev
      password: ${KEYCHAIN:com.company.app/local-postgres}

    - identifier: local-redis
      categories: [basic, environment:local-dev]
      username: default
      password: ${PROMPT:Local Redis password}

    - identifier: local-runner
      categories: [ssh, environment:local-dev]
      username: $USER
      keyfile: ${FILE:~/.ssh/id_ed25519}
      keypasswd: ${KEYCHAIN:com.company.app/ssh-id-ed25519}

    - identifier: local-github-pat
      categories: [github-token, environment:local-dev]
      username: $USER
      token: ${KEYCHAIN:com.company.app/github-pat}

    - identifier: local-aws-sso
      categories: [aws-access-key, environment:local-dev, ttl:3600]
      keyid: ${AWSSSO:role/Developer/AccessKey#keyid}
      secret: ${AWSSSO:role/Developer/AccessKey#secret}

    - identifier: local-slack-webhook
      categories: [api-token, environment:local-dev]
      token: ${KEYCHAIN:com.company.app/slack-webhook}

    - identifier: local-app-secret-key
      categories: [api-token, environment:local-dev]
      token: ${LITERAL:changeme}     # explicit; not a real secret

config/runtime/runtime-local-dev.yaml — the runtime configuration:

breakpoints: []
debugger: debugpy
logging:
    levels:
        console: DEBUG
        logfile: DEBUG
parallelism:
    max_workers: 4
timeouts:
    default_seconds: 300       # generous for breakpoints
property_test_caps:
    max_examples: 5            # fast feedback
paths:
    output: ./.output
version: 1.0.0

config/specs/spec-local-dev.yaml — the specification for the local environment:

environment:
    venues:
        - name: localhost
          provider: local
          credential: null

groups:
    - name: local-dev
      description: "Local-dev environment requirements"

profiles:
    - name: local-postgres
      description: "Local Postgres install"
      parameters:
          provider: brew
          formula: postgresql@15
          port: 5432
          database: dev

    - name: local-redis
      description: "Local Redis install"
      parameters:
          provider: brew
          formula: redis
          port: 6379

    - name: local-hosts-entry
      description: "Local /etc/hosts entries"
      parameters:
          entries:
              - hostname: dev.local
                address: 127.0.0.1

    - name: local-trust-cert
      description: "Local SSL cert in system trust store"
      parameters:
          cert_path: ./certs/local-dev.crt

apod:
    devices:
        - identifier: local-postgres
          category: brew-service
          group: local-dev
          profile: local-postgres
          role: database
        - identifier: local-redis
          category: brew-service
          group: local-dev
          profile: local-redis
          role: cache
        - identifier: local-hosts
          category: hosts-file
          group: local-dev
          profile: local-hosts-entry
          role: dns
        - identifier: local-cert
          category: trust-store
          group: local-dev
          profile: local-trust-cert
          role: tls

The specification is consumed by ccai provisioning apply, which (per chapter 4's discipline) is idempotent. The first run installs Postgres, Redis, edits /etc/hosts, and trusts the cert. Subsequent runs are no-ops if the state is already correct.

config/profiles/local-dev.yaml — the profile that ties everything together:

profile:
    extends: base
    name: local-dev
    description: Single-host developer workstation

    select:
        landscape: local-dev
        topology: local-dev
        runtime: local-dev
        credentials: local-dev
        dataprofiles: local-dev

Step 3 — Wire the application code

Code that previously read environment variables now reads typed configurations:

# Before
import os
DATABASE_URL = os.environ["DATABASE_URL"]

# After
from ccai.dataprofiles.wellknown import DataProfileManagerSingleton
from ccai.credentials.wellknown import CredentialManagerSingleton

profile = DataProfileManagerSingleton().lookup_profile("staging-db-rw")
cred = CredentialManagerSingleton().lookup_credential(profile.credential)
DATABASE_URL = f"postgresql://{cred.username}:{cred.password}@{profile.host}:{profile.port}/{profile.dbname}"

The migration is incremental: one configuration value at a time. Until every reader has migrated, the team can keep the env-var fallback in place via the chapter-10 EnvBackend (which lets a credential reference resolve from $DATABASE_URL if it isn't otherwise resolvable). This is the same migration shim from chapter 7.

Step 4 — Replace make setup with ccai provisioning apply

The imperative setup script becomes a wrapper around the spec:

# Makefile (after migration)
setup:
	# Toolchain (the legitimate machine-setup imperative)
	asdf install
	poetry install

	# Everything else is declarative
	AM_PROFILE=local-dev ccai provisioning apply --spec config/specs/spec-local-dev.yaml

Two lines of toolchain installation; one line of declarative provisioning. The previous five-line imperative chain — install deps, create db, seed data, generate keys, copy .env.example — is gone, replaced by the spec-driven equivalent.

Step 5 — Activate the profile

The new fresh-checkout sequence is two commands:

git clone <repo>
cd <repo>
make setup
AM_PROFILE=local-dev ccai testbase test run

That's it. No .env.local, no README scavenger hunt, no half-day onboarding ritual. The developer's machine has the toolchain (from asdf install and poetry install); the local infrastructure is provisioned (from the spec); the configurations are typed and per-environment (from the profile); the test suite runs.

Step 6 — Empty the README

The README's "you might also need to..." section, after migration:

Fresh checkout? — Install asdf or mise (one-time machine setup) — Run make setup — Run ccai testbase test run --profile local-dev

Three lines. None of them are tribal knowledge. None of them will silently rot when the seventh dependency shifts. Each line is small enough to keep correct.

The pattern is universal

The migration above is concrete to one project, but the pattern generalises:

  1. Inventory every piece of configuration state the project currently reads from environment variables, .env files, shell scripts, the README, or imperative setup logic.
  2. Group the inventory by category (Specifications, Landscapes, Topologies, Runtimes, Credentials, Data Profiles).
  3. Author the local-dev configuration for each category under config/<category>/<file>-local-dev.yaml.
  4. Author the local-dev profile that selects them as a bundle.
  5. Author the local-dev specification that captures the one-time machine-setup imperatives.
  6. Migrate application code to read typed configurations instead of environment variables. Use the chapter-10 EnvBackend as a transitional shim during the migration.
  7. Replace make setup's imperative steps with the specification-driven equivalent.
  8. Empty the README of tribal knowledge; what remains is the genuine one-time machine-setup imperative (toolchain plus make setup).

The pattern is the same regardless of project size. A small project might compress steps 3 and 4 into one combined configuration; a large project might split each category into multiple files for clarity. The discipline is the inventory and the elimination of tribal-knowledge prose, not the file-count pedantry.

What about existing developers' workflows?

A common objection: "the migration breaks every developer's existing setup." Three responses to this objection.

Existing setups keep working during migration

The chapter-10 EnvBackend and the chapter-14 profile system are both opt-in. When AM_PROFILE is unset, the system runs with the existing shipped behaviour — every existing os.environ.get(...) in the codebase still works. The migration can run alongside the existing setup for as long as needed. Each developer migrates to the profile system on their own schedule.

The migration produces parity, not divergence

The local-dev profile replaces the .env.local content. The values in the profile are the same values the developer had in .env.local (or in their direnv, or in their shell history). No behaviour change — only a behaviour naming change. The first run of AM_PROFILE=local-dev ccai testbase test run produces the same results as the previous pytest invocation against .env.local.

The benefits accrue to new developers immediately

Existing developers see incremental improvement as the migration progresses. New developers see the full benefit on day one — they never set up the old way at all. A team that's hiring rapidly sees the steepest payoff: every new hire's onboarding time drops by a half-day, and the team's senior engineers stop being asked the same setup questions every week.

What this discipline is not

Three boundaries to keep clear:

Not a replacement for tools that work

The chapter is critical of .env.local as a content mechanism. It is not critical of file-based configuration as a concept — the typed configuration files this chapter proposes are themselves files in version control. The difference is structure: .env.local is untyped key-value pairs; a typed configuration file is a structured document with a known schema.

Tools like direnv and asdf that solve real local-dev problems (automatic Python-version selection, project-scoped tool shimming) remain useful and complementary. The discipline asks those tools to stop carrying application configuration in their files; the toolchain-management uses are unaffected.

Not "one tool to rule them all"

The chapter has not proposed that every team adopt codecraftai-* packages exclusively. The discipline of typed configuration is portable; teams that prefer different implementations of the same pattern (Pydantic Settings, dynaconf, Hydra) can apply the same discipline using their preferred toolkit. What matters is that configuration is structured, named, and per-environment-scoped — not whose YAML loader does the loading.

Not a security boundary

The local-dev profile loads credentials and runs them through the chapter-10 resolver. This is not a security boundary against a compromised developer machine. A developer whose laptop has been compromised can read every secret that resolves into their local profile; the chapter's ${KEYCHAIN:...} and ${PROMPT:...} backends are operational conveniences, not security defences.

For real protection of production secrets, the discipline of chapter 12 applies: production secrets are never resolvable from local-dev profiles, because the resolver in local-dev does not have the production Vault token. The local-dev profile exists so developers don't accidentally try to use production credentials; it is not a fence around them if the developer attempts to.

Summary

  • The local-dev tax is the friction every developer pays on fresh checkout. It manifests as .env.local files, direnv configs, make setup scripts, and the README's "you might also need to..." section.
  • Every piece of the tax is a missing typed configuration. The inventory groups each piece into one of the six categories.
  • Migration produces a fresh-checkout flow of three commands: install the toolchain, run make setup (now declarative), activate the local-dev profile. No tribal knowledge, no scavenger hunt, no half-day ritual.
  • The migration is incremental: each configuration value can migrate independently using the chapter-10 EnvBackend as a transitional shim. Existing developer workflows keep working during the migration; new developers see the full benefit on day one.
  • The pattern is universal: inventory, group, author per category, author the profile, replace imperative setup with declarative specification, migrate code, empty the README.
  • The discipline is not a replacement for tools that work (direnv, asdf for what they're good at), not "one toolkit exclusively," and not a security boundary against a compromised local machine.

The next chapter is the operational payoff of this discipline: when a CI failure shows up that doesn't reproduce locally, the captured profile from the failing CI run lets the developer replay the exact configuration on their laptop and debug the difference in their IDE.