refactor(core): consumer-declared lowering-SPI seams, enforced wiring, and deployment results#115
Closed
wmadden-electric wants to merge 12 commits into
Closed
refactor(core): consumer-declared lowering-SPI seams, enforced wiring, and deployment results#115wmadden-electric wants to merge 12 commits into
wmadden-electric wants to merge 12 commits into
Conversation
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>
commit: |
Contributor
Author
|
Superseded by #117 — same branch, same commits, nothing lost. I renamed the head branch |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
NodeReportapproach 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
LoweredNodewas servingprovision→serialize/deploy, same descriptorServiceLowering<P, S>; opaque to coredeploy→buildConfig, for dependentsWiringOutputs— name-keyed andunknown-valued by necessityNodeReport)DeploymentResult, in S3The 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.serviceIdwas specced asstring. It isn't — Alchemy maps every resource attribute throughOutput, sosvc.idisOutput<string, never>. The old code compiled only becauseprovisioned.outputs['serviceId'] as stringlaundered it at the read site, andPrisma.Deployment'scomputeServiceId: Input<string>accepts both the truth and the lie.Typing the handoff honestly deletes the cast. The specced
stringwould 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'blindCastmakes the same kind of uncheckedOutput<string>claim through anotherunknown-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:
CloudApplication.projectId)unknownunknowncannot liewarm.urlandcreds.accessKeyIdare also unresolvedOutput<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
P/Sshare one registry, and it's unsound by construction: core callsserialize(ctx, provisionedNode, config)withprovisionedNode: 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.ComputeSerializedis 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 aLowerErrorat 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
typecheck58/58 ·test48/48 ·lint+lint:depsclean ·lint:casts32 → 30 · noLoweredNodein 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 whilelower()isorDie— 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