Skip to content

fix(env): close the remaining raw env write refusals and publish without hard links - #468

Merged
roodboi merged 1 commit into
nextfrom
wf/g73-env-write-refusals-exfat
Oct 24, 2026
Merged

roodboi merged 1 commit into
nextfrom
wf/g73-env-write-refusals-exfat

Conversation

@roodboi

@roodboi roodboi commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Refs HACK-1377

This PR fixes the env write refusals that were still raw on next after #402 (HACK-1330). All five were pre-existing; none are regressions. Each one now prints a single line with an E_* code and no value, stack, errno or staging path. --json reports the same code.

Root causes and fixes

  1. Filesystems without hard links (ExFAT, FAT). Hack publishes lock receipts (withProjectsRegistryLock) and the first project key (publishPrivateFileExclusive) with link(2). ExFAT returns ENOTSUP (Linux FAT returns EPERM), so the key could never be created. On current next the problem is wider: since fix(env): serialize env layer read-modify-write so concurrent sets keep every key #435, every env set on ExFAT fails at the per-layer lock, including a non-secret one. The output was a raw ENOTSUP … link '<…>.owner' -> '<…>.lock' with a stack.
    Fix: a new src/lib/exclusive-publish.ts. When link reports that hard links are unsupported (ENOTSUP, EOPNOTSUPP or EPERM), it falls back to an exclusive placeholder. Hack creates an empty O_CREAT|O_EXCL|O_NOFOLLOW file at the target, then renames the complete, fsynced sibling over it. The no-overwrite and no-follow guarantees still hold. An existing entry or symlink gets EEXIST. The rename replaces only the empty file this call created, and that is checked again just before the rename. Readers see nothing, an empty file or the complete file, never a partial one. If the rename fails, the reservation is removed. The registry lock now accepts that the rename already used up its staging name. Since the fallback keeps every guarantee, no E_UNSUPPORTED_FILESYSTEM refusal was needed.
  2. Hydrate with a dangling .hack/.internal symlink reached mkdir -p and got a raw EEXIST (E_UNEXPECTED under --json).
    Fix: ensureDirForWrite maps EEXIST to a closed not_a_directory_entry refusal. The hydrate lock now uses the new ensureRealDirectoryForWrite. It refuses a dangling or live symlink, or a file, at .internal as E_PATH_CONFLICT before anything is created through it.
  3. A very long --env name (215 characters) produced lock and staging file names over NAME_MAX, so set, unset and hydrate printed a raw ENAMETOOLONG with the staging path.
    Fix: parseEnvConfigSelection caps overlay names at 128 characters (ENV_CONFIG_NAME_MAX_LENGTH). The longest derived name adds 68 bytes. The CLI refuses longer names up front with the fixed Invalid --env value. message (E_USAGE), and the daemon route uses the same parser. ENAMETOOLONG is also classified as a write failure (name_too_long, E_PATH_CONFLICT) for deep project paths.
  4. A stale key lock. A reused PID is never reclaimed, so ProjectsRegistryLockTimeoutError escaped with a stack.
    Fix: a new ProjectEnvKeyLockTimeoutError with a new code, E_LOCKED. It names the lock and the remedy, and also the .recovery guard when that guard is what is held. ProjectsRegistryLockTimeoutError gains a recoveryBusy field. Its message text is unchanged, because fix(env): name unreclaimable layer locks and the re-include cause #453 (G69) classifies the per-layer lock by that text.
  5. A .hack/.gitignore that is a directory printed a line with no code, and --json gave E_UNEXPECTED.
    Fix: HackDirGitignoreRefusalError is now a HackCliError with E_PATH_CONFLICT, which is what docs/env.md already said. Its wording is unchanged.
  6. Test gaps are now covered: the ENOENT, EPERM and EROFS (and EACCES) entries in isProjectEnvKeyLockUnavailable; the code and message of every AtomicWriteRefusal; behavioural not_regular_file and not_writable refusals; and the staging EISDIR to not_regular_file mapping.

E_LOCKED is added to HACK_ERROR_CODES and the error-code guide, and the plugin reference is regenerated. docs/env.md documents the overlay-name limit, E_LOCKED, the new E_PATH_CONFLICT cases and the behaviour without hard links.

This PR does not touch the per-layer lock message, the unset CAS test, the doctor env-key check or the gitignore-refusal wording. #453 (G69, HACK-1386) owns those. The env-layer lock picks up the fallback and the ENAMETOOLONG mapping only through the shared helpers. git merge-tree of this head with the current #453 head is clean.

Tests

The new tests are in tests/project-env-write-refusal-gaps.test.ts. tests/fixtures/staging-errno-preload.ts gains a link injection step.

  • (1) With link(2) injected as ENOTSUP or EPERM, in and out of git: the first --secret creates a 0600 key, a second set adopts it, the lock is released and nothing is left behind. Concurrent sets of three keys under ENOTSUP keep every key. Unit tests cover the fallback: it publishes and uses up its source, never replaces an existing file, never follows or replaces a symlink, and removes its reservation when the rename fails. A real ExFAT test is opt-in on macOS with HACK_TEST_EXFAT=1, because it attaches a disk image; the image is detached in finally.
  • (2) Hydrate with a dangling or live .internal symlink under all three loggers and --json; unit tests for ensureDirForWrite and ensureRealDirectoryForWrite.
  • (3) The limit boundary (128 accepted, 129 refused); set, unset and hydrate --json with a 215-character name; ENAMETOOLONG injected at layer staging and at the layer lock.
  • (4) A key lock held by a live unrelated PID gives E_LOCKED. Tests also cover recoveryBusy propagation and both messages.
  • (5) A .hack/.gitignore directory under all three loggers and hydrate --json.
  • (6) As listed above.

Mutation proof: each mutation below was applied alone, its focused tests were run, and the source was restored. All 21 were killed.

Mutation Result
M1 no fallback on unsupported link / M1b drop EPERM from the fallback codes 5 fail / 3 fail
M2 lock unlinks the consumed candidate / M3 failed rename keeps its reservation / M3b placeholder path replaces an existing file 2 / 1 / 2 fail
M4 ensureDirForWrite raw EEXIST / M5 hydrate lock uses plain ensureDirForWrite 1 / 1 fail
M6 no overlay-name limit / M6b off by one (<) / M7 ENAMETOOLONG unclassified 2 / 2 / 1 fail
M8 key lock timeout left raw / M9 recoveryBusy dropped 1 / 1 fail
M10 .hack/.gitignore refusal back to E_UNEXPECTED 1 fail
M11a/b/c drop ENOENT / EPERM / EROFS from isProjectEnvKeyLockUnavailable 1 fail each
M12a–d wrong code for not_regular_file, not_writable, owner_not_preserved, group_not_preserved 2 / 2 / 1 / 1 fail
M13 staging EISDIR mapped to not_a_directory 1 fail

Evidence

  • Real ExFAT, macOS (hdiutil create -fs ExFAT, detached in a trap; private HOME, HACK_HOME and DOCKER_CONFIG; keychain fallback disabled; layer file written first):
    • On origin/next (7d9c89f): raw ENOTSUP … link '…hack.env.default.yaml.lock.<uuid>.owner' plus a stack, exit 1, in a plain directory, a git checkout and a linked worktree.
    • At this head: every case exits 0. A plain directory gets a checkout-local key and env get round-trips the value. A git checkout runs a second --secret set and a non-secret set. In a linked worktree, the key is written to the shared location in .git. No .tmp, .owner or .lock file is left behind.
    • Six concurrent first --secret sets on ExFAT, in a plain directory and in a git checkout, all exit 0, and all six values round-trip.
    • The opt-in HACK_TEST_EXFAT=1 test passes (85 expects).
  • Items (2) to (5), before and after, with the built dist/hack:
    • (2) Before: raw EEXIST … mkdir with a stack, and E_UNEXPECTED. After: E_PATH_CONFLICT: Could not create directory <…>/.hack/.internal: that path is a symlink or not a directory. Replace it with a regular directory, then retry.
    • (3) Before: raw ENAMETOOLONG … open '<…>.owner'. After: the fixed Invalid --env value. line; --json gives E_USAGE. A 128-character name still writes its layer.
    • (4) Before: Timed out waiting for project env key lock with a stack. After: E_LOCKED: … remove <…>/.git/.hack.secret.key.lock, then retry. No key is created, and the retry succeeds once the lock is removed.
    • (5) Before: no code, and E_UNEXPECTED. After: E_PATH_CONFLICT: Refused .hack/.gitignore access: ….
  • Focused tests:
    • project-env-write-refusal-gaps: 28 pass, 1 skip (the opt-in ExFAT test).
    • project-env-write-refusals: 74/74 with --timeout 120000.
    • Nine related files with --timeout 120000 (projects-registry-lock, projects-registry-identity, project-env-layer-lock, project-env-key-ignore, project-env-worktree-key, private-env-file, env-selection-parity, env-key-argument-redaction-cli, focused-skills): 113/113.
  • Gates:
    • bun run check (includes privacy:check): exit 0.
    • bun run --cwd packages/cli typecheck: 0 errors.
    • Biome on the changed tests: clean.
    • gitleaks 8.30.1: gitleaks dir on the git log -p origin/next..HEAD export finds no leaks.
    • bun run generate:agent-plugins and bun run docs:cli-reference produce no further diff.
  • Full suite: the hosted CI test job at this PR head.

Gaps

  • Interrupted fallback publish. Without hard links, a process killed between the reservation and the rename leaves an empty .hack.secret.key or lock file. Hack refuses it and never adopts or reclaims it, so it must be removed by hand. docs/env.md says so. ExFAT and FAT also do not store Unix permissions, so mode 0600 does not limit the key there; this is documented as well.
  • ExFAT test is macOS-only and opt-in. Hosted CI covers the fallback only through the injected ENOTSUP and EPERM cases. Linux FAT (EPERM) is covered only by injection.
  • Help text after a usage error. A too-long --env prints the same one-line ERROR on stderr as any other invalid --env, plus the existing usage help on stdout. That is the pre-existing usage-error convention, and this PR leaves it unchanged.
  • Local test runs were affected by host load. This host ran at a load average of 50 to 290. Default-timeout runs of project-env-worktree-key, project-env-key-ignore and project-env-write-refusals failed with 5-second hook timeouts. The same project-env-worktree-key file also fails on an untouched origin/next export under the same load (8 of 14). With a 120-second timeout, all of these files pass. Hosted CI is the full-suite evidence.
  • New error code. E_LOCKED is new. fix(env): name unreclaimable layer locks and the re-include cause #453 leaves the per-layer lock refusal without a code. If it wants one, it can reuse E_LOCKED once both PRs land.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

…out hard links

On a filesystem without hard links (ExFAT, FAT) every env write failed
with a raw ENOTSUP from the lock receipt's and the first project key's
link(2) publish, so the key could never be created there. The exclusive
publish now falls back to an O_CREAT|O_EXCL|O_NOFOLLOW empty reservation
that the complete, fsynced sibling is renamed over, keeping the
no-overwrite and no-follow guarantees.

The other refusals that were still raw on next are now one value-free
line with an E_* code and no stack:

- hydrate with a symlinked (dangling or live) or non-directory
  .hack/.internal is E_PATH_CONFLICT; nothing is created through it
- an --env overlay name over 128 characters is refused up front as
  E_USAGE, and ENAMETOOLONG is classified as E_PATH_CONFLICT
- a project env key lock that stays held is E_LOCKED, naming the lock
  (and its recovery guard when that is held) and the remedy
- a .hack/.gitignore directory is E_PATH_CONFLICT, as docs/env.md said

Adds mutation-proven tests for these and for the previously untested
lock-unavailable codes, atomic-write refusal codes and the staging
EISDIR mapping.

Refs HACK-1377

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Oct 24, 2026

Copy link
Copy Markdown

HACK-1377

@roodboi
roodboi marked this pull request as ready for review October 24, 2026 21:07
@roodboi
roodboi merged commit b54f38d into next Oct 24, 2026
17 checks passed
@roodboi
roodboi deleted the wf/g73-env-write-refusals-exfat branch October 24, 2026 21:07
roodboi added a commit that referenced this pull request Nov 2, 2026
#436)

## Current state at `2bb4271237ee676e32e992d72c703fe024da86a6`

Merges `next` at `ae032e79` (#539) and resolves the conflict with it.
Changes since `eaa083dc`:

- **Scoped deletion with no global value:** a migrated value no longer
restores a key that a service or host scope explicitly deleted when no
global value exists. This is the should-fix from the `eaa083dc` review.
The two new table cases failed before the fix.
- **Retention on the no-contract route:** cleanup now compares what each
selected legacy file's env actually receives, in place of an empty map.
A legacy file holding a value its env still wouldn't receive is kept,
and only key names are reported.
- **Unlistable primary:** the real permission test stays. CI gets a
bounded Linux step for it, and an injected filesystem-failure test
covers the branch on other platforms.
- **Ordering:** `.hack/.gitignore` is created right after the migration,
before the Docker gate.

**Verified locally:** typecheck, and 405 focused tests pass at this
head. Gitleaks passes over `eaa083dc..2bb4271`.

**Not verified yet:** hosted CI at this head was still running when this
was written, including whether the Linux step really runs its test. The
full suite wasn't run locally.

**Not reviewed yet:** the retention and scoped-deletion changes have had
no independent review.

Sections below describe earlier heads.

---

## Current state at `eaa083dc47f7ff03893bbf1a5e205fb53502f64b`

**Held; don't merge.** Besides HACK-1694 below, this is held for the
preservation risks in HACK-1695: this PR extends that deletion to Docker
down. Don't release it without #538 either: on this PR
alone, doctor with a malformed contract still writes an empty layer and
removes `.env` and the contract, as it does on `next`.

Merged with `next` at `f938d629`, which includes HACK-1710 (#537),
HACK-1685 (#535), #533, #534 and the fixture daemon prevention (#536).
The only conflict with #537 was additive, and both refusals are kept.
The sections below this one describe the PR's earlier history; where
they differ, this section is current.

**Review coverage, one independent reviewer, reading only:** source
through `d0376173`, docs and tests through `f980705d`, the merge
`c07cc4ae`, and the `env-arguments.ts` and `env.ts` resolution in
`b9ec8778`. Not read: the docs commit `11d5e8ff`, or the rest of the
later merges. Automatic Codex review stopped on a quota notice at
`91db1781` and wasn't retried; it isn't a gate. This is not an approval.

**Added since `b9ec8778`: a migrated value no longer replaces a service
or host value.** A later env layer beats an earlier one whatever their
scopes, so a migrated machine-local global value silently replaced a
service or host value from an earlier layer. Measured at `b9ec8778` with
real files and synthetic values: `api` moved from its tracked value to
the legacy one, and likewise a host value and a tracked overlay's
service value. On `next` the same inputs don't migrate, so this was a
regression in this PR. The migration now resolves each target with the
planned layers, for the base, every overlay and any overlay a linked
worktree inherits, and refuses before writing, naming keys, targets and
envs only. Each new test failed on the source before its repair; the
first control for the inherited case was invalid (its fixture hit an
unrelated refusal), so it was fixed and rerun. Coverage added after
that: a service and a host value together, a Compose service named
`host`, a scoped secret (replaced, equal, and in the same local layer),
and an unknown scope. Disabling the check fails 4 of those tests and
removing the secret guard fails 1; the rest guard against false
refusals. A layer with an invalid scope refuses earlier, as unreadable,
and has its own test. A Linux-only test covers a primary directory that
can't be listed, and CI is its first real run: check that it ran, not
just that the suite passed. At this head: 395 pass, 0 fail across
env-legacy-service-scope, project-env-config, symlink-boundary,
doctor-fix-noninteractive, doctor-command, local-inheritance,
project-env-metadata and write-refusal-gaps. Biome, typecheck and
gitleaks pass. No local full suite.

**Review of this fix, the same independent reviewer, reading only:**
`b9ec8778..a62921a`, no blockers. The last commit `eaa083dc`
(test-only: its two accuracy points) wasn't read. On macOS a primary
directory that can't be listed turns inheritance off, for migration and
runtime alike, so the listing refusal is reached only on Linux. Still
open: HACK-1709 items 9 and 10. This is not an approval.

**Added since `ca5ef35f`:**
- A generated `.hack/.env` isn't migrated as legacy values, and Hack
checks its text to tell. A hand-edited one refuses.
- These now refuse and write nothing:
- a state entry that exists but can't be read, including a dangling link
or a link into a directory without search permission;
- an env layer that can't be read or resolved, including a missing
project env key on any overlay;
  - a Compose file that can't be read;
- another `env_file` that sets a migrated key as `export K=`, `K:`, a
bare `K` or after a closing quote.
- The project boundary from HACK-1685 now also applies when a layer is a
symlink. Without that, `hack doctor` rewrote a Compose file outside the
project through a linked `.hack`.

**Local verification at `91db1781`** (the merge to `b9ec8778` had its
own focused run and scan; Bun 1.4.2, one file at a time, empty
environment, stubs first on PATH, private HOME and TMPDIR):
- `env-legacy-service-scope` 193, `project-env-metadata` 9,
`project-env-write-refusal-gaps` 28 with the ExFAT case skipped,
`doctor-fix-noninteractive` 22, `doctor-fix-adopted-project` 4,
`doctor-command` 28, `project-env-config` 58,
`daemon-autostart-fixture-guard` 7, `project-env-symlink-boundary` 28,
`project-env-local-inheritance` 9, `project-env-stable-ciphertext` 18,
`cli-spawn-env-isolation` 7, `launchd-isolation` 14, `e2e-launchd-guard`
5.
- Compose merge through `docker compose config` with a socket that
doesn't exist, Compose v5.5.0: 38 inputs, 29 refuse, 9 migrate with the
same rendered values, none differ.
- Controls: the dangling-link case fails on `ce5c770c` source, and the
no-search state link migrated generated values on that source.
- Typecheck, Biome on changed files and gitleaks on `ca5ef35f..91db178`
pass.

**Not verified locally:** the full suite, and `scripts/docs/check.ts`,
which needs generated `site/content/docs` and fails the same way on
`next`. CI at the exact head is the evidence for both. All 20 checks
passed at `91db1781`; read `b9ec8778` from its checks.

**Open decisions, not waived:**
- **HACK-1694:** with a symlinked layer, `hack doctor` can remove
`env_file: .env`, and a service loses a value only that file set.
Measured on this head and on `next` with Docker up; this PR also makes
it happen with Docker down. Documented, not fixed.
- A missing project env key on any overlay blocks the whole migration.
It stays fail-closed until decided.
- `hack env set` refuses an in-project dangling layer link, where `next`
replaces it. `hack env add` still replaces it. Documented until decided.

**Release signal:** squash title `fix(env): refuse --service and
unusable layers in legacy env set/unset`. Non-blocking follow-ups are in
HACK-1709.

---

Refs HACK-1370. Unit G60.

## Root cause

1. **`--service` dropped in legacy mode.** `handleEnvSet`
(`src/commands/env.ts`) sends a project with env layers to `env add`,
which reads `--service`. Its legacy `.hack/.env` branch never read
`args.options.service`, so `hack env set NEW --service api
--value-stdin` wrote a global `NEW` to `.hack/.env` and exited 0. An
invalid scope such as `'Bad Scope!'` was accepted the same way.
`handleEnvUnset` had the same gap in its legacy branch, so `unset KEY
--service api` removed the **global** value.
2. **A dangling layer switched formats.** `projectEnvConfigExists`
checks the default layer with `pathExists`, which follows symlinks, and
checks overlays with `Dirent.isFile()`. A dangling
`.hack/hack.env.default.yaml`, or any symlinked overlay, therefore read
as "no env layers". `env set` then wrote `.hack/.env` and exited 0.
3. **Read paths printed a stack.** `readProjectEnvLayers` threw a plain
`Error("Failed to parse …")` for an invalid layer. The top-level handler
treats a plain `Error` as unexpected, so `hack env list` / `explain` /
`materialize` printed the full stack.

## Fix

- `env set` validates `--service` up front with
`assertValidProjectEnvScopeName`, in both formats, before the project,
stdin or any file is touched. This is the same closed one-line refusal
that `env add` gives. `env unset` does the same in its legacy branch.
- In legacy mode, a non-global scope is refused with a `CliUsageError`
(`LEGACY_ENV_SERVICE_SCOPE_MESSAGE` in `src/cli/env-arguments.ts`). The
message names the remedy: migrate with `hack doctor
--migrate-env-config`, or omit `--service`. The legacy `.hack/.env`
holds one value per key, so the scope can't be honoured. Omitting the
scope, or passing `--service global`, still works. The refusal happens
before stdin is read or a secret store is resolved.
- New `projectEnvConfigExistsForMutation`
(`src/lib/project-env-config.ts`) is used by `env set` / `env unset` to
choose the env format. Every `.hack/hack.env.*.yaml` entry must be a
regular file or a symlink that resolves to one, and either kind counts
as an env layer. Anything else (a dangling link, a link to a directory,
a directory) is refused with the new `ProjectEnvLayerPathConflictError`
(`E_PATH_CONFLICT`, one `CODE: message` line naming the path), and
nothing is written.
- `readProjectEnvLayers` now throws `ClosedCliError` with the same text
as the write path's parse refusal. The layer parser no longer echoes an
invalid scope name: it is file content and could contain a value or a
line break, which would break the one-line output.
- `docs/env.md` ("Legacy repos and migration") documents the legacy
`--service` refusal and the layer-entry rule.

**Decision for review:** a symlink that resolves to a regular layer file
is **accepted** and counts as an env layer. It is not refused. The
atomic writer already writes through such links by design, and refusing
them would break projects that use them today. The only change here is
that a symlinked overlay no longer silently selects legacy mode. If you
want every symlinked layer refused, that is a one-line change in
`projectEnvConfigExistsForMutation`.

## Tests

`tests/env-legacy-service-scope.test.ts` (new, 14 tests). Every CLI
child gets a private HOME, HACK_HOME, global config and Docker client
config, plus an `encrypted_file` secret backend inside the temp dir with
an env-provided key and `HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1`. That
keeps the keychain unreachable even when a guard is mutated away. Values
are synthetic and assembled at runtime, and every test asserts that no
value appears in the output.

- legacy `set --service api` (with and without `--secret`) refuses, and
nothing is written to `.hack/` or the secret store
- legacy `set --service '<invalid>'` gives the closed scope message,
without echoing the argument
- positive control: legacy `set` without `--service`, and with
`--service global`, still writes `.hack/.env` and creates no layer
- legacy `unset --service api` / invalid scope refuses, and `.hack/.env`
is unchanged
- `set` and `unset` refuse a dangling default layer, a dangling overlay,
a local layer linked to a directory, and a directory default layer with
`E_PATH_CONFLICT` on one line; the entry and `.hack/` are unchanged
- a symlinked overlay that resolves to a regular file selects the env
layers (writes `hack.env.default.yaml`, no `.hack/.env`)
- a unit test of `projectEnvConfigExistsForMutation` and the error class
and code
- `env list` / `env explain` on invalid YAML, and on a layer with a
line-break scope name, print one line with no stack under
`HACK_LOGGER=console|clack|gum`
- an invalid layer read is a `ClosedCliError` whose reason does not echo
the scope

**Mutation proof** (each mutation applied alone, then reverted; each was
caught):

| Mutation | Caught by |
|---|---|
| drop `assertLegacyEnvScope` in set | legacy set scope refusal |
| drop it in unset | legacy unset scope refusal |
| no scope validation in set | invalid-scope refusal |
| no scope validation in unset | legacy unset refusal |
| set uses old `projectEnvConfigExists` | 4 layer-entry tests +
symlinked-overlay test |
| unset uses old `projectEnvConfigExists` | 4 layer-entry tests |
| linked layer not counted | symlinked-overlay test |
| read path back to plain `Error` | 3 logger tests + closed-error unit
test |
| scope name echoed again | 3 logger tests + closed-error unit test |
| conflict code `E_UNEXPECTED` | 4 layer-entry tests + unit test |

## Evidence

- Bun 1.4.2 (`mise x bun@1.4.2`), `bun install --frozen-lockfile`, no
`bun.lock` change.
- Local (macOS, this checkout):
- `bun run check` passes, including `privacy:check: ok` (warnings only,
all pre-existing)
- `bun run --cwd packages/cli typecheck` and root `bun run typecheck`
pass with 0 errors
- focused tests pass: the new file, `project-env-write-refusals`,
`env-value-stdin-cli`, `env-key-argument-redaction-cli`, plus 13 other
env/project-env files
- `env-selection-parity` needs the config compiler, which is not built
locally; it runs in the M3 suite below
- `gitleaks` 8.30.1 `dir` scan of the `git log -p origin/next..HEAD`
export: no leaks.
- `bun run docs:cli-reference`: no change (the CLI surface is
unchanged).
- Full suite on the M3 at the exact head: pending (queued on the shared
M3 lock behind other units; this PR will be updated with the result)

## Gaps

- Read paths still fall back to legacy for a dangling or unreadable
default layer: `env list` shows "base (.hack/.env only)" and exits 0.
Only the `set`/`unset` format decision refuses here. Reported below as a
follow-up finding.
- `hack doctor --migrate-env-config` migrates only projects that have
`.hack/hack.env.json`. A legacy project with only `.hack/.env` has no
automated migration, and the refusal message points to the docs section
for that case. **(Fixed in round 2, below.)**

## Further findings (not fixed here)

1. **Read paths silently pick legacy for a dangling or unreadable
default layer** (Medium).
- Repro: `.hack/hack.env.default.yaml` is a dangling symlink, or mode
000; run `hack env list --path <p>`.
   - Expected: a closed refusal, as `set`/`unset` now give.
- Actual: exit 0 with "env_selection base (.hack/.env only)".
`readProjectEnvLayers`' non-strict read treats both as absent, so
runtime injection probably falls back the same way (not verified).
2. **Legacy `env set --local` is ignored** (Low).
- Repro: in a legacy project, `printf v | hack env set NEW --local
--value-stdin`.
   - Expected: a refusal, or a documented no-op.
- Actual: "Updated .hack/.env", exit 0. `.hack/.env` is machine-local,
so the effect matches, but the flag is silently dropped.

## Round 2: a working remedy for `.env`-only legacy projects

**Verifier defect (Medium, blocking).** The legacy `--service` refusal
points to `hack doctor --migrate-env-config`, but that command did
nothing for a project with `.hack/docker-compose.yml`,
`hack.config.json` and `.hack/.env` but no `.hack/hack.env.json`.
`maybeMigrateProjectEnvConfig` returned silently without the contract,
and `migrateLegacyProjectEnv` returned `legacyDetected: false`. The user
looped: refused, doctor did nothing, refused again. A second cause made
the loop worse: doctor ran the env migration only after the Docker gate,
so with Docker unreachable it skipped the migration even for a project
with a contract.

**Fix** (head `456bdad5`; `next` 5208ee5 merged in first, with no
conflicts):

- `migrateLegacyProjectEnv` takes a new `migrateUndeclaredDotenv`
option. Without a contract it now migrates `.hack/.env` and its
overlays:
- each `.hack/.env` entry becomes a plaintext global value in
`hack.env.default.yaml`
- each `.hack/.env.<overlay>` entry that differs from the base becomes a
global value in `hack.env.<overlay>.yaml`
- a project with no legacy env file gets an empty default layer, so the
refusal's remedy works for every legacy shape
- Without a contract, nothing names secret-backend values, so none are
read or migrated. `hack.config.json`, and any secret backend it
configures, is left unchanged, and the backend's store is never offered
for cleanup. Values go only to the layer files; they are never returned
or printed.
- Only doctor sets the option. The implicit migrations that other
commands run (for example `env add`, `env hydrate`, `env materialize`,
and the project and session flows) still need the contract, so no
command starts migrating `.env`-only projects on its own.
- `hack doctor --migrate-env-config` passes the option. It now runs the
env migration, which changes project files only, before the Docker gate.
Its plan line names the `.env`-only case.
- `docs/env.md` ("Legacy repos and migration") documents the `.env`-only
migration: secrets are not migrated and must be set again, the config is
unchanged, the migration runs without Docker, and the cleanup prompt
follows. The `--service` paragraph now names the remedy and says it also
covers `.env`-only projects. The refusal text
(`LEGACY_ENV_SERVICE_SCOPE_MESSAGE`) is unchanged, because the command
it names now works.

**Tests added**

- `tests/env-legacy-service-scope.test.ts`, 3 new tests (17 in total):
- `.env` plus `.env.staging`, no contract: `set --service api` is
refused; the explicit migration writes the expected default and overlay
layers (the overlay keeps only the changed key); the config file is
byte-identical; the cleanup candidates are exactly the two dotenv files;
then the same `set --service api` exits 0 and writes the `api` scope; no
secret store is created and no value appears in the output.
- a project with no legacy env file: the migration writes an empty
default layer, and `set --service api` then works.
- without the option, a contract-less project is not migrated and
`.hack/` is unchanged.
- `tests/doctor-fix-noninteractive.test.ts`, 2 new cases: `doctor
--migrate-env-config --no-interactive` on a `.env`-only project writes
`hack.env.default.yaml` with the legacy value, once with Docker
reachable and once with it unreachable. No value appears in the notes.
The mocked `docker info` exit code is now set per test.

**Mutation proof** (each mutation applied alone by
`scratchpad/G60/mutate.sh`, then reverted; the source was confirmed
restored):

| Mutation | Failing tests |
|---|---|
| doctor does not pass `migrateUndeclaredDotenv` | doctor 2 |
| doctor requires the contract again | doctor 2 |
| env migration moved back after the Docker gate | doctor 1 (the
unreachable case) |
| migrator ignores the option | doctor 2, env 2 |
| migrator migrates without the option | env 1 |
| no overlay-vs-base filter | env 1 |
| contract-less path rewrites `hack.config.json` | env 1 |

**End-to-end repro** (`scratchpad/G60/remedy-loop.sh`, private HOME,
HACK_HOME and DOCKER_CONFIG, fake Docker host, stub `security`,
`launchctl` and `docker` on PATH, keychain fallback and hackd autostart
disabled):

1. `env set NEW_SETTING --service api --value-stdin` is refused (exit
1).
2. `hack doctor --migrate-env-config --no-interactive` writes
`hack.env.default.yaml`, removes `.hack/.env`, then reports Docker
unreachable. Exit 1 comes from the failed Docker check; no value is
printed.
3. The same `env set` exits 0 with `scope=api`. The layer holds
`global.KEPT_SETTING` and `api.NEW_SETTING`, and no secret store is
created.

**Gates at `456bdad5`**

- `bun run --cwd packages/cli typecheck`: 0 errors.
- Root `bun run check`: exit 0, `privacy-check: ok`. No diagnostics in
the changed files.
- `ultracite check` on the changed source and test files: clean.
- gitleaks 8.30.1 `dir` over the `git log -p origin/next..HEAD` export:
no leaks. (`gitleaks git` is refused by the worktree guard.)
- Focused tests, private env: `tests/doctor-*`, `tests/env-*`,
`tests/project-env-*` and `tests/compose-env-*` gave 527 pass and 2 fail
across 42 files. The two failures do not involve this PR:
- `env-selection-parity` needs the config-compiler binary, which is not
built in this checkout.
- `project-env-layer-lock`'s "concurrent legacy dotenv sets and
removals" fails intermittently on `next` 5208ee5 as well (2 of 8 runs
on a clean `next` worktree, 1 of 5 here). This is reported below.
- `doctor-command`'s native-input test needed `--timeout 60000` locally;
it spawns 4 CLI children in a 5 s default. With that timeout it passes
28/28.
- The CLI surface and consumer agent guidance did not change, so
`docs:cli-reference` and `generate:agent-plugins` were not needed.
- Hosted CI at `456bdad5`: every check passes on run 40060725798 (head
SHA `456bdad5`, conclusion success). The `test` job (one job, not
sharded) reports 8112 pass, 0 fail, 8269 tests across 575 files, and
both new test files ran, including the 5 new tests. All four docker-e2e
shards, Candidate core (macOS and Ubuntu), Config compiler (4
platforms), Runtime state models, linux-process-lifetime, runtime-images
and secret-scan pass.

**Round 1 gap now closed:** the second bullet under "Gaps" above (doctor
migrated only projects with `.hack/hack.env.json`) is fixed by this
round. The earlier "Full suite on the M3: pending" line is replaced by
hosted CI's `test` job: round 1 at `f87ee54c` passed (7913 pass, 0
fail), and round 2 is reported above.

**Set aside, not in this PR:** a local draft that refused *every*
symlinked layer, including one that resolves to a regular file, was not
included. It would reverse the documented "Decision for review" above
and the atomic writer's documented symlink behaviour, and no verifier
asked for it.

**Further finding (not fixed here)**

3. **`project-env-layer-lock` legacy dotenv concurrency test is flaky on
`next`** (Low; test or race).
- Repro: run `bun test tests/project-env-layer-lock.test.ts` repeatedly
on a clean `next` 5208ee5.
   - Expected: 9/9 pass every run.
- Actual: about 1 run in 4 fails "concurrent legacy dotenv sets and
removals of different keys all apply". One worker exits 1 with the
`readReplaceablePrivateFile` refusal "the env file … is a symlink or not
a regular file", which means the inode changed between `lstat` and
`open`. The legacy `.hack/.env` read in `upsertDotEnvValue` seems to
race with another worker's rename, outside the serialized section. The
failing worker's stderr also carries a stack, not one closed line.

## Round 3: a safe `.env`-only migration (verifier defects A, B and C)

Head `78b72762`. It merges `origin/next` `7dac4a03` (no conflicts) and
adds one commit. This section replaces the round 2 behaviour described
above. Round 2 copied `.hack/.env` values into the tracked
`hack.env.default.yaml`. The round 2 test descriptions and repro lines
that name that file now describe the old behaviour.

**What changed**

- **C (blocking): values stay out of tracked files.** `.hack/.env` is
gitignored and can hold passwords or API keys, so the migration no
longer copies its values into a tracked layer. Each `.hack/.env` entry
becomes a plaintext global value in the gitignored machine-local layer
`hack.env.local.yaml`. Each overlay entry that differs from `.hack/.env`
goes to `hack.env.<overlay>.local.yaml`. The tracked
`hack.env.default.yaml`, and `hack.env.<overlay>.yaml` for each migrated
overlay, are written with no values, which switches the project to env
layers. The `.hack/.gitignore` managed block is ensured first, as `hack
env set --local` does. A machine-local target that git tracks is
refused. So is one inside a git checkout where git cannot report its
status (fail closed, as `hack env hydrate` does). The resolved env still
holds every value.
- **A: invalid keys refuse closed.** Before anything is written, every
key in `.hack/.env` and its overlays must match `^[A-Z_][A-Z0-9_]*$`.
Otherwise the migration exits with `E_CONFIG_INVALID` on one line, for
example "2 keys in .env, 1 key in .env.staging do not". The keys
themselves are not shown, because a malformed line (`export X=…`, base64
padding) may hold a value; this matches `ENV_KEY_INVALID_MESSAGE`.
Nothing is written, the legacy files are kept, and no cleanup is
offered.
- **B: doctor uses the mutation format check.**
`maybeMigrateProjectEnvConfig`, and the `--plan` step, now decide the
format with `projectEnvConfigExistsForMutation`. A symlinked layer
counts as an env layer: doctor says the project already uses env layers
and writes nothing through the link. A dangling or non-file layer
refuses with `E_PATH_CONFLICT`, and the entry is left in place. The
migration also runs the same check itself, so a direct caller cannot
write through or replace such an entry either.
- **Related guards in the same path.** A legacy `.env.default`,
`.env.local` or `.env.<name>.local` would land on the default layer or a
machine-local layer rather than an overlay, so it is refused with
`E_CONFIG_INVALID`. `.env.state.json` is Hack's own state file and is no
longer read as an overlay. Every layer is created with a
compare-and-swap against an absent file. If a later write fails, the
layers this call created are removed, so the project stays on the legacy
files.
- `docs/env.md` "Legacy repos and migration" documents where the values
go, that teammates do not get them through git, and each refusal.

**Tests** (`tests/env-legacy-service-scope.test.ts`,
`tests/doctor-fix-noninteractive.test.ts`)

- The `.env`-only migration test now asserts that values are in
`hack.env.local.yaml` and `hack.env.staging.local.yaml`, that the
tracked layers hold `global: {}`, and that `resolveProjectEnvConfig`
still returns every value. A service-scoped `env set` still works after
migration.
- New, C: in a `git init` repo with `DATABASE_PASSWORD` and an
`sk_test_…`-shaped `STRIPE_SECRET_KEY` (synthetic), no file that `git
ls-files --cached --others --exclude-standard` lists contains either
value, and the local layers are ignored.
- New, A: `GOOD_KEY`, `node_env` and `export OTHER` (plus `lower_case`
in `.env.staging`) give a closed `E_CONFIG_INVALID` refusal with
per-file counts. No key or value appears, and the `.hack` entries and
legacy file bytes are unchanged.
- New: `.env.default`, `.env.local` and `.env.staging.local` are each
refused, and nothing is written.
- New: a machine-local target that is in the git index (but not on disk)
is refused with `E_PATH_CONFLICT`.
- New, B (library): a symlinked `hack.env.staging.yaml` is refused, and
its outside target keeps its content. A dangling `hack.env.default.yaml`
gives `ProjectEnvLayerPathConflictError` and is still a symlink
afterwards.
- New, B (doctor): `doctor --migrate-env-config --no-interactive` with a
symlinked overlay exits 0, reports "already uses the new env config
files", and leaves the link, its target, `.env` and `.env.staging`
unchanged. With a dangling default layer it exits 1 (`E_PATH_CONFLICT`),
and the link and `.env` are kept.
- The round 2 doctor migration test now expects the value in
`hack.env.local.yaml` and an empty tracked default layer.

**Mutation proof** (each change made alone, then the named test run,
then the source restored):

| Mutation | Killed by |
| --- | --- |
| invalid-key refusal disabled (A) | invalid-keys test |
| doctor back to `projectEnvConfigExists` (B) | doctor symlinked-overlay
test |
| migration's own format guard removed (B) | library existing-entries
test |
| values written to the tracked default layer (C) | secret-shaped git
test |
| reserved overlay check disabled | all 3 reserved-name tests |
| git-tracked check disabled | git-tracks test |

**Evidence**

- `bun run --cwd packages/cli typecheck`: 0 errors. Root `bun run check`
(includes `privacy-check: ok`): passes, with warnings only from existing
files.
- gitleaks 8.30.1 `dir` over the `git log -p origin/next..HEAD` export:
no leaks.
- Focused tests, run locally with load average around 83:
`env-legacy-service-scope` passes 24/24 and `doctor-fix-noninteractive`
passes 19/19 (each file run on its own).
- The CLI surface and consumer agent guidance did not change, so
`docs:cli-reference` and `generate:agent-plugins` were not needed.
- Hosted CI at `78b72762`: every check passes on run 40129035737 (head
SHA `78b72762`, conclusion success). That includes `test` shards 1-4
(the full suite, now sharded), docker-e2e shards 1-4, secret-scan,
Candidate core (macOS and Ubuntu), Config compiler (4 platforms),
Runtime state models, linux-process-lifetime and runtime-images.

**Gaps**

- The rollback of created layers after a failed write has no test,
because a write failure after the preflight is hard to inject. Its logic
is short and only removes paths that did not exist before the call.
- The doctor dangling-layer test survives when only the doctor check is
reverted, because the migration's own guard still refuses. It fails only
when both guards are reverted; each guard is covered by its own test.
- Behaviour change for review: migrated values now stay on the machine
that ran the migration. Teammates need `hack env set` or `hack env set
--secret` for the values they share. This is documented.

**Further findings (not fixed here)**

4. **Legacy `.hack/.env.<overlay>` files are outside the managed
`.hack/.gitignore` block** (Medium; pre-existing).
- Repro: in a git repo with the managed block, create
`.hack/.env.staging`, then run `git ls-files --others
--exclude-standard`.
   - Expected: ignored, like `.hack/.env`.
- Actual: listed as committable. `HACK_DIR_GITIGNORE_ENTRIES` covers
`.env` and `.env.state.json` only, so legacy overlay values can be
committed. This PR routes overlay values to ignored local layers and
does not touch the input files.
5. **The contract migration (`hack.env.json` present) still writes
contract-declared plain values to tracked layers** (Low; by design, but
worth confirming).
- Repro: a contract that marks `DB_PASSWORD` as non-secret, with the
value in `.hack/.env`; run `doctor --migrate-env-config`.
- Expected: confirm whether plain values should also go to the local
layer.
- Actual: the value lands in tracked `hack.env.default.yaml`
(`buildMigratedValues`). Only keychain-sourced values are encrypted.

Refs HACK-1370

## Round 4 (2026-10-31): reads refuse too, review findings, Codex P1s

This round merges `origin/next` (`e42bcf08`, no conflicts) and adds
these commits:

- `23c8e7ea`: **reads refuse a broken layer** (finding 1 above, now
fixed).
  - Only ENOENT counts as an absent layer.
- A selected layer entry that is not a regular file (dangling link,
directory, link to a directory) refuses with `E_PATH_CONFLICT`. An
unreadable file refuses with one closed line.
- This covers `env list`, `env explain` and runtime env resolution,
which previously exited 0 and showed "base (.hack/.env only)".
- Two tests that pinned the old null fallback were updated:
`project-env-metadata` and the #468 directory case.
- `69fb8ea0`: reviewer round 1 (one P2, four P3s).
- The contract migration decides the format with the mutation check and
writes create-only, so it never replaces a dangling layer.
- Doctor `env mode` and project metadata report a broken layer instead
of "legacy".
- The `.env`-only migration refuses non-overlay `.env.<name>` names,
skips template and backup copies, and never offers a git-tracked file
for cleanup.
- Doctor offers files Compose references for cleanup only after the
Compose repair removed those references.
  - Adds a test for an unreadable layer.
- `0e732928`: Codex P1 threads.
  - Machine-local layers are created 0600 and create-only.
- Inside a git checkout, each local layer must be git-ignored, so a
read-only `.gitignore` refuses instead of only warning.
- An unreadable legacy file refuses with `E_PERMISSION` instead of
migrating as empty.
- `69def4f0` (another session): symlinked legacy overlays are migrated,
and cleanup removes only the link.
- `ede4cb9b`: refusals name the files, JSON-quoted, and say to select a
renamed overlay with `--env`.
- `1b1806d1` (another session): refuse a migration whose Compose
`env_file` points at a template or backup copy that the migration leaves
out.

**Mutation proof:** each new guard was disabled on its own and its test
failed, then the source was restored and confirmed. That covers the
read-path refusal (4 tests), the contract guard, the doctor and metadata
classifiers, the unreadable branch, the name refusal, the template skip,
the tracked-cleanup filter, the Compose-repair cleanup helper, the 0600
publish, the ignore check, and the unreadable legacy file.

**Real-target repro:** `./dist/hack` built from this branch, with a
private HOME and synthetic values. Exit codes:

| Command | Exit | Result |
|---|---|---|
| legacy `env set NEW --service api --value-stdin` | 1 | refuses and
names the remedy; `.hack/.env` unchanged |
| legacy `env set NEW --service 'Bad Scope!'` | 1 | closed scope message
|
| legacy `env unset KEPT --service api` | 1 | `.hack/.env` keeps `KEPT`
|
| legacy `env set` with no scope (control) | 0 | still works |
| dangling `hack.env.default.yaml`, `env set` | 1 | one-line
`E_PATH_CONFLICT` |
| dangling `hack.env.default.yaml`, `env list` | 1 | one-line
`E_PATH_CONFLICT` (exited 0 with legacy fallback before this round) |
| invalid YAML layer, `env list` / `explain` | 1 | one line, no stack |

No value appeared in any output.

**Review:** one persistent Claude Opus 5.5 reviewer. Round 1 at
`23c8e7ea`: CHANGES. Round 2 at `69fb8ea0`: CLEAN. Round 3 at
`ede4cb9b`: CLEAN. Codex's four P1 threads are answered and resolved.

**Gates:** `bun run typecheck` and `bun run check` pass on Bun 1.4.2.
The focused env, doctor, project-env, compose-env and project-meta
suites pass, except `doctor-fix-adopted-project`, which fails locally
with or without this PR (native adoption state), and
`env-selection-parity`, which needs the config compiler. Hosted CI
covers both.

**Follow-up (not fixed here; already true before this PR):** the
contract branch of `migrateLegacyProjectEnv` does not apply the
`.env`-only overlay-name rules. With `.hack/hack.env.json`, a
`.hack/.env.local`, `.env.example` or `.env.state.json` file is treated
as an overlay, so its values can land in the wrong layer. No value is
lost.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/hack-dance/codesmith/hack/pr/436?autoLogin=true&ref=codesmith_pr_footer"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v3.svg"><img
alt="View with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3-3x.png"
width="162" height="28"></picture></a> <a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1795309313&installation_model_id=17053&pr_number=436&ref=codesmith_pr_footer&repository=hack-dance%2Fhack&return_to=https%3A%2F%2Fgithub.com%2Fhack-dance%2Fhack%2Fpull%2F436&signature=d4db006104551d52ac8f82bada2e90e9bce1aa20066501a5bca05ca148ac30ce"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png"
width="190" height="28"></picture></a>
<sup>Need help on this PR? Tag <code>@codesmith-bot</code> with what you
need.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->



## Migration refusals added through review (head 49cf488)

The `.env`-only migration now refuses, writing nothing, when:

- only some Compose services list a legacy file, a service would gain
base keys, or a default overlay is not listed while another legacy file
is
- Compose uses top-level `include`, service `extends` or a variable
`env_file` path
- a Compose `environment` entry (including `${KEY}`) or another
`env_file` sets a migrated key
- the Compose file interpolates a key the default overlay changes
- an entry is read differently by Compose: unquoted ` #`, `$` outside
single quotes, `\` in a quoted value, text after a closing quote, or a
line without `=`
- `.hack/.env` or an overlay links outside `.hack`

The contract migration reads regular overlay files only and refuses
`.hack/.env` or a default overlay linked outside `.hack`.

Known behaviour: if no service lists a legacy file, the migration
proceeds and every service receives its keys once that env is selected,
because those keys may only feed interpolation. Doctor names them.

Validation at 49cf488: focused env, gitignore, doctor and pre-commit
suites pass (env-legacy-service-scope 90, 0 fail); mutation runs fail as
expected; independent exact-head review reports no blocking defects,
using `docker compose config` on Compose v5.5.0 and not running
containers. No native, timing or TLC tests were run. Hosted CI is the
check for the full suite.

Follow-ups: HACK-1682, HACK-1685, HACK-1688, HACK-1689.


## Further changes through review (head b3f8f00)

These supersede the 49cf488 validation above. In addition to the
refusals listed there, the `.env`-only migration now refuses:

- an `env_file` order that gives a key a different value from the layers
- a repeated `env_file` entry, a `format`, a `~` path, a `!reset` or
`!override` tag, or `include` with no `services`
- a Compose file that isn't one YAML document
- any key the default overlay changes whose name appears in the Compose
file or another `env_file`, or a `COMPOSE_*` setting
- `DOCKER_*`, `BUILDKIT_*`, `BUILDX_*`, proxy settings,
`SOURCE_DATE_EPOCH`, `HOME` or `PATH` in a legacy file

The contract migration refuses any symlinked `.hack/.env` or default
overlay, wherever it points.

**Other checkouts.** Once a migration is committed, a teammate's own
`.hack/.env` and overlays may stop reaching services. Hack compares
those files with what the layers deliver. `hack up` warns, doctor
reports it, `hack env materialize` refuses, and `hack doctor
--migrate-env-config` moves the values to their local layers. Messages
name valid keys only, count malformed entries, and name unreadable
files.

**Behaviour changes to note:**
- `hack env materialize` can now refuse where it used to overwrite.
- `hack up` can print a new warning.
- `replacePrivateFileAtomic` has a new optional `beforeRename` hook.

**Validation at b3f8f00:**
- Focused suites: 0 fail (env-legacy-service-scope 141; hack-gitignore,
project-env-metadata, project-env-write-refusal-gaps,
doctor-fix-noninteractive, doctor-fix-adopted-project, doctor-command,
pre-commit-hook, project-env-config, private-env-file,
project-env-write-refusals, missing-project-error-code).
- Same-reviewer review of this exact head: no blocking defects, using
`docker compose config` on Compose v5.5.0 and direct calls to the
migration functions. It didn't use running containers, a real `git pull`
between checkouts, or the interactive prompts.
- Concurrency evidence is deterministic steps through a test seam or a
git shim. No timed race was run. As with the shared atomic writer, the
last check narrows the window and can't close it.
- No native, timing or TLC tests were run.

**Hooks:** commits 581da64 through ef9147f were made with `HUSKY=0`,
so the repository hooks didn't run on them. commitlint on each message
and Biome on the final tree passed separately, which isn't the same as
the hooks having run. 190c66b onward used the normal hooks.

Added follow-ups: HACK-1686, HACK-1690, HACK-1691, HACK-1692, HACK-1693.


## Final behaviour change (head ca5ef35)

This supersedes the "Known behaviour" and b3f8f00 validation above.

**Global delivery now needs consent.** Migrated values are global and
reach every service, including third-party images. The `.env`-only
migration refuses unless every service lists each legacy file with
values to migrate. This includes a file no service lists, whose values
today only feed Compose interpolation, and a non-default overlay, which
must become `env.defaultOverlay` or have its values moved. Listing the
file is how a developer accepts this. A value Compose interpolates can't
be set per service: keep it in the shell, or list the file.

**Also refused:** a migrated base value that would replace a value a
tracked overlay sets, when the legacy overlay file doesn't set that key.

**Remaining exposure, documented and noted by doctor but not refused:**
a service added after migrating receives the global values, and host
lifecycle hooks gain them. Tracked in HACK-1692.

**Validation at ca5ef35:**
- Focused suites: 0 fail (env-legacy-service-scope 150; the other 11
suites as listed above).
- `bun run typecheck` and `bun run check`: exit 0.
- Same-reviewer review of this exact head: no blocking defects, using
`docker compose config` on Compose v5.5.0 and the real `hack doctor` and
`hack env` CLI. It didn't use containers, a real `git pull` between
checkouts, or interactive prompts. Nothing is a timed race.
- No native, timing or TLC tests were run.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Dimitri Kennedy <344842+roodboi@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant