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 sshattempting 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 onnas_deploywith "string is too large".- A post-start hook running
drush updbanddrush crautomatically, 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-ddevdevcontainer 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¶
- No host-relative paths in tracked configuration. A definition that depends on a directory layout outside the repository is
PORTABLE=NOand is deleted or replaced. - 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. - An unrelated credential must never break project bootstrap.
- 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_TOKENsolves CI-native GitLab access. Service identities solve non-interactive automation. Choose the mechanism that matches the identity plane; never build one universal credential pipe. - 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. - 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. - Environment start does not mutate the application.
ddev startstarts an environment. Database updates, cache rebuilds and health checks are separate, explicitly invoked operations. - 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.
- 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:
- 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.
- Which custom commands and overlays have upstream equivalents? This is the deletion list, audited against the restart log rather than from memory.
- 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. - 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_AGENTandCI. 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.