Skip to content

refactor(core): define each deploy-pipeline type by the code that reads it#117

Merged
wmadden-electric merged 39 commits into
mainfrom
claude/spi-inversion
Jul 17, 2026
Merged

refactor(core): define each deploy-pipeline type by the code that reads it#117
wmadden-electric merged 39 commits into
mainfrom
claude/spi-inversion

Conversation

@wmadden-electric

@wmadden-electric wmadden-electric commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

What this looks like

Here's one step of the compute descriptor on main — the code that turns a service node into a Prisma Compute deployment:

deploy: ({ id }, provisioned, artifact, serialized) =>
  Prisma.Deployment(`${id}-deploy`, {
    computeServiceId: provisioned.outputs['serviceId'] as string,
    environment: serialized.outputs['environment'] as readonly Prisma.EnvironmentVariable[],
    port: typeof serialized.outputs['port'] === 'number' ? serialized.outputs['port'] : 3000,
  })

Three values, three casts. Each one is this descriptor reading back something it produced itself, one or two steps earlier. It has to cast, because the type it wrote those values into says nothing more specific than Record<string, unknown>.

After this PR:

deploy: ({ id }, provisioned, artifact, serialized) =>
  Prisma.Deployment(`${id}-deploy`, {
    computeServiceId: provisioned.serviceId,
    environment: serialized.environment,
    port: serialized.port,
  })

The casts aren't tidied away. They stopped being necessary.

The decision

Every value handed between parts of the deploy pipeline now has a type defined by the code that reads it, not by the code that writes it.

Three types where there was one, each named for its one job, each answering to its one reader — and named in the glossary's vocabulary (docs/design/03-domain-model/glossary.md), not new coinage.

Why the casts were there

A single type was the return value of every step:

interface LoweredNode { readonly outputs: Readonly<Record<string, unknown>> }

It was doing three unrelated jobs at once:

What it carried Written by Read by Now
A descriptor's own working values (serviceId, environment, port) one descriptor the same descriptor, two steps later descriptor-owned types, generic as ServiceLowering<P, S>
The Outputs a dependent node's connection params resolve against (url) a descriptor core's buildConfig, by param name Outputs — the glossary's own noun
Anything else anyone needed to move — this is where #101's NodeReport went anyone anyone nothing; reporting has its own types (below)

The first two have nothing to do with each other. The third only became possible because the first two already shared a type that accepts anything.

One type serving three jobs can only be as specific as the vaguest of them. So it was Record<string, unknown>, and every reader cast.

What the casts were hiding

serviceId was never a string.

Alchemy wraps every attribute of a resource in Output<T> — a lazy reference that becomes a real value later, when alchemy applies the plan. So svc.id is Output<string>. The old line said:

computeServiceId: provisioned.outputs['serviceId'] as string

and it compiled — because Prisma.Deployment's prop accepts Input<string>, which is string | Output<string> | …. It accepts the truth and the lie equally, so nothing ever complained.

Nobody wrote that cast dishonestly. outputs['serviceId'] reads like ordinary field access. Nothing marked it as the moment someone decided an unknown was a string, and no reviewer was ever asked to agree.

I made the same mistake writing the plan for this PR: I read the cast, believed it, and specified serviceId: string. The compiler refused it on first contact. Typing the value honestly is what deletes the cast — specifying string would have forced it straight back.

So the point isn't "untyped bags are bad"

That conclusion is tempting, and this PR disproves it with its own code. One blindCast survives, in the same file. It claims an Output<string> out of a value core hands over as unknown, and we are keeping it.

The difference isn't whether an unchecked claim exists. It's whether anyone was asked to agree with it:

  • That surviving cast is one site, carrying a written justification, findable by grep. A reviewer can weigh it and say no.
  • The old shared type let the same kind of claim be made anonymously, at every place anyone read it, with nothing recording that a claim was being made at all.

ADR-0033 records that, and the rule it implies: where the compiler can't reach — across the boundary into an extension — a precise claim needs a runtime check instead (isCloudApplication), and where nothing needs to claim anything, unknown is the honest type. That last one explains why the bug could only ever have been where it was: warm.url and creds.accessKeyId are also unresolved Output<string>s, but they travel as unknown-valued Outputs and never claimed otherwise.

Two things this made possible

1. A deploy now fails when a producer doesn't supply what its consumer asked for

Once the values a dependent consumes have a contract of their own — the consumer's declared connection params — you can check it.

Before, a missing value became undefined, was written into the consuming service's environment, and broke at boot, far from the mistake. Now:

LowerError: Connection input "auth.main" declares param "url", but its producer
"data" did not supply it — the producer's outputs carry [nothing]. Add "url" to
the outputs the producer returns from its lowering, or declare the param
optional on the connection.

This is a behaviour change: an app that deployed yesterday can fail today, without its author changing a line. That's deliberate. The mistake was always there; it now reports itself where it was made, before anything is provisioned. It's reachable only if you wrote the connection or the extension on one side of the wire — every block shipped with the framework supplies what it declares.

No first-party pair under-delivers. That's measured, not assumed: deliberately breaking the real postgres descriptor turns three end-to-end tests red, so we know the check reaches real code.

2. A deploy now tells you what it built

Reporting was the third job the old type was doing badly. With it gone, results have their own types — the map from Composer entities to Deployment entities: DeploymentResult is the result of the Deploy operation ({app, nodes}), each DeployedNode pairs a graph node with the DeployedEntitys it became (kind, platform id, url only when the descriptor says the address is public).

Proven live, both cases. A fresh deploy printed:

spi-render-proof
├─ auth
│  ├─ database   postgres-database db_cmrp02nz60gui1adzqrexwri0
│  └─ service    compute-service cps_yylkilyzn6h1lnrf2ffmeigk
│                https://yylkilyzn6h1lnrf2ffmeigk.ewr.prisma.build
└─ storefront    compute-service cps_vxadcvnbcmlj0pmk2j1dng66
                 https://vxadcvnbcmlj0pmk2j1dng66.ewr.prisma.build

and an unchanged redeploy — Plan: 17 to noop, Done: 0 succeededstill printed the same tree (that's what the report's nonce exists for). No JSON blob in either run. The printed database id was then fetched directly (GET /databases/db_cmrp02… → 200, named auth.database), so the ids are real platform objects, not plausible strings. The live run also caught a docs error nothing else could: the sample trees had an invented pdb_ id prefix; the real one is db_. Deployed resources were torn down after (service first, then project; workspace back to its prior state).

The database reports its id and no URL — a connection string is not a public endpoint. The credentials resource reports nothing at all. Core can't infer any of this: postgres's own Outputs have a url key meaning the exact opposite of compute's. The descriptor names what's publishable; LoweredResult.entities is required, so "this node became no Deployment entities" must be said out loud ([]), never by omission.

The renderer runs inside the deploy child process, which already holds both your graph and the results — so there's no transport to build. It rides an alchemy Action, which runs during apply with real resolved values.

Verification

typecheck 58/58 · test 48/48 · lint clean · lint:deps clean · casts 32 → 30 · website content 9/9.

Two checks were found not checking, both by deliberately breaking something to see if they'd notice:

  • lint:deps was passing blind for new public files. A deliberate layering violation sailed through. architecture.config.json lists public source files one by one, so a new file matches nothing and no rule applies to it; separately, an unlisted path made the import unresolvable. Fixed for the new file — the mechanism fix is filed separately, because new public files are unguarded by default and that's bigger than this PR.
  • A pnpm lint failure was reported as a pass, because the command only printed on success and silence got read as success. Caught in review.

Every alchemy citation in the ADR was derived against the installed source (2.0.0-beta.59) and then independently re-verified; several line numbers in our working notes had drifted.

What we're deliberately not doing

  • Per-node success/failure diagnostics. lower() is orDie — one failure kills the run, so a per-node ok would read true on everything that exists. Making it real means collect-and-continue, which is a deploy-semantics decision, not a reporting one.
  • Per-resource created/updated/noop. Alchemy publishes that only to its own CLI event stream, not to programs.
  • A --json output for CI. That needs a real cross-process transport; nothing needs it yet.
  • Refreshing core-model.md's wider model, which is stale beyond this PR's reach. Filed separately rather than bundled into a code review.

Alternatives considered

  • NodeReport on the shared type (#101). The original attempt, and the reason this PR exists. It hung an untyped reporting field on the type dependents read for their config — a fourth job for a type already failing at three.
  • A separate describe() hook for descriptors to report what they built. Buys nothing: the alchemy resources live inside deploy()'s effect, so describe() would need them handed back out, which is just returning them with extra plumbing.
  • Sending results to the CLI parent process — by parsing stdout, writing a JSON file, or reading them back out of the state store. All rejected once it became clear the child process already holds both the graph and the results. The state-store version was the worst of the three: it let a storage concern dictate the shape of an in-memory return value.
  • Making the whole result the stack's return value. Alchemy persists whatever a stack returns, so this would have forced our types to be JSON-serializable, and graph nodes carry functions and schemas. Only plain data crosses; the join back to nodes happens on our side.
  • Leaving entities optional. Raised in review and rejected: it let a descriptor claim "I built nothing" by staying silent — the same anonymous claim this PR exists to remove.

🤖 Generated with Claude Code

Records the design settled in session: `LoweredNode` serves three unrelated
contracts (intra-descriptor phase handoffs, inter-node wiring, and — via the
reverted `NodeReport` — presentation), so each seam gets a consumer-declared
type and the lowering loop becomes the only router.

Three slices: invert the SPI (+ADR), enforce the wiring contract, then build
typed per-node deployment results and CLI rendering on the clean seams.

Design notes carry the alchemy execution model verified against 2.0.0-beta.59:
the stack effect runs before apply, resource yields are lazy Output proxies,
and resolved values reach program code only through apply-time evaluation —
which is why rendering rides an Action rather than a transport.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Retires LoweredNode, the single shared bag that served three unrelated
roles (intra-descriptor phase handoffs, inter-node wiring, and reporting).
Each role gets its own type: WiringOutputs for the inter-node wiring
contract core actually reads, and generic P/S params on ServiceLowering
for a descriptor's own provision/serialize handoff, opaque to core.

ApplicationDescriptor.provision and LowerContext.application both become
unknown — core never reads the application hook's product; an extension
narrows it with its own type guard.

The stack effect now returns undefined instead of a hardcoded
{ outputs: {} }, killing the raw alchemy stack-output dump on every
deploy (verified against alchemy source: Apply.apply short-circuits on a
falsy plan output, so no setOutput write happens either).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Fake descriptors return bare records instead of { outputs: ... }
wrappers; the fake compute descriptor gets its own FakeProvisioned/
FakeSerialized types so its provision/serialize/deploy match the new
generic ServiceLowering<P, S> shape. run()/runError() and every
buildConfig test map literal move from LoweredNode to WiringOutputs.

Adds a dedicated test pinning that lowering() now resolves to
undefined, on top of the existing assertions that already exercised it
incidentally.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…s idiom

The D1 fixture hand-annotated all six hook parameters and returns, which
made the generics look far worse to write than they are. `satisfies` on
the object literal supplies P/S contextually: provisioned.projectId and
serialized.environment now read with zero per-hook annotations, and the
method-bivariance assignment to the erased NodeDescriptor registry arm
goes through unchanged.

The ctx.application narrow stays — application is `unknown` by design and
satisfies does not change that.

Core's tests are the working reference for the idiom the prisma-cloud
descriptors are about to adopt, so they should demonstrate it correctly.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
ctx.application is `unknown`: core never reads the application hook's
product and cannot type it. Replaces projectIdOf's blindCast with an
extension-owned contract (CloudApplication) and a real type guard, so a
node that lowers without the prismaCloud() application hook having run
fails naming the seam instead of handing `undefined` to a Project id.

The guard narrows with `in` rather than the cast the spec sketched —
`in` carries the key through to the read, so the guard adds no cast where
it removes one.

The application hook returns the bare { projectId } product; the
serviceKeyProvisioner is untouched, its ref staying opaque per ADR-0031.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…g both casts

compute declares ComputeProvisioned/ComputeSerialized and types its hooks
against ServiceLowering<P, S> via satisfies. Both `as` casts go: with the
handoff typed, provisioned.serviceId and serialized.environment read
directly, and deploy's duplicate `typeof port === number` fallback drops
out because the type carries port: number from serialize onward.

serviceId is Output<string>, NOT string. The whole stack effect runs
before Alchemy applies anything, so a yielded resource's attributes are
lazy references (Resource.d.ts:95-100 maps every attribute through
Output). The old `as string` was laundering that reference into a string,
and only compiled because Deployment.computeServiceId takes Input<string>,
which accepts both. The honest type needs no cast at all.

computeDescriptor returns the precise descriptor type rather than the
erased NodeDescriptor: control.ts's registry erases it on assignment
anyway (method bivariance), but s3-store composes over these hooks and
needs P/S visible — annotating NodeDescriptor would force s3-store to
cast them straight back. s3-store's kind check goes with it: the
discriminant is now a compile-time fact.

The keyOuts blindCast stays — a provisioner ref is unknown by ADR-0031
and Output<string> is not runtime-guardable.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…descriptors

postgres, prisma-next and s3-credentials each drop the { outputs: ... }
wrapper and return WiringOutputs directly. Their values stay honestly
unknown-typed: warm.url and creds.accessKeyId are lazy Output references,
and the wiring seam is the one place that genuinely cannot know — which
producer feeds which consumer is the user's graph, resolved at runtime
against the consumer's connection declaration.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…uard

Fixtures return bare records; ctx.application is the bare product; result
assertions lose the .outputs hop; lowering() resolves to undefined.

Adds the seam test: projectIdOf throws naming the seam on undefined (what
core hands a node whose extension declares no application hook), null, a
non-object, and objects with a missing or non-string projectId.

The handoff shapes are declared test-locally as Mocked*, not imported
from the descriptors. This file mocks alchemy/Output so every Output is
already resolved, so the hooks hand back plain strings here — reusing the
real ComputeProvisioned would re-assert Output<string> over a string,
which is the exact lie this slice just deleted from compute.ts.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…ypes

The Mocked* shapes were hand-written restatements: a renamed field in a
real handoff type would leave them compiling against a shape that no
longer exists. run<A> cannot catch that — it takes Effect<unknown>, so A
is an unchecked caller assertion — which makes these definitions the only
compile-time link back to the real types.

Mirror<T> derives them, collapsing Output<T> to T exactly as the mocks do
at runtime. Verified by renaming ComputeProvisioned.serviceId: the derived
mirror fails both the assertion and the property read; the hand-written
one compiled silently.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Records the design behind retiring LoweredNode: each seam typed by the
party that reads it, and the lowering loop as the only router.

The thesis is deliberate-and-audited, not bag-vs-no-bag — the blunt
version is refuted by the blindCast we deliberately keep in
compute.serialize. What separated that cast from the bag is that it is
named, justified and singular; the bag made the same kind of claim
anonymously, at every read site, with nothing recording a claim was made.

Carries the serviceId finding as the evidence: an Output<string> read
back as a string through the bag, compiling only because the consuming
prop accepts both, invisible until the first descriptor migrated off it.

Documents the three-seam taxonomy (compiler / runtime guard / nothing to
defend, since unknown cannot lie), the alchemy execution facts with
2.0.0-beta.59 file:line citations verified against the installed source,
that the registry rests on the loop rather than the compiler, and the
ComputeSerialized tripwire.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Mechanical update of the quoted signatures: ServiceLowering<P, S>,
ApplicationLowering returning unknown, Lowering/LowerContext over
WiringOutputs, and lowering() resolving to undefined. Notes why each
shape is what it is, pointing at ADR-0033 for the argument.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Three spec amendments, each because an executing agent caught the spec wrong:
`ComputeProvisioned.serviceId` is `Output<string>` not `string`;
`computeDescriptor` returns the precise type (the `NodeDescriptor` pin
contradicted compose-over-base); the `LoweredNode` grep DoD was incoherent
(an ADR recording the retirement must name it).

The common root, recorded in learnings.md: a cast in the code you specify
against is evidence someone made a claim, not evidence it was true. Applied
forward — S3's spec had the identical defect in `DeployedPrimitive.id` and
was amended pre-emptively rather than discovering it two slices later.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Operator decision. Records the two real consequences: S2 and S3 serialize
(one branch, one implementer — parallelism was the only thing the split
bought), and slice-INVEST's "ships as one PR" / "one review sitting" no
longer hold literally, mitigated only by each slice being reviewed inside
the loop before the next starts.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@pkg-pr-new

pkg-pr-new Bot commented Jul 17, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@prisma/composer@117
npm i https://pkg.pr.new/@prisma/composer-prisma-cloud@117

commit: 6d13738

Writes the four Slice-DoD cases ahead of the guard. Case 1 (a producer
omitting a declared required param must fail the deploy naming the edge)
is RED — the guard does not exist yet. The three exemptions are GREEN
already, which is the point of writing them first: they pin optional
params, provisioned params (ADR-0031) and the unwired-input path so the
guard cannot quietly break them.

Two notes on what the cases had to work around:

- The replaced test asserted the behaviour this slice retires: a wired
  producer supplying nothing left the consumer with a silent `undefined`.
  It was the old contract written down, so case 1 is that test inverted.

- An unwired input is not authorable — h.provision rejects a service whose
  declared input has no dep — so buildConfig's `edge === undefined` branch
  is defensive. The case reaches it by dropping the edge after Load.

The required-provisioned case is deliberately NOT optional, so it pins the
provision exemption on its own rather than passing incidentally.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
A producer that fails to supply a required param its consumer's connection
declares now fails the deploy, naming the edge, the param, the producer,
and what the producer did supply. Previously the consumer received a
silent `undefined`, which serialized into its environment and failed at
the consumer's boot — far from the mistake.

Under ADR-0033 the consumer's connection declaration IS the contract, so
buildConfig's inputs loop is the one place that can hold a producer to it.

The old behaviour was characterized, not designed: the test this replaces
recorded what buildConfig did, and nothing in it argued that a missing
producer output SHOULD reach a booting service as undefined. Operator
confirmed the change.

Presence only, and deliberately so — the comment records why, because the
reason is not local: wiring values here are routinely alchemy Output
proxies that resolve only when the stack is applied, strictly after this
effect runs, so no Standard Schema can validate one. Checking existence is
the most this seam can honestly do.

Exemptions: provisioned params (the mint is the source, ADR-0031) and
optional params (the consumer said absent is legal). `edge === undefined`
is untouched — with no producer there is nobody to hold to the contract.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The old silent-undefined behaviour was written down as a test assertion —
characterized, not designed. S2 inverts it, so the diff shows a deleted
assertion deliberately.

Also records that buildConfig's `edge === undefined` branch is unreachable
through the authoring API (a declared input cannot go unwired and still
type-check), so its test reaches it by dropping the edge post-Load. Guard
kept as pinned; the question of whether Load should assert the invariant is
an open item, not this slice's work.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
S2 made a producer that under-delivers a declared connection param a
deploy-time error. That is behaviour a user hits without changing a line
of their code, so both user-facing surfaces owed it.

Written as the consequence, not the mechanism: why a deploy that worked
last week now fails, and which end to fix. The key line is that the app
did not get worse — the same mistake used to reach the consumer as
undefined and crash it at boot, blaming the service that READ the value
rather than the one that failed to supply it.

Scoped honestly: only reachable by whoever authored the connection or the
extension on one side of the wire. Every shipped block supplies what it
declares — verified by the full suite plus a mutation of the real postgres
descriptor, which the guard caught end to end.

The skill mirrors the guide and adds one agent-specific guardrail: do not
mark the param optional just to clear the error, since that reinstates the
silent undefined. No links added — it stays self-contained per
skills/README.md.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
F3 was a spec-authoring miss: S2 is exactly the case
user-facing-surface-changes.mdc names as most-missed (a failure mode a user
hits without changing their code), and no slice owned the docs debt — S2's
DoD listed tests, S3's spec is rendering. Now owned as S2-D3.

Also records the bound S2's review drew for S3: the guard enforces that a
producer declared a key, not that the key resolves — a mis-named attribute
read fabricates a PropExpr, passes presence, and fails at apply. So
rendering must never present a wiring value as verified.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…wrong

The probe confirms Actions are the right vehicle: an action referencing a
not-yet-created resource plans, runs during apply, and its runner receives
values resolved two levels deep. A control deploy (same nonce, unchanged
stack) proves the noop is real and the nonce is what defeats it.

It also refutes the spec: `readonly` arrays DO map correctly through
Input<>. The premise was right (readonly T[] fails `T extends any[]`) but
the conclusion was wrong — the object branch is homomorphic over a naked
type parameter, which TypeScript special-cases over arrays, preserving both
elements and the readonly modifier. Same failure as `serviceId`: a claim
derived by reading types instead of compiling them.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
A node's final lowering phase now returns LoweredResult — wiring for
dependents, primitives for reporting — so the two roles stop sharing a
channel. The loop collects primitives in topo order, and (only when a
caller supplied opts.report) declares one Action whose input carries them.

The primitive needs two shapes. DeployedPrimitive is the RESOLVED thing a
report consumer sees; ReportedPrimitive = Input<DeployedPrimitive> is what
a descriptor returns, because a descriptor holds svc.id and
deployment.deployedUrl — Output references, not strings, since the whole
stack effect runs before apply. A primitive type promising resolved values
at construction would be the serviceId lie again.

Why an Action: the stack effect runs before apply, so program code cannot
see resolved values afterwards, and the bin has no post-apply hook. Actions
are alchemy's designed run-during-apply-with-resolved-input primitive. The
Date.now() nonce defeats alchemy's input-hash noop so the report runs on an
unchanged redeploy.

The conditionality is required, not an optimization: an unconditional
Action puts alchemy's Stack service in the requirements and core's sync
unit tests die with "Service not found: Stack". Verified by making it
unconditional and watching them fail.

joinDeployment is exported and pure. The Action's input carries addresses
and plain primitives only — never graph nodes, since the plan hashes the
resolved input and a node carries functions and Standard Schemas — so the
join reads the node back from the graph the runner holds by closure.

The stack effect still returns undefined (S1).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The fake descriptors return LoweredResult and report primitives, so the
suite exercises the collection path the loop now runs.

joinDeployment gets its own unit tests — it is pure precisely so it can be
tested without alchemy: order preserved, a node reporting no primitives
still yielding a result, no entries yielding none, and the defensive skip
for an address the graph no longer holds.

The report-path test pins the conditionality: lowering() without
opts.report must stay sync-runnable. Confirmed it is not vacuous by making
the Action unconditional, which fails it with "Service not found: Stack".

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
… D3)

Each descriptor now returns LoweredResult and names the platform primitives
it became, per the pinned table. What it OMITS is the point:

- postgres/prisma-next report the database id with NO url. A connection
  string is not a public endpoint. Note that the wiring still carries a
  `url` key — the same name, meaning the opposite thing — which is exactly
  why core cannot infer publishability and only the descriptor can say.
- s3-credentials reports nothing at all. Both its values are secret
  material; the wiring carries them because consumers need them, but a
  primitive exists to be printed to a terminal.
- compute publishes its deployed URL deliberately, because a Compute
  endpoint IS public.

s3-store spreads the base result and overrides only `wiring`, so compute's
primitives pass through exactly — an s3-store IS a compute service and
became nothing else. Rebuilding the result by hand would have silently
dropped them.

Tests assert the primitives per descriptor, including a new case pinning
that s3-credentials reports [] while its wiring still carries the pair.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
A pure renderer over DeploymentResults: the address tree with box guides,
one line per primitive, urls on their own line, and a node that reported
nothing listed rather than dropped. Returns the string; the caller prints.

It lives in the CLI because presentation does — core assembles the results
and never formats (ADR-0033).

What it renders is only what a descriptor deliberately named and apply
resolved. Nothing is scraped from wiring outputs: S2's guard proves a
producer DECLARED a key, not that the key resolves to anything real, so a
wiring value is not evidence of anything worth printing. The module comment
says so, since that distinction is invisible locally.

Unit-tested against the pinned format, plus nested addresses, an
intermediate segment that is structure rather than a deployed node,
multi-primitive nodes, no-primitive nodes, and deep nesting holding one
column.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The generated stack file imports deploymentReport from a new
@prisma/composer/report path and passes it as LowerOptions.report. That
file runs in the alchemy child, which is where the resolved results are —
the CLI parent never sees them.

The renderer gets its OWN @internal/cli export path rather than riding the
`.` barrel. The barrel would drag clipanion, c12 and — via main.ts —
@internal/lowering into the deploy child, deepening the known-debt CLI to
prisma-cloud edge for nothing. The renderer imports only core's types, so
its own entry keeps it that way: the shipped report bundle is 2.7 kB with
zero runtime imports.

Also makes the layering gate able to see this: report.ts is classified in
architecture.config.json and @internal/cli/report is aliased to source in
tsconfig.depcruise.json. Both were needed — without them the new file was
unclassified and its edges unresolvable, so lint:deps passed blind.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…es lesson

S3-D3 controlled the layering gate by introducing a deliberate plane
violation and watching it pass. Two independent causes: new files under
packages/9-public/composer/src/ match no glob in architecture.config.json so
no rule applies to them, and unlisted paths in tsconfig.depcruise.json make
the edge unresolvable. Fixed for the new file; the mechanism is a follow-up.

That is the fourth control in this project (postgres mutation, same-nonce
deploy, unconditional Action, plane violation). Generalized: a passing check
and an absent check are indistinguishable from outside — make a gate go red
on purpose before letting its green support a claim.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
… (F4)

The three JSON edits were made with a whole-document rewriter, which
reformatted every file it touched: two failed biome (lost array packing)
and the published composer manifest had its description em-dash re-encoded
for no reason.

The formatting was not the real cost. The change that matters — the
report.ts glob classification — was buried in ~40 lines of unrelated
churn, which is the opposite of reviewable. Restored main's copies and
redid the edits as targeted insertions: 10 added lines, zero deletions,
across all three files.

Also: `pnpm lint` was RED when I reported it green. Not a stale run — I ran
it last, it failed, and my command was `pnpm lint >/dev/null && echo "exit
0 clean"`, so the failure printed nothing and I read the silence as
success. The gate told me and I did not look. Never report a gate green
from the absence of a failure; read the exit code.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
"This node became no reportable platform primitive" is a claim. An
optional field lets a descriptor make it by staying silent — no error, no
type complaint, no failing test. ADR-0033 says the shared bag's sin was
letting claims be made anonymously, with nothing recording that a claim
was made at all. An omitted optional field is exactly that shape.

The evidence is in this branch: s3-store spreads the base result rather
than rebuilding it, and the commit that did so notes rebuilding "would
have silently dropped them". That was care preventing a drop the TYPE
should have made impossible — the substitution this project exists to
reverse. s3-credentials already modelled the honest form: primitives: [],
deliberately, with a comment saying why.

The two `result.primitives ?? []` fallbacks go with it: the type now
guarantees what the fallback was covering for. Zero churn — all seven
construction sites already supplied it explicitly.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
S3-D3 reported lint green while it was red. My diagnosis (stale run) was
wrong; the implementer corrected it. The command was
`pnpm lint >/dev/null 2>&1 && echo "exit 0 clean"` — the && swallowed the
failure and the absent success line was read as success. That distinction
matters: "re-run gates last" would not have helped, since it was run last.
Never infer success from the absence of a failure signal.

Also records that my own adjudication method was unsound: checking main via
--stdin-file-path reported it dirty too and would have let me dismiss a true
finding. Stdin mode does not resolve the same config.

primitives is now required (F5): an omitted optional field is the bag's
anonymous claim in miniature.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The appendix says resolved values are unreachable from the stack effect.
Actions are the exception S3 builds on, so the appendix now records how
they behave — decision unchanged, appendix grown.

Four facts, each with the evidence rather than just the claim, because a
fact recorded without it is the next set of drifted line numbers:

- An Action referencing a not-yet-created resource plans and runs after
  it; its upstream edges come from Output refs found in its input.
- The runner receives resolved values arbitrarily deep — probed two levels
  down, entries[].primitives[].id arrives a real string.
- Alchemy hashes the RESOLVED input and noops on an unchanged hash, which
  is why a stack-effect-time nonce defeats it. Recorded WITH the control:
  three deploys — fresh, unchanged+new nonce, unchanged+same nonce — where
  only the third failing to run proves the nonce is the cause rather than
  actions always running.
- Input<T> maps readonly T[] correctly, and the reasoning that says
  otherwise is recorded as the trap it is: the premise holds, but the
  object branch is homomorphic over a naked type parameter, so elements
  are mapped and readonly survives.

Every citation derived against installed alchemy source at write time. Two
of my own line guesses were wrong before I checked them.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Every deploy now ends with the app's own topology instead of the deploy
engine's empty { outputs: {} } blob. That is behaviour a user hits without
changing a line, so both surfaces owed it.

Neither surface ever documented the blob, so there was no stale text — but
both told users to go to the Console for a URL the deploy now prints.
getting-started is where that mattered most: the tutorial says "open the
gateway's URL" and sent a first-time reader hunting in a web console for
something now on their screen.

Written as the consequence: what the tree means, and — the question users
will actually ask — where the JSON went.

The omissions are documented too, because they are the design: a URL
appears only where the address is genuinely public, a database never
prints one (a connection string is not an endpoint), and a node whose
product is secret material reports no line at all. A node that published
nothing is still listed, so nothing goes silently missing.

Both sample trees were generated by running the real renderer, not written
by hand — which caught that getting-started's services render in topo
order (quotes before gateway, since gateway depends on it), the reverse of
what I first wrote.

Describes what it does, not what is proven in the wild — the live deploy is
D4b and has not run.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…esson

Reviewer SATISFIED, no findings: eleven new ADR citations verified exact,
documented trees diffed byte-identical against the live renderer.

Records two orchestrator errors alongside the agents' findings. Both were
the same failure as the thing being checked: the check you ran was not the
check you thought you ran. A gate that reports by printing on success is
silent in two different worlds — not-reached and reached-but-failed.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@wmadden-electric
wmadden-electric marked this pull request as ready for review July 17, 2026 13:03
@wmadden-electric wmadden-electric changed the title refactor(core): consumer-declared lowering-SPI seams, enforced wiring, and deployment results refactor(core): define each deploy-pipeline type by the code that reads it Jul 17, 2026
… (S3 D4b)

The sample trees used an invented `pdb_` prefix for a Postgres database.
A real deploy prints `db_` — e.g. db_cmrp02nz60gui1adzqrexwri0. `cps_` for
a compute service was right, by luck rather than knowledge.

Caught by deploying examples/storefront-auth for real. Generating the tree
from the renderer got its SHAPE right; only a live deploy could get the
platform ids right, because the renderer prints whatever id it is handed.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
"Seam" was invented jargon: it sounded precise and told a reader nothing.
Renames the file to ADR-0033-lowering-types-are-defined-by-their-readers.md
and replaces every use with the literal thing — "where one piece of code
hands a value to another", "the application hook hands its product to that
extension's descriptors", "a node hands values to the nodes wired
downstream of it".

Where a sentence only worked because the jargon blurred it, the sentence is
rewritten rather than word-swapped. The taxonomy table now names three
PLACES and asks what checks the claim, which is what it always meant.
"Load-bearing" goes too.

Substance unchanged: the decision, the three-way taxonomy, the
deliberate-and-audited argument, and every alchemy citation stand exactly
as they were. Only the index links the filename; core-model.md and the code
cite ADR-0033 by number, so nothing else moves.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The ADR sweep stopped at published surfaces, but the objection was to the
word, not to where it appears — and comments and test names are read by
exactly the people the rule protects.

Five instances, all added by this branch, all replaced with the literal
thing rather than a synonym:

- shared.ts now says projectIdOf narrows ctx.application, which core hands
  over as unknown, to this extension's own product, and throws naming the
  hook when it has not run.
- deploy.ts's wiring check says presence is the most that can honestly be
  checked there.
- The test now reads "projectIdOf — narrowing ctx.application to this
  extension's own product", and its cases throw "naming the hook that must
  run" rather than "its seam error".

No user-visible text changed — the word never appeared in an error message.
Pre-existing instances in core-model.md, testing.md, SKILL.md and the
tooling packages are left alone: they are not ours, and sweeping them here
would bury this PR in unrelated churn.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Operator-directed rework: the branch coined names (WiringOutputs,
DeployedPrimitive, ReportedPrimitive, "wiring contract") for concepts the
glossary already covers (Outputs, connection contract) or that carried no
plane meaning (naked "primitive"). DeploymentResult is promoted to name the
result of the Deploy operation; the per-node record becomes DeployedNode;
things on the deployment target are Deployment entities (DeployedEntity).
Pure rename, zero behaviour change, identical gate counts required.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…bulary (S4)

Pure rename, zero behaviour change. The branch coined names for concepts
the domain glossary already covers:

- WiringOutputs → Outputs: what a node provides to its dependents already
  has a name (glossary § Outputs). LoweredResult.wiring → .outputs.
- DeployedPrimitive → DeployedEntity: naked "primitive" carries no meaning
  without a plane qualifier, and "Deployed" is not a plane. A thing on the
  deployment target is a Deployment entity.
- ReportedPrimitive deleted: its two use sites write Input<DeployedEntity>
  literally — Alchemy's own idiom for "this shape, fields possibly
  unresolved" — instead of hiding it behind an alias.
- DeploymentResult now names what it says: THE result of the Deploy
  operation, { app, nodes }. The per-node record it used to name is
  DeployedNode { address, node, entities }. The Action runner assembles the
  operation result with app = opts.name; joinDeployment stays pure and
  returns the nodes.
- The S2 error text drops "wiring": the producer's OUTPUTS carry [...],
  and the check is the connection contract (the glossary's name for it).

kind values ('compute-service', 'postgres-database') are hosting-plane
nouns and do not change. Nonce mechanics and the Action name unchanged.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…e (S4)

Mechanical: each descriptor's LoweredResult literal renames wiring →
outputs and primitives → entities; comments that used "primitive" for our
report type now say entity. kind values unchanged — they are hosting-plane
nouns. Tests follow the field renames; assertions otherwise identical.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…is the callback (S4)

renderDeployment(result) reads the app name from the result instead of a
separate parameter, and deploymentReport is no longer a factory — it IS
the report callback, since everything it needs now rides in the
DeploymentResult. The generated stack template emits
`report: deploymentReport` with no call and threads no name; snapshot
tests pin that (including that no call expression appears).

The one rendered-text change is the pinned empty case:
"(no primitives reported)" → "(no entities reported)". Tree format
otherwise byte-identical, proven by the unchanged test trees.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…nt entity" (S4)

The guides, skill and ADR-0033 follow the rename: "wiring contract" →
"connection contract" (the glossary's name for what the check enforces),
"wiring outputs" → "outputs", our-type "primitive(s)" → entities, and the
quoted S2 error text updates to the live template — re-verified as a true
prefix in the skill. deploying.md's section retitles to name the actual
event: "When a deploy stops on a missing connection value".

Records the one genuinely new noun where vocabulary lives: layering.md's
hosting-plane bullet and the glossary's planes entry both name a thing on
the deployment target a Deployment entity (DeployedEntity).

core-model.md: the lines this branch already edited follow the rename, and
the half-migrated deploy signature — it read WiringOutputs while the code
returns LoweredResult — now shows the renamed truth, with LoweredResult
quoted alongside (closes review defect B5). The file's broader stale text
is untouched (filed follow-up).

Also catches one comment the rename commits missed: s3-store's "only the
WIRING gains" now reads "only the OUTPUTS gain".

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@wmadden-electric
wmadden-electric merged commit 8e15f50 into main Jul 17, 2026
11 of 12 checks passed
@wmadden-electric
wmadden-electric deleted the claude/spi-inversion branch July 17, 2026 15:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants