Skip to content

refactor(core): consumer-declared lowering-SPI seams, enforced wiring, and deployment results#115

Closed
wmadden-electric wants to merge 12 commits into
mainfrom
claude/spi-inversion-s1
Closed

refactor(core): consumer-declared lowering-SPI seams, enforced wiring, and deployment results#115
wmadden-electric wants to merge 12 commits into
mainfrom
claude/spi-inversion-s1

Conversation

@wmadden-electric

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

Copy link
Copy Markdown
Contributor

Draft — slice 1 of 3 landed. This PR carries the whole SPI-inversion project; slices 2 and 3 land on this branch. Reviewable now for direction, not for merge.

What this does

Retires LoweredNode — a single type serving three unrelated contracts — gives each seam of the lowering SPI a type declared by its consumer, and then builds deployment reporting on the clean seams.

It supersedes the NodeReport approach in #101, which failed because of the shared bag this removes: reporting data had nowhere to live except bolted onto the wiring contract.

The three contracts LoweredNode was serving

Role Producer → consumer Now
Intra-descriptor phase handoffs provisionserialize/deploy, same descriptor Descriptor-owned types, carried generically as ServiceLowering<P, S>; opaque to core
Inter-node wiring deploybuildConfig, for dependents WiringOutputs — name-keyed and unknown-valued by necessity
Presentation (the reverted NodeReport) Its own type — DeploymentResult, in S3

The descriptors were casting to recover types they themselves produced two phases earlier, because the SPI laundered them through a bag. Those casts are gone: the ratchet moves 32 → 30.

The bug S1 surfaced immediately

ComputeProvisioned.serviceId was specced as string. It isn't — Alchemy maps every resource attribute through Output, so svc.id is Output<string, never>. The old code compiled only because provisioned.outputs['serviceId'] as string laundered it at the read site, and Prisma.Deployment's computeServiceId: Input<string> accepts both the truth and the lie.

Typing the handoff honestly deletes the cast. The specced string would have forced it back.

Why the ADR's thesis isn't "bags are bad"

ADR-0033 states it as deliberate-and-audited vs. accidental, because the blunt version is refuted by this very PR: keyOuts' blindCast makes the same kind of unchecked Output<string> claim through another unknown-typed seam, and we are keeping it. The difference is that it's named, justified, and singular — a reviewer can weigh it, a grep can find it. The bag made the same claim anonymously, at every read site, with nothing recording that a claim was being made at all.

The ADR also records a three-seam taxonomy, which explains why the bug could only have appeared where it did:

Seam Claim Defended by
Phase handoffs Precise The compiler
Application (CloudApplication.projectId) Precise A runtime guard — the compiler can't cross core's unknown
Wiring None Nothing to defend; unknown cannot lie

warm.url and creds.accessKeyId are also unresolved Output<string>s — but they flow through the wiring seam, where no precise claim was ever made, so no lie was available to tell.

Two consequences the ADR records for whoever touches this next

  • The registry's type safety rests on the lowering loop, not the compiler. Method bivariance is what lets descriptors with different P/S share one registry, and it's unsound by construction: core calls serialize(ctx, provisionedNode, config) with provisionedNode: unknown, so nothing would object if the loop threaded the wrong node's value. The loop is correct today; nothing but the loop makes it correct. Slices 2 and 3 both edit it.
  • ComputeSerialized is the one producer-side shape crossing a module boundary (s3-store's handoff extends it). Legitimate — s3-store composes compute's own descriptor. A third descriptor importing it without composing compute would be the bag reforming.

Still to land on this branch

S2 — enforce the wiring contract. Today a producer that fails to supply a consumer's declared connection param yields a silent undefined, serialized into the consumer's environment and failing at boot, far from the mistake. Under the inverted seam the connection declaration is the contract, so under-delivery becomes a LowerError at deploy naming the edge. Behaviour change, deliberate.

S3 — DeploymentResult + rendered deploys. The deploy phase reports the platform primitives a node became, distinctly from its wiring. An Alchemy Action — which runs during apply with resolved inputs — joins them to the graph and renders the app's own topology with authored names, ids, and public URLs. No cross-process transport: the child already holds both the graph and the results.

Verification so far

typecheck 58/58 · test 48/48 · lint + lint:deps clean · lint:casts 32 → 30 · no LoweredNode in live code or in docs describing the current system.

Every Alchemy citation in the ADR was derived against the installed source (2.0.0-beta.59) and then independently re-verified, rather than copied from working notes — several line numbers in those notes had drifted.

Not in this PR at all

Per-node ok/diagnostics, which stays vacuous while lower() is orDie — making it real means collect-and-continue, a deploy-semantics decision bigger than reporting · per-resource created/updated/noop status, which Alchemy exposes only to its CLI event session · any --json/parent-process consumer · core-model.md's surrounding model, stale beyond this work's reach and filed separately.

🤖 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>
@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@115
npm i https://pkg.pr.new/@prisma/composer-prisma-cloud@115

commit: 9ba85b1

@wmadden-electric
wmadden-electric deleted the claude/spi-inversion-s1 branch July 17, 2026 11:45
@wmadden-electric wmadden-electric changed the title refactor(core): lowering-SPI seams are consumer-declared (retire LoweredNode) refactor(core): consumer-declared lowering-SPI seams, enforced wiring, and deployment results Jul 17, 2026
@wmadden-electric

Copy link
Copy Markdown
Contributor Author

Superseded by #117 — same branch, same commits, nothing lost.

I renamed the head branch claude/spi-inversion-s1claude/spi-inversion (the project is landing all three slices on one PR, so the -s1 suffix was wrong). GitHub closed this PR on the rename rather than retargeting it, and it cannot be reopened because the old head ref no longer resolves. #117 carries the identical branch at the same SHA.

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.

1 participant