Skip to content

ADR-0024: Portable Execution Contract for Bluefly Projects

Field Value
Status Proposed
Date 2026-08-25
Author BLU (lead architect)
Approver Thomas Scola
Scope Platform-wide — every Bluefly project environment
Related ADR-0019 (execution context as governed resource), ADR-0020 (1Password Connect + SDK), ADR-0023 (repository authority model), std-exec-001-execution-environment-constitution

Context

A Bluefly project today cannot be run reliably by anyone who is not sitting at one specific Mac.

This is not a hypothesis. A single ddev restart on DEMOs/bluefly.io produced, in one run:

  • ~20 custom host/web commands, 6 Compose overlays, 3 custom config files, a custom web Dockerfile, and custom Traefik cert/config files — all reported by DDEV itself as unexpected customization.
  • Unset host variables — nodejs_version, ANTHROPIC_API_KEY, HOST_OPENCODE_DIR — silently defaulting to empty strings.
  • Host-absolute paths under /Users/<operator>/... embedded in tracked configuration.
  • ddev auth ssh attempting to inject 13 SSH private keys — Bluefly, Drupal.org, a retired GitLab identity, Acquia-era and 423interactive credentials, and a NAS deploy key — then aborting entirely on nas_deploy with "string is too large".
  • A post-start hook running drush updb and drush cr automatically, and continuing past requirements errors (MariaDB below the Drupal 11 minimum, an incompatible theme, missing Canvas modules).
  • Mutagen sync completing "with problems" and two Traefik configuration errors.

Two facts matter more than the individual failures.

First: DDEV worked. It started eleven services — web, db, Redis, Solr, Varnish, Adminer, OpenCode, Playwright MCP, Beads, agents-sync, gascity-sidecar — and the multi-service topology came up. Every failure in that run originated in the Bluefly-authored layer around DDEV, not in DDEV.

Second: two of the six Compose overlays were already proven workstation-only. docker-compose.worktrees.yaml bind-mounts ../../../worktrees — a host-relative path escaping the repository. It resolves on exactly one machine and silently does nothing everywhere else. That is the portability defect in miniature: not an error, an absence.

Independent evaluation of alternatives (recorded in the research trail) established:

  • Single-container developer images — including the specific third-party project evaluated — cannot represent a multi-service topology. Grepped rather than assumed: no PHP, Composer, Solr, Varnish or Drupal anywhere in the candidate's image definition, and no Compose topology in the repository. Eliminated on the multi-service test.
  • Nix / devenv solve declarative toolchains well and are not service orchestrators. Adopting them for the Drupal stack means reimplementing DDEV's Compose logic as Nix modules — new custom code, working directly against the deletion metric.
  • The Dev Container specification is a standard, and DDEV ships an official install-ddev devcontainer feature maintained by the DDEV core team. The multi-service stack runs unmodified inside it, because DDEV is still doing the composing.

The distinction that decides the architecture: the Dev Container specification is not the same thing as GitHub Codespaces, VS Code, or any one person's devcontainer repository. Bluefly may adopt the standard without adopting a vendor, a framework, or an operational dependency on GitHub.

Decision

Keep DDEV as the environment and service authority. Delete the Bluefly-authored glue that makes DDEV machine-specific. Add a thin Dev Container workspace contract. Make authentication explicit and least-privilege.

Rejected, with reasons: replacing DDEV (fails multi-service, adds custom code), the evaluated third-party devcontainer repository (single-container, no licence, unmaintained), Nix/devenv as environment authority (net-positive custom code), and GitHub Codespaces as an operational dependency (GitLab is the delivery authority).

Authority map — exactly one owner per concern

Concern Authority
PHP / Node / Composer runtime DDEV (config.yaml)
Service topology (db, Solr, Redis, Varnish, Adminer) DDEV add-ons, official where one exists
Developer / agent workspace Dev Container specification, wrapping DDEV
CI GitLab CI, consuming the same DDEV definition
Source GitLab
Secret values 1Password secret references (op:// via op run/op read/op inject)
SSH identity 1Password SSH Agent or a proven least-privilege agent integration — never op://
CI authentication GitLab native first (CI_JOB_TOKEN), governed service identity only where native auth cannot satisfy the requirement
Identity selection Explicit, by destination and execution context
Agent toolchain Devcontainer features and DDEV add-ons
Host Disposable. No unique assumptions.

No concern has two owners. The Dev Container hosts DDEV; it does not redefine what DDEV defines. A custom Dockerfile declaring PHP alongside DDEV declaring PHP is configuration drift by design, and does not exist in the target state.

SHARED_BASE_IMAGE = NO for now. Devcontainer features and DDEV add-ons are the composition mechanism, both upstream-maintained. A bespoke Bluefly base image is new custom surface requiring a build and publication pipeline — and the estate's Composer publication path has not worked through CI since November 2025. We do not build a shared image on a mechanism known to be broken.

Rule

  1. No host-relative paths in tracked configuration. A definition that depends on a directory layout outside the repository is PORTABLE=NO and is deleted or replaced.
  2. No bulk identity forwarding. An environment declares the identity class it needs. A Bluefly GitLab project receives the Bluefly GitLab identity and nothing else. Drupal.org identity is on-demand only; GITLAB_AUTH != DRUPAL_ORG_AUTH. NAS credentials are not injected unless the project requires NAS.
  3. An unrelated credential must never break project bootstrap.
  4. No private key is ever copied anywhere. 1Password remains the credential authority, but 1Password is not one authentication mechanism. Secret references solve secret values. The SSH Agent solves SSH identities. CI_JOB_TOKEN solves CI-native GitLab access. Service identities solve non-interactive automation. Choose the mechanism that matches the identity plane; never build one universal credential pipe.
  5. Identity is selected by destination, not by presence. gitlab.com/blueflyio/* gets the Bluefly GitLab identity. Drupal.org identity is requested explicitly. NAS identity is requested explicitly. PROJECT_DEFAULT_SSH_IDENTITIES=1, UNRELATED_IDENTITIES_VISIBLE=0.
  6. Developer auth, CI auth and runtime auth are three contracts, not one. DEVELOPER_SSH_AUTH != CI_AUTH != RUNTIME_AUTH. None of the three is forced through SSH keys because one of them uses them.
  7. Environment start does not mutate the application. ddev start starts an environment. Database updates, cache rebuilds and health checks are separate, explicitly invoked operations.
  8. Prove a gap before retaining custom code. For every customization: does DDEV already support this? A DDEV add-on? A devcontainer feature? Plain Compose? GitLab CI? Custom code is retained only after the gap is stated.
  9. An agent is a first-class user. A clean agent must clone, enter the declared environment, authenticate through governed references, and run Composer, Drupal, tests, lint and build — without knowing /Users/<operator>, a directory layout, hidden global packages, shell aliases, or undocumented mounts.

Target state: CLONE → ENTER DECLARED ENVIRONMENT → AUTHENTICATE THROUGH GOVERNED REFERENCES → RUN.

Credential planes — four, deliberately not collapsed

Plane Authority Mechanism
SECRET_VALUE_INJECTION 1Password op:// references through op run / op read / op inject
DEVELOPER_SSH_AUTH 1Password-managed SSH identities SSH agent with per-destination key selection
CI_AUTH GitLab native CI_JOB_TOKEN first; governed 1Password service identity only where native auth cannot satisfy the requirement
RUNTIME_AUTH 1Password Official long-lived runtime integration

Interim mechanism (workstation-safe, today): DDEV supports ddev auth ssh -f <key> to load a single named key rather than scanning the whole ~/.ssh directory. That bounded change alone removes the "thirteen keys, and nas_deploy aborts the run" failure. It is SPECIFIC_DDEV_KEY_SELECTION=INTERIM_MECHANISM — it still names a host-absolute path and therefore still violates HOST_SPECIFIC_PATHS=0. PORTABLE_SSH_IDENTITY_MECHANISM=OPEN.

Two facts that constrain the target mechanism. First, DDEV's ssh-agent container is documented as global across DDEV projects — adding a key is not a project-local action, so PROJECT_A_LOADS_KEY → PROJECT_B_CAN_USE_KEY must be measured, not assumed. Second, adopting the 1Password SSH Agent does not by itself mean only the Bluefly key is visible: that agent can hold many identities, so host-to-key selection remains a required part of the design either way.

Headless agents are the hard canary, not the Mac. A headless agent cannot depend on desktop approval prompts, a human clicking an SSH authorization, local GUI state, a specific Mac socket, or a home-directory path. If non-interactive execution requires a 1Password Service Account, that account is used for the specific secret-value operation — it is not a substitute for personal SSH-agent authentication, and the two must not be conflated.

Consequences

Deleted: the custom web Dockerfile; docker-compose.worktrees.yaml and docker-compose.mounts.yaml (proven workstation-only); every host-relative mount and tracked symlink into /Users/<operator>; every custom command with a proven upstream equivalent; bulk ddev auth ssh forwarding as the auth model.

Under review, not preserved by default: gascity-sidecar is retired architecture surviving in Compose — Gas City is the current model, and existing in an overlay is not a reason to keep it. Mutagen's requirement, the custom Traefik files, and the post-start hook's remaining actions are each classified before they are kept.

Retained: DDEV config; genuinely Bluefly-specific commands after a proven gap; the op:// reference pattern.

Added: one devcontainer.json per project — configuration, not code.

Measured outcome, beyond lines of code:

Metric Before Target
Environment authorities 5 2
Host assumptions many 0
SSH keys forwarded by default 13 1, declared
Agent bootstrap steps requiring host knowledge several 0
Net custom environment LOC — negative

Open decisions, each with the evidence that closes it:

  1. Does DDEV's devcontainer feature work GitLab-hosted, or is it Codespaces-bound? GitLab is the delivery authority. If the feature only functions where GitHub hosts the repository, the standard still stands but the remote-execution answer changes.
  2. Which custom commands and overlays have upstream equivalents? This is the deletion list, audited against the restart log rather than from memory.
  3. Does op:// resolve non-interactively inside a devcontainer? This governs secret values only. It does not answer SSH identity, and must not be treated as if it does.
  4. Can the Dev Container + DDEV topology consume a forwarded 1Password SSH Agent socket safely? Safely meaning: no private key material copied, no bulk identity forwarding, no Thomas-specific filesystem path, no second SSH-agent authority, and no Mac/Linux divergence. Not asserted until tested, across MAC_INTERACTIVE, LINUX_INTERACTIVE, DEVCONTAINER, HEADLESS_AGENT and CI. Unresolved, rule 2 has an interim mitigation but no target implementation.

None of these changes the authority map. They change scope and sequencing.

Canary: bluefly.io — it exercises Drupal 11.4.5, Composer, Node, database, Redis, Solr, Varnish, GitLab package dependencies, 1Password references, and agent execution, and it is the project whose friction produced this decision.

Canary acceptance: DDEV_START=PASS, DDEV_RESTART=PASS, HOST_RELATIVE_PATHS=0, BULK_SSH_KEY_FORWARDING=0, UNRELATED_KEY_BREAKS_BOOTSTRAP=NO, BLUEFLY_GITLAB_AUTH=PASS, DRUPAL_ORG_AUTH=ON_DEMAND_ONLY, NAS_AUTH=NOT_INJECTED, UNDECLARED_HOST_ENV_VARS=0, POST_START_MUTATING_MAINTENANCE=0, MUTAGEN_ERRORS=0, TRAEFIK_CONFIG_ERRORS=0, CUSTOM_CONFIG_WARNINGS=0 or justified, ONLY_EXPECTED_SSH_IDENTITY=YES, PRIVATE_KEY_COPY=0, HOST_PATH_DEPENDENCY=0, CI_JOB_TOKEN_NATIVE_PATH=PASS, HEADLESS_AGENT_AUTH=PASS, CROSS_PROJECT_KEY_LEAK=0, FRESH_CLONE=PASS on Mac, Linux, CI and agent. The canary must show a fresh clone entering the devcontainer, DDEV starting, Composer reaching private Bluefly dependencies, GitLab fetch and push using the Bluefly identity, Drupal.org and NAS identities absent by default, application secrets resolving through op://, and the headless path completing without Thomas-specific interaction.

Migration sequence: audit the glue against the restart log → delete what is workstation-only → convert the remainder to add-ons or features → add devcontainer.json → run the canary. Each step is independently revertible, and DDEV keeps working untouched throughout.

Risk: the canary disproves the architecture. Mitigation: it is a canary, and the architecture is revised rather than defended.