Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions .drive/deferred.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,19 @@
deploy target, lowering unchanged.
- **Factory name** — `pnPostgres` is a placeholder; Prisma Next → Prisma Data
rename incoming at GA. Rename the factory when the product name lands.

# Deploy log redaction — deferred (2026-07-16)

- **Redact known secret values in deploy log output.** Separate from
[ADR-0032](../docs/design/90-decisions/ADR-0032-the-deploy-reports-what-each-node-became.md),
which makes the *node report* safe by construction (an allowlist projected by
each descriptor). This is the complementary net for the strings we do NOT
author: Alchemy progress lines, errors that echo a DSN, stack traces. Match
known secret values in stdout/stderr and mask them, as GitHub Actions does for
registered secrets. Known limits to design around, and why it can't be the
primary control: some credentials are minted mid-run (`s3Credentials` mints a
SigV4 pair), so the redactor must be updated as they appear or anything printed
first is unmasked; and values are transformed before printing (a password in a
DSN is percent-encoded, JSON escapes it again), so literal matching misses
encoded forms. Defense-in-depth only — never the thing a feature relies on to
be safe. Origin: operator design discussion 2026-07-16 (ADR-0032 shaping).
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# ADR-0032: The deploy reports what each node became, projected by its descriptor

## Decision

`prisma-composer deploy` reports what it did: a map from each graph node to a
small description of the platform entity that node became. It is keyed by the
node's graph id, which is already its deployment address — the same id the
config keys and bundle ids ride.

The split is: **core stamps the identity it already owns from the graph** —
`kind`, `extension`, `name` — and the **descriptor that owns the entity** adds
the facts only it knows: the platform id, and an address when that address is
public. A `LoweredNode`, what every lowering phase already returns, gains an
optional `report` beside its `outputs`; the descriptor builds it where it has
the resource in hand. Core never reaches into `outputs` and decides for itself
what to publish. A node that reports nothing is simply absent; a node with
nothing to add reports `{}` and is listed by its identity alone.

The map is the stack's outputs, which Alchemy already prints — so a deploy that
today ends in `{ outputs: {} }` says what it did instead.

A compute service reports its public address; a database reports no connection
information at all:

```jsonc
{
"site": {
"kind": "compute",
"extension": "@prisma/composer-prisma-cloud",
"name": "site",
"id": "cps_m2u4w6w93v8m1hkc8bdl4wzz",
"url": "https://m2u4w6w93v8m1hkc8bdl4wzz.ewr.prisma.build"
},
"catalog.database": {
"kind": "prisma-next",
"extension": "@prisma/composer-prisma-cloud",
"name": "database",
"id": "db_cmdye4tfpe2xiv84v75tqfsz"
// no url: this node's `url` is its connection string, a credential
}
}
```

The rule a descriptor follows is **include, never exclude**: an entry carries
the fields that were deliberately put in it. A field a descriptor does not name
is absent, so a new field is never published by having been forgotten about.

## Reasoning

**The map already exists; we throw it away.** `lowering()` (core's `deploy.ts`)
walks the graph holding `lowered` — a Map from node id to that node's
`descriptor.deploy(...)` result — and then ends with a hardcoded
`return { outputs: {} }`. The print path exists too: the generated stack's
default export *is* `lower(app, config, opts)`, and Alchemy prints a stack's
outputs. The `{ outputs: {} }` at the end of every deploy log is that mechanism
working correctly with nothing in it.

**The interesting value is already computed.** The Prisma Cloud compute
descriptor already returns `outputs: { url: deployment.deployedUrl, projectId }`.
The deployed URL — the single thing a person or a CI job most wants after a
deploy — is derived, used for wiring, and then discarded.

**Recovering it afterwards is archaeology, and it is ambiguous.** Today the only
way to answer "where did that go?" is to query the Management API and match on
names (`website/scripts/verify-deployed.ts` does exactly this). That needs
credentials and an SDK dependency, and it cannot always answer: the API reports
`branchId: null` for every compute service, so a project holding a production
`site` and a staged `site` offers no way to tell which is which. The deploy never
has to ask — it is holding the id it just deployed.

**Safety cannot be decided centrally, because field names do not carry
sensitivity.** A field called `url` is a public HTTP endpoint on a compute node
and a Postgres connection string on a `prisma-next` node. Same name, opposite
sensitivity. A rule phrased over field names would either publish a credential or
suppress the endpoint. Only the descriptor knows which it produced, and
`LoweredNode.outputs` is extension-defined and opaque to core by design — the
core stays thin and target-specific knowledge stays in the pack.

**Nor by node kind.** "A service's outputs are public, a resource's are not" is
the obvious rule and it is also wrong: `s3-store` is a *service* whose `deploy`
spreads `accessKeyId` and `secretAccessKey` into its outputs, and
`s3-credentials` is a resource whose outputs are *only* a minted key pair —
neither of them named `url`, so a field-name rule prints both. Every heuristic
available to core is a heuristic about someone else's data.

**An allowlist cannot leak what it never held.** Because the report is
constructed rather than filtered, a mistake in it omits a field; it does not
expose one. That matches how this codebase already treats sensitivity — carried
by the type at the source (`SecretBox`, Effect's `Redacted`) rather than scrubbed
out of text at the edge (ADR-0029).

## Consequences

- A deploy now says where it published. The docs site's smoke check can drop its
`@prisma/management-api-sdk` dependency, its project-name lookup, and the
guard it needs today for two indistinguishable `site` services — but only
once the report is *machine*-readable. Alchemy prints the outputs for a
person; the CLI runs it with `stdio: 'inherit'` and never sees them. A
parseable surface (`.prisma-composer/deployment.json`, or `--json`) is a
follow-on, and it needs its own decision about who writes it: the stack, from
inside the alchemy child, or the CLI, by capturing what it currently streams.
- `lower()`'s return value is published surface (`@prisma/composer/deploy`), so
this changes a public type and the stack's printed outputs.
- A new extension reports nothing until its descriptor opts in. A missing report
is a visible gap, which is the failure we want; the alternative failure is a
published credential.
- Adding a field to a report is a deliberate act by the person who owns that
entity and knows whether it is a secret.
- The report is the deploy-time counterpart to the topology artifact: the
topology is what you declared, this is what it became.

## Alternatives considered

- **Print the lowered map and redact secrets by string-matching the output.**
Rejected *as the control for this report*, for three reasons. Some credentials
do not exist when the deploy starts — `s3Credentials` mints a SigV4 key pair
mid-run — so a redactor only masks what has been registered by the time a line
prints, and one missed registration is a leak. Values are transformed before
they are printed: a password inside a DSN is percent-encoded, and JSON output
escapes it again, so literal matching misses every encoded form. And it inverts
the stance the codebase already takes, letting a raw secret exist as a plain
string and hoping to catch it at the edge. Redaction is still worth having as
defense-in-depth over the deploy log *at large* — progress lines, errors that
echo a DSN, stack traces — where we do not author the string. That is a
separate change; it is not what makes this report safe.
- **Decide safety centrally by field name** (for example: omit `url` on
resources, keep it on services). Rejected: `url` is public on a compute node
and a credential on a `prisma-next` node, and the S3 credentials resource's
secrets are `accessKeyId`/`secretAccessKey` — not named `url` at all — so a
name rule would print them untouched.
- **Keep querying the Management API afterwards.** The status quo. Rejected as
the answer: it needs credentials the caller may not have, and `branchId: null`
makes production and a stage indistinguishable.
- **Report the root module's outputs.** Rejected: a closed root returns nothing,
and a deployed URL is not an authoring-time value — it does not exist until the
deploy has run.

## Related

- [ADR-0029](ADR-0029-secrets-are-a-forwardable-slot.md) — sensitivity carried by
the type rather than by scrubbing; the projection keeps that true of the report.
- [ADR-0017](ADR-0017-control-plane-loads-through-the-app-config.md) — the
extension registries a descriptor is routed through.
- [ADR-0024](ADR-0024-a-stage-is-a-deploy-time-environment-resolved-to-project-and-branch.md)
— stages resolve to a Project and Branch; the report removes the need to
re-derive which one a deploy hit.
- [`deploy-cli.md`](../10-domains/deploy-cli.md) — the pipeline this reports on.
1 change: 1 addition & 0 deletions docs/design/90-decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,4 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl
- [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)*
- [ADR-0030](ADR-0030-rpc-callers-verified-with-an-auto-provisioned-service-key.md) — Every RPC binding carries a distinct, framework-minted **service key**: the client sends it, `serve()` rejects a caller without one (401). Auto-provisioned per edge at deploy, carried on the binding's own `COMPOSER_*` env-var rail; its value is minted and kept in the workspace deploy state (a capability token, deliberately not an ADR-0029 name-only secret). *(Proposed)*
- [ADR-0031](ADR-0031-provisioned-param-values-are-a-need-resolved-through-a-target-registry.md) — A framework-minted param value is an opaque, branded **provisioning need** (not a named facet on the param): core forwards it and resolves its brand against the deploy target's `provisions` registry, failing loudly on a miss. The provisioner owns all mint/size/stability/rotation policy; core's surface stays one field. Resolves against the consumer's extension; cross-extension edges fail closed. *(Proposed)*
- [ADR-0032](ADR-0032-the-deploy-reports-what-each-node-became.md) — The deploy reports a map from each graph node to the entity it became (keyed by the node's address; a compute service carries its public `url`, a database carries no connection). Each entry is projected by the descriptor that owns the entity — an allowlist, because field names don't carry sensitivity (`url` is public on compute, a connection string on prisma-next) and core can't tell them apart. *(Proposed)*
104 changes: 104 additions & 0 deletions packages/0-framework/1-core/core/src/__tests__/lowering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -939,3 +939,107 @@ describe('mergedProviders', () => {
expect(mergedProviders(config)).toBeDefined();
});
});

// ——— ADR-0032: the stack's outputs are a report of what each node became.
// The fake extension here mirrors the real shape that makes the decision
// necessary: every node's `outputs` carry a secret (a connection string, a
// minted key), and only the fields the descriptor names in `report` are
// published.
function reportingExtension() {
const descriptor: ExtensionDescriptor = {
id: 'test/pack',
providers: () => {
throw new Error('providers() must not be called by lowering()');
},
application: { provision: () => Effect.succeed({ outputs: {} }) },
nodes: {
// Mirrors postgres: `url` in outputs IS the connection string, so the
// report carries identity only.
'fake/db': Object.assign(
(ctx: LowerContext) =>
Effect.succeed({
outputs: { url: `postgres://user:hunter2@host/${ctx.id}` },
report: { id: `db_${ctx.id}` },
}),
{ kind: 'resource' as const },
),
// A node that publishes nothing — the default for an extension that has
// not opted in.
'fake/quiet': Object.assign(
() => Effect.succeed({ outputs: { secretAccessKey: 'sk_live_quiet' } }),
{ kind: 'resource' as const },
),
// Mirrors s3-store: a SERVICE whose outputs carry a minted secret
// alongside its public url.
'fake/compute': {
kind: 'service' as const,
provision: () => Effect.succeed({ outputs: { serviceId: 'cps_1' } }),
serialize: () => Effect.succeed({ outputs: {} }),
package: () => Effect.succeed({ path: '/tmp/a.tar.gz', sha256: 'sha' }),
deploy: (_ctx, provisioned) =>
Effect.succeed({
outputs: { url: 'https://svc.example', secretAccessKey: 'sk_live_deploy' },
report: { id: provisioned.outputs['serviceId'], url: 'https://svc.example' },
}),
},
},
};
return {
extensions: [descriptor],
state: () => stateSentinel('config-default'),
} satisfies PrismaAppConfig;
}

describe('the deploy reports what each node became (ADR-0032)', () => {
const dbNode = () =>
resource({
name: 'database',
extension: 'test/pack',
provides: providerContract('fake/db', { url: '' }),
});
const quietNode = () =>
resource({
name: 'quiet',
extension: 'test/pack',
provides: providerContract('fake/quiet', {}),
});

const root = () =>
module('shop', {}, (h) => {
const db = h.provision(dbNode(), { id: 'db' });
h.provision(quietNode(), { id: 'quiet' });
h.provision(app('fake/compute', { db: dbEnd() }), { id: 'svc', deps: { db } });
return {};
});

test('keys every reported node by its address, and stamps the identity core owns', () => {
const result = run(lowering(root(), reportingExtension(), opts(svcBundles)));

expect(result.outputs).toEqual({
db: { kind: 'fake/db', extension: 'test/pack', name: 'database', id: 'db_db' },
svc: {
kind: 'fake/compute',
extension: 'test/pack',
name: 'test-service',
id: 'cps_1',
url: 'https://svc.example',
},
});
});

test('publishes only what the descriptor named — a secret in a node’s outputs never reaches the report', () => {
const result = run(lowering(root(), reportingExtension(), opts(svcBundles)));

// The db's `url` is its connection string and the service's outputs carry a
// minted key; both are wired downstream and neither is published.
expect(JSON.stringify(result.outputs)).not.toContain('hunter2');
expect(JSON.stringify(result.outputs)).not.toContain('sk_live_deploy');
expect(JSON.stringify(result.outputs)).not.toContain('sk_live_quiet');
});

test('a node whose descriptor reports nothing is absent, rather than guessed at', () => {
const result = run(lowering(root(), reportingExtension(), opts(svcBundles)));

expect(Object.keys(result.outputs).sort()).toEqual(['db', 'svc']);
});
});
42 changes: 39 additions & 3 deletions packages/0-framework/1-core/core/src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,28 @@ export interface LowerContext {
readonly provisioned: ReadonlyMap<string, unknown>;
}

/**
* What a node publishes about the entity it became (ADR-0032) — the fields its
* own descriptor decided are safe to print. Include-only: a field the
* descriptor does not name is never published, so a field nobody thought about
* cannot leak. This is why core never derives a report from `outputs` itself:
* one field name can be a public address on one node kind and a credential on
* another, and only the descriptor that produced it knows which it made.
*/
export type NodeReport = Readonly<Record<string, unknown>>;

/**
* What a lowering hands downstream — e.g. a deployed URL a later node's env
* wiring consumes. The inter-node config-wiring hook for Connections.
*/
export interface LoweredNode {
readonly outputs: Readonly<Record<string, unknown>>;
/**
* Safe facts about the entity this node became, for the deploy's report
* (ADR-0032). Absent means the node publishes nothing — the deliberate
* default, since silence is a visible gap and a guess could be a credential.
*/
readonly report?: NodeReport;
}

export interface LowerOptions {
Expand Down Expand Up @@ -332,6 +348,20 @@ export function lowering(
// hooks, before any node), then threaded read-only through every ctx and
// into buildConfig — the same declare-then-mutate idiom as `lowered`.
const provisioned = new Map<string, unknown>();
// ADR-0032: what each node became, keyed by its address — the stack's
// outputs. Core stamps the identity it already owns from the graph; the
// descriptor adds the facts only it knows about the entity it made (its
// platform id, and an address when that address is public).
const reports: Record<NodeId, NodeReport> = {};
const recordReport = (id: NodeId, node: ServiceNode | ResourceNode, result: LoweredNode) => {
if (result.report === undefined) return;
reports[id] = {
kind: node.type,
extension: node.extension,
name: node.name,
...result.report,
};
};

// Each extension's application hook runs ONCE, before any node, in config
// order — its outputs reach that extension's own nodes via ctx.application
Expand Down Expand Up @@ -431,7 +461,9 @@ export function lowering(
const descriptor = yield* descriptorFor(extensions, node, id);

if (descriptor.kind === 'resource') {
lowered.set(id, yield* descriptor(ctx));
const result = yield* descriptor(ctx);
lowered.set(id, result);
recordReport(id, node as ResourceNode, result);
continue;
}
if (descriptor.kind !== 'service') {
Expand All @@ -457,10 +489,14 @@ export function lowering(
assembled: { dir: bundle.dir, entry: bundle.entry },
address: id,
});
lowered.set(id, yield* descriptor.deploy(ctx, provisionedNode, artifact, serialized));
const deployed = yield* descriptor.deploy(ctx, provisionedNode, artifact, serialized);
lowered.set(id, deployed);
recordReport(id, service, deployed);
}

return { outputs: {} };
// The stack's outputs ARE the report (ADR-0032): alchemy prints them, so a
// deploy says what each node became instead of ending in `{ outputs: {} }`.
return { outputs: reports };
}) as Effect.Effect<LoweredNode, LowerError, unknown>;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,16 @@ export function computeDescriptor(o: ResolvedCloudOptions): NodeDescriptor {
});
return {
outputs: { url: deployment.deployedUrl, projectId: provisioned.outputs['projectId'] },
// ADR-0032. A compute service's address is public by construction —
// it is the endpoint callers hit — so it is the one thing worth
// reporting. The artifact hash and the service's environment stay
// out: the environment is where the secrets are. (kind/name/extension
// come from the graph, so core stamps those.)
report: {
id: provisioned.outputs['serviceId'],
url: deployment.deployedUrl,
projectId: provisioned.outputs['projectId'],
},
};
}),
};
Expand Down
Loading
Loading