Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
18e13da
docs: ADR-0029 — secrets are env-named params
wmadden-electric Jul 13, 2026
a494d40
docs: fix config-params prefix consistency + sharpen ADR-0029 bundle-…
wmadden-electric Jul 13, 2026
b880578
feat(core): envSecret + external facet; secret forbids default
wmadden-electric Jul 13, 2026
633156e
fix(core): fold secret cast to satisfy ratchet; exhaustive external g…
wmadden-electric Jul 13, 2026
596d695
feat(prisma-cloud): COMPOSE_ prefix + secret pointer rows + restricte…
wmadden-electric Jul 13, 2026
30b9362
refactor(prisma-cloud): route pointer check through isPointerParam; a…
wmadden-electric Jul 13, 2026
937e35c
feat(prisma-cloud): deploy preflight verifies secret manifest + shell…
wmadden-electric Jul 13, 2026
e2151a9
fix(prisma-cloud): paginate preflight env-var lookup; accurate missin…
wmadden-electric Jul 13, 2026
3ec8636
test(e2e): storefront-auth proves an envSecret round-trips via runner…
wmadden-electric Jul 13, 2026
f3391d7
feat(core): secrets as a distinct forwardable slot; SecretBox
wmadden-electric Jul 13, 2026
5d119c8
fix(core): reject lone-service secret slots at Load; pin used-trackin…
wmadden-electric Jul 13, 2026
9ba4f9c
feat(prisma-cloud): re-key pointer rows + preflight on secret slots; …
wmadden-electric Jul 13, 2026
19aa3b3
fix(core): reject param name colliding with a secret slot name
wmadden-electric Jul 13, 2026
ef368f0
test(e2e): storefront-auth forwards a secret from root to auth module
wmadden-electric Jul 13, 2026
6b95702
test(storefront-auth): pin secret forwarding in a Load test
wmadden-electric Jul 13, 2026
da64506
docs: rewrite ADR-0029 — secrets are a forwardable slot
wmadden-electric Jul 13, 2026
507ace6
refactor: move envSecret to the target; core's SecretSource is opaque
wmadden-electric Jul 13, 2026
3a1a58f
fix(prisma-cloud): expose the resolved service port as process.env.PO…
wmadden-electric Jul 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/e2e-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ jobs:
env:
PRISMA_SERVICE_TOKEN: ${{ secrets.PRISMA_SERVICE_TOKEN }}
PRISMA_WORKSPACE_ID: ${{ vars.PRISMA_WORKSPACE_ID }}
# The root binds the auth module's secret need to AUTH_SIGNING_SECRET (ADR-0029).
# This non-sensitive test marker in the runner env is what deploy preflight
# fill-missing provisions onto the ephemeral per-run stack — the single-step
# CI path (runner env -> direct API POST -> platform). It must match the
# auth service's EXPECTED_SIGNING_SECRET (modules/auth/src/server.ts); the
# value never enters deploy state, only its pointer name does.
AUTH_SIGNING_SECRET: sk_test_ci_storefront_auth
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
Expand Down
101 changes: 86 additions & 15 deletions docs/design/10-domains/config-params.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ read back at boot. Rests on
[ADR-0018](../90-decisions/ADR-0018-config-params-carry-a-caller-owned-schema.md)
(a param's type is a caller-owned schema),
[ADR-0019](../90-decisions/ADR-0019-the-target-owns-config-serialization.md) (the
deploy target owns serialization), and
deploy target owns serialization),
[ADR-0021](../90-decisions/ADR-0021-params-are-read-through-config-not-load.md)
(params are read through `config()`).
(params are read through `config()`), and
[ADR-0029](../90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md) (a secret
is a distinct forwardable slot, read through `secrets()`).

## The problem in one example

Expand Down Expand Up @@ -46,9 +48,9 @@ compute({
});
```

The facets are `default` (used when no value is stored), `secret` (redacted in
introspection, placed securely by the target), and `optional`. They describe how
a value is *handled*, not what it *is*. `string()`/`number()` are one-word helpers
The facets are `default` (used when no value is stored) and `optional`. They
describe how a value is *handled*, not what it *is*. (A secret is not a param
facet — it is its own slot, see § Secrets.) `string()`/`number()` are one-word helpers
for the common scalars; `param(schema)` wraps any other schema. There is no
framework enum of permitted types.

Expand Down Expand Up @@ -95,14 +97,16 @@ Config = { service: { jobs: [ {jobId:'tick',…}, {jobId:'mrr',…} ], port: 300
```

**Deploy — serialize (the target).** `@prisma/compose-prisma-cloud` encodes each value into
its medium — key/value strings, keyed `ADDRESS_OWNER_NAME` to stay unique in the
shared project — validating structured values and passing dependency-input values
(provisioning refs) through untouched:
its medium — key/value strings, keyed `COMPOSE_ADDRESS_OWNER_NAME` to stay unique in
the shared project — validating structured values and passing dependency-input values
(provisioning refs) through untouched. Every generated key carries the `COMPOSE_`
prefix — the framework's reserved namespace, kept clear of the user-provisioned
variables secrets point at (§ Secrets):

```
SCHEDULER_JOBS = '[{"jobId":"tick","every":"60s"},…]'
SCHEDULER_PORT = '3000'
SCHEDULER_TRIGGER_URL = 'https://…runner…'
COMPOSE_SCHEDULER_JOBS = '[{"jobId":"tick","every":"60s"},…]'
COMPOSE_SCHEDULER_PORT = '3000'
COMPOSE_SCHEDULER_TRIGGER_URL = 'https://…runner…'
```

The encoding (JSON here) is app-cloud's own; a different target would store the
Expand Down Expand Up @@ -135,12 +139,76 @@ Job[] → Config (structured) → target serialize (its medium) → stored confi
→ target deserialize → schema-validated Job[] → config()
```

## Secrets

A secret is **not** a param — it is its own forwardable slot (ADR-0029). The
value is provisioned out-of-band on the platform; the framework carries only the
NAME. A service declares a nameless *need* with `secret()`; the root binds it to
a platform env-var with `envSecret('NAME')` and forwards it in; it reads back
through a third accessor, `secrets()`, as a redacting `SecretBox`:

```ts
// A service/module declares the need — no platform name here:
compute({ name: 'auth', secrets: { signingKey: secret() }, build: … });

// A module forwards its need down to an inner service (ctx.secrets):
module('auth', { secrets: { signingKey: secret() }, expose: … }, ({ secrets, provision }) =>
provision(inner, { secrets: { signingKey: secrets.signingKey } }),
);

// Only the ROOT names the platform variable (envSecret is the TARGET's, from
// @prisma/compose-prisma-cloud — secret() is core's, from @prisma/compose):
provision(auth, { secrets: { signingKey: envSecret('AUTH_SIGNING_KEY') } });

// Read at the point of use — expose() is the sole reader:
const { signingKey } = service.secrets(); // SecretBox<string>
signingKey.expose(); // the one door to the value
```

`secrets()` sits beside `load()`/`config()` (ADR-0021). The `SecretBox` redacts
under `toString`/`toJSON`/`valueOf`/`inspect`, so a stray log or serialization
prints `[REDACTED]`; only `expose()` returns the value. `secret()` and the opaque
`SecretSource` are core (`@prisma/compose`); `envSecret('NAME')` — the source
constructor that names and validates a platform variable — is the target's, from
`@prisma/compose-prisma-cloud` (ADR-0018/0019 applied to secrets).

The round trip carries only the name, never the value:

1. **Bind + forward.** The root binds each need to a platform name and wires it
down the module topology (the same rail dependency inputs use). Load records
one binding per resolved service secret slot: `{ serviceAddress, slot, name }`.
2. **Preflight.** Before Alchemy runs, the deploy verifies every bound name
exists on the platform for the target stage's class/branch, filling a name
from the deploy shell when the platform lacks it (a direct write-only POST,
never an Alchemy resource, never overwriting an existing value). A name
missing from both fails the deploy, listing exactly what's absent.
3. **Pointer row.** The target writes a pointer per slot — the generated key
(`COMPOSE_`-prefixed, like every generated key) maps to the bound name, not a
value:
```
COMPOSE_AUTH_SIGNINGKEY = "AUTH_SIGNING_KEY"
```
4. **Boot double-lookup.** Boot reads the pointer for the name, then reads
`process.env[name]` for the platform value, and wraps it in a `SecretBox`.

The `COMPOSE_` prefix reserves the framework's generated keys into their own
namespace, so a generated key never collides with — and silently overwrites — a
user-provisioned platform variable.

**Rotation is PATCH + redeploy.** A compute version snapshots its whole env map
at creation and never re-resolves it, so changing a secret's value is the
platform's own semantics: `PATCH` the value, then create a new version.

See [ADR-0029](../90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md) for the full design and its
alternatives.

## Introspection

A service's config surface is enumerable from the graph alone, nothing booted:
every param lists its owner, facets, and **schema**, so a structured param reports
its real shape rather than "a string". Secret params appear with values redacted.
This is what keeps topology tooling and agents able to answer "what is this service
its real shape rather than "a string". Secrets are not params — they are a
separate slot (§ Secrets) whose value never enters this surface at all. This is
what keeps topology tooling and agents able to answer "what is this service
configured with?" truthfully.

## Boundaries
Expand All @@ -153,15 +221,18 @@ Three lines the model holds everywhere:
- **Params are static data.** A value that must come from another node — an
address, a URL, credentials a resource mints — is a dependency input, never a
field inside a param value.
- **`secret` is handling, not type.** The schema says what a value is; `secret`
says it must be redacted and placed securely. The two never mix.
- **Secrets are not params.** A secret is a distinct forwardable slot (§ Secrets,
ADR-0029), read through `secrets()` as a `SecretBox` — sensitivity is carried by
the type, never a param facet.

## Related

- [ADR-0018](../90-decisions/ADR-0018-config-params-carry-a-caller-owned-schema.md),
[ADR-0019](../90-decisions/ADR-0019-the-target-owns-config-serialization.md),
[ADR-0021](../90-decisions/ADR-0021-params-are-read-through-config-not-load.md) —
the decisions this documents.
- [ADR-0029](../90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md) — the
secrets model this document's § Secrets summarizes.
- [`core-model.md`](core-model.md) — where params sit in the node → graph → Config
model.
- [`ADR-0020`](../90-decisions/ADR-0020-scheduled-work-is-a-driver-not-a-resource.md)
Expand Down
103 changes: 103 additions & 0 deletions docs/design/90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# ADR-0029: Secrets are a forwardable slot

## Status

Proposed

## Context

A service needs a secret — a signing key, an API token — whose value the platform
holds and the deploy machine must never see. The value is provisioned out-of-band
on the platform; the framework's only job is to route the service to it at boot.
That routing has to survive composition: a reusable module declares that it needs
a secret without knowing which platform variable an application will bind it to,
and the application composing that module supplies the name. The framework already
forwards ordinary inputs down a module's boundary (ADR-0016) — secrets ride the
same rail.

## Decision

A secret is its own slot kind — not a config param, not a dependency.

- **`secret()`** (from `@prisma/compose`) declares a nameless *need* on
`compute()`/`module()` (`secrets: { signingKey: secret() }`). A module forwards its need to an inner
service through `ctx.secrets`, exactly as it forwards a dependency input.
- **`envSecret('NAME')`** (from `@prisma/compose-prisma-cloud`) is the *source*:
the root binds a need to a platform env-var name — `provision(auth, { secrets: { signingKey: envSecret('AUTH_SIGNING_KEY') } })`.
Only the root names the variable; the module never does.
- **`secrets()`** reads the value at the point of use, a third accessor beside
`load()`/`config()` (ADR-0021), returning one `SecretBox<string>` per slot.
`expose()` is the sole reader; the box prints `[REDACTED]` from `toString`,
`toJSON`, `valueOf`, and `inspect`.

The framework carries only the **name**. Deploy writes a pointer row
`COMPOSE_<addr>_<slot> = NAME` — never a value. Boot double-looks-up: read the
pointer row for the name, then read `process.env[NAME]` (the platform injects the
user-provisioned variable into the version's env), and wrap the result in a
`SecretBox`. Load records each resolved binding on the graph, and the deploy
target keys its pointer row off that. **Deploy preflight** verifies every bound
name exists on the platform for the stage's class/branch before provisioning; a
name absent from the platform but present in the deploy shell is provisioned with
a direct write-only POST (never an Alchemy resource, so no value reaches deploy
state); a name absent from both fails the deploy.

**The need is core; the source is the target's.** `secret()` and the opaque
`SecretSource` are `@prisma/compose`; core forwards a source and never reads its
payload. The source constructor belongs to the target — Prisma Cloud ships
`envSecret('NAME')` from `@prisma/compose-prisma-cloud`, which validates the name
and wraps it via core's `secretSource()`. The ADR-0018/0019 split, for secrets.

## Rationale

- **A distinct slot makes sensitivity structural.** Redaction is carried by the
type (`SecretBox`), not a flag every sink must remember to check — a secret
cannot be logged or serialized by accident, and `expose()` marks the one place
value access is intended.
- **Names, not values, keep secrets out of inspectable state.** The value never
enters the typed `Config`, the generated stack file, deploy state, or a log —
only a name does, which is as safe to write and diff as any other key. This is
also exactly the constraint a future platform-side secrets-manager integration
needs, with nothing to unwind.
- **Root-binds-and-forwards keeps modules reusable.** A module declares the need
and forwards it; it never hardcodes a platform variable name, so the same
module composes into any application, each binding its own name.

## Consequences

- Every generated key carries a reserved `COMPOSE_` prefix, so a framework key can
never collide with — and silently overwrite — a user-provisioned platform
variable.
- Every wired secret is required; an "optional secret" would be a distinct future
construct, not a facet on this one.
- Rotation is `PATCH` the value on the platform, then redeploy — the platform's
own version-snapshot semantics (an env map is frozen per compute version), not a
framework mechanism.
- A secret slot and a service-own param may not share a name (they derive the same
config key); a dependency may, since its keys carry the input and param segments.

## Alternatives considered

- **A secret as a param facet bound at the leaf** (the service names the platform
variable itself). Rejected: the binding cannot be forwarded through the topology,
so a reusable module would have to hardcode the platform name — the opposite of
composable.
- **A secret as an ordinary dependency.** Rejected: it would read back as a client
through `load()`, and its sensitivity would be a flag on a value rather than a
property of the type — the same leak-by-omission the distinct slot removes.
- **Value-as-default sourced from the deploy environment.** Rejected: it persists
the secret value in deploy state and offers no way to verify the value is present
before provisioning.
- **Unprefixed generated keys.** Rejected: a generated key could collide with a
user-provisioned platform variable of the same name and silently overwrite it.

## Related

- [ADR-0016](ADR-0016-a-module-has-the-same-boundary-as-a-service.md) — the module
input-forwarding rail secrets ride.
- [ADR-0019](ADR-0019-the-target-owns-config-serialization.md) — the declaration/
encoding split this applies to secrets: core carries the need, the target owns
the source and its serialization.
- [ADR-0021](ADR-0021-params-are-read-through-config-not-load.md) —
`load()`/`config()`/`secrets()` as separate read namespaces.
- [`../10-domains/config-params.md`](../10-domains/config-params.md) — the config
model, with a § Secrets summary.
1 change: 1 addition & 0 deletions docs/design/90-decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,4 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl
- [ADR-0026](ADR-0026-name-the-framework-prisma-compose.md) — The framework is **Prisma Compose**; "Prisma App" names only the artifact you build. Supersedes ADR-0014's framework, package, and CLI names: `@prisma/compose*` (incl. `compose-alchemy`), `prisma-compose`, `prisma-compose.config.ts`.
- [ADR-0027](ADR-0027-two-packages-compose-and-compose-prisma-cloud.md) — Ship two **public** packages: `@prisma/compose` (core + CLI + agnostic subpaths) and `@prisma/compose-prisma-cloud` (target + first-party modules as entrypoints, cron first). Boundary = the user's one choice: where does it run.
- [ADR-0028](ADR-0028-numbered-domains-and-layers-enforced-by-dependency-cruiser.md) — `packages/` organizes into numbered domains (0-framework, 1-prisma-cloud, 9-public) and layers; planes as config-mapped entrypoints; internals are `@internal/*`; only 9-public publishes; dependency-cruiser enforces.
- [ADR-0029](ADR-0029-secrets-are-a-forwardable-slot.md) — A secret is a distinct forwardable slot (not a param): a service declares a nameless `secret()` need, the root binds it to a platform env-var via `envSecret`, and it reads back as a redacting `SecretBox`; the framework carries only the name (pointer rows + boot double-lookup + preflight). *(Proposed)*
18 changes: 18 additions & 0 deletions examples/storefront-auth/module.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, test } from 'bun:test';
import { Load } from '@prisma/compose';
import { secretName } from '@prisma/compose-prisma-cloud';
import root from './module.ts';

describe('storefront-auth root graph', () => {
test('the auth secret need forwards from the root binding down to the inner service', () => {
// Loads the REAL app graph: dropping the module→service forward, renaming
// the slot, or removing the root envSecret binding all break this — none of
// which typecheck catches, and the only other graph-Loading gate is CI E2E.
const graph = Load(root);
expect(graph.secrets.length).toBe(1);
expect(graph.secrets[0]?.serviceAddress).toBe('auth.service');
expect(graph.secrets[0]?.slot).toBe('signingKey');
// The env-var name lives in the target's opaque payload, read via secretName.
expect(secretName(graph.secrets[0]!)).toBe('AUTH_SIGNING_SECRET');
});
});
8 changes: 7 additions & 1 deletion examples/storefront-auth/module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { module } from '@prisma/compose';
import { envSecret } from '@prisma/compose-prisma-cloud';
import authModule from '@storefront-auth/auth';
import storefrontService from '@storefront-auth/storefront';

Expand All @@ -10,6 +11,11 @@ import storefrontService from '@storefront-auth/storefront';
* that contract.
*/
export default module('storefront-auth', ({ provision }) => {
const auth = provision(authModule);
// The ROOT binds the auth module's secret need to the platform env var
// AUTH_SIGNING_SECRET (ADR-0029); preflight fill-missing provisions it from
// the deploy shell (the CI runner env).
const auth = provision(authModule, {
secrets: { signingKey: envSecret('AUTH_SIGNING_SECRET') },
});
provision(storefrontService, { deps: { auth: auth.rpc } });
});
3 changes: 3 additions & 0 deletions examples/storefront-auth/modules/auth/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ import { type } from 'arktype';

export const authContract = contract({
verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }),
// Non-leaking proof that auth received its injected `AUTH_SIGNING_SECRET`
// (ADR-0029): returns ONLY a boolean, never the secret value.
secretCheck: rpc({ input: type({}), output: type({ ok: 'boolean' }) }),
});
23 changes: 17 additions & 6 deletions examples/storefront-auth/modules/auth/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,24 @@
* The auth service keeps an explicit "service" id: its own name is "auth", the
* same as this enclosing module, so a defaulted id would read as "auth.auth".
*/
import { module } from '@prisma/compose';
import { module, secret } from '@prisma/compose';
import { postgres } from '@prisma/compose-prisma-cloud';
import { authContract } from './contract.ts';
import authService from './service.ts';

export default module('auth', { expose: { rpc: authContract } }, ({ provision }) => {
const db = provision(postgres({ name: 'database' }));
const service = provision(authService, { id: 'service', deps: { db } });
return { rpc: service.rpc };
});
export default module(
'auth',
// Declare the secret NEED as a forwardable module input; the root binds it to
// a platform name. This module never learns the name (ADR-0029) — that is the
// point of the forwarding model.
{ secrets: { signingKey: secret() }, expose: { rpc: authContract } },
({ secrets, provision }) => {
const db = provision(postgres({ name: 'database' }));
const service = provision(authService, {
id: 'service',
deps: { db },
secrets: { signingKey: secrets.signingKey },
});
return { rpc: service.rpc };
},
);
Loading
Loading