# Pack: package/workspace management, build config, CI, runtime process

Load when: the touch list exhibits `package.json` (deps, `exports`, scripts, `packageManager`), lockfiles, `.env*` files, tsconfig `paths`, Dockerfiles/compose, `.github/workflows/`, pino logger config, or Express servers behind proxies.

## env-var-reader-vs-declaration — HIGH · config-elsewhere
**Contract:** Every `process.env.X` read in code must have X declared in `.env.example`, each `.env.*`, CI `env:`/secrets, and every deploy dashboard — the reader and the declarations share only the literal name.
**Detect:** `process.env\.[A-Z_]+`, `.env.example`, `.env*`, `env:` / `secrets\.` in `.github/workflows/*.yml`, `loadEnv(`, `dotenv`
**Ships green, breaks:** Undefined var is `undefined`, not an error — `|| defaultValue` fallbacks silently run prod against the default; env values are ALWAYS strings, so `if (process.env.FEATURE_ENABLED)` is truthy for `"false"` and `"0"`; dotenv loads only locally, so a var present in `.env` but absent from the deploy dashboard works on every laptop and fails only in prod.
**Safe change:** Add the key to `.env.example` + every deploy target BEFORE merging the reader; validate all env at boot with a zod schema and crash on missing/malformed (fail-fast, no silent defaults); parse booleans explicitly (`=== 'true'`); grep workflows and dashboards when renaming a key.

## vite-client-env-prefix-gate — CRITICAL · trust-invariant
**Contract:** The `VITE_` prefix is the only gate deciding whether an env var is inlined verbatim into the public JS bundle; server secrets rely on NOT having the prefix.
**Detect:** `import.meta.env.VITE_`, `envPrefix` in `vite.config.*`, `define:` blocks, `VITE_` keys in `.env*`
**Ships green, breaks:** Prefixing a secret (`VITE_API_KEY`) ships it string-searchable in `dist/assets/*.js` with zero warning; `import.meta.env.VITE_*` is statically replaced at BUILD time — rotating the value in a deploy dashboard changes nothing until rebuild, and a build-once/promote-across-envs pipeline bakes the build env's values into every environment; widening `envPrefix` silently exposes every matching var; `define:` entries bypass the prefix gate entirely.
**Safe change:** Before adding `VITE_` to any var, confirm it is public; audit `dist/` with `grep -r "<secret>" dist/` after build; treat `envPrefix` and `define` changes as security-review items; never promote a client bundle across environments whose `VITE_` values differ.

## lockfile-manifest-sync — HIGH · generated-artifact
**Contract:** `pnpm-lock.yaml` (or `package-lock.json`/`yarn.lock`) is a generated artifact that must move in the same commit as any `package.json` dependency, `overrides`/`resolutions`, or `pnpm.overrides` change.
**Detect:** `pnpm-lock.yaml`, `package-lock.json`, `yarn.lock` (more than one present = hazard), `"overrides"`, `"resolutions"`, `"pnpm": { "overrides"` in `package.json`
**Ships green, breaks:** Hand-editing `package.json` without running install works locally (bare `pnpm install` silently updates the lock) then fails CI with `ERR_PNPM_OUTDATED_LOCKFILE` — pnpm defaults `--frozen-lockfile` to true when `CI=true`; adding an `overrides` entry without re-locking means the vulnerable version stays pinned and installed while review shows the override "applied"; two lockfiles in one repo means detect-from-lockfile tooling picks nondeterministically and installs a different tree per machine; CI using `npm install` instead of `npm ci` silently rewrites resolutions.
**Safe change:** Run the detected manager's install and commit the lockfile with the manifest change; keep exactly one lockfile (delete strays); CI installs use `pnpm install --frozen-lockfile` / `npm ci` / `yarn install --immutable`; after any `overrides` change, verify the resolved version with `pnpm why <pkg>`.

## pnpm-strict-layout-phantom-imports — HIGH · rendezvous-string
**Contract:** Every module specifier imported by a package must appear in THAT package's `dependencies` — the import string and the manifest meet only through node_modules layout, and pnpm's symlinked layout exposes only declared deps.
**Detect:** `import .* from ['"]` specifiers absent from the nearest `package.json` `dependencies`, `workspace:` protocol entries, `.npmrc` `public-hoist-pattern`/`shamefully-hoist`, `node-linker`
**Ships green, breaks:** An undeclared transitive import (phantom dep) typechecks (types hoisted or ambient) and works under npm's flat hoisting, then throws `ERR_MODULE_NOT_FOUND` at runtime under pnpm — or vice versa when a repo migrates managers; a cross-package workspace import without a `workspace:*` dep entry works via stale symlinks locally and breaks on fresh CI install; relying on `shamefully-hoist=true` masks all of this until someone removes it.
**Safe change:** Declare every imported package in the importer's own `package.json` (workspace siblings as `"@nurix/x": "workspace:*"`); never widen `public-hoist-pattern` to fix a missing-module error — add the dep; verify with a fresh `pnpm install` on a clean clone.

## package-exports-subpath-map — HIGH · rendezvous-string
**Contract:** Consumers' import specifiers (`@nurix/components/button`) must match literal keys in the package's `exports` map; the `files` allowlist and `types` conditions must cover the same paths.
**Detect:** `"exports"` and `"files"` in `package.json`, `from ['"]@nurix/[^'"]+/`, `"moduleResolution"` in `tsconfig.json`
**Ships green, breaks:** Any subpath not mapped in `exports` throws `ERR_PACKAGE_PATH_NOT_EXPORTED` at runtime — and tsc under `moduleResolution: "node10"` ignores `exports` entirely, so types resolve while Node throws (use `"bundler"`/`"nodenext"` to make tsc see the real contract); adding a new component file without an `exports` entry publishes fine and breaks every consumer import; a built `dist/` path referenced by `exports` but missing from the `files` allowlist installs cleanly and fails only on first import of the published tarball.
**Safe change:** Add the `exports` subpath (with `types` condition first in each condition block) in the same change as the new file; confirm `files` includes everything `exports` references; check the shipped surface with `npm pack --dry-run`; treat removing/renaming an exports key as a breaking change for all consumers.

## private-registry-auth-split — HIGH · config-elsewhere
**Contract:** `@nurix/*` installs authenticate via an ambient read token in `~/.npmrc` locally and via `NODE_AUTH_TOKEN` + `setup-node`'s `registry-url` in CI — two disconnected declarations of the same credential.
**Detect:** `@nurix:registry=`, `_authToken` in `.npmrc`, `registry-url:` in workflows, `NODE_AUTH_TOKEN` in step `env:`
**Ships green, breaks:** The npm registry returns **404 (not 401)** for restricted packages without valid auth — CI failures read as "package doesn't exist," which tempts agents into substituting public packages (forbidden here); `setup-node` with `registry-url` writes an `.npmrc` referencing `${NODE_AUTH_TOKEN}` that is resolved at INSTALL time, so the token must be in the `env:` of the step running `pnpm install`/`npm publish`, not on the setup-node step; local installs succeed off `~/.npmrc` so the CI gap is invisible until the pipeline runs.
**Safe change:** In CI, pair `setup-node` `registry-url: https://registry.npmjs.org` with `NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}` on every step touching the registry; on a 404 for `@nurix/*`, diagnose auth first — never swap packages; keep `publishConfig.access: restricted` on every `@nurix` package.

## publish-pipeline-artifact-and-loop — HIGH · lifecycle-protocol
**Contract:** CI publish sequencing: `prepack`/`prepublishOnly` builds the shipped artifact, the version-bump commit carries `[skip ci]`, `npm version` runs `--no-git-tag-version` in monorepo subdirs, and workspace deps are rewritten at pack time.
**Detect:** `"prepack"`, `"prepublishOnly"` in `package.json`, `npm version` in workflows, `[skip ci]` in bump-commit messages, `workspace:` in `dependencies`
**Ships green, breaks:** No `prepack` → the published tarball carries whatever stale `dist/` was on the runner (or none, silently trimmed by `files`); a bump commit without `[skip ci]` pushed to the trigger branch re-fires the publish workflow — infinite loop or a duplicate publish dying with `E403 cannot publish over previously published version`; `npm version` in a subdirectory of the git root silently skips the commit+tag step, so tag automation quietly stops; publishing a pnpm workspace package with `npm publish` ships literal `workspace:*` in dependencies — every consumer install fails with `EUNSUPPORTEDPROTOCOL` (only `pnpm publish`/`pnpm pack` rewrites the protocol to real versions).
**Safe change:** Build in `prepack` so published always matches source; `npm version --no-git-tag-version`, then commit with `[skip ci]` and tag explicitly (`git push origin HEAD:<ref> --follow-tags`); always publish pnpm workspaces with `pnpm publish`; verify content with `npm pack --dry-run` in CI before publish.

## tsconfig-paths-vs-bundler-alias — HIGH · dual-authority
**Contract:** tsconfig `compilerOptions.paths` (what the typechecker resolves) and Vite `resolve.alias` (what the bundle resolves) independently define the same aliases and must stay identical.
**Detect:** `"paths"` in `tsconfig*.json`, `resolve:` `alias` in `vite.config.*`, `vite-tsconfig-paths`
**Ships green, breaks:** `paths` never rewrites emitted JS — code compiled with tsc alone typechecks, then Node throws `ERR_MODULE_NOT_FOUND` on the alias at runtime; Vite does NOT read tsconfig `paths` (needs `vite-tsconfig-paths` or a duplicated `resolve.alias`), so an alias added on one side only means typecheck passes while the build/tests resolve a different file — worst case the two point at different targets (`src/` vs `dist/`) and the code you typechecked is not the code you shipped; the same drift applies to Vitest, which uses Vite resolution, not tsc.
**Safe change:** Single-source aliases via `vite-tsconfig-paths` (tsconfig as authority) or a script that derives one from the other; when adding an alias, update both in one commit and run both `tsc --noEmit` and `vite build`; for Node-run (unbundled) code, avoid `paths` entirely — use package subpath `imports` (`#alias`) which both sides honor.

## corepack-packagemanager-version-drift — MED · config-elsewhere
**Contract:** `packageManager` and `engines` in `package.json` declare the pnpm/Node versions, but the versions actually running in CI, Docker, and laptops are set independently (setup-node/action-setup inputs, `FROM node:` tags, whatever's installed).
**Detect:** `"packageManager"`, `"engines"` in `package.json`, `corepack enable`, `node-version:` in workflows, `FROM node:` in Dockerfiles, `engine-strict` in `.npmrc`
**Ships green, breaks:** `packageManager` is enforced ONLY when corepack is active — Node 22 bundles corepack disabled by default and newer Node lines drop it from the distribution, and `setup-node` never enables it — so a dev on an older pnpm regenerates the committed lockfile at a different `lockfileVersion` (wholesale churn, then `ERR_PNPM_OUTDATED_LOCKFILE` in CI); npm ignores `engines` entirely unless `engine-strict=true`, so a newer-Node-only syntax feature passes local dev and crashes the Node-22 Docker runtime; `pnpm/action-setup` errors on "Multiple versions of pnpm specified" when its `version` input contradicts `packageManager`.
**Safe change:** Treat `packageManager` as the single source: `corepack enable` in Dockerfiles/dev setup, omit `version` from `pnpm/action-setup` so it reads the field; bump Node in `engines`, workflows `node-version`, and `FROM node:` together; set `engine-strict=true` in `.npmrc` to make drift loud.

## gha-workflow-repo-rendezvous — HIGH · rendezvous-string
**Contract:** Workflows reference repo internals purely by literal strings — npm script names in `run:`, secrets/vars by name, `paths:` filters by directory — with no static link to what they name.
**Detect:** `run: (npm|pnpm|yarn) (run )?[a-z:-]+` in `.github/workflows/*.yml`, `secrets\.[A-Z_]+`, `vars\.[A-Z_]+`, `paths:` / `paths-ignore:` filters
**Ships green, breaks:** A referenced secret that doesn't exist interpolates to **empty string with no error** — deploys run with a blank API key and publish steps fail with a misleading 404/403 far downstream; renaming a `package.json` script breaks every `run:` line only when that workflow next executes (possibly a rarely-fired release workflow, weeks later); moving `packages/cli/` to a new path while the publish workflow filters `paths: ['packages/cli/**']` means the publish job silently never triggers again — the package just stops shipping; `node-version` drifting from `engines`/Dockerfile builds with a different Node than production runs.
**Safe change:** When renaming scripts, secrets, or moving package dirs, grep `.github/workflows/` for the old literal; assert required secrets non-empty at job start (`if [ -z "$TOKEN" ]; then exit 1; fi`); after moving a path-filtered directory, push a trivial change inside it and confirm the workflow fires.

## docker-arg-env-boundary — CRITICAL · config-elsewhere
**Contract:** Dockerfile `ARG` exists only at build time and `ENV` persists into the running container; app code reading `process.env.X` at runtime sees only `ENV` (or orchestrator-injected) values, and both are recorded in inspectable image metadata.
**Detect:** `^ARG `, `^ENV ` in Dockerfiles, `--build-arg` in workflows/compose, `_authToken`/`NPM_TOKEN`/`NODE_AUTH_TOKEN` near `RUN.*install`
**Ships green, breaks:** A value declared only as `ARG` builds fine but is `undefined` in `process.env` at runtime — config appears set in the Dockerfile yet the app runs on defaults; any `ARG` consumed by a `RUN` (e.g. `--build-arg NPM_TOKEN=` for the `@nurix` install) is permanently visible in `docker history` of that stage, and secrets put in `ENV` are readable via `docker inspect` by anyone who can pull the image; multi-stage copies quietly drop `ENV` set in earlier stages.
**Safe change:** Runtime config via `ENV` or orchestrator env, never bare `ARG`; registry tokens via BuildKit `RUN --mount=type=secret,id=npmrc,target=/root/.npmrc pnpm install` (never `--build-arg`); audit with `docker history --no-trunc` and `docker inspect` before pushing an image containing credentials.

## pid1-signal-delivery — HIGH · lifecycle-protocol
**Contract:** The container's PID 1 must actually receive SIGTERM for the app's graceful-shutdown handler (`server.close()`, draining) to run before the orchestrator's SIGKILL.
**Detect:** shell-form `ENTRYPOINT node ...` / `CMD npm start` (no JSON array) in Dockerfiles, `CMD ["npm"`, absence of `process.on('SIGTERM'`, `init:` in compose, `--init`
**Ships green, breaks:** Shell-form ENTRYPOINT/CMD makes `/bin/sh -c` PID 1, and sh does not forward SIGTERM to the node child — `docker stop` waits the default 10s then SIGKILLs, so shutdown hooks never run and in-flight requests are dropped on every single deploy while everything looks healthy; `CMD ["npm","start"]` has the same defect (npm does not reliably forward signals to its child); the failure is invisible in logs because the process dies before it can log.
**Safe change:** Exec form running node directly: `ENTRYPOINT ["node","dist/index.js"]`; register `SIGTERM`/`SIGINT` handlers that `server.close()` and exit; add `--init`/tini (or compose `init: true`) for zombie reaping; verify with `docker stop` and confirm the shutdown log line appears in under 10s.

## pino-redact-and-field-name-rendezvous — CRITICAL · rendezvous-string
**Contract:** pino `redact` paths, serializer keys, and downstream dashboard/alert queries all pin the EXACT field names your code passes to the logger — nothing checks they still match.
**Detect:** `redact:` in pino init, `stdSerializers`, `errorKey`, `logger\.(info|error|warn)\(`, field names referenced in Grafana/Datadog/alert configs
**Ships green, breaks:** `redact: ['req.headers.authorization']` stops matching the moment the field is renamed or re-nested — the secret/PII logs in cleartext with zero warning (fast-redact wildcards are single-level `*` only, no deep `**`; hyphenated keys need bracket notation `'req.headers["x-api-key"]'`); pino 10 serializes an Error only when it sits under the configured `errorKey` (default `err`) — `logger.error({ error: e })` skips `stdSerializers.err` and logs `{}` because Error properties are non-enumerable, silently destroying stack traces; renaming a logged field (`userId` → `user_id`) blanks every dashboard panel and alert keyed on the old name without firing anything.
**Safe change:** Treat logged field names as a public schema — grep `redact` config and dashboard/alert definitions before renaming; always log errors as `{ err }` (or set `errorKey` and use it consistently); after changing redact paths, assert on a captured log line that the secret is `[Redacted]`.

## express-trust-proxy-hop-count — CRITICAL · trust-invariant
**Contract:** `app.set('trust proxy', n)` must equal the real number of trusted proxy hops (Cloudflare, LB) so `req.ip`/`req.secure` derive from headers only as far as infrastructure you control actually sets them.
**Detect:** `trust proxy` in server setup, `X-Forwarded-For` / `CF-Connecting-IP` / `X-Forwarded-Proto` reads, `express-rate-limit` config, `req\.ip`, `req\.secure`
**Ships green, breaks:** Default (`false`) behind Cloudflare: `req.ip` is the CF edge IP for every request — rate limits collapse onto one bucket throttling all users, and IP audit logs are garbage; `trust proxy: true`: `req.ip` becomes the LEFTMOST `X-Forwarded-For` entry, which the client controls — rate-limit and IP-allowlist bypass by header injection; with trust proxy unset, `req.secure` is always false behind TLS-terminating Cloudflare, so an https-redirect middleware loops infinitely.
**Safe change:** Set the hop count as a number (`app.set('trust proxy', 1)` for Cloudflare directly in front; +1 per additional LB); prefer `CF-Connecting-IP` for client identity when Cloudflare is guaranteed in path; verify with a test endpoint echoing `req.ip` from a known client; re-check the number whenever a proxy layer is added or removed.

## port-and-health-rendezvous — MED · rendezvous-string
**Contract:** The port in `listen(PORT)`, Dockerfile `EXPOSE`, compose/K8s `ports:`, and the healthcheck URL+path all name the same endpoint from four disconnected files.
**Detect:** `\.listen\(`, `EXPOSE` in Dockerfiles, `ports:` / `healthcheck:` in compose, `HEALTHCHECK`, health route paths (`/health`, `/healthz`, `/ready`) in app routes vs infra config
**Ships green, breaks:** `EXPOSE` is pure documentation — changing the app port and updating only `EXPOSE` leaves the compose `ports:` mapping and healthcheck pointing at the old port (connection refused only in the deployed environment); a healthcheck probing `/health` when the app serves `/healthz` marks the container unhealthy → restart loop while the app itself is fine; `listen(PORT, '127.0.0.1')` inside a container is unreachable through the port mapping (must bind `0.0.0.0`/default), which works in local dev and fails only containerized.
**Safe change:** Single-source the port as `PORT` env consumed by app, compose, and healthcheck alike; when changing port or health path, grep Dockerfile, compose, K8s manifests, and proxy config for the old literal; never bind loopback in a container; after deploy, watch one full healthcheck interval pass green.
