refactor(core): define each deploy-pipeline type by the code that reads it#117
Merged
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>
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>
commit: |
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
marked this pull request as ready for review
July 17, 2026 13:03
… (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
approved these changes
Jul 17, 2026
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 looks like
Here's one step of the compute descriptor on
main— the code that turns a service node into a Prisma Compute deployment: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:
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:
It was doing three unrelated jobs at once:
serviceId,environment,port)ServiceLowering<P, S>url)buildConfig, by param nameOutputs— the glossary's own nounNodeReportwentThe 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
serviceIdwas never astring.Alchemy wraps every attribute of a resource in
Output<T>— a lazy reference that becomes a real value later, when alchemy applies the plan. Sosvc.idisOutput<string>. The old line said:and it compiled — because
Prisma.Deployment's prop acceptsInput<string>, which isstring | 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 anunknownwas astring, 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 — specifyingstringwould 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
blindCastsurvives, in the same file. It claims anOutput<string>out of a value core hands over asunknown, and we are keeping it.The difference isn't whether an unchecked claim exists. It's whether anyone was asked to agree with it:
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,unknownis the honest type. That last one explains why the bug could only ever have been where it was:warm.urlandcreds.accessKeyIdare also unresolvedOutput<string>s, but they travel asunknown-valuedOutputsand 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: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:
DeploymentResultis the result of the Deploy operation ({app, nodes}), eachDeployedNodepairs a graph node with theDeployedEntitys it became (kind, platform id, url only when the descriptor says the address is public).Proven live, both cases. A fresh deploy printed:
and an unchanged redeploy —
Plan: 17 to noop,Done: 0 succeeded— still 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, namedauth.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 inventedpdb_id prefix; the real one isdb_. 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
urlkey meaning the exact opposite of compute's. The descriptor names what's publishable;LoweredResult.entitiesis 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
typecheck58/58 ·test48/48 ·lintclean ·lint:depsclean · 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:depswas passing blind for new public files. A deliberate layering violation sailed through.architecture.config.jsonlists 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.pnpm lintfailure 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
lower()isorDie— one failure kills the run, so a per-nodeokwould readtrueon everything that exists. Making it real means collect-and-continue, which is a deploy-semantics decision, not a reporting one.--jsonoutput for CI. That needs a real cross-process transport; nothing needs it yet.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
NodeReporton 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.describe()hook for descriptors to report what they built. Buys nothing: the alchemy resources live insidedeploy()'s effect, sodescribe()would need them handed back out, which is just returning them with extra plumbing.entitiesoptional. 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