From 6978f42487959e7491b385e5f6c465811fe27c1a Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 2 Nov 2026 17:03:42 +0100 Subject: [PATCH 01/37] docs(drive): module-input S11 to S13 slice spec, before the cli-engine change Signed-off-by: willbot Signed-off-by: Will Madden --- .../s11-s13-configuration-read-once/spec.md | 2255 +++++++++++++++++ 1 file changed, 2255 insertions(+) create mode 100644 .drive/projects/module-input/slices/s11-s13-configuration-read-once/spec.md diff --git a/.drive/projects/module-input/slices/s11-s13-configuration-read-once/spec.md b/.drive/projects/module-input/slices/s11-s13-configuration-read-once/spec.md new file mode 100644 index 000000000..2df2f061f --- /dev/null +++ b/.drive/projects/module-input/slices/s11-s13-configuration-read-once/spec.md @@ -0,0 +1,2255 @@ +# S11 to S13 — Configuration is read once, and no known secret is printed + +Parent: [module-input spec](../../spec.md) and [plan](../../plan.md). Design: [design.md](../../design.md), which is authoritative; these slices build section 8 ("The two adapters that ship", "Which commands run which adapters", "The deploy's two processes", "`prisma dev`'s two processes"), section 9, the section 10 items "The 'pool' names in code" and "The second process's own run of the config adapters", and the section 12 changes to the decisions on secrets, env-sourced params, the `composer` config section, dev secrets and errors, with the glossary. Decisions: [ADR-0062](../../../../../docs/design/90-decisions/ADR-0062-environment-values-come-from-resolve-and-config-adapters.md), [ADR-0049](../../../../../docs/design/90-decisions/ADR-0049-composers-configuration-is-the-composer-section-of-prisma-config.md), [ADR-0058](../../../../../docs/design/90-decisions/ADR-0058-prisma-dev-reads-secrets-from-the-environment-and-never-invents-them.md), [ADR-0029](../../../../../docs/design/90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md), [ADR-0032](../../../../../docs/design/90-decisions/ADR-0032-params-bind-at-provision-env-sourcing-is-a-target-source.md) and [ADR-0044](../../../../../docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md). + +The three slices ship as one pull request. It is built on a new branch cut from main after prisma/composer#412 (S9 and S10) merges. This spec is grounded in `claude/module-input-s9` at `e435d04b`, which holds S9; § 19 lists where S10 can touch the same files. + +This spec leaves nothing to the implementer's judgement. Every file, type, signature, error message, document edit, test and commit is pinned. Where the implementer finds the spec and the code in conflict, the implementer stops and reports; it does not choose. § 20 lists the decisions this spec takes, and § 21 what it found that the design gets wrong or does not say. + +Read for this spec: `CLAUDE.md`; `docs/design/01-principles/`; `design.md` in full; `plan.md`; `spec.md`; the S4, S8 and S9 slice specs; ADR-0029, ADR-0032, ADR-0044, ADR-0049, ADR-0058, ADR-0061 and ADR-0062; the glossary entries Input, Value, Config pool, Secrets pool and Config adapter; `config-params.md`, `deploy-cli.md`, `local-dev.md`; the code each section names; `@prisma/cli-engine` 0.9.0's `ctx.spawn` types and implementation; `dotenv-flow` 4.1.0's `lib/dotenv-flow.js`; Alchemy 2.0.0-beta.83's `src/Util/interactive.ts` and `src/Cli/CliKit/layer.ts`. + +## 1. At a glance + +```ts +// prisma.config.ts +composer({ extensions: [prismaCloud(), nodeBuild()], state: prismaState(), + config: [dotenv(), environmentVariables()], secrets: [dotenv(), environmentVariables()] }) +``` + +```sh +$ prisma deploy --environment staging +``` + +1. The first process runs the `secrets` adapters, then the `config` adapters, once. `dotenv()` reads `.env`, `.env.local`, `.env.staging` and `.env.staging.local` through `dotenv-flow`, never `.env.defaults`. +2. It Loads the graph with the app configuration. The root's schema checks it. Load records what it read: each root field the body names, with its checked value, and the text of each name an `envParam()` reads. +3. It writes `.prisma-composer/configuration--.json`, mode 0600: + + ```json + {"rootInput":[{"path":["EMAIL_FROM"],"value":"shop@example.com"},{"path":["EMAIL_DELIVERY_MODE"],"value":"none"}],"values":{}} + ``` + +4. It starts the second process with `PRISMA_COMPOSER_CONFIGURATION_FILE` naming that file and `PRISMA_COMPOSER_NODE_ADDRESSES` listing the node addresses it checked. The second process runs no adapter. It Loads the graph with the file's values, without running the root's schema, and refuses to deploy a graph whose node addresses differ. +5. The second process's output reaches the terminal through Composer, which redacts every secret value it knows, including one split across two chunks of output. +6. When the deploy ends, success or failure, the first process deletes the configuration file. + +What the user sees when the second process prints a secret, and when an adapter's error holds one: + +```text +api key is [redacted] +✘ [CONFIG.ADAPTER_FAILED] `composer.config[0]` failed to read the values for the environment "staging". + Why: It threw: cannot use token [redacted] +``` + +`prisma dev` does the same, with `.prisma-composer/dev//configuration.json`, and also hands its second process the secrets its local services need, in `PRISMA_COMPOSER_APP_SECRETS`, never in a file. + +## 2. The model + +- **The app configuration** is what the `config` adapters return, merged. **The app secrets** is what the `secrets` adapters return, merged. These names replace "config pool" and "secrets pool" everywhere: in types, functions, options, messages, docs and tests (§ 4). +- **Only the first process runs adapters.** `deploy` and `dev` run both lists; `destroy` runs only `config`. The order is now secrets first, then configuration, so a failing config adapter's message is redacted with every value a written `secrets` list returned (§ 7.2). +- **Load records what it read** from the app configuration, as `graph.checkedConfiguration` (§ 5). That record is exactly what the configuration file holds. It holds no secrets: a name may not be read both by `envParam()` and by `envSecret()` (§ 5.5). +- **The second process Loads with `checkedConfiguration`** instead of `configuration`. Load then takes each root field from the record without running the root's schema, and reads each configuration key from the record's text. A root field the record lacks is an error: the two processes built different graphs (§ 5.4). +- **The second process checks the node addresses.** `lower()` takes `checkedNodeAddresses` and refuses a graph whose addresses differ (§ 6.2). +- **Core knows each secret's name.** `secretSource()` takes `secretsKey`, the app secrets key that holds the secret's value; `envSecret('NAME')` passes `NAME`. Core's `secretsKeys(graph)` lists them. The CLI uses it to know what to redact and, under `prisma dev`, which secrets to hand the second process (§ 6.1). +- **Redaction** (§ 9). A `KnownSecrets` object per command collects every secret value the command reads: under every name an `envSecret()` in the graph names, the value in the app configuration and in the app secrets, at any length; with a written `secrets` list, every value it returns, at any length; and every app configuration value of eight characters or more under a name that looks like a credential, as S4 did. Every error an operation returns, every event `prisma dev` emits, the run report, the deploy reporter's message, the deployment summary and the second process's output pass through it. +- **The second process's output goes through a pipe.** For programmatic hosts, `spawnAlchemy` pipes it in-process. For the CLI, the engine's `ctx.spawn` can only hand a child the terminal, so Composer starts a small relay process from the app's `@prisma/composer` (`internal/output-relay`), which runs Alchemy with piped output and writes it on, redacted (§ 9.5, § 21 finding 2). +- **`environmentVariables()` no longer reads `PRISMA_COMPOSER_PARENT_VALUES`.** That variable existed so the child's second run of the adapters returned the parent's values. No child runs an adapter now, so it goes (§ 7.1). + +## 3. S11: `dotenv()` delegates to `dotenv-flow` + +### 3.1 Dependencies + +- `packages/0-framework/3-tooling/cli/package.json` and `packages/9-public/composer/package.json`: the entry `"dotenv": "^17.4.2"` is replaced by `"dotenv-flow": "4.1.0"`, keeping alphabetical order in `dependencies`. Run `pnpm install` so the lockfile records it. No other package depends on `dotenv` for Composer's use; `examples/storefront-auth` keeps its own `dotenv` dependency. +- `dotenv-flow` 4.1.0 depends on `dotenv` `^16.0.0`. It is CommonJS with types in `lib/dotenv-flow.d.ts`; `import { listFiles, parse } from 'dotenv-flow'` works under Node and Bun (§ 18). + +### 3.2 `packages/0-framework/3-tooling/cli/src/config-adapters.ts` + +The file becomes, in full: + +```ts +import * as path from 'node:path'; +import type { ConfigAdapter, ConfigValues } from '@internal/core/config'; +import { listFiles, parse } from 'dotenv-flow'; + +/** The prefix of the variables Composer sets for its own second process. */ +const COMPOSER_PREFIX = 'PRISMA_COMPOSER_'; + +/** + * Reads the process environment when called: every variable whose value is a string, + * except Composer's own `PRISMA_COMPOSER_` variables. The default for both `config` and `secrets`. + */ +export function environmentVariables(): ConfigAdapter { + return () => { + const values: Record = {}; + for (const [name, value] of Object.entries(process.env)) { + if (typeof value === 'string' && !name.startsWith(COMPOSER_PREFIX)) values[name] = value; + } + return values; + }; +} + +/** `dotenv-flow`'s own default pattern. Given it, `listFiles` also lists `.env.defaults`, first. */ +const DOTENV_FLOW_PATTERN = '.env[.node_env][.local]'; +const ENV_DEFAULTS_FILE = '.env.defaults'; + +function envFiles(configDirectory: string, environment: string): string[] { + const files = listFiles({ + node_env: encodeURIComponent(environment), + path: configDirectory, + pattern: DOTENV_FLOW_PATTERN, + }); + return files[0] === path.resolve(configDirectory, ENV_DEFAULTS_FILE) ? files.slice(1) : files; +} + +function readEnvFile(file: string): ConfigValues { + try { + return parse(file); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`dotenv() could not read ${file}: ${message}`, { cause: error }); + } +} + +/** + * Reads `.env`, `.env.local`, `.env.` and `.env..local` from the + * directory of prisma.config.ts, in `dotenv-flow`'s order, a later file winning. It skips + * `.env.local` for the environment `test`, as `dotenv-flow` does, and never reads + * `.env.defaults`. It does not read or change the process environment. + */ +export function dotenv(): ConfigAdapter { + return ({ environment, configDirectory }) => { + const values: Record = {}; + for (const file of envFiles(configDirectory, environment)) { + Object.assign(values, readEnvFile(file)); + } + return values; + }; +} +``` + +`childEnvironment`, `PARENT_VALUES_ENV`, `parentValues`, `readEnvFile`'s `ENOENT` handling and `isMissingFile` are removed: `listFiles` lists only files that exist. `dotenv-flow`'s `parse(list)` merges with `Object.assign` in list order; parsing one file at a time and merging the same way gives the same result and lets the error name the file (§ 20, decision 3). + +### 3.3 `packages/0-framework/3-tooling/cli/src/exports/config.ts` + +Its doc comment becomes `/** The config adapters (ADR-0062), imported by prisma.config.ts, so this entry imports nothing heavier than node:path and dotenv-flow. */`. + +### 3.4 `run-alchemy.ts` + +The import of `childEnvironment` goes. In `alchemyInvocation`, `env: childEnvironment({ ...input.containerEnv, ...input.preflightEnv, ...input.env })` becomes `env: { ...input.containerEnv, ...input.preflightEnv, ...input.env }` (§ 9.6 adds `redactedValues`). + +## 4. The rename: "pool" becomes "the app configuration" and "the app secrets" + +### 4.1 Identifiers + +| Before | After | Where | +| --- | --- | --- | +| `ConfigPool` | `AppConfiguration` | core `control/app-config.ts`, every importer | +| `SecretsPool` | `AppSecrets` | the same | +| `ConfigPools` | removed; each use becomes two fields, `configuration: AppConfiguration` and `secrets: AppSecrets` | `PreflightInput`, `LocalTargetProvidersInput`, `LocalTargetAttachment.serve`'s input, `PipelineResult` | +| `PreflightInput.pools` | `PreflightInput.configuration` and `PreflightInput.secrets` | core, target preflight, CLI operations | +| `LocalTargetProvidersInput.pools` | `.configuration` and `.secrets` | core, local target | +| `serve`'s input `pools` | `configuration` and `secrets` | core, `local-target/attach.ts`, `execute-dev.ts` | +| `localTargetProviders(resolved, containers, devDir, pools)` | `localTargetProviders(resolved, containers, devDir, configuration, secrets)` | core `control/local-target.ts`, the dev stack file | +| `materializeEnv(devDir, address, port, pools)` | `materializeEnv(devDir, address, port, configuration, secrets)` | `local-target/src/compute.ts`, `attach.ts` | +| `LoadOptions.configPool` | `LoadOptions.configuration` | core `graph-types.ts`, every caller | +| `LowerOptions.configPool` | removed; replaced by `checkedConfiguration` and `checkedNodeAddresses` (§ 6.2) | core `control/deploy.ts` | +| `ParamSourceOptions.poolKey` | `ParamSourceOptions.configurationKey` | core `node.ts`, target `param.ts` | +| the `'source'` origin's `poolKey` | `configurationKey` | core `value.ts`, `node.ts`, `resolve-values.ts` | +| `ProvisionedModuleInput.fromConfigPool` | `ProvisionedModuleInput.isRoot` | core `resolve-values.ts`, `load-module.ts` | +| `resolveValues`'s input `configPool` | `configuration` | core `resolve-values.ts` | +| `config-pools.ts`, `readConfigPool`, `readSecretsPool`, `PoolReadInput`, `readPool`, `PoolList` | `app-configuration.ts`, `readAppConfiguration`, `readAppSecrets`, `AdapterReadInput`, `readAdapters`, `AdapterList` | CLI | +| `PipelineResult.pools` | `PipelineResult.configuration` and `PipelineResult.secrets` | CLI `pipeline.ts`, operations | +| `samePools` | `sameValues` | CLI `execute-dev.ts` | +| `poolSecrets` | removed (§ 9.2) | CLI `deployment-summary.ts` | +| `src/exports/pools.ts` (`@internal/cli/pools`) and `packages/9-public/composer/src/exports/internal-pools.ts` (`@prisma/composer/internal/pools`) | removed; § 8.6 adds `internal/configuration-file` and `internal/output-relay` | CLI, public composer | +| `pool-text.ts`, `poolText(pool, name, notText)` | `configuration-text.ts`, `configurationText(configuration, name, notText)` | Prisma Cloud target | +| `poolValue` (preflight's map) | `valueOf` | target `preflight.ts` | +| the test fixture folder `pipeline-config-pool` | `pipeline-app-configuration` | CLI tests | +| `config-pools.test.ts` | `app-configuration.test.ts` | CLI tests | + +### 4.2 Words, in code comments, messages, test titles and docs + +| Before | After | +| --- | --- | +| `the config pool` | `the app configuration` | +| `config pool` (any other use, such as "a config pool value") | `app configuration` | +| `the secrets pool` | `the app secrets` | +| `secrets pool` (any other use) | `app secrets` | +| `the pools` / `both pools` / `the two pools` | `the app configuration and the app secrets` | +| `pool key` | `configuration key` | +| `a pool-key source` | `a source with a configuration key` | + +### 4.3 How the implementer checks it + +After the change, this finds nothing in `packages/`, `examples/`, `test/`, `skills/`, `docs/guides/`, `docs/design/03-domain-model/` and `docs/design/10-domains/`, outside `node_modules` and `dist`: + +```sh +git grep -n -i -E 'config ?pool|secrets ?pool|configPool|ConfigPools?|SecretsPool|poolKey|pool-key|pool key|readConfigPool|readSecretsPool|internal/pools|internal-pools|poolText|pool-text|poolSecrets|samePools|PoolReadInput|\bpools\b' -- packages examples test skills docs/guides docs/design/03-domain-model docs/design/10-domains +``` + +`docs/design/90-decisions/` is covered by § 14, and keeps the word only where a rewritten record says what it replaced. `.drive/` keeps it. + +## 5. Core: Load records what it read, and takes it back + +### 5.1 `packages/0-framework/1-core/core/src/graph-types.ts` + +After `NodeId`: + +```ts +/** One field of the root's input that its body names, with the real value the root's schema produced there. */ +export interface CheckedRootField { + /** The key path inside the root's input, such as `['smtp', 'host']`. */ + readonly path: readonly string[]; + /** The real value; absent when it is `undefined`. */ + readonly value?: unknown; +} + +/** + * What a Load read from the app configuration, once the root's schema checked it (ADR-0062). + * The deploy's and `prisma dev`'s first process writes it to the configuration file, and + * their second process Loads with it in place of the app configuration. + */ +export interface CheckedConfiguration { + /** Each field of the root's input that its body names. Empty when the root declares no input. */ + readonly rootInput: readonly CheckedRootField[]; + /** The text the app configuration holds for each configuration key a source reads, such as `envParam('NAME')`'s `NAME`. A key the app configuration does not hold, or holds as `''`, is absent. */ + readonly values: Readonly>; +} +``` + +`LoadOptions.configPool` and its doc comment become: + +```ts + /** The app configuration (ADR-0062): what the `config` adapters return. It is the root's input, which the root's schema checks, and what a source with a configuration key, such as `envParam('NAME')`, reads. The deploy's and dev's first process pass it. */ + readonly configuration?: Readonly>; + /** What the first process's Load read, from the configuration file. The deploy's and dev's second process pass it in place of `configuration`: Load takes each root field from it without running the root's schema. */ + readonly checkedConfiguration?: CheckedConfiguration; +``` + +`Graph` gains, after `secretSources`: + +```ts + /** What this Load read from the app configuration (ADR-0062). Empty when Load was given none. */ + readonly checkedConfiguration: CheckedConfiguration; +``` + +`load-service.ts`'s graph gains `checkedConfiguration: { rootInput: [], values: {} },` after `secretSources: [],`. + +### 5.2 `packages/0-framework/1-core/core/src/load-module.ts`, `loadModule` + +The root's input block becomes: + +```ts + const moduleInputs: ProvisionedModuleInput[] = []; + if (opts?.configuration !== undefined && opts.checkedConfiguration !== undefined) { + throw new LoadError( + 'Load takes configuration, in the first process, or checkedConfiguration, in the second, not both.', + ); + } + if (root.inputSchema !== undefined) { + if (opts?.checkedConfiguration !== undefined) { + moduleInputs.push({ + moduleAddress: rootId, + schema: root.inputSchema, + binding: undefined, + isRoot: true, + checked: opts.checkedConfiguration.rootInput, + }); + } else if (opts?.configuration !== undefined) { + moduleInputs.push({ + moduleAddress: rootId, + schema: root.inputSchema, + binding: opts.configuration, + isRoot: true, + }); + } else { + throw new LoadError( + `Module "${root.name}" declares input and is being deployed as the root, but Load was given ` + + 'no app configuration to fill it from. Pass { configuration } to Load; prisma deploy and prisma dev ' + + 'pass the values the `config` adapters return.', + ); + } + } +``` + +The module-child push in `flatten` sets `isRoot: false` in place of `fromConfigPool: false`. The `resolveValues` call passes `configuration: opts?.configuration` and `checkedConfiguration: opts?.checkedConfiguration` in place of `configPool`. The returned graph gains `checkedConfiguration: resolved.checkedConfiguration,` after `secretSources`. + +### 5.3 `packages/0-framework/1-core/core/src/resolve-values.ts` + +1. Imports: `type CheckedConfiguration` and `type CheckedRootField` join the `./graph-types.ts` import. +2. `ProvisionedModuleInput`: `fromConfigPool` and its doc comment become: + + ```ts + /** True for the root, whose binding is the app configuration. */ + readonly isRoot: boolean; + /** For the root in the second process: its fields as the first process checked them. The root's schema does not run. */ + readonly checked?: readonly CheckedRootField[]; + ``` + +3. New module-private functions, after `describeNotPlain`: + + ```ts + /** How `value` would not survive `JSON.stringify` and `JSON.parse` unchanged, or `undefined` when it would. A key whose value is `undefined` is dropped, which a body cannot tell from absent. */ + function jsonInexactKind(value: unknown): string | undefined { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return undefined; + if (typeof value === 'number') return Number.isFinite(value) ? undefined : 'a non-finite number'; + if (Array.isArray(value)) { + for (const member of value) { + if (member === undefined) return 'an array with a missing member'; + const kind = jsonInexactKind(member); + if (kind !== undefined) return kind; + } + return undefined; + } + if (isPlainObject(value)) { + for (const member of Object.values(value)) { + if (member === undefined) continue; + const kind = jsonInexactKind(member); + if (kind !== undefined) return kind; + } + return undefined; + } + return describeNotPlain(value) ?? describeKind(value); + } + + const SAME_GRAPH_ADVICE = + 'A module body must build the same graph every time; one that reads process.env, a file or the clock can build a different one.'; + ``` + +4. `validateModuleInput`: `entry.fromConfigPool` becomes `entry.isRoot`. +5. `resolveValues`'s input: `readonly configPool: Readonly> | undefined;` becomes: + + ```ts + readonly configuration: Readonly> | undefined; + readonly checkedConfiguration: CheckedConfiguration | undefined; + ``` + + Its return type gains `readonly checkedConfiguration: CheckedConfiguration;`. +6. At the start of `resolveValues`'s body, `const { environment, configPool } = input;` becomes: + + ```ts + const { environment } = input; + const rootFields = new Map(); + const keysRead = new Map(); + /** The text the app configuration holds for `key`, recorded; `undefined` when it holds none or `''`. */ + const configurationText = (key: string, label: string, site: ValueBindingSite): string | undefined => { + let raw: unknown; + if (input.checkedConfiguration !== undefined) raw = input.checkedConfiguration.values[key]; + else if (input.configuration !== undefined) raw = input.configuration[key]; + else { + throw new LoadError( + `${describeSite(site)} depends on ${label}, but Load was given no app configuration to read "${key}" from. Pass { configuration } to Load; the deploy and dev commands pass the values the \`config\` adapters return.`, + ); + } + if (raw !== undefined && raw !== '' && typeof raw !== 'string') { + throw new LoadError( + `${describeSite(site)} depends on ${label}, which reads "${key}" from the values the \`config\` adapters return, but they return ${describeKind(raw)} for it, not text. ${label} reads text; to pass other data, declare it in the root module's input.`, + ); + } + const text = raw === undefined || raw === '' ? undefined : raw; + keysRead.set(key, { label, text }); + return text; + }; + ``` + +7. In `resolveOne`, the `'source'` case's `else if (configPool === undefined) { … } else { … }` chain becomes: + + ```ts + if (origin.configurationKey === undefined) { + result = origin.source; + } else { + result = configurationText(origin.configurationKey, origin.label, site); + } + ``` + + In the `'computed'` case, `source.poolKey` becomes `source.configurationKey`, twice. +8. In the `'input'` case, `entry.fromConfigPool` becomes `entry.isRoot`, and `result = readPath(validatedInputOf(entry), origin);` becomes: + + ```ts + if (entry.checked !== undefined) { + const field = entry.checked.find( + (candidate) => candidate.path.join('\0') === origin.path.join('\0'), + ); + if (field === undefined) { + throw new LoadError( + `${inputFieldLabel(origin)} of root module "${origin.module}" is not in the configuration file. The first process's root body did not pass it on, so the deploy's two processes built different graphs. ${SAME_GRAPH_ADVICE}`, + ); + } + result = field.value; + } else { + result = readPath(validatedInputOf(entry), origin); + if (entry.isRoot) { + rootFields.set( + origin.path.join('\0'), + result === undefined ? { path: origin.path } : { path: origin.path, value: result }, + ); + } + } + ``` + +9. `validatedInputOf`: its first line gains, before the cache check, `if (entry.checked !== undefined) return undefined;`. +10. New closure inside `resolveValues`, after `validatedInputOf`: + + ```ts + const checkedConfigurationOf = ( + inputBindings: readonly ServiceInputBinding[], + params: readonly ParamBinding[], + secretSources: readonly SecretSourceUse[], + ): CheckedConfiguration => { + if (input.configuration !== undefined || input.checkedConfiguration !== undefined) { + const kept: { readonly binding: unknown; readonly site: ValueBindingSite }[] = [ + ...inputBindings.map(({ serviceAddress, binding }) => ({ + binding, + site: { address: serviceAddress, kind: 'serviceInput' as const, path: '' }, + })), + ...params.map(({ serviceAddress, slot, binding }) => ({ + binding, + site: { address: serviceAddress, kind: 'param' as const, path: slot }, + })), + ]; + for (const { binding, site } of kept) { + for (const pointer of pointersIn(binding)) { + if (!isParamSource(pointer)) continue; + const origin = sourceOriginOf(pointer); + if (origin?.configurationKey !== undefined) { + configurationText(origin.configurationKey, origin.label, site); + } + } + } + } + for (const use of secretSources) { + const origin = sourceOriginOf(use.source); + const key = origin?.secretsKey; + const read = key === undefined ? undefined : keysRead.get(key); + if (key !== undefined && read !== undefined) { + throw new LoadError( + `${read.label} reads "${key}" from the app configuration, and ${origin?.label} names "${key}" as a secret. A name is a plain value or a secret, not both: Composer hands the deploy's second process every plain value it reads, in a file, and never writes a secret to a file. Give the plain value and the secret different names.`, + ); + } + } + for (const field of rootFields.values()) { + const kind = 'value' in field ? jsonInexactKind(field.value) : undefined; + if (kind !== undefined) { + throw new LoadError( + `${inputFieldLabel({ kind: 'input', module: rootModuleAddress(), path: field.path })} of root module "${rootModuleAddress()}" holds ${kind}, which JSON cannot hold exactly. The deploy hands the root's checked input to its second process in a JSON file, so a field the root's body passes on must hold text, a finite number, a boolean, null, or arrays and plain objects of these.`, + ); + } + } + const values: Record = {}; + for (const [key, { text }] of keysRead) if (text !== undefined) values[key] = text; + return { rootInput: [...rootFields.values()], values }; + }; + ``` + + `rootModuleAddress()` is a closure, declared before it: `const rootModuleAddress = (): string => input.moduleInputs.find((entry) => entry.isRoot)?.moduleAddress ?? '';`. `inputFieldLabel` takes an `InputOrigin`, so the object literal above is that origin. +11. Both returns of `resolveValues` gain `checkedConfiguration`. The early return becomes: + + ```ts + if (found.length === 0) { + for (const [address, props] of authoredProps) refusePointersInProps(address, props); + const secretSources = secretSourcesReceivedBy(input.inputBindings, input.params); + return { + inputBindings: input.inputBindings, + params: input.params, + resourceProps: authoredProps, + secretSources, + checkedConfiguration: checkedConfigurationOf(input.inputBindings, input.params, secretSources), + }; + } + ``` + + and the last return becomes: + + ```ts + const secretSources = secretSourcesReceivedBy(inputBindings, params); + return { + resourceProps, + inputBindings, + params, + secretSources, + checkedConfiguration: checkedConfigurationOf(inputBindings, params, secretSources), + }; + ``` + +### 5.4 What Load does in each process + +| | First process: `{ configuration }` | Second process: `{ checkedConfiguration }` | +| --- | --- | --- | +| The root's schema | runs over the whole app configuration | does not run | +| A root field the body names | read from the schema's output, recorded | taken from `checkedConfiguration.rootInput`; missing is a `LoadError` | +| A configuration key read in a module's input or a `resolve()` | read from `configuration`, recorded | read from `checkedConfiguration.values` | +| A configuration key in a service's input or a service param | read and recorded after resolution; non-text is a `LoadError` | read from `checkedConfiguration.values` | +| A key read as both a configuration key and a secrets key | `LoadError` | `LoadError` | +| A recorded root field JSON cannot hold | `LoadError` | cannot happen: the values came from JSON | + +### 5.5 `node.ts` and `value.ts` + +1. `value.ts`, the `'source'` origin: `poolKey` and its doc comment become: + + ```ts + /** The app configuration key whose text at deploy is the real value, as for `envParam()`. `undefined` for a value known only at boot, whose real value is `source`, a pointer. */ + readonly configurationKey: string | undefined; + /** The app secrets key that holds a secret source's value, as for `envSecret()`. `undefined` for any other source. */ + readonly secretsKey: string | undefined; + ``` + +2. `node.ts`, after `SourceOptions`: + + ```ts + /** What a deploy target says about a secret source it builds. */ + export interface SecretSourceOptions extends SourceOptions { + /** The app secrets key that holds this secret's value, as `envSecret('NAME')`'s `NAME`. Composer redacts the value under it wherever it prints, and `prisma dev` hands it to its second process. */ + readonly secretsKey?: string; + } + ``` + + `ParamSourceOptions.poolKey` and its doc comment become `/** The app configuration key whose text at deploy is the source's real value, as for \`envParam()\`. Without one the source is known only at boot, and its real value is the source itself, a pointer. */ readonly configurationKey?: string;`. +3. `secretSource(payload, options?: SourceOptions)` becomes `secretSource(payload, options?: SecretSourceOptions)`. Its body gains, after the `label` line: + + ```ts + const secretsKey = options?.secretsKey; + if (secretsKey !== undefined && (typeof secretsKey !== 'string' || secretsKey.length === 0)) { + throw new Error('secretSource() secretsKey must be a non-empty string.'); + } + ``` + + and its origin literal becomes `{ kind: 'source', label, configurationKey: undefined, secretsKey }`. +4. `paramSource`: `poolKey` becomes `configurationKey` throughout; the two messages become `'paramSource() configurationKey must be a non-empty string.'` and `'paramSource() takes configurationKey, for a value known at deploy, or redacted, for a secret known only at boot, not both.'`; its origin literal becomes `{ kind: 'source', label, configurationKey, secretsKey: undefined }`. +5. `sourceMisuse`: `origin.poolKey === undefined` becomes `origin.configurationKey === undefined`. +6. `ParamSource`'s doc comment: `known at deploy when it names a config pool key (\`envParam\`)` becomes `known at deploy when it names an app configuration key (\`envParam\`)`. +7. `exports/index.ts`: the `../node.ts` type export gains `SecretSourceOptions`; the `../graph.ts` type export gains `CheckedConfiguration` and `CheckedRootField`. + +## 6. Core: secret names, node addresses and the second process's check + +### 6.1 `packages/0-framework/1-core/core/src/graph.ts` + +New exports, after `Load`: + +```ts +/** The app secrets key of every secret source the graph names, once each, in the order Load met them. A source built without a `secretsKey` is left out. */ +export function secretsKeys(graph: Graph): readonly string[] { + const keys = new Set(); + for (const { source } of graph.secretSources) { + const key = sourceOriginOf(source)?.secretsKey; + if (key !== undefined) keys.add(key); + } + return [...keys]; +} + +/** The address of every node in the graph, sorted. Two Loads of the same graph give the same list. */ +export function nodeAddresses(graph: Graph): readonly string[] { + return graph.nodes.map(({ id }) => id).sort(); +} +``` + +`graph.ts` imports `sourceOriginOf` from `./node.ts`. `exports/index.ts` exports `nodeAddresses` and `secretsKeys` beside `Load`. + +### 6.2 `packages/0-framework/1-core/core/src/control/deploy.ts` + +1. `LowerOptions.configPool` and its doc comment are replaced by: + + ```ts + /** What the first process's Load read from the app configuration (ADR-0062), from the configuration file. Load takes the root's input and every configuration key's text from it, and the Prisma Cloud serializer reads a service's `envParam()` leaves from its `values`. The generated stack files pass it. */ + readonly checkedConfiguration?: CheckedConfiguration; + /** The node addresses the first process checked. When given, lowering refuses a graph whose addresses differ. The generated stack files pass them. */ + readonly checkedNodeAddresses?: readonly string[]; + ``` + +2. In `lowering`, the `Load` call's `...(opts.configPool !== undefined ? { configPool: opts.configPool } : {})` becomes `...(opts.checkedConfiguration !== undefined ? { checkedConfiguration: opts.checkedConfiguration } : {})`, and directly after the `Load` call: + + ```ts + if (opts.checkedNodeAddresses !== undefined) { + assertSameNodeAddresses(nodeAddresses(graph), opts.checkedNodeAddresses); + } + ``` + +3. New module-private function, after `LowerError`: + + ```ts + function assertSameNodeAddresses(built: readonly string[], checked: readonly string[]): void { + const onlyBuilt = built.filter((address) => !checked.includes(address)); + const onlyChecked = checked.filter((address) => !built.includes(address)); + if (onlyBuilt.length === 0 && onlyChecked.length === 0) return; + const list = (addresses: readonly string[]) => + addresses.length === 0 ? 'none' : addresses.map((address) => `"${address}"`).join(', '); + throw new LowerError( + `The deploy's second process built a different graph from the one Composer checked in its first process. Only in the second process: ${list(onlyBuilt)}. Only in the first process: ${list(onlyChecked)}. A module body must build the same graph every time; one that reads process.env, a file or the clock can build a different one.`, + ); + } + ``` + + `deploy.ts` imports `nodeAddresses` from `../graph.ts` and the type `CheckedConfiguration` from `../graph-types.ts`. Core's `process.env` count stays `control/deploy.ts`, 2 (`invariants.test.ts` is unchanged): the addresses reach `lower()` as an option. + +### 6.3 `control/app-config.ts` + +1. `ConfigPool`, `SecretsPool` and `ConfigPools` and their doc comments are replaced by: + + ```ts + /** The app configuration (ADR-0062): what the `config` adapters return, merged by top-level key, a later adapter winning. It is the root's input, and what `envParam()` reads. */ + export type AppConfiguration = Readonly>; + + /** The app secrets (ADR-0062): what the `secrets` adapters return, merged the same way. Every value is text. Preflight fills a missing `envSecret()` platform variable from it, and `prisma dev` hands its second process the values the graph's `envSecret()`s name. Composer redacts its values wherever it prints. */ + export type AppSecrets = Readonly>; + ``` + +2. `ConfigAdapter`'s doc comment becomes: `A function in the \`config\` or \`secrets\` list of the \`composer\` section (ADR-0062): it receives the environment and returns that environment's values. Only the command's first process calls it; the second process reads what the first checked from the configuration file.` +3. `PrismaAppConfig`'s doc comment's last sentence becomes `` `config` and `secrets` list the adapters the app configuration and the app secrets come from. ``; `config`'s member comment becomes `Where the app configuration comes from. …` and `secrets`' `Where the app secrets come from. …`, the rest unchanged. +4. `PreflightInput.pools` is replaced by: + + ```ts + /** The app configuration the command read for this environment. Preflight fills a missing `envParam()` param's platform variable from it. */ + readonly configuration: AppConfiguration; + /** The app secrets the command read for this environment. Preflight fills a missing `envSecret()` platform variable from it. */ + readonly secrets: AppSecrets; + ``` + +5. `LocalTargetProvidersInput.pools` is replaced by the same two members, with the comments `The app configuration the second process read: the text of each configuration key the graph reads. A local deployment takes the values its param rows point at from it.` and `The app secrets the first process handed the second process. A local deployment takes the values its \`$secret\` pointers name from it.` +6. `serve`'s `pools` member is replaced by `configuration: AppConfiguration` and `secrets: AppSecrets`, commented `The app configuration the command read for this environment.` and `The app secrets the command read for this environment.` +7. `control/local-target.ts`: `localTargetProviders`'s fourth parameter becomes `configuration: AppConfiguration`, a fifth `secrets: AppSecrets` follows, and the call becomes `descriptor.providers({ container: containers.get(id), devDir, configuration, secrets })`. + +## 7. CLI: reading the app configuration and the app secrets + +### 7.1 `config-adapters.ts` + +§ 3.2. + +### 7.2 `app-configuration.ts` (renamed from `config-pools.ts`) + +`git mv src/config-pools.ts src/app-configuration.ts`, then: + +1. The type imports become `ConfigAdapter`, `AppConfiguration`, `AppSecrets`, `PrismaAppConfig` from `@internal/core/config`; it imports `type KnownSecrets` from `./redaction.ts`. +2. `PoolReadInput` becomes `AdapterReadInput`, doc comment `What the adapters are read for.`; `PoolList` becomes `AdapterList`; `isText(pool)` becomes `isText(values): values is AppSecrets`; `readPool` becomes `readAdapters`, with a fourth parameter `onValue?: (value: string) => void`. In its merge loop, after `pool[key] = value;` (the local is renamed `merged`), it calls `if (typeof value === 'string') onValue?.(value);`. +3. The two exports become: + + ```ts + /** Calls the section's `config` adapters (default `[environmentVariables()]`) and merges what they return: the app configuration. */ + export function readAppConfiguration( + section: PrismaAppConfig, + input: AdapterReadInput, + ): Promise { + return readAdapters('config', section.config ?? [environmentVariables()], input); + } + + /** + * Calls the section's `secrets` adapters (default `[environmentVariables()]`) and merges what they + * return: the app secrets. Every value must be text. When the section writes its own list, each + * value is added to `known` as soon as its adapter returns it. + */ + export async function readAppSecrets( + section: PrismaAppConfig, + input: AdapterReadInput, + known: KnownSecrets, + ): Promise { + const merged = await readAdapters( + 'secrets', + section.secrets ?? [environmentVariables()], + input, + section.secrets === undefined ? undefined : (value) => known.add([value]), + ); + if (!isText(merged)) throw new InternalError('readAdapters admitted a secret that is not text'); + return merged; + } + ``` + + The error messages of § 4.3 of the S4 spec do not change. + +### 7.3 `pipeline.ts` + +1. Imports: `type ConfigPools` becomes `type AppConfiguration, type AppSecrets`; `readConfigPool, readSecretsPool` from `./config-pools.ts` become `readAppConfiguration, readAppSecrets` from `./app-configuration.ts`; new imports `secretsKeys` from `@internal/core` and `KnownSecrets, secretValuesIn` from `./redaction.ts`. +2. `PipelineDeps.readSecrets`'s comment becomes `Read the app secrets too: \`deploy\` and \`dev\` do; \`destroy\` does not, and its \`secrets\` is empty.` It gains: + + ```ts + /** Collects every secret value the run reads, for redaction. A new one when not given. */ + readonly knownSecrets?: KnownSecrets | undefined; + ``` + +3. `PipelineResult.pools` is replaced by: + + ```ts + /** The app configuration read for this run. In memory only; the configuration file holds only what Load read from it. */ + readonly configuration: AppConfiguration; + /** The app secrets read for this run. In memory only: never written to a file, an event, a result or a report. */ + readonly secrets: AppSecrets; + ``` + +4. Step 3 of `runPipeline` becomes: + + ```ts + // 3. Read the app secrets, then the app configuration. Secrets first, so a config adapter's + // failure is redacted with every value a written `secrets` list returned. + const known = deps.knownSecrets ?? new KnownSecrets(); + const adapterInput = { environment, configFile: configSource.file }; + const secrets: AppSecrets = + deps.readSecrets === true ? await readAppSecrets(config, adapterInput, known) : NO_SECRETS; + const configuration = await readAppConfiguration(config, adapterInput); + known.add(secretValuesIn(configuration, secrets, [])); + ``` + +5. Step 4's `configPool: pools.config` becomes `configuration`. Directly after the `ROOT_NOT_MODULE` check: + + ```ts + known.add(secretValuesIn(configuration, secrets, secretsKeys(graph))); + ``` + +6. The return becomes `return { configSource, entryModule, graph, name, assembled, configuration, secrets };`. + +## 8. CLI: the configuration file and the second process + +### 8.1 New file `packages/0-framework/3-tooling/cli/src/configuration-file.ts` + +It imports `node:fs` and the types `AppSecrets`, `CheckedConfiguration` and `CheckedRootField`, and no value from core or `@prisma/cli-engine`, because the generated stack files import it in the second process. + +```ts +/** Names the configuration file the first process wrote for this run (ADR-0062). */ +export const CONFIGURATION_FILE_ENV = 'PRISMA_COMPOSER_CONFIGURATION_FILE'; +/** Lists, as JSON, the node addresses the first process checked. */ +export const NODE_ADDRESSES_ENV = 'PRISMA_COMPOSER_NODE_ADDRESSES'; +/** `prisma dev` only: the value of each app secret an `envSecret()` in the graph names, as a JSON object. */ +export const APP_SECRETS_ENV = 'PRISMA_COMPOSER_APP_SECRETS'; + +/** Writes `checked` to `file` as JSON, readable only by its owner. A file already there is replaced. */ +export function writeConfigurationFile(file: string, checked: CheckedConfiguration): void { + fs.rmSync(file, { force: true }); + fs.writeFileSync(file, JSON.stringify(checked), { mode: 0o600, flag: 'wx' }); +} + +/** Deletes `file`. Never throws: the run's own result is the one to report. */ +export function removeConfigurationFile(file: string): void { + try { + fs.rmSync(file, { force: true }); + } catch { + // The run already ended; a file left behind is a smaller problem than a masked result. + } +} + +/** What the first process adds to the second process's environment. `secrets` is given by `prisma dev` only. */ +export function secondProcessEnvironment(input: { + readonly configurationFile: string; + readonly nodeAddresses: readonly string[]; + readonly secrets?: AppSecrets; +}): Record { + return { + [CONFIGURATION_FILE_ENV]: input.configurationFile, + [NODE_ADDRESSES_ENV]: JSON.stringify(input.nodeAddresses), + ...(input.secrets === undefined ? {} : { [APP_SECRETS_ENV]: JSON.stringify(input.secrets) }), + }; +} + +/** Second process: what the first process checked, from the configuration file and the node addresses it names. */ +export function readCheckedConfiguration(env: Readonly>): { + readonly configuration: CheckedConfiguration; + readonly nodeAddresses: readonly string[]; +}; + +/** Second process, under `prisma dev`: the app secrets the first process handed it. */ +export function appSecretsFromEnvironment(env: Readonly>): AppSecrets; +``` + +`readCheckedConfiguration`: + +1. `const file = env[CONFIGURATION_FILE_ENV]`; when it is `undefined` or `''`, throws `new Error(missingVariable(CONFIGURATION_FILE_ENV, 'names the configuration file it wrote there'))`. +2. Reads the file with `fs.readFileSync(file, 'utf8')`. A read error throws `new Error(\`Could not read the configuration file ${file}: ${message}. Composer deletes it when the run ends, so a stack file run by hand after that cannot read it; run the command again.\`, { cause })`. +3. Parses it with `JSON.parse`. A parse error, or a result that is not an object whose `rootInput` is an array of objects each with a `path` that is an array of strings, and whose `values` is an object of strings, throws `new Error(\`The configuration file ${file} is not one Composer wrote: .\`)`, where `` is `it is not JSON`, `rootInput is not a list of fields`, or `values is not an object of text`. +4. `const addresses = env[NODE_ADDRESSES_ENV]`; when it is `undefined` or `''`, throws `new Error(missingVariable(NODE_ADDRESSES_ENV, 'lists the node addresses it checked there'))`. When it does not parse to an array of strings, throws `new Error(\`${NODE_ADDRESSES_ENV} is not a JSON list of node addresses.\`)`. +5. Returns `{ configuration: { rootInput, values }, nodeAddresses }`, built from the parsed data without a cast: module-private type guards `isRecord(value): value is Record` and `isTextList(value): value is string[]` check each part, each field is rebuilt as `{ path }` or `{ path, value }` (a field without a `value` key stays without one), and `values` is rebuilt entry by entry, refusing an entry that is not text. + +`appSecretsFromEnvironment`: `env[APP_SECRETS_ENV]` undefined or `''` throws `new Error(missingVariable(APP_SECRETS_ENV, 'hands the app secrets the local services need there'))`; a value that does not parse to an object of strings throws `new Error(\`${APP_SECRETS_ENV} is not a JSON object of text.\`)`; otherwise returns a frozen object with no prototype holding the parsed entries. + +`missingVariable(name, what)` is module-private and returns `` `${name} is not set. This stack file runs as the second process of a Composer deploy, destroy or prisma dev, which ${what}.` ``. + +### 8.2 The generated deploy stack file, `generate-stack.ts` + +1. `renderOptions`: `lines.push(' configPool,');` becomes: + + ```ts + lines.push(' checkedConfiguration: checked.configuration,'); + lines.push(' checkedNodeAddresses: checked.nodeAddresses,'); + ``` + +2. In the template, the header line `// It reads the config pool again here, from the same adapters, so an adapter must return the same values for the same environment every time.` becomes `// It runs no config adapter: it reads the values the CLI checked from the configuration file named in PRISMA_COMPOSER_CONFIGURATION_FILE.` The header's `(the PRISMA_COMPOSER_CONTAINER_*/_PREFLIGHT_* vars —` becomes `(the PRISMA_COMPOSER_CONTAINER_*/_PREFLIGHT_* vars and the configuration file —`. +3. The import `import { readConfigPool } from '@prisma/composer/internal/pools';` becomes `import { readCheckedConfiguration } from '@prisma/composer/internal/configuration-file';`. +4. The lines from `const configPool = await readConfigPool(…);` to `report.captureEngineFailure?.();` become: + + ```ts + // The app's own @prisma/composer may predate the failure recorder. + report.captureEngineFailure?.(); + + const checked = readCheckedConfiguration(process.env); + ``` + + The failure recorder starts first, so a missing configuration file is recorded as the cause. + +### 8.3 The generated dev stack file, `dev/generate-dev-stack.ts` + +1. `renderOptions`: `lines.push(' configPool: pools.config,');` becomes the same two lines as § 8.2, and `' providers: localTargetProviders(resolved, containers, devDir, pools),'` becomes `' providers: localTargetProviders(resolved, containers, devDir, checked.configuration.values, secrets),'`. +2. In `imports`, `"import { readConfigPool, readSecretsPool } from '@prisma/composer/internal/pools';"` becomes `"import { appSecretsFromEnvironment, readCheckedConfiguration } from '@prisma/composer/internal/configuration-file';"`. +3. The header line `// It reads the config and secrets pools again here, …` becomes `// It runs no config or secrets adapter: it reads the values the CLI checked from the configuration file named in PRISMA_COMPOSER_CONFIGURATION_FILE, and the app secrets from PRISMA_COMPOSER_APP_SECRETS.` +4. The two lines `const poolInput = …;` and `const pools = …;` become: + + ```ts + const checked = readCheckedConfiguration(process.env); + const secrets = appSecretsFromEnvironment(process.env); + ``` + +### 8.4 `operations/execute-deploy-destroy.ts` + +1. Imports: `poolSecrets` goes from the `../deployment-summary.ts` import; new imports `nodeAddresses` from `@internal/core`, `removeConfigurationFile, secondProcessEnvironment, writeConfigurationFile` from `../configuration-file.ts` and `KnownSecrets, redactError, redactSummary` from `../redaction.ts`. +2. `StackPipelineOptions` gains `readonly knownSecrets: KnownSecrets;`. `executeDeploy` and `executeDestroy` pass `knownSecrets: new KnownSecrets()`. +3. `pipelineDeps` gains `knownSecrets: opts.knownSecrets`. +4. `extension.preflight({ …, pools: pipeline.pools, … })` becomes `configuration: pipeline.configuration, secrets: pipeline.secrets,`. +5. After `resultFilePath`: + + ```ts + const configurationFilePath = path.join( + cwd, + '.prisma-composer', + `configuration-${String(process.pid)}-${randomUUID()}.json`, + ); + ``` + +6. Inside the try that writes the stack file, after `stackPath = writeStackFile({ … });`: `writeConfigurationFile(configurationFilePath, pipeline.graph.checkedConfiguration);`. A failure is `DEPLOY.STACK_WRITE_FAILED`, as for the stack file. +7. `alchemyInvocation`'s `env` becomes: + + ```ts + env: { + ...reporterChildEnv(reporters, opts.knownSecrets), + ...secondProcessEnvironment({ + configurationFile: configurationFilePath, + nodeAddresses: nodeAddresses(pipeline.graph), + }), + [DEPLOYMENT_RESULT_FILE_ENV]: resultFilePath, + }, + redactedValues: opts.knownSecrets.values(), + ``` + +8. `readEngineFailureCause(resultFilePath, poolSecrets(…))` becomes `readEngineFailureCause(resultFilePath, (text) => opts.knownSecrets.redact(text))`. +9. `return ok(readDeploymentSummary(resultFilePath));` becomes `return ok(redactSummary(readDeploymentSummary(resultFilePath), opts.knownSecrets));`. +10. The `finally`'s cleanup gains `removeConfigurationFile(configurationFilePath);` after the two `fs.rmSync` calls, outside their `try`. +11. `runStackPipeline`: + - The `catch (error)` that records a defect: before `await finishReporters(…)`, `if (error instanceof Error) redactThrown(error, opts.knownSecrets);`, where `redactThrown` is a module-private function setting `error.message = known.redact(error.message)` and, when `error.stack` is a string, `error.stack = known.redact(error.stack)`. The defect's message reaches `finishReporters` already redacted. + - Directly after `outcome = await runStackPipelineInner(…)` succeeds and before `if (!outcome.ok) opts.steps.failOpen();`: `if (!outcome.ok) outcome = notOk(redactError(outcome.failure, opts.knownSecrets));`. The run report, the reporters' `errorMessage` and the returned failure all read the redacted failure. +12. `beginReporters`, `attachReporters`, `finishReporters` and `reporterChildEnv` gain a last parameter `known: KnownSecrets`, and each `console.warn` passes `known.redact(detail)` in place of `detail`. + +### 8.5 `operations/execute-dev.ts` + +1. Imports: `type ConfigPools` goes; new imports `type PipelineResult` from `../pipeline.ts`, `nodeAddresses` and `secretsKeys` from `@internal/core`, `removeConfigurationFile, secondProcessEnvironment, writeConfigurationFile` from `../configuration-file.ts`, `KnownSecrets, redactDevEvent, redactError` from `../redaction.ts`, and `type AlchemyInvocation` from `../run-alchemy.ts`. +2. `executeDev`'s body moves into a module-private `executeDevInner(input, deps, cwd, known: KnownSecrets)`, unchanged except as below. `executeDev` becomes: + + ```ts + export async function executeDev( + input: DevInput, + deps: OperationDeps, + cwd: string, + ): Promise> { + const known = new KnownSecrets(); + try { + const result = await executeDevInner(input, deps, cwd, known); + return result.ok ? result : notOk(redactError(result.failure, known)); + } catch (error) { + if (error instanceof Error) { + error.message = known.redact(error.message); + if (error.stack !== undefined) error.stack = known.redact(error.stack); + } + throw error; + } + } + ``` + +3. `emit` becomes `const emit = (event: DevEvent): void => { try { onEvent?.(redactDevEvent(event, known)); } catch { … } };`, the catch unchanged. +4. `samePools` becomes: + + ```ts + function sameValues(started: PipelineResult, now: PipelineResult): boolean { + return ( + isDeepStrictEqual(started.configuration, now.configuration) && + isDeepStrictEqual(started.secrets, now.secrets) + ); + } + ``` + + and its call `samePools(pipeline.pools, rePipeline.pools)` becomes `sameValues(pipeline, rePipeline)`. +5. `pipelineDeps` and `watchDeps` gain `knownSecrets: known`. +6. `dev.preflight({ …, pools: pipeline.pools, … })` becomes `configuration: pipeline.configuration, secrets: pipeline.secrets,`; `serve({ address, node, server, pools: pipeline.pools })` becomes `serve({ address, node, server, configuration: pipeline.configuration, secrets: pipeline.secrets })`. +7. The constant `invocation` goes. In its place: + + ```ts + const configurationFilePath = path.join(devDir, 'configuration.json'); + const invocationFor = (p: PipelineResult): AlchemyInvocation => { + const secrets: Record = {}; + for (const key of secretsKeys(p.graph)) { + const value = p.secrets[key]; + if (value !== undefined && value !== '') secrets[key] = value; + } + return alchemyInvocation({ + command: 'deploy', + stackFileRelativePath: devStackRelativePath(environment), + cwd, + stage: environment, + containerEnv: containerEnv(containers), + preflightEnv: preflightTransportEnv, + env: secondProcessEnvironment({ + configurationFile: configurationFilePath, + nodeAddresses: nodeAddresses(p.graph), + secrets, + }), + redactedValues: known.values(), + }); + }; + ``` + +8. `converge` becomes `converge(p: PipelineResult): Promise<{ outcome; stackPath; invocation }>`. It builds `const invocation = invocationFor(p);`; writes the dev stack file from `p` and, in the same try, `writeConfigurationFile(configurationFilePath, p.graph.checkedConfiguration);` (both `DEV.STACK_WRITE_FAILED`); runs the converge; and in a `finally` around the converge call, `removeConfigurationFile(configurationFilePath);`. It returns `invocation` too. The first converge calls `converge(pipeline)`, and its `reproduceCommand(invocation, …)` uses the returned invocation. +9. `rebuild`: the block from `const stackPath = writeDevStackFile({ … });` to the `converge-failed` emit becomes: + + ```ts + const { outcome, stackPath, invocation } = await converge(rePipeline); + if (outcome.signal !== null || outcome.exitCode !== 0) { + emit({ + kind: 'converge-failed', + stackFilePath: stackPath, + reproduceCommand: reproduceCommand(invocation, outcome), + cwd, + }); + return; + } + ``` + + A `CliStructuredError` that `converge` throws is caught by `rebuild`'s existing catch and emitted as `rebuild-failed`. + +The dev configuration file lives only while a converge runs, which is shorter than the design's "deleted when `prisma dev` stops" (§ 20, decision 9). + +### 8.6 Entries and packaging + +1. `packages/0-framework/3-tooling/cli/src/exports/pools.ts` is deleted. New `src/exports/configuration-file.ts`: + + ```ts + /** What the generated stack files read in the second process. Not public API: published as `@prisma/composer/internal/configuration-file`, it may change in any release. */ + export { + appSecretsFromEnvironment, + readCheckedConfiguration, + } from '../configuration-file.ts'; + ``` + + New `src/exports/output-relay.ts`: + + ```ts + /** The relay the CLI runs the deploy engine through. Not public API: `@prisma/composer/internal/output-relay` runs it. */ + export { runOutputRelay } from '../output-relay.ts'; + ``` + +2. `packages/0-framework/3-tooling/cli/tsdown.config.ts`: the entry `pools: 'src/exports/pools.ts',` becomes `'configuration-file': 'src/exports/configuration-file.ts',` and `'output-relay': 'src/exports/output-relay.ts',`. Run `pnpm --filter @internal/cli build`; it regenerates `package.json#exports` with `./configuration-file` and `./output-relay` and without `./pools`. Commit the regenerated file; do not hand-edit it. +3. `packages/9-public/composer/src/exports/internal-pools.ts` is deleted. New `internal-configuration-file.ts`: `export * from '@internal/cli/configuration-file';`. New `internal-output-relay.ts`: + + ```ts + /** Run as a process, never imported: `node `. */ + import { runOutputRelay } from '@internal/cli/output-relay'; + + runOutputRelay(process.argv.slice(2), process.env); + ``` + +4. `packages/9-public/composer/tsdown.config.ts`: `'internal-pools': 'src/exports/internal-pools.ts',` becomes `'internal-configuration-file': 'src/exports/internal-configuration-file.ts',` and `'internal-output-relay': 'src/exports/internal-output-relay.ts',`. +5. `packages/9-public/composer/package.json#exports`: `"./internal/pools": "./dist/internal-pools.mjs",` becomes `"./internal/configuration-file": "./dist/internal-configuration-file.mjs",` and `"./internal/output-relay": "./dist/internal-output-relay.mjs",`. +6. `architecture.config.json`: the entry for `packages/9-public/composer/src/exports/internal-pools.ts` becomes two entries, for `internal-configuration-file.ts` and `internal-output-relay.ts`, each `"domain": "public", "layer": "public", "plane": "control"`. +7. `tsconfig.depcruise.json`: the `@prisma/composer/internal/pools` path becomes `@prisma/composer/internal/configuration-file` → `./packages/9-public/composer/src/exports/internal-configuration-file.ts`, and a new `@prisma/composer/internal/output-relay` → `./packages/9-public/composer/src/exports/internal-output-relay.ts`. + +## 9. CLI: Composer redacts every secret it knows + +### 9.1 What a command knows + +| Values | When they are added | Length | +| --- | --- | --- | +| Every value a written `secrets` list returns | as each adapter returns, in `readAppSecrets` | any | +| Each app configuration value under a name that looks like a credential (`SECRET_ENV_NAME`, S4's list) | after the app configuration is read | eight characters or more | +| Under each name `secretsKeys(graph)` lists, the value in the app configuration and the value in the app secrets | after Load | any | + +`KnownSecrets.redact(text)` also applies `redactSecrets(text, process.env)`: the values of this process's own variables under credential-looking names, eight characters or more, bearer and basic tokens, JWTs, a password in a URL, and `token=…` pairs, as today. The stream redactor (§ 9.4) uses exact values only: the known values and `secretNamedValues` of its own environment. + +### 9.2 `deployment-summary.ts` + +1. `import type { ConfigPools } from '@internal/core/config';` goes. +2. `readEngineFailureCause`'s signature and doc comment become: + + ```ts + /** Parent half: the cause the child recorded, passed through `redact`, or undefined when it recorded none. The child redacts only what its own environment names as a credential; the parent knows every secret the command read. */ + export function readEngineFailureCause( + resultFilePath: string, + redact: (text: string) => string, + ): string | undefined + ``` + + and its last line `return cause.length > 0 ? redactValues(cause, secrets) : undefined;` becomes `return cause.length > 0 ? redact(cause) : undefined;`. +3. `poolSecrets` is removed. +4. `redactValues` and its doc comment become exported, without a minimum length: + + ```ts + /** `text` with each value in `values` replaced by `[redacted]`, longest first. A value is replaced at any length; an empty one is skipped. */ + export function redactValues(text: string, values: Iterable): string { + let redacted = text; + for (const value of [...values].sort((a, b) => b.length - a.length)) { + if (value.length > 0) redacted = redacted.replaceAll(value, REDACTED); + } + return redacted; + } + ``` + +5. New export, before `redactSecrets`: + + ```ts + /** The values of `env`'s variables whose names look like credentials, eight characters or more, so a one-letter placeholder does not blank every matching letter. */ + export function secretNamedValues(env: Readonly>): string[] { + return Object.entries(env).flatMap(([name, value]) => + SECRET_ENV_NAME.test(name) && value !== undefined && value.length >= 8 ? [value] : [], + ); + } + ``` + + `SECRET_ENV_NAME` and `REDACTED` move above it and `REDACTED` is exported. `redactSecrets`'s first statement becomes `return redactValues(text, secretNamedValues(env))` followed by the same four `.replace` calls; its doc comment's "Values shorter than 8 characters are left, so a one-letter placeholder does not blank every matching letter." stays. + +`deployment-summary.ts` still imports nothing but `node:fs`, `node:util`, core's types and `blindCast`, so the `report` entry stays light. + +### 9.3 New file `packages/0-framework/3-tooling/cli/src/redaction.ts` + +```ts +import type { AppConfiguration, AppSecrets } from '@internal/core/config'; +import { CliStructuredError } from '@internal/foundation/errors'; +import { + type DeploymentSummary, + REDACTED, + redactSecrets, + redactValues, + secretNamedValues, +} from './deployment-summary.ts'; +import type { DevEvent } from './operations/dev.ts'; + +/** + * The secret values one command knows (ADR-0062). It learns them as the command reads them, + * and everything the command prints, returns or reports passes through `redact`. + */ +export class KnownSecrets { + private readonly known = new Set(); + + /** Adds each value that is not empty. */ + add(values: Iterable): void { + for (const value of values) if (value.length > 0) this.known.add(value); + } + + /** Every value added so far, longest first. */ + values(): readonly string[] { + return [...this.known].sort((a, b) => b.length - a.length); + } + + /** `text` with every known value replaced by `[redacted]` at any length, then whatever `redactSecrets` finds by name and by shape. */ + redact(text: string): string { + return redactSecrets(redactValues(text, this.known), process.env); + } +} + +/** + * The secret values the app configuration and the app secrets hold: under each name in + * `secretsKeys`, the value in either, at any length; and each app configuration value of + * eight characters or more under a name that looks like a credential. + */ +export function secretValuesIn( + configuration: AppConfiguration, + secrets: AppSecrets, + secretsKeys: readonly string[], +): string[] { + const text = Object.fromEntries( + Object.entries(configuration).flatMap(([name, value]) => + typeof value === 'string' ? [[name, value]] : [], + ), + ); + const named = secretsKeys.flatMap((key) => + [text[key], secrets[key]].filter((value): value is string => value !== undefined), + ); + return [...named, ...secretNamedValues(text)]; +} + +function redactData(value: unknown, known: KnownSecrets): unknown { + if (typeof value === 'string') return known.redact(value); + if (Array.isArray(value)) return value.map((member) => redactData(member, known)); + if (typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype) { + return Object.fromEntries( + Object.entries(value).map(([key, member]) => [key, redactData(member, known)]), + ); + } + return value; +} + +/** + * `error` with every known secret removed from its summary, `why`, `fix` and every string in + * its `meta`. The code, severity, `where` and docs link are kept. The cause is dropped, because + * it holds the same text unredacted and a renderer may print it. + */ +export function redactError(error: CliStructuredError, known: KnownSecrets): CliStructuredError { + return new CliStructuredError(error.code, known.redact(error.message), { + severity: error.severity, + ...(error.why === undefined ? {} : { why: known.redact(error.why) }), + ...(error.fix === undefined ? {} : { fix: known.redact(error.fix) }), + ...(error.where === undefined ? {} : { where: error.where }), + ...(error.meta === undefined + ? {} + : { + meta: Object.fromEntries( + Object.entries(error.meta).map(([key, value]) => [key, redactData(value, known)]), + ), + }), + ...(error.docsUrl === undefined ? {} : { docsUrl: error.docsUrl }), + }); +} + +/** `event` with every known secret removed from the text it carries: a message, or a dev server's output line. */ +export function redactDevEvent(event: DevEvent, known: KnownSecrets): DevEvent { + switch (event.kind) { + case 'target-warning': + case 'rebuild-failed': + case 'watch-error': + case 'stop-error': + return { ...event, message: known.redact(event.message) }; + case 'dev-server-output': + return { ...event, line: known.redact(event.line) }; + default: + return event; + } +} + +/** `summary` with every known secret removed from each entity's id, url and details. */ +export function redactSummary( + summary: DeploymentSummary | undefined, + known: KnownSecrets, +): DeploymentSummary | undefined { + if (summary === undefined) return undefined; + return { + app: summary.app, + nodes: summary.nodes.map((node) => ({ + address: node.address, + entities: node.entities.map((entity) => ({ + ...entity, + id: known.redact(entity.id), + ...(entity.url === undefined ? {} : { url: known.redact(entity.url) }), + ...(entity.details === undefined + ? {} + : { + details: Object.fromEntries( + Object.entries(entity.details).map(([key, value]) => [key, known.redact(value)]), + ), + }), + })), + })), + }; +} + +/** + * Writes a stream of text on with every value in `values` replaced by `[redacted]`. It holds + * back one fewer character than the longest value, so a value split across two chunks is still + * replaced; `end` writes what it held back. + */ +export class RedactingWriter { + private held = ''; + private readonly pattern: RegExp | undefined; + private readonly keep: number; + + constructor( + values: readonly string[], + private readonly write: (text: string) => void, + ) { + const sorted = [...new Set(values)] + .filter((value) => value.length > 0) + .sort((a, b) => b.length - a.length); + this.pattern = + sorted.length === 0 + ? undefined + : new RegExp(sorted.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'g'); + this.keep = (sorted[0]?.length ?? 1) - 1; + } + + push(chunk: string): void { + const text = this.held + chunk; + let cut = Math.max(0, text.length - this.keep); + let out = ''; + let from = 0; + if (this.pattern !== undefined) { + for (const match of text.matchAll(this.pattern)) { + if (match.index >= cut) break; + out += text.slice(from, match.index) + REDACTED; + from = match.index + match[0].length; + if (from > cut) cut = from; + } + } + out += text.slice(from, cut); + this.held = text.slice(cut); + if (out.length > 0) this.write(out); + } + + end(): void { + const text = this.held; + this.held = ''; + const out = this.pattern === undefined ? text : text.replace(this.pattern, REDACTED); + if (out.length > 0) this.write(out); + } +} +``` + +Neither `redaction.ts` nor `configuration-file.ts` casts, so `pnpm lint:casts` reports `delta=0`. A value that starts before the cut is always whole inside `text`, because it is at most `keep + 1` characters long, so the redactor never cuts a value in two (§ 18 shows the probe). + +### 9.4 `run-alchemy.ts` + +1. `AlchemyInvocation` gains, after `env`: + + ```ts + /** Every secret value the command knows, for the output pipe to redact. Never in `env` or on the command line, which the engine may log. */ + readonly redactedValues: readonly string[]; + ``` + + `AlchemyInvocationInput` gains the same member with the same comment, and `alchemyInvocation` returns `redactedValues: input.redactedValues`. +2. New export, before `spawnCommandLine`: + + ```ts + /** Where a piped child's output is written. */ + export interface OutputStreams { + readonly stdout: { write(text: string): unknown }; + readonly stderr: { write(text: string): unknown }; + } + ``` + +3. `spawnCommandLine` becomes: + + ```ts + /** Starts a resolved command line with stdin inherited and its output piped through redaction, and returns how it ended. */ + export function spawnCommandLine( + line: AlchemyCommandLine, + redactedValues: readonly string[], + outputs: OutputStreams = process, + ): Promise { + return new Promise((resolve, reject) => { + const child = spawn(line.command, [...line.args], { + cwd: line.cwd, + stdio: ['inherit', 'pipe', 'pipe'], + env: { ...process.env, ...line.env }, + }); + const values = [...redactedValues, ...secretNamedValues(process.env)]; + const stdout = new RedactingWriter(values, (text) => outputs.stdout.write(text)); + const stderr = new RedactingWriter(values, (text) => outputs.stderr.write(text)); + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => stdout.push(chunk)); + child.stderr?.setEncoding('utf8').on('data', (chunk: string) => stderr.push(chunk)); + child.on('error', reject); + child.on('close', (exitCode, signal) => { + stdout.end(); + stderr.end(); + resolve({ exitCode, signal }); + }); + }); + } + ``` + + It imports `secretNamedValues` from `./deployment-summary.ts` and `RedactingWriter` from `./redaction.ts`. +4. `spawnAlchemy` becomes `return { ...(await spawnCommandLine(commandLine, invocation.redactedValues)), commandLine };`, and its doc comment's "inherited stdio" becomes "stdin inherited and output piped through redaction". +5. The file header's "hand the terminal to the generated stack file" becomes "run the generated stack file, with its output piped through redaction". + +### 9.5 The output relay, for the CLI + +The engine's `ctx.spawn` hands its child the terminal (human mode) or routes its output to the engine's diagnostic stream (JSON mode); it cannot pipe it to Composer. So the CLI's child is a relay, which pipes Alchemy's output and writes it on. + +New file `packages/0-framework/3-tooling/cli/src/output-relay.ts`: + +```ts +import spawn from 'cross-spawn'; +import { secretNamedValues } from './deployment-summary.ts'; +import { RedactingWriter } from './redaction.ts'; + +/** Names the JSON list of values the relay redacts. The relay removes it from its child's environment. */ +export const REDACTED_VALUES_ENV = 'PRISMA_COMPOSER_REDACTED_VALUES'; + +function listedValues(text: string | undefined): readonly string[] | undefined { + if (text === undefined) return []; + try { + const parsed: unknown = JSON.parse(text); + return Array.isArray(parsed) && parsed.every((value) => typeof value === 'string') ? parsed : undefined; + } catch { + return undefined; + } +} + +/** + * Runs `argv[0]` with the rest of `argv`, its stdin inherited and its output piped, and writes + * that output on with every known secret replaced by `[redacted]`. The child's stdout goes to + * this process's stdout when that is a terminal, and to its stderr otherwise, because a piped + * stdout carries the CLI's data lines. The terminal's signals reach the child directly; SIGTERM, + * which has no such path, is forwarded. The relay ends as the child did: with its status, or by + * its signal. + */ +export function runOutputRelay(argv: readonly string[], env: NodeJS.ProcessEnv): void { + const [command, ...args] = argv; + const { [REDACTED_VALUES_ENV]: listed, ...childEnv } = env; + const known = listedValues(listed); + if (command === undefined || known === undefined) { + process.stderr.write( + command === undefined + ? 'output-relay: no command to run.\n' + : `output-relay: ${REDACTED_VALUES_ENV} is not a JSON list of text.\n`, + ); + process.exitCode = 2; + return; + } + const values = [...known, ...secretNamedValues(childEnv)]; + const child = spawn(command, args, { stdio: ['inherit', 'pipe', 'pipe'], env: childEnv }); + const stdoutTarget = process.stdout.isTTY === true ? process.stdout : process.stderr; + const stdout = new RedactingWriter(values, (text) => stdoutTarget.write(text)); + const stderr = new RedactingWriter(values, (text) => process.stderr.write(text)); + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => stdout.push(chunk)); + child.stderr?.setEncoding('utf8').on('data', (chunk: string) => stderr.push(chunk)); + const leaveToChild = (): void => {}; + for (const signal of ['SIGINT', 'SIGQUIT', 'SIGHUP'] as const) process.on(signal, leaveToChild); + process.on('SIGTERM', () => child.kill('SIGTERM')); + child.on('error', (error) => { + process.stderr.write(`output-relay: could not start ${command}: ${error.message}\n`); + process.exitCode = 1; + }); + child.on('close', (exitCode, signal) => { + stdout.end(); + stderr.end(); + if (signal !== null) { + process.removeAllListeners(signal); + process.kill(process.pid, signal); + return; + } + process.exitCode = exitCode ?? 1; + }); +} +``` + +`alchemy-bin.ts` gains: + +```ts +/** + * The relay the CLI runs the deploy engine through, from the app's own `@prisma/composer`, found + * as `resolveAlchemyBin` finds Alchemy. Raises DEPLOY.OUTPUT_RELAY_MISSING when it cannot be resolved. + */ +export function resolveOutputRelay(appDir: string): string { + try { + return createRequire(path.join(appDir, 'package.json')).resolve( + '@prisma/composer/internal/output-relay', + ); + } catch { + throw new CliStructuredError( + 'DEPLOY.OUTPUT_RELAY_MISSING', + `Could not resolve @prisma/composer/internal/output-relay from "${appDir}".`, + { + why: "Composer runs the deploy engine through this relay, which removes known secrets from the engine's output. The app's @prisma/composer is older than @prisma/composer-cli, or is not installed.", + fix: 'Install the same version of @prisma/composer as of @prisma/composer-cli.', + }, + ); + } +} +``` + +`family/converge.ts`: + +1. Imports gain `REDACTED_VALUES_ENV` from `../output-relay.ts` and `resolveOutputRelay` from `../alchemy-bin.ts`. +2. `convergeSpawn` and its doc comment become, with a new exported type and a module-private default: + + ```ts + /** How the engine starts the output relay: a command and the arguments before the Alchemy command line. */ + export interface RelayCommand { + readonly command: string; + readonly args: readonly string[]; + } + + const appOutputRelay = (line: AlchemyCommandLine): RelayCommand => ({ + command: line.command, + args: [resolveOutputRelay(line.cwd)], + }); + + /** + * The adapter the operations call, backed by the engine's `ctx.spawn`, so the engine owns + * signal policy. The engine's child is the output relay, which runs Alchemy with its output + * piped and writes it on with every known secret redacted. The values travel in the relay's + * environment, never on its command line, which the engine may log. + */ + export function convergeSpawn( + ctx: ConvergeContext, + outputRelay: (line: AlchemyCommandLine) => RelayCommand = appOutputRelay, + ): RunAlchemy { + return async (invocation) => { + const commandLine = alchemyCommandLine(invocation); + const relay = outputRelay(commandLine); + const result = await ctx.spawn({ + command: relay.command, + args: [...relay.args, commandLine.command, ...STDOUT_TO_STDERR, ...commandLine.args], + cwd: commandLine.cwd, + env: { ...commandLine.env, [REDACTED_VALUES_ENV]: JSON.stringify(invocation.redactedValues) }, + }); + return { ...result, commandLine }; + }; + } + ``` + + `converge.ts` imports `type AlchemyCommandLine` from `../run-alchemy.ts`. Tests pass a relay that Bun runs from source (§ 15.3). + +`reproduceCommand` keeps printing the Alchemy command line, which `outcome.commandLine` carries. Under the relay, Alchemy's stdout is a pipe, so `STDOUT_TO_STDERR` and `captureEngineFailure` send its output to stderr, and Alchemy shows plain progress lines without colour (§ 18 shows Alchemy deploying with piped output). + +## 10. The Prisma Cloud target and the local target + +1. `packages/1-prisma-cloud/1-extensions/target/src/param.ts`: in `envParam`, `{ label: \`envParam('${name}')\`, poolKey: name }` becomes `{ label: \`envParam('${name}')\`, configurationKey: name }`; the doc comment's "the variable's value in the config pool" becomes "the variable's value in the app configuration". +2. `secret.ts`: in `envSecret`, `{ label: \`envSecret('${name}')\` }` becomes `{ label: \`envSecret('${name}')\`, secretsKey: name }`. +3. `git mv pool-text.ts configuration-text.ts`; the function becomes `configurationText(configuration: Readonly>, name: string, notText: (kind: string) => Error): string | undefined`, doc comment `The text the app configuration holds for \`name\`, as an environment variable would: \`undefined\` when it is absent or empty. Throws the error \`notText\` builds when it holds anything else there.` Its three importers (`serializer.ts`, `preflight.ts`, `local-target/preflight.ts`) import and call it by the new name. +4. `serializer.ts`: `SerializeInputOptions.env`'s doc comment becomes `Where \`envParam\` leaves are read: the app configuration, or in the second process the checked values from the configuration file. The process environment when not given.` +5. `descriptors/compute.ts`: `...(ctx.opts.configPool !== undefined ? { env: ctx.opts.configPool } : {}),` becomes `...(ctx.opts.checkedConfiguration !== undefined ? { env: ctx.opts.checkedConfiguration.values } : {}),`. +6. `preflight.ts`: `poolValue` becomes `valueOf`; `input.pools.secrets[meta.name]` becomes `input.secrets[meta.name]`; `poolText(input.pools.config, …)` becomes `configurationText(input.configuration, …)`; the doc comments use § 4.2's words. +7. `preflight-names.ts`: `/** An env-sourced param's config pool value that preflight cannot write to a platform variable. */` becomes `/** An env-sourced param's app configuration value that preflight cannot write to a platform variable. */`. +8. `local-target/preflight.ts`: `const env = input.pools.config;` becomes `const env = input.configuration;`; `input.pools.secrets[name]` becomes `input.secrets[name]`; the header's first sentence becomes `Every value comes from what the command read: secrets from the app secrets, env-sourced params from the app configuration.` +9. `local-target/attach.ts`: `serve: async ({ address, node, server, pools }) =>` becomes `serve: async ({ address, node, server, configuration, secrets }) =>`, and its `materializeEnv(…, pools)` becomes `materializeEnv(input.devDir, address, instance.port, configuration, secrets)`. +10. `packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts`: `materializeEnv`'s last parameter `pools: ConfigPools` becomes `configuration: AppConfiguration, secrets: AppSecrets`; `pools.secrets[name]` becomes `secrets[name]` and `pools.config[name]` becomes `configuration[name]`; the provider's call passes `input.configuration, input.secrets`. +11. `packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts`: in invariant 4's comment, "values come from the pools" becomes "values come from the app configuration and the app secrets". +12. `local-target/preflight.ts`, `howToSet`: its return becomes + + ```ts + const files = + environment === 'test' + ? `.env, .env.${encoded} and .env.${encoded}.local` + : `.env, .env.local, .env.${encoded} and .env.${encoded}.local`; + return ( + 'Set each missing variable where an adapter in prisma.config.ts reads it: the `secrets` list of ' + + 'the `composer` section for a secret, the `config` list for an env-sourced param. ' + + `environmentVariables() reads the shell you run \`prisma dev\` from; dotenv() reads ${files} beside prisma.config.ts.` + ); + ``` + + so it never names `.env.local` for the environment `test`, which `dotenv()` skips. + +In the dev second process, `configuration` is `checked.configuration.values`, the text of each configuration key the graph reads, which is every name a param row points at; `secrets` is what `PRISMA_COMPOSER_APP_SECRETS` holds, every name a `$secret` pointer names. + +## 11. What breaks, and what does not + +Breaks: + +1. `LoadOptions.configPool` is now `configuration`. `LowerOptions.configPool` is gone; a hand-written stack passes `checkedConfiguration` or nothing. +2. `paramSource()`'s `poolKey` option is now `configurationKey`. +3. `ConfigPool`, `SecretsPool` and `ConfigPools` are now `AppConfiguration` and `AppSecrets`; `PreflightInput`, `LocalTargetProvidersInput` and a local attachment's `serve` input take `configuration` and `secrets` in place of `pools`; `localTargetProviders()` takes them as two arguments. This touches extension authors only. +4. `@prisma/composer/internal/pools` is gone. It was not public API. +5. `dotenv()` now skips `.env.local` for the environment `test`, and parses with `dotenv` 16 through `dotenv-flow`. +6. The deploy engine's output reaches the terminal through a pipe: Alchemy shows plain progress lines, without colour or a live progress display. +7. A known secret is redacted at any length. A one-character secret blanks that character everywhere in the output. +8. A module body that builds a different graph in the second process fails the deploy, naming the addresses. +9. A root field whose real value JSON cannot hold exactly, such as a `Date`, fails the Load in the first process. +10. A name read both by `envParam()` and by `envSecret()` fails the Load. +11. An `envParam()` in a service's input or params whose app configuration value is not text fails at Load, in the first process. Before, it failed later, in the serializer or preflight. +12. A config adapter now runs after the secrets adapters, so a broken secrets adapter is reported before a broken config adapter. +13. The app's `@prisma/composer` must be at least the version that ships `internal/output-relay` and `internal/configuration-file`; an older one fails with `DEPLOY.OUTPUT_RELAY_MISSING` under the CLI, or when the second process imports the stack file. + +Unchanged: + +1. A project with neither `config` nor `secrets` reads the shell, as before, in the first process. +2. The input document, the pointer rows, the `$secret` pointers and every deploy's resources. +3. Every error message not listed in § 12. + +The only existing tests this PR changes are those § 15.1 lists. If any other existing test fails, the implementer stops and reports. + +## 12. Error messages, verbatim + +| Where | Condition | Message | +| --- | --- | --- | +| `loadModule` | both `configuration` and `checkedConfiguration` | `Load takes configuration, in the first process, or checkedConfiguration, in the second, not both.` | +| `loadModule` | a root with `input` and neither | ``Module "" declares input and is being deployed as the root, but Load was given no app configuration to fill it from. Pass { configuration } to Load; prisma deploy and prisma dev pass the values the `config` adapters return.`` | +| `configurationText` | no app configuration | `` depends on