diff --git a/.drive/projects/module-input/deferred.md b/.drive/projects/module-input/deferred.md index 6580fa8af..ceab3f38b 100644 --- a/.drive/projects/module-input/deferred.md +++ b/.drive/projects/module-input/deferred.md @@ -9,3 +9,10 @@ - Later: a service wrapper generic only in `I`, with no dependencies, that does not widen the node, cannot call `provision(s, { input })`. TypeScript defers the conditional on an unbound `I` and refuses the call. Pre-existing (it failed before S9 as well). Widening the node to `ServiceNode` works around it, as `module-composition.md` documents. Found in the S10 round-2 review. - Later: an app that installs a different `@prisma/composer` version from the one `@prisma/composer-prisma-cloud` pins exactly gets a second, nested copy. `SecretBox` and the node brand then split again, and a redacted `generatedParam()` no longer fits a `secretString()` field. Pre-existing and not specific to S10's fix. Found in the S10 round-2 review. - Later: `@prisma/composer-prisma-cloud`'s published JavaScript still inlines some foundation code: `describeKind` from `@internal/foundation/errors` (no public entry exports it), and `blindCast`, `field` and the secret and pointer helpers inside the `testing` entries and `local-target`, which come from internal packages' prebuilt `dist`. Each reads `Symbol.for` brands or is a plain function, so behavior matches, and no foundation type is inlined in the declarations. Found while fixing D16 of the S10 round-2 review. +- Later: two redaction markers reach the user. `SecretString` and `SecretBox` print `[REDACTED]`; the CLI's redactor prints `[redacted]`. Pick one. Pre-existing; found in the S11 to S13 design review. +- Later: Composer's variables use two prefixes: `COMPOSER_` on the platform variables a deployment sets (ADR-0029), and `PRISMA_COMPOSER_` on the variables Composer's processes read. Pick one, or record why both stay. Pre-existing; found in the S11 to S13 design review. +- Known limit: `destroy` runs only the `config` adapters, not the `secrets` adapters (Will's ruling: an unreachable secrets manager must not block a teardown). So on `destroy`, which reads no secrets, a secret only the `secrets` list returns is not known, and the file check cannot refuse it. Destroy still compares against a value under an `envSecret()` name in the app configuration, and Load still refuses a root field named like an `envSecret()`. A deploy of the same configuration refuses first, so the impact is small. Found in the S11 to S13 round-2 code review (F11); still open after round 4 (F22). +- Known limit: the stream redactor's cost grows with the square of the longest secret's length. Only programmatic hosts use it, through `spawnAlchemy`. It matters only for secrets of many kilobytes, such as a CA bundle in a credential-named variable: a 32,000-character secret costs about 2 seconds per megabyte of output. A trie or rolling hash would make it linear. Found in the S11 to S13 round-2 code review (F12). +- Shelved by Will, 2026-11-05: a command that seeds platform values from the app configuration and the app secrets (`.env`, the shell). From S18, `deploy` never does it; until then deploy preflight fills a missing `envSecret()` variable from the app secrets. +- Deferred to the in-process Alchemy work: redacting the second process's (Alchemy's) output. Will dropped the engine change it needed (`@prisma/cli-engine` piping a child's output, prisma/prisma-cli#383) on 2026-11-05, because the Alchemy integration moves to the programmatic API soon. That work must redact Alchemy's output itself: in Composer's own process, Alchemy writes to Composer's output, and nothing redacts it unless the integration routes it through the command's `SecretRedaction`. Until then Alchemy's output prints unredacted under the CLI, as before S13. A programmatic host's `spawnAlchemy` does redact it. +- Known limit: Nothing is redacted by the known secrets before Load has named them: an adapter that throws, and a root schema failure that echoes a field's value, are redacted only by the credential-looking rule. A fix needs the secret names before Load, which the design does not provide. Found in the S11 to S13 round-4 reviews (F20). diff --git a/.drive/projects/module-input/design.md b/.drive/projects/module-input/design.md index 7452b3975..11e0bd342 100644 --- a/.drive/projects/module-input/design.md +++ b/.drive/projects/module-input/design.md @@ -1,17 +1,19 @@ # Module input: the design -This is the whole design of the module input project, as settled with Will on 2026-10-29 and 2026-10-30. It is the authoritative statement of it. It describes how the system behaves, not how the code is arranged. Slices S2 to S5 build an earlier version of it; [plan.md](plan.md) says what is built and which slice builds each remaining change. +This is the whole design of the module input project, as settled with Will on 2026-10-29 and 2026-10-30, and extended on 2026-11-05 with platform variables (section 5) and secrets in the root's input (section 7). It is the authoritative statement of it. It describes how the system behaves, not how the code is arranged. Slices S2 to S5 build an earlier version of it; [plan.md](plan.md) says what is built and which slice builds each remaining change. + +The 2026-11-05 extension follows a platform change. Prisma Cloud is moving environment variables to the project and workspace, with rules that decide which branches see which values, and a service gets a variable only by subscribing to it. So the platform, not Composer, isolates each service's environment, and a deploy may name a variable that has no value yet. Composer's part is to put the subscription in the graph, as a node. ## 1. What it is for -A module takes its input the way a service does. It declares one schema, `input`, and whoever provisions it binds that input. The root module declares `input` too, and the deploy fills it from config adapters named in `prisma.config.ts`. +A module takes its input the way a service does. It declares one schema, `input`, and whoever provisions it binds that input. The root module declares `input` too, and the deploy fills it from the config and secrets adapters named in `prisma.config.ts`. A value the platform owns, which a service reads at boot, is a **platform variable**: a node the module provisions, whose value it binds like any other. ```ts // a reusable module -import { field, secretString } from '@prisma/composer/arktype'; +import { field, secret } from '@prisma/composer/arktype'; export default module('email', - { input: type({ from: field(type('string')), deliveryCredential: secretString() }), expose: { send: emailSendContract } }, + { input: type({ from: field(type('string')), deliveryCredential: secret(type('string')) }), expose: { send: emailSendContract } }, ({ input, provision }) => { const service = provision(emailService(), { id: 'service', @@ -22,11 +24,13 @@ export default module('email', // the root export default module('shop', - { input: type({ EMAIL_FROM: 'string', 'SITE_HOSTNAME?': 'string' }) }, + { input: type({ 'SITE_HOSTNAME?': 'string', REGISTRY_TOKEN: secret(type('string')) }) }, ({ input, environment, provision }) => { - provision(email(), { - input: { from: input.EMAIL_FROM, deliveryCredential: envSecret('EMAIL_DELIVERY_CREDENTIAL') }, - }); + const emailFrom = provision(platformVariable({ name: 'EMAIL_FROM' })); + const emailCredential = provision(platformSecret({ name: 'EMAIL_DELIVERY_CREDENTIAL' })); + const port = provision(platformVariable({ name: 'PORT', parse: type('number') })); + + provision(email(), { input: { from: emailFrom.value, deliveryCredential: emailCredential.value } }); const host = resolve([input.SITE_HOSTNAME, environment.name], ([hostname, name]) => hostname ?? `${name}.shop.com`); }); @@ -34,7 +38,12 @@ export default module('shop', ```ts // prisma.config.ts -composer({ extensions: [prismaCloud(), nodeBuild()], state: prismaState(), config: [dotenv(), environmentVariables()] }) +composer({ + extensions: [prismaCloud(), nodeBuild()], + state: prismaState(), + config: [dotenv(), environmentVariables()], + secrets: [dotenv(), environmentVariables()], +}) ``` ```sh @@ -43,6 +52,8 @@ prisma deploy --environment staging # the same files for staging, then the she prisma dev # the files for dev, then the shell ``` +`SITE_HOSTNAME` and `REGISTRY_TOKEN` are the root's input: the deploy needs them, so Composer reads them from the adapters and carries them. `EMAIL_FROM`, `EMAIL_DELIVERY_CREDENTIAL` and `PORT` are platform variables: only the running service needs them, so Composer names them and never sees their values. A typical app's root input is small; most of what a service reads is a platform variable. + ## 2. Requirements The design must meet these four requirements. Sections 3 to 9 say how it does. @@ -56,7 +67,7 @@ The design must meet these four requirements. Sections 3 to 9 say how it does. ### Declaring and binding it -- `module(name, { input: schema, deps?, expose? }, body)`. The schema comes from any Standard Schema library (ArkType, Zod, …). Secret fields are typed `secretString()`. +- `module(name, { input: schema, deps?, expose? }, body)`. The schema comes from any Standard Schema library (ArkType, Zod, …). Secret fields are typed `secret(inner)`. - `provision(module, { input: { … } })` binds it. The binding is typed from the schema's input type. A module with an input schema must be bound; a module without one must not be. - Each key of the binding takes a literal or a value (section 4). Every source is a value (section 5). @@ -66,7 +77,7 @@ The design must meet these four requirements. Sections 3 to 9 say how it does. - A **deploy-time value**: a `Value` whose `T` contains no pointer. Load computes its real value, a `T`, before anything is created. - A **runtime value**: a `RuntimeValue`, which is `Value>`. Its real value at deploy is a **pointer**, which names the platform variable that holds the data. At boot the service reads that variable's text. -A pointer has one of two kinds. A **secret pointer**, typed `Pointer`, comes from `envSecret()` and from a redacted `generatedParam()`. A **plain pointer**, typed `Pointer`, comes from `serviceOrigin()` and from a plain `generatedParam()`. A pointer also records its kind in its data, so a check that runs without types can tell the two apart. +A pointer has one of two kinds. A **secret pointer**, typed `Pointer`, comes from a platform secret (section 5) and from a redacted `generatedParam()`. A **plain pointer**, typed `Pointer`, comes from a platform variable, where `T` is the type its `parse` gives and `string` without one, and from `serviceOrigin()` and a plain `generatedParam()`, where `T` is `string`. A pointer also records its kind in its data, so a check that runs without types can tell the two apart. ### Wrapped fields and plain fields @@ -75,26 +86,26 @@ A module should not care which form its caller uses. So the idiomatic way to dec This ArkType example is a module's schema. In a service's schema it fails the deploy: `port`'s transform makes ArkType copy the input, and the copy loses `deliveryCredential`'s secret ("Limits of the schema", below). ```ts -// ArkType, a module's schema: import { field, secretString } from '@prisma/composer/arktype' +// ArkType, a module's schema: import { field, secret } from '@prisma/composer/arktype' type({ from: field(type('string')), baseUrl: field(type('string.url')), port: field(type('string.integer.parse')), - deliveryCredential: secretString(), - smtp: { user: field(type('string')), password: secretString() }, + deliveryCredential: secret(type('string')), + smtp: { user: field(type('string')), password: secret(type('string')) }, host: type('string'), // plain: a resolve() computes with it }) -// Zod: import { field, secretString } from '@prisma/composer/zod' -z.object({ from: field(z.string()), baseUrl: field(z.string().url()), deliveryCredential: secretString(), host: z.string() }) +// Zod: import { field, secret } from '@prisma/composer/zod' +z.object({ from: field(z.string()), baseUrl: field(z.string().url()), deliveryCredential: secret(type('string')), host: z.string() }) ``` -- **Types.** `field(inner)` accepts `In | Pointer` and outputs `Out | Pointer`, where `In` and `Out` are the inner schema's input and output types. The pointer is typed by `In` because it stands for the variable's text, before the inner schema runs. So `port` above accepts `Pointer`, and the service receives a number. Every plain pointer is a `Pointer`, so binding a runtime value to `field(type('number'))` fails to compile. Neither library infers the pointer's type parameter through a union, so `field()` casts its result to these types. +- **Types.** `field(inner)` accepts `In | Pointer` and outputs `Out | Pointer`, where `In` and `Out` are the inner schema's input and output types. The pointer is typed by `In` because it stands for what the service's schema receives at boot, before the inner schema runs. So `port` above accepts `Pointer`, from a text platform variable or a `serviceOrigin()`, and the service receives a number. A platform variable with `parse: type('number')` gives a `Pointer`, which `field(type('number'))` accepts and `field(type('string'))` refuses. Neither library infers the pointer's type parameter through a union, so `field()` casts its result to these types. - **Checking.** `field(inner)` is a union that the schema library checks itself: a plain pointer, or a value the inner schema accepts. So the deploy check runs the author's whole schema, with pointers in place. For a field bound to a runtime value, the inner schema's check happens only at boot, when the service's schema sees the variable's text. - **Leaves only.** `field()` wraps a leaf. An object field stays a plain object schema, and its leaves are wrapped, as `smtp`'s are above. Wrapping an object schema fails three ways. At boot the pointer becomes text, which an object schema refuses. In the body, `input.smtp.host` stops type-checking. And ArkType cannot build the union around an object schema that has a transform anywhere inside it. A root's fields stay plain too, because the app configuration never holds a pointer (section 7). -- **Secrets.** `secretString()` accepts a secret box or a secret pointer, so a secret field needs no `field()`. `field()` accepts only plain pointers, and `secretString()` only secret ones. So an `envSecret()` bound to a wrapped text field fails the deploy check, and so does a `serviceOrigin()` bound to a `secretString()` field. +- **Secrets.** `secret(inner)` is the secret counterpart of `field(inner)`: it accepts a secret box or a secret pointer, and `inner` says what the secret's content must be, checked at boot when the service unboxes it. `field()` accepts only plain pointers, and `secret()` only secret ones. So a platform secret bound to a wrapped text field fails the deploy check, and so does a `serviceOrigin()` bound to a `secret()` field. `secretString()` is the older name for `secret(type('string'))`, and goes. - **Error messages.** When the value is not a pointer, a wrapped field reports the error the plain field would. A missing required field and a value of the wrong type keep the inner schema's message, and no message names a Composer class. For Zod, the union's error function reports the inner schema's issue. For ArkType, a configured problem message reports the inner schema's message for every present value. A missing key is the one exception: ArkType builds that message from the union's branches, so it reads, for example, `from must be a string or an object (was missing)`. It still names no Composer class, and a missing key in a typed module binding already fails to compile. Tests compare each wrapped field's messages with the plain field's. - **ArkType.** The pointer branch checks that the value is an object before it checks for a pointer. Otherwise a union with a transforming inner schema throws when the schema is defined. A default goes outside the wrapper, `field(type('string')).default('x')`, because `type('string').default('x')` is not a type that can be wrapped. -- **Entry points.** `@prisma/composer/arktype` and `@prisma/composer/zod` each export `field()` and `secretString()`. For any other Standard Schema library, `@prisma/composer` exports the `Pointer` type and a check for each kind, `isPlainPointer()` and `isSecretPointer()`, beside `isSecretString()`. With these an author can write the same two helpers. +- **Entry points.** `@prisma/composer/arktype` and `@prisma/composer/zod` each export `field()` and `secret()`. For any other Standard Schema library, `@prisma/composer` exports the `Pointer` type and a check for each kind, `isPlainPointer()` and `isSecretPointer()`, beside `isSecretString()`. With these an author can write the same two helpers. - The guide, the agent skill and the examples wrap every leaf field of a reusable module's schema, unless a `resolve()` needs the field. ### What the body receives @@ -119,14 +130,14 @@ Some misuse cannot be caught, because JavaScript calls no hook for it: a truthin A service's `input` binding is typed from the service's schema, as a module's is, with the same mapping. So a wrong literal, an `envSecret()` in a plain or wrapped text field, or a `serviceOrigin()` in a plain field fails to compile. This is what makes requirement 4 hold for services. It also makes a module and a service look the same to whoever provisions them, as the module boundary decision asks. -- In a binding type, `SecretString` and `Pointer` are leaves: the mapping never looks inside them. A `secretString()` field takes a secret runtime value, or a module's secret field passed on. It never takes a literal: a `SecretBox` written in a binding fails to compile. A pointer is never written as a literal either. -- A service's schema uses `field()` the same way a module's does. A field bound to `generatedParam()` or `serviceOrigin()` must be wrapped, or be a `secretString()` field for a redacted `generatedParam()`. -- `service.input()` returns the schema's output with every pointer removed, at any depth, because at boot every pointer has been replaced by the variable's text. So a wrapped `from` is `string` in service code, and a `secretString()` field is `SecretString`. Testing helpers that build a service's input use the same type. +- In a binding type, `SecretString` and `Pointer` are leaves: the mapping never looks inside them. A `secret()` field takes a secret runtime value, or a module's secret field passed on. It never takes a literal: a `SecretBox` written in a binding fails to compile. A pointer is never written as a literal either. +- A service's schema uses `field()` the same way a module's does. A field bound to `generatedParam()` or `serviceOrigin()` must be wrapped, or be a `secret()` field for a redacted `generatedParam()`. +- `service.input()` returns the schema's output with every pointer removed, at any depth, because at boot every pointer has been replaced by the variable's text. So a wrapped `from` is `string` in service code, and a `secret()` field is `SecretString`. Testing helpers that build a service's input use the same type. - The deploy check still tells the two pointer kinds apart, as "Secrets" above says. It catches the bindings the compiler cannot see: a refinement the type does not carry, such as a URL format, a schema without type information, or a cast. ### When the input is checked, and what a field's real value is -During Load's second pass, from the root down, each module's binding is resolved, assembled into one object, and checked by the module's whole schema. A field bound to a runtime value holds its pointer, which a wrapped field or a `secretString()` field accepts and a plain field refuses. If the check fails, the deploy stops before anything is created. The check runs synchronously; an async schema fails the Load. The deploy checks each service's binding the same way, with pointers in place, before it writes the service's input document. +During Load's second pass, from the root down, each module's binding is resolved, assembled into one object, and checked by the module's whole schema. A field bound to a runtime value holds its pointer, which a wrapped field or a `secret()` field accepts and a plain field refuses. If the check fails, the deploy stops before anything is created. The check runs synchronously; an async schema fails the Load. The deploy checks each service's binding the same way, with pointers in place, before it writes the service's input document. A field's real value is read at its path from **the schema's output**, not from the raw binding. So a field typed `number` by `string.integer.parse` receives `4`, not `'4'`, and defaults apply. A field bound to a runtime value is the exception: a schema may check it, never replace it or move its pointer to another field, and Load and the deploy each refuse a schema that does. A child then checks the value again with its own schema. @@ -139,14 +150,14 @@ An optional field the binding leaves out has no real value. In a child's binding Composer cannot see which keys a Standard Schema declares: the standard exposes only `validate`. So: - An input field the body never uses is not an error. -- An **object-typed field** carries its whole subtree after validation. If the schema declares `smtp: { host: 'string' }` and the binding's `smtp` also holds `password`, ArkType's default keeps `password` in its output, and a `resolve()` that lists `input.smtp` receives it. Zod drops undeclared keys by default; ArkType does with `'+': 'delete'`. The guide recommends that a module's schema drop them. An ArkType service schema with a `secretString()` field must not, for the reason in the next item. +- An **object-typed field** carries its whole subtree after validation. If the schema declares `smtp: { host: 'string' }` and the binding's `smtp` also holds `password`, ArkType's default keeps `password` in its output, and a `resolve()` that lists `input.smtp` receives it. Zod drops undeclared keys by default; ArkType does with `'+': 'delete'`. The guide recommends that a module's schema drop them. An ArkType service schema with a `secret()` field must not, for the reason in the next item. - **An ArkType service schema that copies its input loses its secrets.** ArkType copies an object's input when a transform runs in it or when it deletes undeclared keys. A secret box in the copy loses its value, so at boot the service cannot read the secret. At deploy the service's schema sees pointers, not boxes, so the deploy checks for this separately. For each service with a field bound to a secret pointer, it runs the service's schema a second time, with a stand-in box in place of each secret pointer. The stand-in holds a known text that is not a secret. If a box in the output no longer gives back that text, the deploy refuses the service, with an error naming the field and saying that the schema copies its input. A wrapped field counts too: `field(type('string.integer.parse'))` runs a transform, so an ArkType service schema cannot pair it with a secret field. A module's schema is not affected, because a module never boots. Section 11 defers a secret box that survives the copy. - **A union schema.** For a discriminated union, the body's types offer only the keys every member has. With the union below, `input.stripeEnabled` type-checks and `input.stripeSecretKey` does not, because the `false` member lacks it. The body cannot narrow with an `if`, and it cannot pass the whole input on. So a module cannot declare a union input and still pass every field to its service. It declares a flat schema instead, for example with an optional `stripeSecretKey`, and passes each field by name. ```ts z.discriminatedUnion('stripeEnabled', [ z.object({ stripeEnabled: z.literal(false) }), - z.object({ stripeEnabled: z.literal(true), stripeSecretKey: secretString() }), + z.object({ stripeEnabled: z.literal(true), stripeSecretKey: secret(z.string()) }), ]); ``` @@ -156,21 +167,48 @@ A **value** is an opaque stand-in, in a module body, for data known later. Its * | | Plain | Secret | | --- | --- | --- | -| **Deploy-time:** the deploy has the real value | a literal; a `resolve()` result; a configuration value | none yet (section 11) | -| **Runtime:** only the platform has it; the service reads it at boot | `generatedParam()`, `serviceOrigin()` | `envSecret('NAME')`, a redacted `generatedParam()` | +| **Deploy-time:** Composer carries the real value | a literal; a `resolve()` result; a configuration value | a root `secret()` field, read from the app secrets | +| **Runtime:** only the platform has it; the service reads it at boot | a platform variable; `generatedParam()`; `serviceOrigin()` | a platform secret; a redacted `generatedParam()` | + +The two axes are independent. Whether a value is secret decides how it is handled: boxed, redacted, never written where a plain value may be. Whether Composer carries it or the platform owns it decides where it lives: a carried value is read from the adapters at deploy and written into the service's input document, as data or, for a carried secret, as a secret variable the deploy sets on the service; a platform-owned value is named in the document as a pointer and read from the platform at boot. + +A **configuration value** is a value whose real value is read from the app configuration (section 8): a field of the root's input, or `envParam('NAME')` in a binding. A **carried secret** is a value whose real value is read from the app secrets: a root `secret()` field. + +A runtime value's real value at deploy is a pointer (section 3). The service reads the variable the pointer names at boot. + +## 5. Platform variables, and the other sources + +### A platform variable is a node + +A value the platform owns is pulled into the topology the way any external dependency is: by provisioning it as a Resource. `@prisma/composer-prisma-cloud` exports two resources, **`platformVariable({ name, parse? })`** and **`platformSecret({ name, parse? })`**. Each node has one output, `value`: a runtime value whose pointer names the platform variable `name`. A plain pointer for a variable, a secret pointer for a secret. + +- **The node is the subscription.** A service whose input holds the pointer depends on the variable. When the deploy writes the service, it also records that the service subscribes to that variable. Once the platform has subscriptions, it gives a service only the variables it subscribes to. Until then, Prisma Cloud's variables are set per project, and the Prisma Cloud target already sets variables on each service directly, so the first step is to stop stripping a service's environment at boot and let a service see the project's variables plus what Composer sets on it. The local target keeps giving each service only what its input points at, because there is no platform. +- **The deploy creates subscriptions, never values.** A deploy succeeds when a variable it names has no value on the platform; the deploy report lists the variable as not set, and so does the platform. The platform's own rules decide which branches and environments see which values, so a preview environment inherits what its rules give it, and Composer provisions nothing per environment. Seeding a value from a local file is a separate, explicit command if there is one at all, never a side effect of `deploy`. +- **Three platform states.** A variable may have no entry, an entry set to void, or an entry with a value, the empty string included. The deploy report flags only the first. At boot the first two leave the field's key out of the service's input document, and the third gives the value. The receiving field's schema decides whether an absent key is acceptable, as it does for any optional input. So a variable must be set on the platform, possibly to void, and whether it is required is the service's business, not the node's. +- **Its identity is its address.** `name` is a parameter, and the node's address is the ordinary one, so renaming the variable is a graph change a diff shows. Two modules may each provision a node for the same `name`; both subscribe the services under them to the one platform variable. +- **Load lists them.** Without deploying, Load knows every platform variable the app depends on, which services subscribe to each, and what each expects. That is the inspectability requirement, applied to variables. The deploy report lists the variables the platform has no entry for; today it can tell by reading the project's variable names, as preflight already does. -A **configuration value** is a value whose real value is read from the app configuration (section 8): a field of the root's input, or `envParam('NAME')` in a binding. +### `parse`: the variable's type travels with the subscription -A runtime value's real value at deploy is a pointer (section 3). The service reads the variable the pointer names at boot, as today. +A platform variable's value is text. A service field typed `number` should still be able to take one, without the service's schema having to know that the value arrives as text. The conversion cannot be code: the value exists only at boot, in the service's process, and the module's code is not there. So the conversion is **data that travels with the subscription**: a type the author declares on the node. -## 5. Sources are values +`parse` takes a schema from any library that implements [Standard JSON Schema](https://standardschema.dev/json-schema), the companion to Standard Schema that ArkType 2.1.28 and Zod 4.2 implement. A schema object exposes `~standard.jsonSchema.input()` and `.output()`, which return plain JSON Schema, and keeps its TypeScript type. -`envParam()`, `envSecret()`, `generatedParam()` and `serviceOrigin()` each return one object that is both the target's source and a value. The serializer, preflight and boot receive the same objects as before, so the format the deploy writes does not change. +- **For the compiler,** `value` is `RuntimeValue` where `T` is the schema's output type: `platformVariable({ name: 'PORT', parse: type('number') }).value` is `RuntimeValue`, so `field(type('number'))` accepts it. Without `parse`, `T` is `string`. +- **At deploy,** Composer calls the schema's `jsonSchema.input()` and writes the result into the subscription and into the service's input document, beside the pointer. The schema never runs on a value, because there is none. +- **At boot,** the variable's text is the JSON encoding of the value, except when the JSON Schema's type is `string`, where the text is the value itself. Boot decodes by that rule, validates the result against the JSON Schema in the marker with a validator Composer ships in the service runtime, and hands it to the service's schema, which judges it as it judges any input. A value that does not decode or does not fit fails boot with an error naming the platform variable and the schema, before the service's own schema runs. Composer decodes nothing it was not told to. The platform is not expected to validate anything; Composer does it. The local target validates `.env` values the same way before `prisma dev` starts a service, so a bad value is reported before any service boots. +- **No transforms.** A schema that changes its value, such as `string.numeric.parse`, is code, and no code runs on a platform value anywhere. Load refuses a `parse` whose `jsonSchema.input()` and `jsonSchema.output()` differ, naming the variable. +- **Encodable only.** A schema the library cannot express as JSON Schema, such as a custom refinement function, fails at Load with the library's message. Boot can enforce only what the document says. +- **One schema per variable.** Two nodes for the same `name` with different `parse` schemas fail the deploy, because one variable has one meaning. -- **`envParam('NAME')`** in a binding is a deploy-time configuration value. Its real value is `NAME` in the app configuration. In a service's input, the deploy writes that text into the input document, as today. An unset or empty `NAME` gives no real value: in a binding the key is left out; listed in `resolve()`, the Load fails naming the variable. Once a root can declare `input`, `envParam()` is redundant, and a later step deprecates it. -- **`envParam('NAME')` in a service's per-key params**, the older form of input, behaves as today. It is not a value there. The deploy writes a pointer row, deploy preflight fills a missing platform variable from the app configuration (section 8), and the service reads the variable at boot. -- **`envSecret('NAME')`** is a runtime secret. Its real value is a secret pointer. Preflight fills a missing platform variable from the app secrets (section 8). -- **`generatedParam()`** and **`serviceOrigin()`** are runtime values the framework itself produces. Their real value at deploy is a pointer: a secret pointer for a redacted `generatedParam()`, and a plain pointer otherwise. A field bound to one must be a wrapped field, or a `secretString()` field for a redacted `generatedParam()`. +### The other sources + +`generatedParam()` and `serviceOrigin()` return one object that is both the target's source and a value. They are runtime values the framework itself produces: a secret pointer for a redacted `generatedParam()`, a plain `Pointer` otherwise. A field bound to one must be a wrapped field, or a `secret()` field for a redacted `generatedParam()`. + +`envParam('NAME')` and `envSecret('NAME')` stay as shortcuts, deprecated together, and are removed in a later release: + +- **`envParam('NAME')`** in a binding is a deploy-time configuration value: `NAME` in the app configuration. A root input field says the same thing. In a service's per-key params, the older form of input, it behaves as today. +- **`envSecret('NAME')`** is a platform secret with no `parse`, provisioned where it is written. Its real value is a secret pointer. The deploy no longer fills the platform variable from the app secrets (section 8). ## 6. `resolve()` over input @@ -188,9 +226,11 @@ The root may declare `input`. Nothing provisions the root, so the deploy binds i The root meets the first requirement in section 2 the same way as any other module. Its body names the fields it needs. Nothing stands for the whole configuration, which by default holds every variable of the process environment. -A root's fields stay plain. The app configuration never holds a pointer, so wrapping a root field would only stop a `resolve()` from using it. +A root's fields stay plain or `secret()`, never `field()`. Neither the app configuration nor the app secrets holds a pointer, so wrapping a root field would only stop a `resolve()` from using it. -A root's input cannot hold a secret yet: the app configuration is plain, and a `secretString()` field refuses a plain value. A root binds `envSecret()` where it provisions a child. +**A root `secret()` field is a carried secret.** The deploy fills it from the app secrets, under the field's name, in a secret box; a plain root field is filled from the app configuration. A name present in both, for a field of either kind, fails the deploy, so one name never means two things. A carried secret reaches a service as a secret variable the deploy sets on that service, with its value, which is what a build parameter such as a private registry token needs. It is never written into the app configuration file; section 8 says how it reaches the second process. So the known secrets are the values under the root's `secret()` field names and, while `envSecret()` exists, the names it passes (section 9). + +The root's input is for what the deploy needs: a `resolve()` computes with it, a build consumes it, or it fixes something the deploy writes. A value only a service reads at runtime is a platform variable (section 5), not an input. The guides say so. ## 8. Config adapters and secrets adapters @@ -198,66 +238,65 @@ The `composer` section of `prisma.config.ts` gains two lists: `config`, a list o - An adapter receives the environment's name and the folder of the declaring `prisma.config.ts`, and returns an object, possibly asynchronously. - The adapters of a list run one at a time, in order. Their results merge by top-level key; a later adapter wins. A key whose value is `undefined` does not hide an earlier adapter's value. -- The merged result of the config adapters is **the app configuration**. It is the root's binding, and `envParam()` reads it. Deploy preflight also fills a missing platform variable for an `envParam()` in a service's per-key params from it. -- The merged result of the secrets adapters is **the app secrets**. Every value in it must be text; an adapter that returns anything else fails the command with `CONFIG.ADAPTER_INVALID`. Deploy preflight and dev preflight fill a missing `envSecret()` platform variable from the app secrets, by name. A name no adapter supplies fails as today. -- Preflight fills only a platform variable that does not exist yet. Editing a value in `.env.production` and deploying again does not change a variable the platform already holds. To change one, change it on the platform. -- An adapter that throws fails the command with `CONFIG.ADAPTER_FAILED`. Its message is redacted like any other output (section 9). +- The merged result of the config adapters is **the app configuration**. The root's plain fields are filled from it, and `envParam()` reads it. +- The merged result of the secrets adapters is **the app secrets**. Every value in it must be text; an adapter that returns anything else fails the command with `CONFIG.ADAPTER_INVALID`. The root's `secret()` fields are filled from it, and nothing else reads it. A required root field no adapter supplies fails the deploy with the schema's message. +- The app configuration and the app secrets hold values Composer may carry. They are read only at the root's boundary, for the fields the root declares; a value in the lists that no root field names is never carried and never a secret Composer knows. `environmentVariables()` returning the whole shell is therefore harmless. +- **The deploy no longer fills platform variables.** Before this extension, deploy and dev preflight copied a missing `envSecret()` variable from the app secrets to the platform. That is seeding, and with platform rules deciding which environment sees which value, seeding as a side effect of `deploy` is the way a developer's local test key reaches production. A variable is set on the platform, by its UI, API or CLI. `prisma dev`'s local target, having no platform, fills a platform variable from the app configuration and the app secrets: a platform secret from the app secrets and a platform variable from the app configuration, each under the variable's `name`. +- An adapter that throws fails the command with `CONFIG.ADAPTER_FAILED`. Its message is redacted only by the credential-looking rule, because it fails before Load has named any known secret (section 9). - Both lists default to **`[environmentVariables()]`**. A project with neither key reads its values where it did before. A list the author writes replaces the default; to keep reading the shell, include `environmentVariables()`. - Composer reads its own credentials (`PRISMA_SERVICE_TOKEN`, `PRISMA_WORKSPACE_ID`, `PRISMA_REGION`) from the process environment, as today. ### The two adapters that ship -- **`environmentVariables()`** returns the process environment, except the variables whose names start with `PRISMA_COMPOSER_`, which Composer uses to pass data to its own second process. -- **`dotenv()`** reads `.env` files from the folder of `prisma.config.ts`. It delegates to `dotenv-flow`, pinned to one version, because `dotenv-flow`'s file order becomes Composer's. `listFiles` gives the files for the environment, and `parse` merges them without touching the process environment. Composer passes the pattern `.env[.node_env][.local]`, so `dotenv-flow`'s `.env.defaults` file is not read. The order, lowest first, is `.env`, `.env.local`, `.env.`, `.env..local`. Following `dotenv-flow`, `.env.local` is skipped when the environment is `test`. `dotenv()` reads files only. +- **`environmentVariables()`** returns the whole process environment, with no exception. +- **`dotenv()`** reads `.env` files from the folder of `prisma.config.ts`. It delegates to `dotenv-flow`, pinned to one version, because `dotenv-flow`'s file order becomes Composer's. `listFiles` gives the files for the environment, and `parse` merges them without touching the process environment. `listFiles` lists `dotenv-flow`'s `.env.defaults` file first whenever it is given its own default pattern, `.env[.node_env][.local]`, so `dotenv()` drops that file from the list. It never reads `.env.defaults`. The order, lowest first, is `.env`, `.env.local`, `.env.`, `.env..local`. Following `dotenv-flow`, `.env.local` is skipped when the environment is `test`. `dotenv()` reads files only. - A project that loads `.env` with `import 'dotenv/config'` in `prisma.config.ts` must remove that import when it adopts `dotenv()`. Otherwise the import puts `.env`'s values into the process environment, `environmentVariables()` returns them last, and they override `.env.`. The release note says so. ### Which commands run which adapters - `prisma deploy` runs both lists, in its first process. - `prisma dev` runs both lists, in its first process, at start and at each rebuild. -- `destroy` runs only the config adapters. It loads the graph, so the app configuration must satisfy the root's schema. It runs no preflight, so it does not need the app secrets, and an unreachable secrets manager cannot block a teardown. +- `destroy` runs only the config adapters. It loads the graph, so the app configuration must satisfy the root's plain fields. It runs no preflight and sets nothing on a service, so it does not need the app secrets, and an unreachable secrets manager cannot block a teardown. Load under `destroy` binds each root `secret()` field to a stand-in box, so the root's schema still passes. - No second process runs an adapter (see below), and no other command runs one. So `dev url`, `dev stop`, `log` and `deploy list` keep working when a `.env` file is broken or a secrets manager is unreachable. In `prisma dev`, a changed adapter value pauses rebuilds until dev is restarted, as an edit to `prisma.config.ts` does today. A later slice watches every input and applies new values (section 11). ### The deploy's two processes -`prisma deploy` runs the adapters in its own process, to check the root's input and run preflight. It then starts a second process, which loads `prisma.config.ts` and the graph again, and runs Alchemy to do the deploy. The second process runs no adapter. +`prisma deploy` runs the adapters once, in its own process, to check the root's input and run preflight. It then starts a second process, which loads `prisma.config.ts` and the graph again, and runs Alchemy to do the deploy. The second process runs no adapter. -Instead, the first process writes a **configuration file** beside the generated stack file, in `.prisma-composer/`. It holds only the values the graph reads from the app configuration: +Instead, the first process writes the **app configuration file**, `.prisma-composer/app-configuration--.json`, beside the generated stack file, and passes its path in `PRISMA_COMPOSER_APP_CONFIGURATION_FILE`. It is not `prisma.config.ts`. It holds what Load read from the app configuration (`AppConfigurationRead`) and the first process's node addresses: -- for each path of the root's input that the root's body names, the real value the first process's check produced; -- for each name an `envParam()` in the graph reads, that name's value in the app configuration. +- `rootInput`: for each path of the root's input that the root's body passes on, the real value the first process's check produced; +- `configurationKeyText`: for each configuration key an `envParam()` in the graph reads, its text in the app configuration; +- the list of node addresses the first process's graph has. The second process takes the root's real values from the file and does not run the root's schema again. It could not: the file lacks the fields the body never names, and a required one among them would fail the check. The first process has already checked the whole configuration. - The file is JSON. A root field whose real value JSON cannot hold exactly, such as a `Date` a transform produced, fails the deploy in the first process, naming the field. - The file is readable only by its owner (mode 0600). The first process deletes it when the deploy ends, whether the deploy succeeds or fails. -- The second process never needs the app secrets, so the file holds none. It inherits the shell's environment, as today, so a secret in the shell is present in it; it reads none. -- The second process reads the values the first process checked, so a `.env` file edited during the deploy, or an adapter that would return something else a second time, cannot change what is deployed. -- The first process also passes the second the list of node addresses it checked, in a `PRISMA_COMPOSER_` environment variable, as it passes the preflight payload. The second process refuses to deploy a graph whose list differs. A body that reads `process.env` directly is the way this can still happen (section 2). +- The file holds plain values only. A carried secret, the value of a root `secret()` field, goes to the second process in memory, in `PRISMA_COMPOSER_NAMED_SECRETS`, as `prisma dev` already does for its second process; the deploy's second process needs it to set the secret variable on the service. Load refuses, with `COMPOSE.GRAPH_INVALID`, a name that both a plain root field and a `secret()` root field declare, or that both an `envParam()` and an `envSecret()` read. The CLI refuses to write the file, with `CONFIG.SECRET_AS_PLAIN_VALUE`, when any value in it contains a known secret (section 9). Both refusals name the field, never the value. The second process inherits the shell's environment, as today, so a secret in the shell is present in it; it reads none. +- The second process reads what the first process's Load read, so a `.env` file edited during the deploy, or an adapter that would return something else a second time, cannot change what is deployed. +- The second process refuses to deploy a graph whose node addresses differ from the list in the file. A body that reads `process.env` directly is the way this can still happen (section 2). `destroy`'s second process receives the app configuration through the same file. +A run stopped by a forced exit cannot delete its file. At the start of each deploy, destroy and `prisma dev`, the CLI deletes the app configuration files under `.prisma-composer/` and each dev environment directory whose process id, in the file name, is no longer running. + ### `prisma dev`'s two processes -`prisma dev` starts a second process at start and at each rebuild. That process receives the app configuration through the same kind of file, which `prisma dev` deletes when it stops. Unlike the deploy's second process, it needs the app secrets, because the local services run with them. The first process passes the value of each name an `envSecret()` in the graph names through the second process's environment, in memory. Neither process writes a secret to a file for this. +`prisma dev` starts a second process at start and at each rebuild. That process receives the app configuration through the same kind of file, `/app-configuration-.json`, which `prisma dev` writes before each converge and deletes as soon as that converge ends. It needs secrets, because the local services run with them. The first process passes the **named secrets**, the carried secrets plus the value of each platform secret's `name` in the app secrets, in one environment variable, `PRISMA_COMPOSER_NAMED_SECRETS`, in memory. Neither process writes a secret to a file for this. So the handover is one file, plus one variable under `prisma dev`. The dev stack file passes the local target `configurationKeyText` and `namedSecrets: NamedSecrets`, never the whole app configuration or app secrets. ## 9. Keeping secret values out -- A secret's value enters no file Composer writes for a deploy, no deploy state, no deploy report and no log line. An `envSecret()` reaches the service's input document as a pointer. The configuration file holds no secrets (section 8). -- **What Composer redacts.** Everywhere it prints, Composer redacts: - - every value under a name that an `envSecret()` in the graph names, whether in the app configuration or in the app secrets; - - every value a project's own secrets adapters return, when the project writes a `secrets` list. - - The default list, `[environmentVariables()]`, only supplies the values of the names `envSecret()` names, so under it only those values are redacted. Redacting every value in the shell would blank every `1` and `true` in the output. - - A value is redacted at any length. There is no minimum. -- **Where.** Errors, adapter failures including an adapter's own message, the second process's output, and the second process's failure cause. -- **Plain values are redacted too, for now.** Composer redacts by value, and a `.env` file often holds secrets and plain values side by side. When a project's own secrets list reads such a file, every value in it is redacted, the plain ones included. A later slice gives secrets a home distinct from plain values, so that only secrets are redacted (section 11). -- **The second process's output.** To redact it, Composer reads that output through a pipe and writes it on, instead of letting the second process write to the terminal. So the second process has no terminal, and Alchemy's progress display and colours change. A secret can be split across two chunks of output. So the redactor holds back the last characters of each chunk, one fewer than the longest secret's length, until the next chunk or the end of the output. A test splits a secret across two chunks. -- A schema's refusal of a `secretString()` field says what kind of value it got, never the value. +- A secret's value enters no file Composer writes for a deploy, no deploy state, no deploy report and no log line. A platform secret's value never enters Composer at all: it reaches the service's input document as a pointer and the platform delivers it at boot. A carried secret travels in memory between the two processes and is set on the service as a secret variable. The app configuration file holds no secrets (section 8). +- **The known secrets.** The known secrets are the values under the root's `secret()` field names and, while `envSecret()` exists, under the names the graph's `envSecret()`s pass, in the app secrets or the app configuration. Nothing else in the lists is a known secret. A plain value that shares a `.env` file with a secret is not redacted, apart from the credential-looking values below. Under the recommended `secrets: [dotenv(), environmentVariables()]` the shell's `SHLVL=1` is in the app secrets and is not a known secret. `prisma dev` compares the whole app secrets between rebuilds to notice a change, which reads them without making them known secrets. `destroy` reads no app secrets, so on `destroy` a secret only the `secrets` list returns is not known, and the file check cannot refuse it; that is a known limit. +- **What Composer redacts.** Everywhere it prints, Composer redacts every known secret, at any length. There is no minimum. When two known secrets overlap in the text, the redactor hides every character either one covers. Composer also keeps the redaction S4 shipped: a value of eight characters or more under a name that looks like a credential, in the app configuration or the process environment, and token shapes such as bearer tokens, JWTs and a password in a URL. That is existing behaviour, not part of this design. +- **Where.** Errors, events, adapter failures, a thrown defect's cause chain, and the second process's failure cause. Every output uses the same set of known secrets. The command's step labels and its `Deployed ` line carry the app name and node addresses, which the author wrote, and are not redacted. Nothing is redacted by the known secrets before Load has named them: an adapter that throws, and a root schema failure that echoes a field's value, are redacted only by the credential-looking rule. That is a known limit. +- **The second process's output is not redacted, for now.** The CLI's command engine gives the second process the terminal, so Composer never sees Alchemy's output. Redacting it needed a change to the engine (prisma/prisma-cli#383), which Will dropped on 2026-11-05: Composer will soon run Alchemy through its programmatic API, in its own process, which lets Composer redact that output. That work must redact Alchemy's output itself: nothing redacts it unless the integration routes it through the command's redaction. Until then Alchemy's output prints as it does today. The redactor still covers everything Composer itself prints, including the second process's failure cause. +- A schema's refusal of a `secret()` field says what kind of value it got, never the value. - Under `prisma dev`, the local compute emulator saves each service's environment, secret values included, in its own state file (mode 0600). That is recorded and deferred. +- **Isolation.** On Prisma Cloud a service receives the variables it subscribes to and nothing else, and the platform enforces that. Composer's own isolation of a service's environment, which the dev secrets decision records, stays only in the local target. ## 10. What goes away @@ -269,9 +308,14 @@ This design also removes or replaces the following. All but the last were built - **A `resolve()` function returning a pointer it received**, and Load's special case that accepts one. - **Forwarding a non-root module's whole `input`**, which S2 to S5 allow and test. - **The stand-in values** that a module's and a service's schema check in place of a runtime value. Schemas see pointers instead. `paramSource()`'s `sentinel` option goes, and its `redacted` option decides the kind of pointer the source gives. -- **The "pool" names in code**: `LoadOptions.configPool`, `paramSource()`'s `poolKey` option, and `readConfigPool` and `readSecretsPool` on `@prisma/composer/internal/pools`, which the generated stack files import. The code takes the names "the app configuration" and "the app secrets", and the generated stack files read the configuration file instead. +- **The old names in code**: `LoadOptions.configPool`, `paramSource()`'s `poolKey` option, and `readConfigPool` and `readSecretsPool` on `@prisma/composer/internal/pools`, which the generated stack files import. The code takes the names "the app configuration" and "the app secrets", and the generated stack files read the app configuration file instead. - **The second process's own run of the config adapters**, and the requirement that adapters return the same values in both processes. - **A service binding's loose type, `InputBinding`**, which accepts any source in any key. +- **Preflight's filling of platform variables** from the app configuration and the app secrets, on deploy and on dev (section 8). The local target fills them itself. +- **Composer's isolation of a service's environment on Prisma Cloud.** The platform does it through subscriptions (section 5). +- **`secretString()`**, replaced by `secret(inner)`. +- **`envSecret()` and `envParam()`**, deprecated together as shortcuts and removed later. +- **The rule that the root's input holds no secret.** A root `secret()` field is a carried secret (section 7). `auth()`, `email()` and the examples move to `input`. The email example's root reads its input from `.env` files. The auth example keeps `serviceOrigin()` and `envParam()`, now inside `input`, and the auth service's schema wraps the fields they bind. The storefront-auth example keeps its root `envSecret()` and drops `import 'dotenv/config'` if it adopts `dotenv()`. @@ -290,19 +334,21 @@ The release note shows each change before and after. Slices of this project, designed with Will after the others ([plan.md](plan.md)): - A slice that watches every input in `prisma dev`, `.env` files and other adapters alike, and applies new values instead of pausing. -- A slice that designs protection against a developer's local secret reaching another environment: deploying production from a machine whose `.env.local` holds a test key fills a missing production variable with that key. -- A slice that gives secrets a home distinct from plain values, so that redaction stops covering the plain values that share a `.env` file with secrets. -- `secretString()` follows the `field()` pattern and becomes `secret(schema)`, such as `secret(type('string'))`, so a secret field can state what its content must be. The wrapped schema checks the content at boot. The name is settled in its slice (plan S17). + +The 2026-11-05 extension settles three items that were listed here: a developer's local secret reaching another environment (the deploy no longer fills platform variables, section 8), secrets getting a home distinct from plain values (only the values the root names are known secrets, section 9), and `secret(schema)` (section 3, built with platform variables). + +To settle in the platform variables slice, not here: + +- **What the deploy writes into the service's input document.** Today it writes the schema's validated output, and boot validates it again. A transforming schema, such as `field(type('string.numeric.parse'))`, then fails at boot for a literal binding: the deploy wrote the number, and boot offers the number to a schema that takes text. Writing the resolved binding instead, and running the schema once at boot, fixes that and makes "deploy and boot judge the same data" literally true. The slice decides. Outside this project: -- Deprecate `envParam()`. -- A runtime config source that provisions a plain platform variable and keeps its value in deploy state. -- Secrets adapters such as `doppler()`, which would fill the deploy-time secret cell and let a root's input hold a secret. -- Routing runtime secrets to a secret store such as Doppler. +- Removing `envParam()` and `envSecret()` after their deprecation. +- Routing a carried secret through a secret store such as Doppler, as a secrets adapter. - A secret box that survives an ArkType copy. - Listing an unset `envParam()` in a module's input among the deploy report's absent keys. -- Running the deploy through Alchemy's programmatic API, in Composer's own process. That removes the second process, and with it the configuration file and the piping of its output. +- Running the deploy through Alchemy's programmatic API, in Composer's own process, soon. That removes the second process and the app configuration file. It must redact Alchemy's output itself, because in Composer's own process Alchemy writes to Composer's output and nothing redacts it unless the integration does. +- Seeding platform values from the app configuration and the app secrets by a command. Shelved by Will on 2026-11-05. ## 12. Decision records and glossary this amends @@ -320,8 +366,18 @@ Each amendment says plainly which recorded statement it reverses: - **Module input**, as amended by S2: "to narrow, forward the whole `input`". Now the whole `input` is refused everywhere a value goes. A module declares a flat schema and passes fields by name. - **Service input**: at deploy the schema sees each secret as an opaque `SecretString` stand-in. Now it sees a pointer, and a field bound to `generatedParam()` or `serviceOrigin()` must be wrapped. A service's binding is also now typed from its schema. - **Service input**: an env-sourced value that is unset or empty is left out. The root's input keeps the empty text from a `.env` line such as `KEY=`. -- **The `composer` config section**, as amended by S4: the second process reads the adapters again, and an adapter must return the same values for the same environment in both processes. Now the second process reads the configuration file and runs no adapter. +- **The `composer` config section**, as amended by S4: the second process reads the adapters again, and an adapter must return the same values for the same environment in both processes. Now the second process reads the app configuration file and runs no adapter. - **Dev secrets**: it rejected "Load `.env` in Composer itself", and told projects to add `import 'dotenv/config'`. `dotenv()` partly reverses the first: Composer ships the loader, but the user still names it in `prisma.config.ts`. A project that adopts `dotenv()` removes the import. The dev second process now receives its secrets from the first process, not from its own environment or adapters. +- **Errors**: a structured error is never transformed between where it is raised and who receives it. Now redaction is the one change: before an operation returns an error, Composer removes every secret value it knows from its text and drops its cause. + +The 2026-11-05 extension reverses these further, each rewritten in full as one current record in the platform variables slice: + +- **Secrets (ADR-0029)**: "a secret is an environment-sourced value the framework never stores", and `envSecret()` "is the only thing the framework calls a secret". Now a secret is any value handled as one: a platform secret, which Composer never sees, or a carried secret, which the root reads from the app secrets and the deploy sets on a service. Where a value comes from no longer decides whether it is a secret. +- **Env-sourced params (ADR-0032)** and **dev secrets (ADR-0058)**: preflight fills a missing platform variable from the app configuration or the app secrets. Now the deploy fills nothing on the platform; the local target fills its own. +- **Dev secrets (ADR-0058)**: "each service gets only the variables its own rows point at", enforced by Composer. On Prisma Cloud the platform enforces it through subscriptions. +- **`resolve()` and config adapters (ADR-0062)**: "the root's input holds no secret", "the app secrets never reach Load", and "every value a project's own secrets adapters return" is redacted. Now a root `secret()` field is filled from the app secrets, and only those values are known secrets. +- **Service input (ADR-0042)**: `secretString()` becomes `secret(inner)`; the input document gains a `$platform` marker carrying the variable's name and JSON Schema; and whatever the slice decides about writing the resolved binding instead of the schema's output. +- **The glossary**: `secretString()` becomes `secret()`; **platform variable**, **platform secret**, **subscription**, **carried secret** and **known secrets** get entries; the Secret entry stops saying `envSecret()` is the only secret. ### The glossary diff --git a/.drive/projects/module-input/handover.md b/.drive/projects/module-input/handover.md index ab0163dd5..e0d1d27e0 100644 --- a/.drive/projects/module-input/handover.md +++ b/.drive/projects/module-input/handover.md @@ -48,7 +48,7 @@ Pass `model: opus` explicitly on every agent. - **"App configuration" and "app secrets"** name the merged values of the config adapters and the secrets adapters. "App" is Composer's word for the application, the outermost module. Prisma Cloud's "App" label never constrains Composer's terms. The code still says "config pool" until S12 renames it. - **`envParam()` stays deploy-time.** In a service's input it is read at deploy and written into the input document. Making it a runtime pointer would break every existing app. It gets deprecated later. - **`environmentVariables()` returns the whole process environment.** No special cases, no filtering of credentials. -- **Redaction covers every secret value Composer knows, at any length.** Under the default lists that is the values under names an `envSecret()` names. With a project's own secrets list, it is everything that list returns. +- **Redaction covers every secret value Composer knows, at any length.** Those are the values under the names an `envSecret()` in the graph names, whatever the lists. The lists are pools Composer reads only for those names, so a project's own secrets list does not make its other values secrets. - **Inputs are always truthy `Value` objects.** Will does not want to hear about branching on them. - **Out of scope by Will's ruling:** - `dotenv-flow` skipping `.env.local` for `test`; diff --git a/.drive/projects/module-input/plan.md b/.drive/projects/module-input/plan.md index 9e4e6385b..be1f52d88 100644 --- a/.drive/projects/module-input/plan.md +++ b/.drive/projects/module-input/plan.md @@ -9,9 +9,10 @@ A PR delivers one capability a user would notice, so slices whose limits the nex - **S1** is done: [slices/s1-spike/report.md](slices/s1-spike/report.md). - **S2** is merged: prisma/composer#401. - **S3 to S8** are merged as one PR: prisma/composer#403. -- **S9** is built and in review as prisma/composer#412. **S10** is built in the same PR. -- **S11 to S13** are not built. [deferred.md](deferred.md) lists items S10 must cover. -- **S14 to S17** are designed with Will before they are specified. +- **S9 and S10** are built and in review as prisma/composer#412. +- **S11 to S13** are built on the git branch `claude/module-input-s11-s13`, not yet opened as a PR, through four review rounds. The two changes of 2026-11-05 are done and in review: the known secrets are the values the graph names (S13 below), and the engine-based redaction of Alchemy's output is gone, because Will dropped prisma/prisma-cli#383. The PR then waits only for #412 to merge. +- **S18**, platform variables, was designed with Will on 2026-11-05 ([design.md](design.md) sections 5 and 7). It is specified next and built after S11 to S13 merge. It absorbs S15, S16 and S17. +- **S14** is designed with Will before it is specified. ## S1. Spike: an input field as an opaque typed value @@ -27,7 +28,7 @@ Both return values, and so do `generatedParam()` and `serviceOrigin()`. Deploy p ## S4. Config adapters and the root's input -The `config` and `secrets` lists on the `composer` section, each defaulting to `[environmentVariables()]`; `dotenv()`; the root declares `input`, bound to the app configuration and checked before anything is created; `envParam()` reads the app configuration; deploy and dev preflight fill `envSecret()` from the app secrets. Built on `claude/module-input-s4`. It names the app configuration and the app secrets "pools" in code, lists `dotenv()`'s four files itself, and has the second process run the adapters again; S11 and S12 change these. +The `config` and `secrets` lists on the `composer` section, each defaulting to `[environmentVariables()]`; `dotenv()`; the root declares `input`, bound to the app configuration and checked before anything is created; `envParam()` reads the app configuration; deploy and dev preflight fill `envSecret()` from the app secrets. Built on `claude/module-input-s4`. It gives the app configuration and the app secrets older names in code, such as `configPool`, lists `dotenv()`'s four files itself, and has the second process run the adapters again; S11 and S12 change these. ## S5. Migrate and remove the old slots @@ -59,27 +60,35 @@ The body's `input` carries a brand that every binding type refuses, so passing t ## S12. The second process reads a configuration file -The first process writes a configuration file beside the generated stack file, mode 0600, holding the root's real values at the paths its body names and the values of the names `envParam()` reads. It deletes the file when the deploy ends, whether it succeeds or fails. The deploy's and `destroy`'s second processes read the file and run no adapter, and do not run the root's schema again. The first process passes the list of node addresses it checked, and the second process refuses to deploy a different graph. `prisma dev`'s second process reads the same kind of file, and receives the app secrets its local services need through its environment, never through a file. The "pool" names in code take the names "the app configuration" and "the app secrets", and the glossary's pool and adapter entries are replaced by "the app configuration", "the app secrets", "config adapter" and "secrets adapter". +The first process writes a configuration file beside the generated stack file, mode 0600, holding the root's real values at the paths its body names and the values of the names `envParam()` reads. It deletes the file when the deploy ends, whether it succeeds or fails. The deploy's and `destroy`'s second processes read the file and run no adapter, and do not run the root's schema again. The first process passes the list of node addresses it checked, and the second process refuses to deploy a different graph. `prisma dev`'s second process reads the same kind of file, and receives the app secrets its local services need through its environment, never through a file. The old names in code take the names "the app configuration" and "the app secrets", and the glossary's old entries for the two lists and the adapter are replaced by "the app configuration", "the app secrets", "config adapter" and "secrets adapter". ## S13. Composer redacts every secret it knows -Everywhere Composer prints, it redacts every value under a name an `envSecret()` in the graph names, in the app configuration or the app secrets, and every value a project's own secrets adapters return when it writes a `secrets` list, at any length. That covers errors, adapter failures, the second process's failure cause, and the second process's output, which Composer now reads through a pipe, holding back enough characters to catch a secret split across two chunks. An end-to-end test searches the configuration file, the stack file, the deploy report, the errors and the second process's output for a known secret value. Manual QA of the examples against real targets, including a root `input` from `.env.` in `prisma deploy` and `prisma dev`. +Everywhere Composer prints, it redacts every known secret, at any length. The known secrets are the values under the names the graph's `envSecret()`s pass, in the app secrets or the app configuration, and from S18 under the root's `secret()` field names too. A value in the lists that nothing names is not a known secret, so the recommended `secrets: [dotenv(), environmentVariables()]` redacts nothing it should not. `prisma dev` compares the whole app secrets between rebuilds to notice a change, which reads them without making them known secrets. (The first build counted every value a written `secrets` list returned; with the recommended setup that refused every root input field and blanked every `1` in the output, review finding F16. Will ruled on 2026-11-05 that the lists hold what Composer may carry, read only at the root's boundary.) That covers errors, adapter failures and the second process's failure cause. The second process's own output is not redacted: that needed an engine change (prisma/prisma-cli#383), dropped because the Alchemy integration moves to the programmatic API soon. That work must redact Alchemy's output itself. An end-to-end test searches the configuration file, the stack file, the deploy report, the errors and the second process's output for a known secret value. Manual QA of the examples against real targets, including a root `input` from `.env.` in `prisma deploy` and `prisma dev`. ## S14. `prisma dev` watches its inputs Adapters may report that their values changed, and `dotenv()` watches its files. `prisma dev` applies the new values instead of pausing. Designed and specified after S13. -## S15. A developer's local secret does not reach another environment +## S15, S16 and S17 are absorbed by S18 -Design a solution for this case: deploying production from a machine whose `.env.local` holds a test key fills a missing production variable with that key. Designed with Will after S13. +S15 (a developer's local secret reaching another environment) is settled because the deploy no longer fills platform variables. S16 (secrets get a home of their own) is settled because only the values the root names are known secrets. S17 (`secret(schema)`) ships inside S18, because platform secrets and carried secrets both need it. -## S16. Secrets get a home of their own +## S18. Platform variables are nodes, and the root's input may hold a secret -Give secrets a home distinct from plain values, so a `.env` file no longer mixes the two, and redaction stops covering the plain values that share a file with secrets. Designed with Will after S13. +Design: [design.md](design.md) sections 5, 7, 8 and 9, settled 2026-11-05. One PR, after S11 to S13 merge. -## S17. A secret field wraps its schema, as `field()` does +- `@prisma/composer-prisma-cloud` exports `platformVariable({ name, parse? })` and `platformSecret({ name, parse? })`, resources whose `value` is a runtime value. The Prisma Cloud target creates a subscription of each service to each variable its input points at, and sets no value. The deploy report lists the variables with no entry on the platform. The local target fills a platform variable from the app configuration or the app secrets under its `name`, and validates the value against `parse`. +- `parse` takes a Standard JSON Schema; its `jsonSchema.input()` travels in a `$platform` marker in the input document; Load refuses a transforming or unencodable schema, and two nodes for one `name` with different schemas. Boot decodes the text as JSON unless the schema's type is `string`, validates it against the JSON Schema with a validator Composer ships, then hands it to the service's schema. The platform validates nothing. +- `secretString()` becomes `secret(inner)`, in both entry points. +- A root `secret()` field is filled from the app secrets and reaches the second process in `PRISMA_COMPOSER_NAMED_SECRETS`; the deploy sets it on the service as a secret variable. A name in both the app configuration and the app secrets for a root field fails the deploy. `destroy` binds it to a stand-in box. +- Preflight stops filling platform variables on deploy and dev. The Prisma Cloud target stops stripping a service's environment at boot: a service sees the project's variables plus what Composer sets on it directly. The local target keeps giving each service only what its input points at. +- `envSecret()` and `envParam()` are deprecated as shortcuts. +- The slice decides whether the deploy writes the resolved binding instead of the schema's output into the input document, so a transforming `field()` schema works for a literal. +- Decision records rewritten in full: ADR-0029, ADR-0032, ADR-0042, ADR-0058 and ADR-0062. New glossary entries: platform variable, platform secret, subscription, carried secret, known secrets. +- Breaking: `secretString()` removed; preflight no longer fills a variable, so a project that relied on `deploy` seeding `envSecret()` values sets them on the platform instead. -`secretString()` becomes `secret(schema)`, following the `field()` pattern: `secret(type('string'))` for ArkType and `secret(z.string())` for Zod. The wrapped schema checks the secret's content at boot, when the real value exists; at deploy the field accepts a secret pointer or a secret box. The name is settled when the slice is designed. `secretString()` is removed, which is a breaking change for every schema with a secret field. Designed after S16. +Nothing in it waits for the platform. When the platform ships subscriptions, the target records each service's subscriptions and the "not set" report comes from the platform instead of the project's variable names. ## Close-out diff --git a/.drive/projects/module-input/slices/s10-service-binding-typed/spec.md b/.drive/projects/module-input/slices/s10-service-binding-typed/spec.md index c4069d2f1..07ba47611 100644 --- a/.drive/projects/module-input/slices/s10-service-binding-typed/spec.md +++ b/.drive/projects/module-input/slices/s10-service-binding-typed/spec.md @@ -465,7 +465,7 @@ An `envParam()` field whose variable is unset or empty is a key left out, and wh - [ADR-0016](ADR-0016-a-module-has-the-same-boundary-as-a-service.md): a module has the same boundary as a service. - [ADR-0062](ADR-0062-environment-values-come-from-resolve-and-config-adapters.md): values, runtime values and pointers, and the configuration `envParam()` reads. - [ADR-0021](ADR-0021-params-are-read-through-config-not-load.md), superseded: `config()` is replaced by `service.input()`; dependencies still read through `load()`. -- [ADR-0029](ADR-0029-secrets-are-a-forwardable-slot.md), partly superseded: `envSecret()` survives as a binding leaf; the `secrets()` read and per-slot pointer rows are replaced. +- [ADR-0029](ADR-0029-a-secret-is-an-environment-sourced-value-the-framework-never-stores.md), partly superseded: `envSecret()` survives as a binding leaf; the `secrets()` read and per-slot pointer rows are replaced. - [ADR-0032](ADR-0032-params-bind-at-provision-env-sourcing-is-a-target-source.md), partly superseded: binding at `provision()` and `envParam()` survive; the `config()` read and per-key pointer rows are replaced. - [ADR-0031](ADR-0031-provisioned-param-values-are-a-need-resolved-through-a-target-registry.md): Composer's own provider params, still on per-key rows. - [config-params.md](../10-domains/config-params.md): the sources, and where each is accepted. diff --git a/.drive/projects/module-input/slices/s11-s13-configuration-read-once/qa.md b/.drive/projects/module-input/slices/s11-s13-configuration-read-once/qa.md new file mode 100644 index 000000000..8756359f6 --- /dev/null +++ b/.drive/projects/module-input/slices/s11-s13-configuration-read-once/qa.md @@ -0,0 +1,74 @@ +# S11 to S13 manual QA + +The operator runs this plan on the branch `claude/module-input-s11-s13`, on `@prisma/cli-engine` 0.9.0, and fills in each result. It follows spec § 17. A run on 2026-11-02 against the dropped engine change (prisma/prisma-cli#383, 0.10.0) tested a design this branch no longer has, so it is not recorded here. + +## Setup + +1. From the worktree root: `pnpm build`, then `pnpm turbo run build --filter @prisma/example-email...`. +2. Generate the secret once: `export S13_QA_SECRET="s13qa-$(openssl rand -hex 8)"`. Record only its first five characters. +3. Every hosted step runs from `examples/email` as `( set -a; . ~/.config/prisma-compose/deploy.env; set +a; PRISMA_REGION=us-east-1 bun ../../node_modules/.bin/prisma deploy --name s13qa-email --environment s13-qa )`, called "the deploy" below. Never print a credential. + +Result: run by hipparchus-99 on 2026-11-05 at commit 7ce9f1a3 (the round-4 fixes). `pnpm build` 39/39; the email example built. Secret starts `s13qa`. + +## Step 1. Files, deployed to Prisma Cloud + +Do: write `.env.s13-qa` with `EMAIL_DELIVERY_MODE=none` and `EMAIL_FROM=s13qa@example.com`; `.env.s13-qa.local` with `EMAIL_DELIVERY_CREDENTIAL=$S13_QA_SECRET`; `.env.defaults` with `EMAIL_FROM=defaults-must-not-win@example.com`. In a second shell, from `examples/email`, run `while true; do cp .prisma-composer/app-configuration-*.json ../../wip/s13-qa-configuration.json 2>/dev/null; sleep 0.2; done`. Run the deploy, then stop the loop. + +Expected: + +- The deploy succeeds with `config: [dotenv(), environmentVariables()]` and `secrets: [dotenv(), environmentVariables()]`, and without `CONFIG.SECRET_AS_PLAIN_VALUE`. +- Composer's output shows `s13qa@example.com` and `none` where it prints them, not `[redacted]`. +- `wip/s13-qa-configuration.json` holds `appConfigurationRead.rootInput` with `EMAIL_DELIVERY_MODE` `none` and `EMAIL_FROM` `s13qa@example.com` (not the `.env.defaults` value), `configurationKeyText` `{}`, and `nodeAddresses`. +- The file had mode `-rw-------` while it existed. +- `ls .prisma-composer/app-configuration-*.json` finds nothing after the deploy. +- `grep -r "$S13_QA_SECRET" .prisma-composer ../../wip/s13-qa-configuration.json` finds nothing. +- The Management API lists `EMAIL_DELIVERY_CREDENTIAL` on the `s13-qa` Branch (metadata only). + +Result: pass. The deploy succeeded (`Deployed s13qa-email to s13-qa in 55.6s`), with no `CONFIG.SECRET_AS_PLAIN_VALUE`. The input document line shows `"deliveryMode":"none"` and `"from":"s13qa@example.com"` unredacted and `"deliveryCredential":{"$secret":"EMAIL_DELIVERY_CREDENTIAL"}`. The captured file holds `rootInput` with `EMAIL_DELIVERY_MODE` `none` and `EMAIL_FROM` `s13qa@example.com`, not the `.env.defaults` value, `configurationKeyText: {}` and eight `nodeAddresses`; its mode was `-rw-------`; none is left after the deploy; the secret appears in no file under `.prisma-composer` or the captured copy. The Management API was not queried directly: preflight passed, which requires the variable to exist or be filled, and the document names it. + +## Step 2. A short secret inside a plain value + +Do: set `.env.s13-qa.local` to `EMAIL_DELIVERY_CREDENTIAL=s13qa` (five characters, which `EMAIL_FROM=s13qa@example.com` contains), and run the deploy. + +Expected: the deploy does not fail with `CONFIG.SECRET_AS_PLAIN_VALUE`, because a known secret shorter than eight characters is refused only when it is a whole value. Composer's output still redacts the secret at any length, so it prints `s13qa` as `[redacted]`, for example in `[redacted]-email`. The platform variable set in step 1 is not changed. Restore `.env.s13-qa.local` to `EMAIL_DELIVERY_CREDENTIAL=$S13_QA_SECRET`. + +Result: pass with one correction to the expectation. The deploy succeeded with `EMAIL_DELIVERY_CREDENTIAL=s13qa` and `EMAIL_FROM=s13qa@example.com`, so the 8-character rule works. The output did not print `[redacted]` for `s13qa`: the lines holding it are the command's step labels (`assemble s13qa-email.email.service`), the `Deployed s13qa-email …` line and Alchemy's own output. Those carry the app name and node addresses, which the author wrote; they are not among the places the design redacts (errors, events, adapter failures, cause chains, the summary table). The secret value itself was printed nowhere as a value. The expectation above is wrong on this point; design.md section 9 and spec §21 now say step labels and the app name are not redacted. + +## Step 3. An adapter's error is redacted by the credential-looking rule + +Do: for this step only, put `config: [() => { throw new Error(\`cannot use ${process.env.EMAIL_DELIVERY_CREDENTIAL}\`); }, dotenv(), environmentVariables()]` in `prisma.config.ts`, `export EMAIL_DELIVERY_CREDENTIAL="$S13_QA_SECRET"`, and run the deploy. + +Expected: `CONFIG.ADAPTER_FAILED`, with the `Why:` line `It threw: cannot use [redacted]`. No graph exists yet, so no value is a known secret; the variable's name looks like a credential, and that rule hides it. Restore `prisma.config.ts` and unset the variable. + +Result: pass. `✘ [CONFIG.ADAPTER_FAILED] \`composer.config[0]\` failed to read the values for the environment "s13-qa".` with `why: It threw: cannot use [redacted]`. The 16-character secret appears nowhere in the output. + +## Step 4. A different graph is refused + +Do: for this step only, add to the root body in `examples/email/module.ts`: `if (process.env.PRISMA_COMPOSER_APP_CONFIGURATION_FILE !== undefined) provision(email(), { id: 'extra', input: { deliveryMode: input.EMAIL_DELIVERY_MODE, from: input.EMAIL_FROM, deliveryCredential: envSecret('EMAIL_DELIVERY_CREDENTIAL') } });`, and run the deploy. + +Expected: `DEPLOY.ENGINE_FAILED`, whose cause names `"s13qa-email.extra"` (and the nodes under it) as only in the second process. Nothing new is created on the Branch. Restore `module.ts`. + +Result: pass. The second process failed with `LowerError: The deploy's second process built a different graph from the one Composer checked in its first process. Only in the second process: "s13qa-email.extra", …`. Nothing new appeared on the Branch (the following destroy deleted 21 resources, the same count step 1 created). + +## Step 5. `prisma dev` + +Do: write `.env.dev` with `EMAIL_DELIVERY_MODE=none` and `EMAIL_FROM=dev-qa@example.com`, and `.env.dev.local` with `EMAIL_DELIVERY_CREDENTIAL=$S13_QA_SECRET`. From `examples/email`, run `bun ../../node_modules/.bin/prisma dev --detach`. + +Expected: + +- `prisma dev` starts, and Alchemy's output reaches the terminal unchanged. +- No `.prisma-composer/dev/dev/app-configuration-*.json` exists after it returns. +- `grep -r "$S13_QA_SECRET" .prisma-composer .alchemy` finds nothing. The compute emulator's own copy lives outside the project, as ADR-0058 records. +- `curl -s -o /dev/null -w '%{http_code}' -X POST -H 'content-type: application/json' -d '{"email":"qa@example.com","name":"QA"}' "$(bun ../../node_modules/.bin/prisma dev url email-example.mailer)/signup"` prints a 2xx status. + +Then run `prisma dev stop`. + +Result: pass. `prisma dev --detach` returned `dev: ready` with both service URLs, and Alchemy's output reached the terminal. No `app-configuration-*.json` under `.prisma-composer/dev/dev/`. The secret appears in no file under `.prisma-composer` or `.alchemy`. `POST /signup` on the mailer returned 201. `prisma dev stop` exited 0. + +## Step 6. Clean up + +Do: run `( set -a; . ~/.config/prisma-compose/deploy.env; set +a; PRISMA_REGION=us-east-1 bun ../../scripts/composer-destroy.ts --environment s13-qa --name s13qa-email )`, and the same with `--environment production` if step 1 created it. For this check only, put step 3's throwing adapter in `secrets`. Then delete `.env.s13-qa`, `.env.s13-qa.local`, `.env.defaults`, `.env.dev`, `.env.dev.local` and `wip/s13-qa-*`, and restore `prisma.config.ts`. + +Expected: destroy succeeds, because it runs no secrets adapter. `git status` shows only this branch's changes. + +Result: pass. With a throwing adapter first in `secrets`, destroy ran to `Destroyed.` and deleted 21 resources, so no secrets adapter ran. The env files and the config edit are removed; `git status` shows only this file. 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..f304c2adc --- /dev/null +++ b/.drive/projects/module-input/slices/s11-s13-configuration-read-once/spec.md @@ -0,0 +1,1244 @@ +# 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-app-secrets-and-never-invents-them.md), [ADR-0029](../../../../../docs/design/90-decisions/ADR-0029-a-secret-is-an-environment-sourced-value-the-framework-never-stores.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 `claude/module-input-s11-s13`, stacked on prisma/composer#412 (S9 and S10). This spec is grounded in that branch at `78166d80`, which holds S9 and S10; § 19 says what S10 changed. + +Release order: this PR needs no engine release. It runs on `@prisma/cli-engine` 0.9.0, the version on `main`. The engine change it once waited for, prisma/prisma-cli#383, was dropped on 2026-11-05 (§ 9.5). + +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; `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 the app configuration file, `.prisma-composer/app-configuration--.json`, mode 0600. It first refuses, with `CONFIG.SECRET_AS_PLAIN_VALUE`, when any value in it would contain a known secret: + + ```json + {"appConfigurationRead":{"rootInput":[{"path":["EMAIL_FROM"],"value":"shop@example.com"},{"path":["EMAIL_DELIVERY_MODE"],"value":"none"}],"configurationKeyText":{}},"nodeAddresses":["shop","shop.email"]} + ``` + +4. It starts the second process with `PRISMA_COMPOSER_APP_CONFIGURATION_FILE` naming that file. The second process runs no adapter. It Loads the graph with what the first process read, without running the root's schema, and refuses to deploy a graph whose node addresses differ from the file's. +5. The second process prints to the terminal, as before this PR; its output is not redacted (§ 9.5). Everything Composer itself prints is redacted, including the second process's failure cause. +6. When the deploy ends, success or failure, the first process deletes the app configuration file. It also deletes it when the process exits. A later run deletes a file left by a process killed outright, once that process is not running and the file is ten minutes old. + +What the user sees when an adapter's error holds a credential-looking value: + +```text +✘ [CONFIG.ADAPTER_FAILED] `composer.config[0]` failed to read the values for the environment "staging". + Why: It threw: cannot use token [redacted] +``` + +Nothing is redacted by the known secrets before Load has named them: an adapter that throws, and a root schema failure that echoes a field's value, are redacted only by the credential-looking rule. + +`prisma dev` does the same, with `/app-configuration-.json`, and also hands its second process the secrets its local services need, in `PRISMA_COMPOSER_NAMED_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 secrets are read first, then the configuration (§ 7.3). +- **Load records what it read** from the app configuration, as `graph.appConfigurationRead` (§ 5). The app configuration file holds that record and the graph's node addresses. It holds no secrets: Load refuses a name read both by `envParam()` and by `envSecret()`, and a root input field the body passes on whose name an `envSecret()` reads (§ 5.5). The CLI refuses to write the file when any value in it contains a known secret (§ 8.1). +- **The second process Loads with `appConfigurationRead`** 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 `firstProcessNodeAddresses`, read from the app configuration file, 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 `SecretRedaction` object per command holds two kinds of value. The **known secrets** (`KnownSecrets`) are values Composer knows are secrets: under every name an `envSecret()` in the graph names, the value in the app configuration and in the app secrets, at any length. Nothing else in the app configuration or the app secrets is a known secret: the lists hold values Composer may carry, and only the values under the names the graph names become known secrets. Under `secrets: [dotenv(), environmentVariables()]`, a plain `.env` value and the shell's `SHLVL=1` are not known secrets. The **credential-looking values** only look like secrets by their variable's name: text of eight characters or more under a credential-looking name, in the process environment and the app configuration. That second kind is the redaction S4 shipped, kept for now; it is not part of this design. Every error an operation returns, every thrown defect, every event `prisma dev` emits, the run report, the deploy reporter's message, the deployment summary and the second process's failure cause hide both kinds, and the token shapes S4's redactor finds. +- **The CLI does not redact the second process's output, for now** (§ 9.5). The CLI's engine gives the second process the terminal, as before this PR. For programmatic hosts, `spawnAlchemy` pipes the output in-process through a stream redactor (§ 9.4). +- **`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. `environmentVariables()` returns the whole process environment, `PRISMA_COMPOSER_` variables included (Will's ruling; § 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'; + +/** Reads the process environment when called: every variable whose value is a string. 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') 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.4 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`, `LocalTargetAttachment.serve`'s input, `PipelineResult` | +| `PreflightInput.pools` | `PreflightInput.configuration` and `PreflightInput.secrets` | core, target preflight, CLI operations | +| `LocalTargetProvidersInput.pools` | `.configurationKeyText` (the text of each configuration key the graph reads, from the app configuration file) and `.namedSecrets: NamedSecrets` (the app secrets the graph's `envSecret()`s name) | 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, configurationKeyText, namedSecrets)` | 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 `appConfigurationRead` and `firstProcessNodeAddresses` (§ 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/app-configuration-file` (`internal-app-configuration-file.ts`) | 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|from its pool|(^|[^a-z])pools([^a-z]|$)' -- 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 RootInputField { + /** 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 app configuration file, and + * their second process Loads with it in place of the app configuration. + */ +export interface AppConfigurationRead { + /** Each field of the root's input that its body names. Empty when the root declares no input. */ + readonly rootInput: readonly RootInputField[]; + /** 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 configurationKeyText: Readonly>; +} +``` + +`LoadOptions` becomes a union of two option types over common options, so the compiler refuses a call that passes both (`load-options.test-d.ts`): + +```ts +interface CommonLoadOptions { + readonly id?: NodeId; + readonly environment?: EnvironmentRealValues; +} + +/** Load's options in the first process, or wherever the app configuration is at hand. */ +export interface FirstProcessLoadOptions extends CommonLoadOptions { + /** 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. */ + readonly configuration?: Readonly>; + readonly appConfigurationRead?: never; +} + +/** Load's options in the deploy's and dev's second process. */ +export interface SecondProcessLoadOptions extends CommonLoadOptions { + readonly configuration?: never; + /** What the first process's Load read, from the app configuration file. Load takes each root field from it without running the root's schema. */ + readonly appConfigurationRead: AppConfigurationRead; +} + +export type LoadOptions = FirstProcessLoadOptions | SecondProcessLoadOptions; +``` + +`Load` also refuses both at runtime (§ 5.2), for a caller without type checks. + +`Graph` gains, after `secretSources`: + +```ts + /** What this Load read from the app configuration (ADR-0062). Empty when Load was given none. */ + readonly appConfigurationRead: AppConfigurationRead; +``` + +`load-service.ts`'s graph gains `appConfigurationRead: { rootInput: [], configurationKeyText: {} },` 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.appConfigurationRead !== undefined) { + throw new LoadError( + 'Load takes configuration, in the first process, or appConfigurationRead, in the second, not both.', + ); + } + if (root.inputSchema !== undefined) { + if (opts?.appConfigurationRead !== undefined) { + moduleInputs.push({ + moduleAddress: rootId, + schema: root.inputSchema, + binding: undefined, + isRoot: true, + checked: opts.appConfigurationRead.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 `appConfigurationRead: opts?.appConfigurationRead` in place of `configPool`. The returned graph gains `appConfigurationRead: resolved.appConfigurationRead,` after `secretSources`. + +### 5.3 `packages/0-framework/1-core/core/src/resolve-values.ts` + +1. Imports: `type AppConfigurationRead` and `type RootInputField` 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's root schema produced them. The root's schema does not run. */ + readonly checked?: readonly RootInputField[]; + ``` + +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 the process environment, 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 appConfigurationRead: AppConfigurationRead | undefined; + ``` + + Its return type gains `readonly appConfigurationRead: AppConfigurationRead;`. +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.appConfigurationRead !== undefined) raw = input.appConfigurationRead.configurationKeyText[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 app 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`: `appConfigurationReadOf(inputBindings, params, secretSources)`, with `rootModuleAddress()` declared before it. When Load has a `configuration` or an `appConfigurationRead`, it reads, through `configurationText`, the configuration key of every param source (`sourceKind: 'param'`) left in a service's input binding or a service param. Then, for each secret source (`sourceKind: 'secret'`) with a `secretsKey`, it throws a `LoadError` when a configuration key read the same name, and when a recorded root field's first path segment is that name (§ 12 has both messages). Then it throws when a recorded root field holds a value JSON cannot hold exactly. It returns `{ rootInput: [...rootFields.values()], configurationKeyText }`, where `configurationKeyText` holds each recorded key's text. The code is in `packages/0-framework/1-core/core/src/resolve-values.ts`. +11. Both returns of `resolveValues` gain `appConfigurationRead`. 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, + appConfigurationRead: appConfigurationReadOf(input.inputBindings, input.params, secretSources), + }; + } + ``` + + and the last return becomes: + + ```ts + const secretSources = secretSourcesReceivedBy(inputBindings, params); + return { + resourceProps, + inputBindings, + params, + secretSources, + appConfigurationRead: appConfigurationReadOf(inputBindings, params, secretSources), + }; + ``` + +### 5.4 What Load does in each process + +| | First process: `{ configuration }` | Second process: `{ appConfigurationRead }` | +| --- | --- | --- | +| 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 `appConfigurationRead.rootInput`; missing is a `LoadError` | +| A configuration key read in a module's input or a `resolve()` | read from `configuration`, recorded | read from `appConfigurationRead.configurationKeyText` | +| A configuration key in a service's input or a service param | read and recorded after resolution; non-text is a `LoadError` | read from `appConfigurationRead.configurationKeyText` | +| A key read as both a configuration key and a secrets key | `LoadError` | `LoadError` | +| A root field the body passes on whose name a secrets key reads | `LoadError` | `LoadError` | +| Both `configuration` and `appConfigurationRead` | refused by the type, and by a `LoadError` at runtime | the same | +| 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 splits in two by `sourceKind`, so each kind has exactly one key. `sourceKind: 'param'` carries `configurationKey: string | undefined` (in place of `poolKey`), the app configuration key whose text at deploy is the real value, as for `envParam()`. `sourceKind: 'secret'` carries `secretsKey: string | undefined`, the app secrets key that holds the secret's value, as for `envSecret()`. Both keep `label` and the non-enumerable `source`. + +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', sourceKind: 'secret', label, 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', sourceKind: 'param', label, configurationKey }`. +5. `sourceMisuse`: `origin.poolKey === undefined` becomes `origin.sourceKind === 'secret' || 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 `AppConfigurationRead` and `RootInputField`. + +## 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`. `secretsKeys` takes `secretsKey` only from an origin with `sourceKind: 'secret'`. `exports/deploy.ts` (`@internal/core/deploy`, republished as `@prisma/composer/deploy`) exports `nodeAddresses` and `secretsKeys`; they are not on the main entry. + +### 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 app 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 `configurationKeyText`. The generated stack files pass it. */ + readonly appConfigurationRead?: AppConfigurationRead; + /** The node addresses of the graph the first process built. When given, lowering refuses a graph whose addresses differ. The generated stack files pass them. */ + readonly firstProcessNodeAddresses?: readonly string[]; + ``` + +2. In `lowering`, the `Load` call's `...(opts.configPool !== undefined ? { configPool: opts.configPool } : {})` becomes `...(opts.appConfigurationRead !== undefined ? { appConfigurationRead: opts.appConfigurationRead } : {})`, and directly after the `Load` call: + + ```ts + if (opts.firstProcessNodeAddresses !== undefined) { + assertSameNodeAddresses(nodeAddresses(graph), opts.firstProcessNodeAddresses); + } + ``` + +3. New module-private function, after `LowerError`: + + ```ts + function assertSameNodeAddresses(built: readonly string[], firstProcess: readonly string[]): void { + const onlyBuilt = built.filter((address) => !firstProcess.includes(address)); + const onlyFirst = firstProcess.filter((address) => !built.includes(address)); + if (onlyBuilt.length === 0 && onlyFirst.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(onlyFirst)}. A module body must build the same graph every time; one that reads the process environment, a file or the clock can build a different one.`, + ); + } + ``` + + `deploy.ts` imports `nodeAddresses` from `../graph.ts` and the type `AppConfigurationRead` 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, read by the stack file from the app configuration file, and the messages say "the process environment", not `process.env`, because the invariant counts the text. + +### 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 the values the graph's `envSecret()`s name 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 process's Load read from the app 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 `configurationKeyText: Readonly>`, `The text of each configuration key the graph reads, from the app configuration file. A local deployment takes the text its param rows point at from it.`, and `namedSecrets: NamedSecrets`, `The app secrets the graph's \`envSecret()\`s name, which the first process handed the second. A local deployment takes the text its \`$secret\` pointers name from it.` `app-config.ts` exports `type NamedSecrets = Readonly>`, the subset of the app secrets under the graph's secrets keys. +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 `configurationKeyText: Readonly>`, a fifth `namedSecrets: NamedSecrets` follows, and the call becomes `descriptor.providers({ container: containers.get(id), devDir, configurationKeyText, namedSecrets })`. + +## 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`. +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`, and its local `pool` becomes `merged`. +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. A value is a known secret only once the graph + * names it, so this adds none. + */ + export async function readAppSecrets( + section: PrismaAppConfig, + input: AdapterReadInput, + ): Promise { + const merged = await readAdapters('secrets', section.secrets ?? [environmentVariables()], input); + if (!isText(merged)) throw new InternalError('readAdapters admitted a secret that is not text'); + return merged; + } + ``` + + Whether the section writes its own `secrets` list makes no difference to what is known: the known secrets come from the graph (§ 9.1). + + 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/deploy` and `credentialLookingValues, SecretRedaction, valuesUnderSecretsKeys` 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 over the process environment when not given. */ + readonly redaction?: SecretRedaction | undefined; + ``` + +3. `PipelineResult.pools` is replaced by: + + ```ts + /** The app configuration read for this run. In memory only; the app 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. + const redaction = deps.redaction ?? new SecretRedaction(process.env); + const adapterInput = { environment, configFile: configSource.file }; + const secrets: AppSecrets = + deps.readSecrets === true ? await readAppSecrets(config, adapterInput) : NO_SECRETS; + const configuration = await readAppConfiguration(config, adapterInput); + redaction.addCredentialLookingValues(credentialLookingValues(configuration)); + ``` + +5. Step 4's `configPool: pools.config` becomes `configuration`. Directly after the `ROOT_NOT_MODULE` check: + + ```ts + redaction.addKnownSecrets(valuesUnderSecretsKeys(configuration, secrets, secretsKeys(graph))); + ``` + +6. The return becomes `return { configSource, entryModule, graph, name, assembled, configuration, secrets };`. + +## 8. CLI: the app configuration file and the second process + +### 8.1 New file `packages/0-framework/3-tooling/cli/src/app-configuration-file.ts` + +The first process's handover to the second, and the second process's reading of it, in one module. It imports `node:fs`, `node:path`, `DEV_DIR` and the types it needs, and no value from core's main entry or `@prisma/cli-engine`, because the generated stack files import it in the second process. Its exports: + +- `APP_CONFIGURATION_FILE_ENV = 'PRISMA_COMPOSER_APP_CONFIGURATION_FILE'`, which names the file, and `NAMED_SECRETS_ENV = 'PRISMA_COMPOSER_NAMED_SECRETS'`, which carries the named secrets under `prisma dev` only. +- `interface AppConfigurationFile { appConfigurationRead: AppConfigurationRead; nodeAddresses: readonly string[] }`: what the file holds, as JSON. +- `deployAppConfigurationFile(cwd, runId)`: `.prisma-composer/app-configuration--.json`, unique per run. `devAppConfigurationFile(devDir)`: `/app-configuration-.json`. +- `LEFTOVER_APP_CONFIGURATION_FILE_AGE_MS = 10 * 60 * 1000`. +- `removeLeftoverAppConfigurationFiles(cwd)`: deletes the app configuration files in `.prisma-composer/` and in each dev environment folder whose process id (from the file name) is not this process, is not running (`process.kill(pid, 0)`; `EPERM` counts as running), and whose file is older than `LEFTOVER_APP_CONFIGURATION_FILE_AGE_MS`. The age check keeps the file of a run in a container or on another machine that shares the folder but not the process ids. Never throws. +- `prepareSecondProcess({ file, appConfigurationRead, nodeAddresses, secretsKeys, knownSecrets: KnownSecrets, appSecrets? })` returns a `SecondProcessHandover { env; writeFile(); removeFile() }`. It first refuses, with `refuseSecretsIn`, a file that would hold a known secret. `env` holds `APP_CONFIGURATION_FILE_ENV`, and, when `appSecrets` is given (only `prisma dev` gives it), `NAMED_SECRETS_ENV` with the app secrets under `secretsKeys` that hold text. `writeFile()` replaces any file at `file` with the JSON, mode 0600, and registers a `process.on('exit')` handler that deletes it with `fs.rmSync`; writing again does not register a second handler. `removeFile()` removes the handler and deletes the file, and never throws. +- `refuseSecretsIn(read, knownSecrets)`, module-private: walks every root input field's value and every configuration key's text. A string is checked as it is; a number, a boolean or a bigint as `String(value)`. When `knownSecrets.nameIn(text)` finds a known secret in one, it throws `CONFIG.SECRET_AS_PLAIN_VALUE` (§ 12), naming the field and the secrets key that matched, never the value. A known secret of eight characters or more (`MIN_SECRET_LENGTH_INSIDE_A_VALUE`) is found anywhere in the text; a shorter one only when it is the whole text, so a placeholder such as `dev` does not refuse `dev@example.com`. +- `readAppConfigurationFile(env): AppConfigurationFile` (second process): throws `missingVariable(APP_CONFIGURATION_FILE_ENV, 'names the app configuration file it wrote there')` when the variable is unset or `''`; a read error throws `Could not read the app 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.`; a file that does not have the expected shape throws `The app configuration file ${file} is not one Composer wrote: .`, where `` is `it is not JSON`, `appConfigurationRead is not an object`, `rootInput is not a list of fields`, `configurationKeyText is not an object of text` or `nodeAddresses is not a list of node addresses`. It rebuilds the data with type guards, without a cast. +- `namedSecretsFromEnvironment(env): NamedSecrets` (second process, `prisma dev`): throws `missingVariable(NAMED_SECRETS_ENV, 'hands the secrets the local services need there')` when unset, and `PRISMA_COMPOSER_NAMED_SECRETS is not a JSON object of text.` when malformed; otherwise returns a frozen object with no prototype. + +`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}.` ``. + +`prepareSecondProcess` and `KnownSecrets` are also exported from `@internal/cli/testing` (republished as `@prisma/composer-cli/testing`), so the integration tests hand over to the second process the way the CLI does. + +### 8.2 The generated deploy stack file, `generate-stack.ts` + +`renderOptions` passes `appConfigurationRead: file.appConfigurationRead` and `firstProcessNodeAddresses: file.nodeAddresses` in place of `configPool`. The template imports `readAppConfigurationFile` from `@prisma/composer/internal/app-configuration-file`, starts the failure recorder (`report.captureEngineFailure?.()`) first, so a missing app configuration file is recorded as the cause, then reads `const file = readAppConfigurationFile(process.env);`. The header says the stack file runs no config adapter and reads what the first process's Load read from the app configuration file named in `PRISMA_COMPOSER_APP_CONFIGURATION_FILE`. + +### 8.3 The generated dev stack file, `dev/generate-dev-stack.ts` + +The same two options as § 8.2, and `providers: localTargetProviders(resolved, containers, devDir, file.appConfigurationRead.configurationKeyText, namedSecrets)`. It imports `namedSecretsFromEnvironment` and `readAppConfigurationFile` from `@prisma/composer/internal/app-configuration-file`, and reads `const file = readAppConfigurationFile(process.env);` and `const namedSecrets = namedSecretsFromEnvironment(process.env);`. The header says it runs no config or secrets adapter. + +### 8.4 `operations/execute-deploy-destroy.ts` + +1. `StackPipelineOptions` gains `readonly redaction: SecretRedaction;`. `executeDeploy` and `executeDestroy` pass `redaction: new SecretRedaction(process.env)`, and `pipelineDeps` passes it on. +2. `extension.preflight({ …, pools: pipeline.pools, … })` becomes `configuration: pipeline.configuration, secrets: pipeline.secrets,`. +3. At the start of the run, `removeLeftoverAppConfigurationFiles(cwd)`. +4. Inside the try that writes the stack file: `handover = prepareSecondProcess({ file: deployAppConfigurationFile(cwd, randomUUID()), appConfigurationRead: pipeline.graph.appConfigurationRead, nodeAddresses: nodeAddresses(pipeline.graph), secretsKeys: secretsKeys(pipeline.graph), knownSecrets: opts.redaction.knownSecrets })`, then the stack file, then `handover.writeFile()`. `nodeAddresses` and `secretsKeys` come from `@internal/core/deploy`. A `CONFIG.SECRET_AS_PLAIN_VALUE` refusal is returned as it is; a write failure is `DEPLOY.STACK_WRITE_FAILED`, as for the stack file. +5. `alchemyInvocation`'s `env` is `{ ...reporterChildEnv(reporters, opts.redaction), ...handover.env, [DEPLOYMENT_RESULT_FILE_ENV]: resultFilePath }`, and `redactedValues: opts.redaction.allValues()`. +6. `readEngineFailureCause(resultFilePath, poolSecrets(…))` becomes `readEngineFailureCause(resultFilePath, (text) => opts.redaction.redact(text))`. +7. `return ok(readDeploymentSummary(resultFilePath));` becomes `return ok(redactSummary(readDeploymentSummary(resultFilePath), opts.redaction));`. +8. The `finally` calls `handover?.removeFile();` after removing the result files. +9. `runStackPipeline`: + - The `catch (error)` that records a defect: `const thrown = redactThrown(error, opts.redaction);` first. The reporters get `thrown.message`, and the catch rethrows `thrown`. + - After `runStackPipelineInner` returns: `if (!outcome.ok) outcome = notOk(redactError(outcome.failure, opts.redaction));`. The run report, the reporters' `errorMessage` and the returned failure all read the redacted failure. +10. `beginReporters`, `attachReporters`, `finishReporters` and `reporterChildEnv` take a last parameter `redaction: SecretRedaction`, and each `console.warn` passes `redaction.redact(detail)` in place of `detail`. + +### 8.5 `operations/execute-dev.ts` + +1. `executeDev`'s body moves into a module-private `executeDevInner(input, deps, cwd, redaction: SecretRedaction)`. `executeDev` builds `const redaction = new SecretRedaction(process.env);`, returns `notOk(redactError(result.failure, redaction))` for a failure, and in its catch rethrows `redactThrown(error, redaction)`. +2. At the start, `removeLeftoverAppConfigurationFiles(cwd)`. +3. `emit` passes each event through `redactDevEvent(event, redaction)`. +4. `samePools` becomes `sameValues(started: PipelineResult, now: PipelineResult)`, comparing `configuration` and `secrets` with `isDeepStrictEqual`. +5. `pipelineDeps` and `watchDeps` pass `redaction`. +6. `dev.preflight` and `serve` take `configuration: pipeline.configuration, secrets: pipeline.secrets` in place of `pools`. +7. The constant `invocation` goes. `invocationFor(p: PipelineResult)` returns `{ invocation, handover }`: the handover is `prepareSecondProcess({ file: devAppConfigurationFile(devDir), appConfigurationRead: p.graph.appConfigurationRead, nodeAddresses: nodeAddresses(p.graph), secretsKeys: secretsKeys(p.graph), knownSecrets: redaction.knownSecrets, appSecrets: p.secrets })`, and the invocation passes `env: handover.env` and `redactedValues: redaction.allValues()`. +8. `converge(p)` writes the dev stack file and calls `handover.writeFile()` in one try (both `DEV.STACK_WRITE_FAILED`), runs the converge, and calls `handover.removeFile()` in a `finally`. It returns `{ outcome, stackPath, invocation }`. The first converge and each rebuild call it; a rebuild's `converge-failed` event uses the returned invocation for `reproduceCommand`, and a `CliStructuredError` it throws is emitted as `rebuild-failed`. + +The dev app 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/app-configuration-file.ts` exports `namedSecretsFromEnvironment` and `readAppConfigurationFile`, with the doc comment `What the generated stack files read in the second process. Not public API: published as \`@prisma/composer/internal/app-configuration-file\`, it may change in any release.` +2. `packages/0-framework/3-tooling/cli/tsdown.config.ts`: the entry `pools` becomes `'app-configuration-file': 'src/exports/app-configuration-file.ts'`. `pnpm --filter @internal/cli build` regenerates `package.json#exports`; commit the regenerated file, do not hand-edit it. +3. `packages/9-public/composer/src/exports/internal-pools.ts` is deleted. New `internal-app-configuration-file.ts`: `export * from '@internal/cli/app-configuration-file';`. Its tsdown entry is `'internal-app-configuration-file'`, and `package.json#exports` has `"./internal/app-configuration-file": "./dist/internal-app-configuration-file.mjs"`. `architecture.config.json` and `tsconfig.depcruise.json` name the new file. +4. `nodeAddresses` and `secretsKeys` are exported from core's `deploy` entry, `@prisma/composer/deploy`, not from the main entry. +5. `@internal/cli/testing` exports `prepareSecondProcess`, the type `SecondProcessHandover`, and `KnownSecrets`; `@prisma/composer-cli/testing` republishes them. + +### 8.7 The engine version + +`@prisma/cli-engine` stays at `0.9.0`, the version on `main`, in every manifest, the root `pnpm.overrides` and the lockfile. `pnpm check:cli-engine-pin` passes unchanged. No command checks the engine's version. + +## 9. CLI: Composer redacts every secret it knows + +### 9.1 What a command knows + +One `SecretRedaction` per command holds two kinds of value. The known secrets are in its `knownSecrets` (`KnownSecrets`); the credential-looking values are kept beside them. + +| Values | Kind | When they are added | Length | +| --- | --- | --- | --- | +| Under each name `secretsKeys(graph)` lists, the value in the app configuration and the value in the app secrets (`valuesUnderSecretsKeys`) | known secret | after Load | any | +| Text under a credential-looking name (`credentialLookingValues`) in the process environment. Existing S4 behaviour, kept for now; not part of this design | credential-looking | when the `SecretRedaction` is built | eight characters or more | +| Text under a credential-looking name in the app configuration. The same S4 behaviour | credential-looking | after the app configuration is read | eight characters or more | + +`SecretRedaction.redact(text)` replaces every value of both kinds, then the token shapes S4's redactor finds: bearer and basic tokens, JWTs, a password in a URL, and `token=…` pairs. The stream redactor that `spawnAlchemy` uses for programmatic hosts (§ 9.4) uses exact values only: `allValues()`. The app configuration file check (§ 8.1) uses only the known secrets, so a plain value that merely equals a credential-looking variable is not refused. + +### 9.2 `deployment-summary.ts` + +1. `import type { ConfigPools } from '@internal/core/config';` goes, and `poolSecrets` is removed. +2. `readEngineFailureCause(resultFilePath, redact: (text: string) => string)` passes the recorded cause through `redact` in place of a list of values. The child redacts only what its own environment names as a credential; the parent also knows the known secrets. +3. The child's own failure capture, `captureEngineFailure` and `engineFailureCause`, uses `redactCredentialLooking(text, env)` from `./redaction.ts`, because the second process has no `SecretRedaction`. + +The redaction primitives move to `redaction.ts` (§ 9.3). `deployment-summary.ts` still imports nothing heavy, so the `report` entry stays light. + +### 9.3 New file `packages/0-framework/3-tooling/cli/src/redaction.ts` + +Every redaction primitive lives here, over one `SecretMatcher`. The code is the source; this lists what each export does. + +- `REDACTED = '[redacted]'`. +- `credentialLookingValues(variables)`: the credential-looking values in a record, text of eight characters or more under a name `SECRET_ENV_NAME` matches. It serves both the process environment and the app configuration. +- `SecretMatcher(values)`: one regular expression over every non-empty value, built once. `spans(text)` returns the merged `[start, end)` span of every secret in the text: at each position the longest secret that starts there, and overlapping spans merged, so two overlapping secrets are hidden whole. `startOfSecretAtEnd(text)` returns the length of the longest end of the text that is the start, not the whole, of a secret. `redact(text)` replaces each span with `[redacted]`. +- `redactValues(text, values)`: `new SecretMatcher(values).redact(text)`. +- `redactCredentialLooking(text, env)`: the credential-looking values of `env`, then the token shapes: bearer and basic tokens, JWTs, a password in a URL, and `token=…` pairs. For a process that has no `SecretRedaction`, such as the second process's failure capture. +- `KnownSecret`: `{ name, value }`, a value under a secrets key, with that key. +- `KnownSecrets`: the known secrets of one command, values Composer knows are secrets, each with its secrets key. `add(secrets)` skips empty values, and a value already known keeps its first name; `values()` returns the set of values; `nameIn(text)` returns the secrets key of a known secret in the text, or `undefined`: a secret of `MIN_SECRET_LENGTH_INSIDE_A_VALUE` (8) characters or more anywhere in the text, a shorter one only when it is the whole text. It has a private field, so a plain `Set` cannot stand in for it, and `prepareSecondProcess` can only be given the known secrets. +- `SecretRedaction(env)`: what one command hides. It starts with `credentialLookingValues(env)`. `knownSecrets` is its `KnownSecrets`; `addKnownSecrets(secrets)` and `addCredentialLookingValues(values)` add each kind; `allValues()` returns both kinds, longest first; `redact(text)` replaces both kinds, then the token shapes. It rebuilds its matcher when a value was added since the last call. +- `valuesUnderSecretsKeys(configuration, secrets, secretsKeys)`: the text under each secrets key, in the app configuration and in the app secrets, at any length, each as a `KnownSecret` with its key. +- `redactError(error, redaction)`: a new `CliStructuredError` with the summary, `why`, `fix` and every string in `meta` redacted; the code, severity, `where` and docs link kept; the cause dropped, because it holds the same text unredacted. +- `redactThrown(thrown: unknown, redaction): Error`: the error to rethrow in place of a thrown defect. A thrown value that is not an `Error`, such as a string or a plain object, becomes an `Error` whose message is its redacted text. It redacts the message and stack of the error, of each error in its `cause` chain and of each member of an `AggregateError`, and turns a cause that is text or plain data into a redacted copy. It sets each property with `Object.defineProperty`, which works on a `DOMException`, whose `message` has only a getter. When even that fails, as for a frozen error, it returns a new `Error` (or `AggregateError`) with the same name and the redacted message, stack and cause. It never throws: a `message` that is not text is redacted as text, and when reading the error fails at all, as for a getter that throws or a revoked proxy, it returns a new `Error` holding the redacted text of what was thrown, or a fixed sentence when even that text cannot be read. +- `redactDevEvent(event, redaction)`: the event with its message, or a dev server's output line, redacted. +- `redactSummary(summary, redaction)`: each entity's id, url and details redacted. +- `StreamRedactor(values)`: redacts a stream chunk by chunk with the same matcher. `push(chunk)` returns the text that is ready and holds back only the end that could still be part of a secret: the longest end that could start one, or a match a later secret could extend. It never splits a surrogate pair. `end()` returns the rest, redacted. + +## 9.4 `run-alchemy.ts` + +1. `AlchemyInvocation` gains, after `env`: + + ```ts + /** The known secrets and the credential-looking values, for `spawnAlchemy`'s stream redactor; the CLI's runner ignores them. 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 stdout = new StreamRedactor(redactedValues); + const stderr = new StreamRedactor(redactedValues); + const write = (stream: OutputStreams['stdout'], text: string): void => { + if (text.length > 0) stream.write(text); + }; + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => write(outputs.stdout, stdout.push(chunk))); + child.stderr?.setEncoding('utf8').on('data', (chunk: string) => write(outputs.stderr, stderr.push(chunk))); + child.on('error', reject); + child.on('close', (exitCode, signal) => { + write(outputs.stdout, stdout.end()); + write(outputs.stderr, stderr.end()); + resolve({ exitCode, signal }); + }); + }); + } + ``` + + It imports `StreamRedactor` from `./redaction.ts`. `redactedValues` already holds the credential-looking values of the process environment, because the command's `SecretRedaction` starts with them. +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 and, for programmatic hosts, pipe its output through redaction". The header and `RunAlchemy`'s doc comment say that only `spawnAlchemy` redacts the child's output, and that under the CLI the child gets the terminal and its output is not redacted (ADR-0062). + +### 9.5 The second process's output, under the CLI + +Under the CLI the second process's output is not redacted, for now. The CLI's command engine gives the second process the terminal, so Composer never sees Alchemy's output. Redacting it needed a change to the engine, prisma/prisma-cli#383, which let a command transform a child's output. Will dropped that change on 2026-11-05: Composer will soon run Alchemy through its programmatic API, in its own process, which lets Composer redact that output. That work must redact Alchemy's output itself (design.md section 9, "The second process's output is not redacted, for now"). Until then Alchemy's output prints as it did before this PR. The redactor still covers everything Composer itself prints, including the second process's failure cause (§ 9.2). + +`commands/deploy.ts` and `commands/dev.ts` are as on `main`. `family/converge.ts` differs from `main` only in `convergeSpawn`'s doc comment, which says it ignores `invocation.redactedValues`: `convergeSpawn` passes `ctx.spawn` the command, arguments, working directory and environment, and nothing else. The secret values never reach the child's environment or command line, and a test in `converge-spawn.test.ts` checks that. `redactedValues` stays on `AlchemyInvocation` because the operations build one invocation for whichever runner the host chose. `STDOUT_TO_STDERR` and its doc comment are unchanged. `destroy` has no CLI command (ADR-0050); the programmatic operations pipe in-process through the stream redactor (§ 9.4). + +## 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 configuration key text from the app configuration file. The process environment when not given.` +5. `descriptors/compute.ts`: `...(ctx.opts.configPool !== undefined ? { env: ctx.opts.configPool } : {}),` becomes `...(ctx.opts.appConfigurationRead !== undefined ? { env: ctx.opts.appConfigurationRead.configurationKeyText } : {}),`. +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: Readonly>, secrets: Readonly>`; `pools.secrets[name]` becomes `secrets[name]` and `pools.config[name]` becomes `configuration[name]`; the provider's call passes `input.configurationKeyText, input.namedSecrets`. +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, the providers get `configurationKeyText`, the text of each configuration key the graph reads, which is every name a param row points at, and `namedSecrets`, what `PRISMA_COMPOSER_NAMED_SECRETS` holds, every name a `$secret` pointer names. + +## 11. What breaks, and what does not + +Breaks: + +1. `LoadOptions.configPool` is now `configuration`. `LoadOptions` is now a union, `FirstProcessLoadOptions | SecondProcessLoadOptions`, so `interface X extends LoadOptions` stops compiling. `LowerOptions.configPool` is gone; a hand-written stack passes `appConfigurationRead` or nothing. +2. `paramSource()`'s `poolKey` option is now `configurationKey`. +3. `ConfigPool`, `SecretsPool` and `ConfigPools` are now `AppConfiguration` and `AppSecrets`; `PreflightInput` and a local attachment's `serve` input take `configuration` and `secrets` in place of `pools`; `LocalTargetProvidersInput` takes `configurationKeyText` and `namedSecrets`, and `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. Under the CLI the deploy engine's output is not redacted (§ 9.5). A programmatic host's `spawnAlchemy` pipes it through the stream redactor, so there 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, and so does a root input field the body passes on whose name an `envSecret()` reads. +11. The CLI refuses to write the app configuration file, with `CONFIG.SECRET_AS_PLAIN_VALUE`, when any value in it contains a known secret: a secret of eight characters or more anywhere in a value, a shorter one only as the whole value. +12. `environmentVariables()` returns the whole process environment, `PRISMA_COMPOSER_` variables included. +13. 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. +14. A config adapter now runs after the secrets adapters, so a broken secrets adapter is reported before a broken config adapter. +15. Only the values under the names an `envSecret()` in the graph names are known secrets. With a written `secrets` list, its other values are neither redacted nor refused in the app configuration file. Before, every value a written list returned was redacted. +16. The app's `@prisma/composer` must be at least the version that ships `internal/app-configuration-file`; with an older one the second process fails when it 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 `appConfigurationRead` | `Load takes configuration, in the first process, or appConfigurationRead, 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