From 50db4945b85740b691b73558c50c2351f61182f9 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 20 Jul 2026 15:45:40 +0200 Subject: [PATCH 1/2] docs(rules): reading a cast is reading a claim, not a fact The SPI-inversion project retro, landed durably: its spec pinned wrong types three times by deriving them from existing casts, when the casts were what made the wrong types compile. The rule already governed writing casts; this section governs reading them. Signed-off-by: willbot Signed-off-by: Will Madden --- .agents/rules/no-bare-casts.mdc | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.agents/rules/no-bare-casts.mdc b/.agents/rules/no-bare-casts.mdc index 445fcad0..01f8f77d 100644 --- a/.agents/rules/no-bare-casts.mdc +++ b/.agents/rules/no-bare-casts.mdc @@ -55,3 +55,21 @@ Any time you touch a file that contains a bare `as` cast — even as part of unr ## Test code is exempt The rule does not fire on test files (`**/*.test.ts`, `**/*.test-d.ts`, `**/test/**/*.ts`). Tests routinely use `as` for stubbing, narrowing, and negative type assertions; the exemption is encoded in the Biome plugin itself. + +## Reading casts: a cast is a claim, not a fact + +When you are specifying or designing against existing code, a cast in that +code is evidence that *someone made a claim* about a type — it is not +evidence the claim was true. The cast is what made a wrong type compile, so +the code around it cannot corroborate it. + +Derive types from the producing expression — compile a probe if needed — +never from an adjacent cast, and never from a consuming parameter whose type +accepts a union (Alchemy's `Input` is `T | Output | …`, so a value +flowing into it type-checks whether the claim was true or laundered). + +Incident this rule records: PR #117. `provisioned.outputs['serviceId'] as +string` had hidden that `serviceId` was an unresolved `Output`; a +spec written by reading the cast pinned `string`, and the same mistake was +made twice more in the same project before the pattern was named. Typing the +value honestly deleted the cast; the pinned type would have forced it back. From 74142e446e3b8b3169e0c38dcbe1e350d94fba79 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 20 Jul 2026 15:46:01 +0200 Subject: [PATCH 2/2] chore(drive): close out the SPI-inversion project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four slices merged in #117 (+#121, #123 follow-ups); project DoD verified; the durable lessons already live where they belong (ADR-0033, the guides, the skill, layering.md/glossary, and the cast rule). The spec/plan/design-notes/review artifacts were coordination scaffolding — per the drive process they delete at close; this commit and PR #117 are their historical record. Signed-off-by: willbot Signed-off-by: Will Madden --- .../design-notes.md | 188 --------- .../learnings.md | 159 -------- .../spi-inversion-and-deploy-results/plan.md | 148 ------- .../slices/s1-invert-spi/plan.md | 31 -- .../slices/s1-invert-spi/spec.md | 371 ------------------ .../slices/s2-enforce-wiring-contract/plan.md | 36 -- .../slices/s2-enforce-wiring-contract/spec.md | 177 --------- .../slices/s3-deployment-results/plan.md | 52 --- .../slices/s3-deployment-results/spec.md | 360 ----------------- .../slices/s4-glossary-alignment/spec.md | 111 ------ .../spi-inversion-and-deploy-results/spec.md | 159 -------- 11 files changed, 1792 deletions(-) delete mode 100644 .drive/projects/spi-inversion-and-deploy-results/design-notes.md delete mode 100644 .drive/projects/spi-inversion-and-deploy-results/learnings.md delete mode 100644 .drive/projects/spi-inversion-and-deploy-results/plan.md delete mode 100644 .drive/projects/spi-inversion-and-deploy-results/slices/s1-invert-spi/plan.md delete mode 100644 .drive/projects/spi-inversion-and-deploy-results/slices/s1-invert-spi/spec.md delete mode 100644 .drive/projects/spi-inversion-and-deploy-results/slices/s2-enforce-wiring-contract/plan.md delete mode 100644 .drive/projects/spi-inversion-and-deploy-results/slices/s2-enforce-wiring-contract/spec.md delete mode 100644 .drive/projects/spi-inversion-and-deploy-results/slices/s3-deployment-results/plan.md delete mode 100644 .drive/projects/spi-inversion-and-deploy-results/slices/s3-deployment-results/spec.md delete mode 100644 .drive/projects/spi-inversion-and-deploy-results/slices/s4-glossary-alignment/spec.md delete mode 100644 .drive/projects/spi-inversion-and-deploy-results/spec.md diff --git a/.drive/projects/spi-inversion-and-deploy-results/design-notes.md b/.drive/projects/spi-inversion-and-deploy-results/design-notes.md deleted file mode 100644 index 379f7d64..00000000 --- a/.drive/projects/spi-inversion-and-deploy-results/design-notes.md +++ /dev/null @@ -1,188 +0,0 @@ -# Design notes — SPI inversion & deployment results - -The settled argument from the design session (2026-07-17), recorded so -slices don't re-derive it. The ADR (S1) distills the durable decisions; -this file keeps the full reasoning and the dead ends. - -## Principles inherited - -- ADR-0005: users build, the framework assembles — deterministic steps, no - guessing. A renderer that walks our own graph (not alchemy's output) is - the reporting expression of this. -- `architecture.config.json` layering: the CLI (framework/tooling) must not - import prisma-cloud; core stays presentation-free. -- Repo cast rules: no bare `as`; the SPI must not force descriptors to cast. - -## The corrected execution model (verified, alchemy 2.0.0-beta.59) - -> **Citation drift, corrected 2026-07-17.** S1-D3 re-derived every line -> number below against the installed source rather than trusting this file, -> and most had drifted a few lines. **ADR-0033 carries the corrected -> citations and is the authority**; the ones in this section are indicative. -> Corrections: `Deploy.ts:25-30`; `Apply.ts:191-193` (short-circuit); -> `Apply.ts:198` + `:203` (evaluate, then `setOutput`); `Resource.ts:275-283` -> (unchanged); and the status-change emit exists at **two** sites — -> `Apply.ts:415-421` (per-resource `report()`) and `Apply.ts:184-185` -> (terminal flush) — where this file cited only the first. The *facts* below -> all re-verified; only the line numbers moved. Lesson for S2/S3: cite -> against the installed source at write time, not against this file. - -The design initially assumed each `yield*` in a descriptor returns after -alchemy applies that resource, resolved values in hand. **Wrong.** Verified -against alchemy source: - -- `deploy = evalStack(stackEffect) → Plan.make → Apply.apply` - (`src/Deploy.ts:25–32`). Our entire `lowering()` generator runs to - completion before any platform call. -- Yielding a resource returns a Proxy whose property reads produce lazy - `Output.PropExpr` references (`src/Resource.ts:275–283`). - `deployment.deployedUrl` in a descriptor is symbolic, not a URL. -- Resolved values reach the program in exactly one place: apply evaluates - **whatever the stack effect returned** against its internal tracker and - returns that resolved structure (`src/Apply.ts:195–205`). It also - unconditionally persists that value via `state.setOutput` - (`src/Apply.ts:203`) — alchemy's cross-stack-reference mechanism. -- Per-resource phase outcomes (created/updated/noop/skipped) are **not** - returned to the program. They live in apply's tracker and are emitted as - status-change events to alchemy's CLI session service - (`src/Apply.ts:415–421`). Capturing them means wrapping that service or - an upstream change. - -Consequences: - -- The association (node → primitives) forms at full context inside the - lowering loop, but as symbolic Output references; values materialize when - the stack's return value is evaluated. -- Returning address-keyed primitives from the stack effect is **alchemy's - designed mechanism** for getting resolved values out — not a transport - hack. `lowering()` returning hardcoded `{ outputs: {} }` is us opting out - of it. -- Because alchemy persists the stack output, the value crossing the stack - boundary must be plain data. That is a rule about *alchemy's channel*, - not about our domain types: `DeploymentResult` (node-bearing) is - assembled after `deploy()` returns, in the same child process, by joining - the resolved primitives to the graph. - -## The three roles of `LoweredNode` - -One type (`{ outputs: Record }`, `deploy.ts:116–118`) -serves: - -1. **Intra-descriptor phase handoffs** (`provision` → `serialize`/`deploy`). - Same-party producer and consumer; core threads them opaquely. Forcing - them through the bag makes descriptors cast to recover their own types - (`compute.ts:155–161`). -2. **Inter-node wiring** (`deploy` → `lowered` map → `buildConfig`). The - only role core reads — by param name, per the consumer's connection - declaration. -3. **Reporting** — the reverted `NodeReport`, possible only because a - shared bag accepts new fields without any consumer's signature changing. - -## Key decisions - -- **Dependency inversion at every seam; interfaces live with consumers.** - Phase-handoff types: descriptor-owned, generic in the SPI - (`ServiceLowering` in spirit), opaque to core. - Wiring: the connection declaration is the interface (consumer side, in - core's graph model); across the extension seam it is necessarily - runtime-checked (schemas), not TypeScript-checked — core cannot know - extension types and the producer/consumer pairing is decided by the - user's graph. Primitives/results: declared by the deploy-result subsystem - in core. Formatting: declared by the CLI beside the renderer. -- **The lowering loop is the sole router.** deploy's product splits into - wiring (→ `lowered`, for dependents) and primitives (→ results). - Descriptors don't know `buildConfig` exists; neither knows reporting - exists. A future consumer must declare an interface and appear as a - visible routing edit in the loop — the dumping-ground regression becomes - structurally impossible. -- **`DeploymentResult` per node** — graph node + typed primitives - (`kind`, platform `id`, `url` only when the descriptor declares it - public) + a diagnostics slot populated later. No aggregate noun; the run - yields a collection. "Deployment" alone is banned — it collides with the - `Prisma.Deployment` alchemy resource. -- **Descriptor names what is publishable** (the allowlist lesson): `url` - means a public endpoint on compute and would mean a connection string on - postgres, so no core-level rule is safe. -- **Renderer runs in the deploy child**, wired through the generated stack - file; it walks our module tree and prints authored names with ids/URLs. - The stack's own output shrinks to primitives (or nothing beyond them), - killing the raw alchemy dump. -- **Wiring enforcement** (pending operator confirmation): after a producer - lowers, core checks its wiring outputs satisfy every param the consumer's - connection declares (except provisioned ones); a gap is a `LowerError` - naming edge, param, and both nodes — at deploy time, where the mistake - is, instead of `undefined` in a booted service's env. - -## Shaping addendum (2026-07-17, slice grounding) - -Facts established while pinning the slice specs, superseding two earlier -leanings: - -- **The render vehicle is an alchemy Action, not a post-apply step.** The - bin (`src/Cli/commands/deploy.ts`) has no post-apply hook: it imports the - stack module's default export, plans, applies, `Console.log`s a non- - undefined output, done. Actions (`src/Action.ts`) are alchemy's "run - during apply with resolved inputs" primitive: recorded on the stack, - given upstream edges from their input's Output references - (`src/Plan.ts:539-546`), executed by apply with the resolved input, with - their output tracked like a resource (`src/Apply.ts:1103-1232`). An - action noops when its input hash matches prior state - (`src/Plan.ts:1069-1082`) — a `Date.now()` nonce in the input forces the - report to run every deploy. -- **Parent-process readback via `getOutput` was rejected on three verified - grounds**: our pg state layer requires alchemy's `Stack` service - (`state/layer.ts:47` does `yield* Stack`) so the parent would fake it; - the layer acquires the deploy lock on build (`state/layer.ts:76`); and - the parent would have to replicate alchemy's stage derivation - (`--stage` absent → `STAGE` config → `dev_${USER}`, - `src/Cli/commands/_shared.ts:85-110`) to key the read. -- **The stack effect returns `undefined` from S1 on.** `Apply.apply` - short-circuits on a falsy plan output (`src/Apply.ts:189-191`): no - `setOutput` write, and the bin skips its `Console.log`. This kills the - `{ outputs: {} }` dump one slice early and makes S3's action the only - output channel. -- **Action inputs must be plain data + alchemy Inputs.** Plan-time - `hashInput(resolvedInput)` serializes the input — graph nodes (functions, - Standard Schemas) must never ride in it. Addresses + primitives in the - input; the graph joins in the runner via closure. -- **`ctx.application` is typed `unknown`, narrowed by an extension-owned - guard** (`CloudApplication`/`projectIdOf`). Full generic threading of the - application type through `ExtensionDescriptor`/`NodeDescriptor` was - rejected: the registry is heterogeneous, and the assignment would lean on - method bivariance in contravariant positions across packages. The - `ServiceLowering` generics are fine because producer and consumer - are the same descriptor; the registry stores them at the `unknown` - defaults through method bivariance (kept deliberately — noted in S1). - -## Alternatives considered - -- **`NodeReport` on `LoweredNode`** (PR #101): rejected — reporting data on - the wiring contract, untyped, meaningless on two of three phases. -- **A separate `describe()` SPI hook**: rejected — the resource handles - live inside `deploy()`'s effect; `describe()` would need them handed back - out, which is just returning them with extra plumbing. -- **Transporting results to the CLI parent** (stdout parsing → JSON file → - state-store `getOutput` readback): all rejected — the child already holds - both the Graph and the results; every transport was solving a problem - that doesn't exist. The state-store version also let a serialization - concern dictate the domain model ("can't hold a Node because Postgres"). -- **Making the whole `DeploymentResult` the stack output**: rejected — - alchemy persists stack output unconditionally, and nodes carry functions/ - schemas. Plain primitives cross; the join to nodes happens on our side. - -## Open questions - -- Operator confirmation on wiring enforcement (S2 contingent). -- `Output.evaluate` behavior on plain values mixed into the returned - structure — verify early in S3. -- Whether `ApplicationDescriptor`/provisioner surfaces get full generic - treatment or a minimal rename (S1 decides from their actual consumers). - -## References - -- `packages/0-framework/1-core/core/src/deploy.ts` — SPI + loop. -- `packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts` - — the cast-recovery example (lines 155–161). -- alchemy `2.0.0-beta.59` `src/Deploy.ts`, `src/Resource.ts`, - `src/Apply.ts` — execution-model evidence. -- ADR-0005, ADR-0031; `architecture.config.json`. diff --git a/.drive/projects/spi-inversion-and-deploy-results/learnings.md b/.drive/projects/spi-inversion-and-deploy-results/learnings.md deleted file mode 100644 index e245190e..00000000 --- a/.drive/projects/spi-inversion-and-deploy-results/learnings.md +++ /dev/null @@ -1,159 +0,0 @@ -# Learnings — SPI inversion & deployment results - -Working ledger. Reviewed with the operator at close-out; cross-cutting -lessons migrate to durable docs, project-local ones drop with this folder. - -## A cast in the code you are specifying against is evidence that someone -## made a claim — NOT evidence that the claim was true - -**The pattern.** Across S1's three dispatches the implementer pushed back on -pinned spec text three times, and was right all three times: - -1. `ComputeProvisioned.serviceId: string` — actually `Output`. I read - the existing `provisioned.outputs['serviceId'] as string` as evidence of - the real type. -2. `computeDescriptor`'s return type `NodeDescriptor` — contradicted the - spec's own "compose-over-base stays", because the erased type would have - forced s3-store to cast `P`/`S` back in. -3. My pinned `isCloudApplication` body — contained a bare `as`, which would - have *relocated* a cast in a cast-removal slice. `'projectId' in value` - narrows without one. - -**The common root, in the reviewer's words:** each pushback surfaced -something the spec got wrong by reading an existing cast as evidence of a -real type, when the cast was what made the wrong type compile. - -**Why it bit here specifically.** This project's whole subject is a seam -where casts hid type lies. So the code I was specifying *against* was -maximally untrustworthy as a source of type facts — the casts were the -disease, and I used them as the diagnosis. - -**How to apply it to S2 and S3.** When pinning a type, derive it from the -producing expression's real type (compile a probe), never from an adjacent -cast or a declared prop type that accepts a union. This is already why S3's -spec was pre-emptively amended: `DeployedPrimitive.id: string` had the -identical defect, caught by applying this lesson rather than by paying a -second halt. - -**Candidate for migration:** this generalizes past this project. Any -refactor that removes casts is specified against code whose casts are lying. -Worth a line in the repo's cast rules (`.agents/rules/no-bare-casts.mdc`) at -close-out — the rule currently governs *writing* casts, not *reading* them. - -## Halts earn their cost when the spec claims to be zero-freedom - -The "zero creative freedom" framing put all the risk on the spec being -right. That only worked because the halt conditions were explicit and the -implementer used them instead of improvising. The `serviceId` halt cost one -round-trip and prevented a laundered type; it also taught me the class of -bug, which I then found pre-emptively in S3's spec. A dispatch that had -"just made it compile" would have buried both. - -**Corollary:** zero-freedom specs need *more* halt discipline, not less. The -freedom removed from implementation has to reappear as permission to stop. - -## A green check is a claim, and claims get controlled - -Four times in this project an agent refused to accept that a check passing -meant the check worked. Each time the control was cheap and the finding was -real: - -| Control | What it proved | -| --- | --- | -| S2-D2 mutated the real postgres descriptor to under-deliver | The guard reaches real descriptor pairs → the "no pair under-delivers" null result means something | -| S3-D1 added a third deploy with the **same** nonce | The action noop is real → the nonce is precisely what defeats it, not "actions always run" | -| S3-D2 made the Action unconditional | The sync test genuinely catches alchemy's `Stack` leaking into core's requirements | -| S3-D3 introduced a deliberate plane violation into `report.ts` | **`lint:deps` was passing blind** — see below | - -The generalization: **a passing check and an absent check are indistinguishable -from the outside.** Before a green gate is allowed to support a claim, make it -go red once on purpose. This is the same epistemics as the project's central -thesis — a claim nobody was asked to defend is not a claim that was checked. - -## Silence is not success — the false-green, and why the obvious diagnosis was wrong - -S3-D3 reported `pnpm lint → exit 0`. It was exit 1, two branch-introduced -errors. The reviewer caught it by running the gate instead of reading the -report. - -**My diagnosis was wrong and the implementer corrected it.** I assumed a -stale run — lint executed before the late JSON edits. It hadn't been: the -command was - -```sh -pnpm lint >/dev/null 2>&1 && echo "exit 0 clean" -``` - -The `&&` swallowed the failure, nothing printed, and **the absence of the -success line was read as success.** The transcript shows `--- lint ---` -followed straight by `--- lint:deps ---`, with `exit 0 clean` conspicuously -missing. The gate reported red, in its own terminal, and the report said -green. - -This matters because **the two diagnoses imply different fixes**. "Re-run -gates last" would not have helped — it *was* run last. The fix is: never -infer success from the absence of a failure signal; capture `$?` explicitly. -A command shape that can only ever emit on success is indistinguishable from -one that didn't run. - -Same family as the blind `lint:deps` ✔, one level up: **a check that cannot -announce its own failure is not a check.** The dispatch that built a control -to prove `lint:deps` fires accepted `pnpm lint`'s silence without turning the -same suspicion on it. - -**The reviewer's sharpening, which is the version to keep:** a gate that -reports by *printing on success* is silent in **two different worlds** — -not-reached, and reached-but-failed — and those must never be conflated. The -shape of the command, not the diligence of the operator, is what makes the -two indistinguishable. - -**And the unifying generalization, from both halves of this dispatch: the -check you ran wasn't the check you thought you ran.** The `&&` swallow ran a -gate whose output channel only existed on success. My `--stdin-file-path` -adjudication asked biome to judge *content* when the real gate judges *a file -at a path* — and biome's config resolution keys on the path, so the stdin form -can disagree, and disagree in the **permissive** direction. The only test that -settles a gate dispute is running the gate the way CI runs it. This bit twice -in one dispatch, in both directions (an agent's false green, and my false -refutation of a true finding). - -**My own verification was also unsound**, and worth recording: checking main's -files via `--stdin-file-path` reported them dirty too, which would have let me -dismiss a true finding. Stdin mode doesn't resolve the same config. Swapping -the files in at their real paths is what settled it. When adjudicating a -factual dispute between two agents, the method has to be one whose failure -mode you've thought about. - -## `lint:deps` does not guard new public files (repo-wide, beyond this project) - -Surfaced by S3-D3's control. Two independent mechanisms each sufficient to -let a layering violation land unnoticed: - -1. **`architecture.config.json` lists every `packages/9-public/composer/src/*` - file individually.** A *new* file matches no glob, joins no module group, - and therefore **no rule applies to it**. New public files are unguarded by - default — the config's per-file listing makes that the default failure mode, - not an oversight in any one PR. -2. **`tsconfig.depcruise.json`'s paths must name each entry**, or the cruiser - cannot resolve the edge to source and cannot check it at all. - -S3-D3 fixed both *for its own file* and correctly did not change the -mechanism — that's an audit, not a slice's work. **Filed as a follow-up.** - -Worth stating plainly: the architecture rules are the thing this whole project -leans on to keep the seams honest (ADR-0033's consequences point at them), and -for new public files they were decorative. - -## Verification beats relay — twice, in both directions - -- The reviewer refuted the implementer's ergonomics claim with a compiled - probe (D1). Had I relayed it unchecked, D2 would have hand-annotated five - descriptors for no reason. -- The implementer re-derived every alchemy citation against installed source - rather than copying `design-notes.md`, and most had drifted (D3). The - reviewer then independently re-verified all eight. - -Both times the cheap move was to trust the upstream artifact. Neither agent -took it. **`design-notes.md` is now known to be a lossy source for line -numbers** — it carries a correction header pointing at ADR-0033 as the -authority. diff --git a/.drive/projects/spi-inversion-and-deploy-results/plan.md b/.drive/projects/spi-inversion-and-deploy-results/plan.md deleted file mode 100644 index 856576a6..00000000 --- a/.drive/projects/spi-inversion-and-deploy-results/plan.md +++ /dev/null @@ -1,148 +0,0 @@ -# SPI inversion & deployment results — Project Plan - -## Summary - -Three slices: the DI refactor first (operator-directed ordering), then -wiring enforcement and deployment results on the clean seams. - -**Spec:** [spec.md](spec.md) · **Design notes:** [design-notes.md](design-notes.md) -· **Learnings:** [learnings.md](learnings.md) - -## Delivery shape — ONE PR (operator decision, 2026-07-17) - -All three slices land on **one branch, `claude/spi-inversion`**, as -[PR #117](https://github.com/prisma/composer/pull/117) — draft until S3 -merges. This overrides the default of one-PR-per-slice. - -**Two real consequences, recorded so they aren't rediscovered:** - -1. **S2 and S3 no longer run in parallel.** The plan sequenced them as a - parallel group because they were to be separate PRs touching different - parts of the loop. One branch plus one persistent implementer means they - **serialize**: S2, then S3. Parallelism was the only thing the split - bought, and the operator traded it deliberately. -2. **Slice-INVEST's _Independent_ ("ships as one PR") no longer holds - literally**, and _Small_ ("manageable in a single code review") is under - real pressure — one reviewer now faces all three slices at once. - Mitigation, not a cure: each slice is independently reviewed inside the - build loop before the next starts, and the PR body separates them so a - reader can take them one at a time. If the final review strains, that is - the predicted cost of this decision, not a surprise. - -**Branch-rename note:** the branch was created as `claude/spi-inversion-s1` -and renamed once the one-PR decision landed. GitHub **closed** the original -PR (#115) on the rename rather than retargeting it, and it could not be -reopened — the old head ref no longer resolves. #117 carries the identical -branch at the same SHA; #115 has a pointer comment. No work was lost, but -rename-after-PR is a trap worth avoiding next time: name the branch for the -delivery shape before opening the PR. - -## Tracker - -Slices are identified by S-number here; this plan is the source of truth. -Linear issues are created per-slice when the slice starts, under -[Prisma Composer: SPI inversion & deployment results](https://linear.app/prisma-company/project/prisma-composer-spi-inversion-and-deployment-results-f87bb6d9de12) -(Terminal/TML). - -## External dependencies - -- **alchemy `2.0.0-beta.59`** — execution-model facts in design-notes are - verified against this version; a version bump mid-project re-opens them. -- **Composer PR #101** — the superseded `NodeReport` PR; disposed of in S3. -- No dependency on other in-flight projects. - -## Slices - -### S1 — Invert the lowering SPI (+ ADR) - -**Spec:** [slices/s1-invert-spi/spec.md](slices/s1-invert-spi/spec.md) -· **Plan:** [slices/s1-invert-spi/plan.md](slices/s1-invert-spi/plan.md) - -Retire `LoweredNode`'s triple duty. Phase handoffs become descriptor-owned -types carried generically by the SPI (opaque to core); the inter-node -wiring record becomes its own named type, still name-keyed because -`buildConfig` reads it by the consumer's declared params. Every descriptor -(compute, postgres, prisma-next, s3-store, s3-credentials) migrates to its -own typed handoffs; the casts that recover a descriptor's own values go -away. Decide and apply the treatment for `ApplicationDescriptor` and -provisioner surfaces from their actual consumers. Author the ADR in -`docs/design/90-decisions/` covering the seam design: consumer-declared -interfaces, the loop as sole router, results assembled at full context, no -transport before a cross-process consumer exists. - -**Builds on:** nothing. -**Hands to:** S2, S3 — a `deploy.ts` SPI whose three contracts are distinct -types with distinct consumers, all descriptors compiling cleanly against it, -behavior unchanged (deploys identical to today). - -### S2 — Enforce the wiring contract *(operator-confirmed 2026-07-17)* - -**Spec:** [slices/s2-enforce-wiring-contract/spec.md](slices/s2-enforce-wiring-contract/spec.md) -· **Plan:** [slices/s2-enforce-wiring-contract/plan.md](slices/s2-enforce-wiring-contract/plan.md) - -After a producer lowers, verify its wiring outputs satisfy every param the -consumer's connection declares (skipping provisioned params, which the mint -supplies). A gap fails the deploy with a `LowerError` naming the edge, the -param, and both nodes. Tests cover the loud path and the provisioned-param -exemption. Behavior change: descriptor pairs silently under-delivering -today start failing — that is the point. - -**Builds on:** S1 (the named wiring type and its single consumer path). -**Hands to:** nothing downstream; independently mergeable. - -### S3 — DeploymentResult and rendered deploys (supersedes #101) - -**Spec:** [slices/s3-deployment-results/spec.md](slices/s3-deployment-results/spec.md) -· **Plan:** [slices/s3-deployment-results/plan.md](slices/s3-deployment-results/plan.md) - -The deploy phase returns wiring and primitives as distinct values -(`LoweredResult`); the lowering loop routes wiring to `lowered` and hands -the address-keyed primitives to an alchemy **Action** declared at the end -of the stack effect. The action runs during apply with the primitives -resolved, joins them to the graph by closure into per-node -`DeploymentResult`s, and calls the CLI's renderer (wired through the -generated stack file): the app's own topology, authored names, platform -ids, public URLs. The stack returns `undefined`, so the raw alchemy -stack-output dump stays gone. First dispatch is a probe of the Action -mechanism on a fresh stack. Close PR #101 with a supersession comment. - -**Builds on:** S1. -**Hands to:** nothing downstream; the project-DoD demo rides on it. - -## Sequencing - -- **Stack:** S1 → S2 → S3, all on `claude/spi-inversion`. -- Originally `S1 → (S2 ∥ S3)`. The one-PR decision serializes S2 and S3 — - see § Delivery shape. Neither waits on a merge now; each starts when the - previous is reviewed. - -## Open items - -- **`buildConfig`'s `edge === undefined` branch is defensive against - something the authoring API already prevents** (surfaced by S2-D1). A - service declaring an input cannot be provisioned without wiring it — it - does not type-check — so the branch is only reachable by mutating the - graph after `Load`. Not acted on in S2: defensive coding in core's loop is - cheap and the pinned guard is correct either way. Worth revisiting if - someone audits core for dead branches; the question is whether `Load` - should assert the invariant the type system implies, which would let the - branch go. - -- **`core-model.md`'s model section is stale beyond this project's reach** - (surfaced by S1-D3, deliberately not fixed there). It still describes a - `Target` with separate `resources`/`services` maps; the code has - `ExtensionDescriptor` with a single `nodes` registry (ADR-0017/0031). - S1-D3 transcribed only the SPI signatures it owned — correctly, since - fixing the surrounding model is its own change with its own review. - **Not a finding; needs a follow-up ticket.** (`Record` - at ~line 447 is *not* part of this: it stays correct under the new - generics, storing at the `unknown` defaults — the erasure ADR-0033 - describes.) - -## Close-out (required) - -- [ ] Verify all acceptance criteria in [spec.md](spec.md) -- [ ] Migrate long-lived docs into `docs/` (the ADR lands in S1; check - design-notes for anything else durable) -- [ ] Strip repo-wide references to `.drive/projects/spi-inversion-and-deploy-results/**` -- [ ] Delete `.drive/projects/spi-inversion-and-deploy-results/` diff --git a/.drive/projects/spi-inversion-and-deploy-results/slices/s1-invert-spi/plan.md b/.drive/projects/spi-inversion-and-deploy-results/slices/s1-invert-spi/plan.md deleted file mode 100644 index cd9f9068..00000000 --- a/.drive/projects/spi-inversion-and-deploy-results/slices/s1-invert-spi/plan.md +++ /dev/null @@ -1,31 +0,0 @@ -# S1 — Dispatch plan - -## D1 — Core SPI + loop + core tests - -**Outcome:** `deploy.ts` carries the new type set (spec § Core verbatim); -`lowering()`/`lower()`/`buildConfig` compile against it; `lowering.test.ts` -updated and green, including the new resolves-to-`undefined` test. -**Builds on:** nothing. -**Hands to:** D2 — a compiling core whose SPI the extension migrates onto. -**Completed when:** `pnpm turbo run test --filter @internal/core` green; -no `LoweredNode` reference remains under `packages/0-framework/`. - -## D2 — Extension migration + target tests - -**Outcome:** the five descriptors, `shared.ts` (`CloudApplication` + -guarded `projectIdOf`), and `control.ts` implement the typed SPI per spec; -compute's two `as` casts and shared's `blindCast` deleted; target tests -(incl. the new `projectIdOf` seam-error test) green. -**Builds on:** D1's SPI. -**Hands to:** D3 — a fully compiling workspace. -**Completed when:** repo-wide typecheck + target package tests green; -cast ratchet net-negative. - -## D3 — ADR + doc sweep + full CI - -**Outcome:** ADR-0033 (spec § Docs content contract) + decisions index -updated; `core-model.md` SPI quotes transcribed to the new types; full CI -green. -**Builds on:** D2 (documents what now exists). -**Hands to:** slice PR open; S2/S3 unblocked. -**Completed when:** `git grep LoweredNode` empty repo-wide; CI green. diff --git a/.drive/projects/spi-inversion-and-deploy-results/slices/s1-invert-spi/spec.md b/.drive/projects/spi-inversion-and-deploy-results/slices/s1-invert-spi/spec.md deleted file mode 100644 index 51b2056b..00000000 --- a/.drive/projects/spi-inversion-and-deploy-results/slices/s1-invert-spi/spec.md +++ /dev/null @@ -1,371 +0,0 @@ -# S1 — Invert the lowering SPI (+ ADR) - -## At a glance - -Retire `LoweredNode` and give each of its three roles its own -consumer-declared type. Behavior-preserving except one deliberate change: -the stack effect returns `undefined`, which kills the `{ outputs: {} }` -dump alchemy prints after every deploy. All type decisions below are -settled — the implementer's freedom is limited to mechanical execution and -test-fixture naming. - -## Chosen design - -### Core: `packages/0-framework/1-core/core/src/deploy.ts` - -**Delete** `LoweredNode`. **Add**: - -```ts -/** - * A node's inter-node wiring outputs — the values downstream nodes' declared - * connection params resolve against (buildConfig reads them by param name). - * Name-keyed and unknown-valued of necessity: core cannot know extension - * types, and which producer feeds which consumer is decided by the user's - * graph at runtime. The connection declaration is the contract. - */ -export type WiringOutputs = Readonly>; -``` - -**Change the SPI signatures** (method syntax must be kept — heterogeneous -descriptors assign to the registry through TS method bivariance; a -property-arrow form would break the assignment, do not "improve" it): - -```ts -/** One node's realization. Runs inside the Alchemy stack effect. */ -export type Lowering = (ctx: LowerContext) => Effect.Effect; - -export interface ApplicationDescriptor { - provision(ctx: LowerContext): Effect.Effect; -} - -/** - * The phased service SPI. `P` and `S` are the descriptor's OWN intra-node - * handoff types — provision's product consumed by serialize/deploy, and - * serialize's product consumed by deploy. Core threads them through without - * inspection; only the descriptor that writes them reads them. - */ -export interface ServiceLowering

{ - provision(ctx: LowerContext): Effect.Effect; - serialize(ctx: LowerContext, provisioned: P, config: Config): Effect.Effect; - package(ctx: LowerContext, input: PackageInput): Effect.Effect; - deploy( - ctx: LowerContext, - provisioned: P, - artifact: Artifact, - serialized: S, - ): Effect.Effect; -} -``` - -**`LowerContext` changes** (doc comments updated to match): - -- `application: LoweredNode` → `application: unknown` — the owning - extension's application hook product; `undefined` when the extension - declares no hook. Core never reads it; the extension narrows it with its - own type guard. -- `lowered: ReadonlyMap` → `ReadonlyMap`. - -**Loop changes in `lowering()`**: - -- `noApplication` constant deleted; `applications` becomes - `Map`; `applications.get(node.extension)` (no `??` - fallback — absent means `undefined`). -- `lowered` becomes `Map`; `lowered.set(id, …)` - stores the descriptor's return directly. -- Final `return { outputs: {} }` → `return undefined`, and the function's - return type becomes `Effect.Effect` (the - trailing `as` assertion on the gen block updates to match; keep the - existing assertion idiom, do not introduce new casts). -- `lower()`: `stackEffect` typed `Effect.Effect`. - -**`buildConfig`**: parameter `lowered: ReadonlyMap`; -`const producedOutputs = edge !== undefined ? (lowered.get(edge.from) ?? {}) : {};` -(the `?.outputs` hop disappears). - -**`app-config.ts`**: no structural change — `NodeDescriptor`'s service arm -stays `{ kind: 'service' } & ServiceLowering` (defaults ``). - -### Extension: `packages/1-prisma-cloud/1-extensions/target/src/` - -**`descriptors/shared.ts`** — replace `projectIdOf`'s `blindCast` with an -extension-owned application contract and a real narrow: - -```ts -/** What prisma-cloud's application hook produces; its own descriptors are the only consumers. */ -export interface CloudApplication { - readonly projectId: string; -} - -export function isCloudApplication(value: unknown): value is CloudApplication { - return ( - typeof value === 'object' && - value !== null && - typeof (value as { projectId?: unknown }).projectId === 'string' - ); -} - -/** Narrows ctx.application at the extension seam; throws naming the seam when the hook didn't run. */ -export function projectIdOf(application: unknown): string { - if (!isCloudApplication(application)) { - throw new Error( - 'prisma-cloud: ctx.application is not this extension\'s application product — ' + - 'the prismaCloud() application hook must run before any node lowers.', - ); - } - return application.projectId; -} -``` - -(`projectIdOf` call sites pass `application` / `provisioned` as before — -see per-file notes. The `blindCast` import goes away here.) - -**`control.ts`** — the application hook returns `{ projectId }` satisfying -`CloudApplication` (drop the `{ outputs: { … } }` wrapper). The -`serviceKeyProvisioner` is untouched — provisioner refs are opaque -`unknown` by design (ADR-0031), and that stays. - -**`descriptors/compute.ts`** — export the descriptor's own handoff types and -type the descriptor against them: - -```ts -export interface ComputeProvisioned { - /** The yielded resource's attribute — an unresolved reference until apply. */ - readonly serviceId: Output.Output; - /** Not a resource attribute: the CLI-supplied project id, a plain string. */ - readonly projectId: string; -} -export interface ComputeSerialized { - readonly environment: readonly Prisma.EnvironmentVariable[]; - readonly port: number; -} -``` - -**Amended 2026-07-17 (D2 halt, evidence accepted).** `serviceId` was -originally pinned as `string`. It cannot be: alchemy maps every resource -attribute through `Output` (`Resource.d.ts:95-100`), so `svc.id` is -`Output` — the lazy-proxy fact design-notes already records, -which the original pin failed to carry into the type. Probe: -`error TS2322: Type 'Output' is not assignable to type 'string'`. - -The accurate type serves this slice's goal *better than the original text -did*: `provisioned.serviceId` flows into `Prisma.Deployment`'s -`computeServiceId: Input` position with **no cast**, because -`Input = T | Output | …`. Pinning `string` would have forced the -`as string` cast to be reintroduced to keep the write site compiling. -`projectId` stays `string` — it comes from the CLI's env, not from a -resource. - -**Why the old code hid this:** `provisioned.outputs['serviceId'] as string` -laundered `Output` into `string`, and the lie was invisible because -the consuming prop accepts both. That is the bag's cost made concrete — an -untyped record let a producer's real type be misdescribed at the read site, -and the cast made it compile. Retiring the bag surfaced it. This is -ADR-0033 evidence, not an incidental fix (see § Docs). - -- Factory return type: **the precise type** - `{ kind: 'service' } & ServiceLowering`, - not `NodeDescriptor`. - - **Amended 2026-07-17 (D2 deviation, reviewer-endorsed).** The original - pin said `NodeDescriptor` *and* told s3-store to keep composing over - compute's base descriptor. Those contradict: `NodeDescriptor` erases - `P`/`S`, so `base.provision` returns `Effect` and s3-store would - have to cast the types back in — adding casts to a cast-removal slice and - **re-creating in s3-store the exact seam this slice exists to kill** (a - producer-side shape a consumer must cast to use). The precise type only - publishes what the erased type discarded; it adds no unsoundness, and the - registry assignment in `control.ts` still goes through by method - bivariance (`prismaCloud` is annotated `ExtensionDescriptor`, assigning - into `Record`). - - Consequence: s3-store's `base.kind !== 'service'` runtime check is - **deleted**, not kept. It was unreachable — the literal is - `kind: 'service' as const` — and existed solely to narrow the erased - union for the compiler. -- `provision` returns `{ serviceId: svc.id, projectId: projectIdOf(application) }` - (no wrapper; `application.outputs['projectId']` read is replaced by - `projectIdOf(ctx.application)`). -- `serialize`: `projectIdOf(provisioned)` → `provisioned.projectId`; returns - `{ environment: records, port }` (no wrapper). The `port` fallback logic - (`typeof config.service['port'] === 'number' ? … : 3000`) stays in - serialize; `port` is a plain `number` from here on. -- `deploy`: `provisioned.serviceId` (cast deleted), `serialized.environment` - (cast deleted), `port: serialized.port` (typeof-fallback deleted — the - type carries it now). Returns - `{ url: deployment.deployedUrl, projectId: provisioned.projectId }` (no - wrapper). -- The `keyOuts` `blindCast` in serialize stays — a provisioner ref is - `unknown` by ADR-0031 and `Output` is not runtime-guardable; the - cast's justification comment already says exactly this. - -**`descriptors/s3-store.ts`**: - -```ts -export interface S3StoreSerialized extends ComputeSerialized { - readonly bucket: unknown; - readonly accessKeyId: unknown; - readonly secretAccessKey: unknown; -} -``` - -- Descriptor typed `ServiceLowering`. -- The compose-over-base pattern stays; `base` narrows via the same - `satisfies`/typed-literal approach used in compute (the current - `base.kind !== 'service'` runtime check may stay for the discriminant). -- serialize spreads `…serialized` (the typed base product) plus the three - fields; the existing D4a↔D4b missing-field error check is unchanged. -- deploy returns `{ …deployed, bucket: serialized.bucket, … }` — `deployed` - is now `WiringOutputs` (a bare record), so the `.outputs` hops disappear. - -**`descriptors/postgres.ts`, `prisma-next.ts`, `s3-credentials.ts`** — each -`Lowering` returns the bare record (drop the `{ outputs: … }` wrapper); -`projectIdOf(application)` call sites unchanged in shape (the helper's -parameter is now `unknown`). - -### Tests - -The compiler finds every remaining site; the known ones: - -- `packages/0-framework/1-core/core/src/__tests__/lowering.test.ts` — - `LoweredNode` import → `WiringOutputs`; `{ outputs: { url: … } }` map - literals → bare `{ url: … }`; `run()`'s effect type parameter → - `undefined`; fake descriptors' returns unwrap. -- `packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts` - — same treatment; application-hook assertions check the bare - `{ projectId }` product. -- Add one new test in `lowering.test.ts`: the lowering effect resolves to - `undefined` (pins the no-stack-output behavior). -- Add one new test near `shared.ts`'s tests (or in - `control-lowering.test.ts`): `projectIdOf` throws its seam error on a - non-conforming value. - -### Docs - -- `docs/design/10-domains/core-model.md` lines ~455–525 quote the old SPI - verbatim — update the quoted signatures to the new ones (mechanical - transcription of the types above). -- **ADR-0033** (next free number; re-check the index at write time) in - `docs/design/90-decisions/`, titled - `ADR-0033-lowering-spi-seams-are-consumer-declared.md`. Content contract: - - Decision: each lowering-SPI seam's type is declared by its consumer — - descriptor-owned generic phase handoffs; the connection declaration as - the wiring contract (runtime-checked across the extension seam, and - silent under-delivery becomes an error — S2); core-declared deployment - results assembled by the lowering loop at full context (S3); the loop - as the only router. No shared producer-side bag. - - Context: the three-roles analysis of `LoweredNode` and the `NodeReport` - failure (PR #101). Include the **`serviceId` finding as concrete - evidence for the thesis**: the shared untyped record let compute's - provision product be *misdescribed* — `serviceId` is an unresolved - `Output`, and `provisioned.outputs['serviceId'] as string` - laundered it into a `string` at the read site, compiling only because - the consuming prop accepts both. Retiring the bag surfaced it on the - first migration. - - **State the thesis precisely — deliberate-and-audited vs. accidental, - NOT bag-vs-no-bag.** The blunt version ("a bag lets you lie, a typed - handoff doesn't") is refuted by our own code: `keyOuts`' surviving - `blindCast` claims `Output` through a different `unknown`-typed - seam (provisioner refs, ADR-0031) and we are keeping it. The difference - that matters is not whether an unchecked claim exists but whether it is - **named, justified, and singular**: the provisioner-ref cast is a - single site carrying its own written justification, which a reviewer - can evaluate and a grep can find. The bag made the same kind of claim - **anonymously, at every read site, with nothing recording that a claim - was being made at all.** That is the version that survives contact with - the cast that stays. - - **The seam taxonomy** — three seams, three different mechanisms: - 1. **Phase handoffs** (same party writes and reads): precise types, - defended by the **compiler**. - 2. **The application seam** (crosses core's `unknown` into an - extension): a precise claim — `CloudApplication.projectId: string` — - defended by a **runtime guard** (`isCloudApplication`), because the - compiler cannot reach across it. - 3. **The wiring seam**: claims **nothing** (`unknown` values, resolved - by the consumer's declared params). `unknown` cannot lie — which is - why `warm.url` and `creds.accessKeyId`, also unresolved - `Output`s, were never mis-typed the way `serviceId` was. - The bug could only ever have lived where a precise claim was made - without a mechanism defending it. - - Consequence to fold into the alchemy-facts appendix: **phase handoff - types legitimately carry unresolved `Output` references**, because - the whole stack effect runs before apply. A handoff type that promises - resolved values is lying — the same lazy-Output truth as the execution - model, showing up in the SPI's types. - - Consequences, three parts: - 1. The alchemy-facts appendix (stack effect runs pre-apply; resolved - values exist only in the stack's evaluated return / action inputs; - per-resource change status is CLI-event-only), so S2/S3 build on - recorded facts. Cite alchemy `2.0.0-beta.59` file:line as in - design-notes. - 2. **The heterogeneous registry's type safety rests on the lowering - loop, not the compiler.** Method bivariance is what lets descriptors - with different `P`/`S` assign into one `Record`, - and it is unsound by construction: core calls - `descriptor.serialize(ctx, provisionedNode, config)` with - `provisionedNode: unknown`, so the compiler would not object if the - loop ever threaded the wrong node's provisioned value. The loop is - correct today; this is the accepted price of a registry core cannot - type. State it, so anyone editing the loop (S2 and S3 both do) knows - what it is holding. - 3. **The one producer-side shape that crosses a module boundary is - `ComputeSerialized`** (s3-store's handoff type extends it). That is - legitimate because s3-store composes compute's own descriptor — same - party, not an unrelated consumer. Record the tripwire: a third - descriptor importing `ComputeSerialized` *without* composing compute - is the shared bag reforming, and the answer is its own handoff type, - not a widened shared one. - - Update `docs/design/90-decisions/README.md` index. - -## Coherence rationale - -One reviewer, one sitting: a single mechanical seam change radiating from -one file (`deploy.ts`) through five descriptors and two test files, plus an -ADR that documents exactly that change. No behavior changes to hold in -one's head beyond the deleted stack dump. - -## Scope - -**In:** everything above. -**Deliberately out:** wiring enforcement (S2); primitives/results/rendering -(S3); any change to `buildConfig`'s resolution semantics; the cron shared -module and examples (they author via `module()`/`compute()`, not the SPI — -verified no `LoweredNode` references outside the files listed). - -## Pre-investigated edge cases - -| Case | Ruling | -| --- | --- | -| `ServiceLowering` registry assignability | Works only through method-syntax bivariance. Keep method syntax; add a one-line comment on the interface saying so. | -| Stack effect returning `undefined` | Verified against alchemy source: `Apply.apply` short-circuits (`if (!plan.output) return undefined`) — no `setOutput` write, and the bin's `Console.log(outputs)` is skipped. Existing `alchemy_stack_output` rows become stale but harmless. | -| `keyOuts` blindCast in compute.serialize | Stays. Provisioner refs are opaque by ADR-0031; do not attempt a guard on `Output`. | -| `applications` map default | Absent application hook now yields `ctx.application === undefined` (was `{ outputs: {} }`). Only prisma-cloud's own descriptors read it, and they now go through `projectIdOf`'s guard, which throws its seam error on `undefined` — correct, since those descriptors require the hook. | - -## Slice-DoD - -- **No `LoweredNode` reference in live code, or in docs describing the - *current* system** — i.e. `git grep LoweredNode -- packages/ docs/design/10-domains/` - returns nothing. - - **Amended 2026-07-17 (D3 flag, accepted).** This was originally written - as "returns nothing repo-wide (docs included)", which is incoherent: an - ADR recording the retirement of `LoweredNode` **must name it** — that is - what the record is for, and it is the house pattern (ADR-0025 names - ADR-0014's superseded noun). The index entry must name it too, or a - reader asking "what happened to `LoweredNode`?" can't find ADR-0033. The - literal condition would have traded a durable record for a passing string - check. Deliberate surviving references: ADR-0033, the decisions index, - and this project's own `.drive/` working docs. - -- `pnpm lint:casts` shows a net decrease, with every delta accounted for. - **Note the accounting rule** (established in D2): the ratchet counts bare - `as` tokens only, so removing a `blindCast` call — as `shared.ts` does — - correctly does **not** move it. The −2 is exactly compute's two `as`. - -## References - -- Project spec: [../../spec.md](../../spec.md) · design notes: - [../../design-notes.md](../../design-notes.md) -- `packages/0-framework/1-core/core/src/deploy.ts:54-118` (current SPI), - `:392-536` (loop) -- `packages/1-prisma-cloud/1-extensions/target/src/control.ts:100-160` diff --git a/.drive/projects/spi-inversion-and-deploy-results/slices/s2-enforce-wiring-contract/plan.md b/.drive/projects/spi-inversion-and-deploy-results/slices/s2-enforce-wiring-contract/plan.md deleted file mode 100644 index 4e1bca3b..00000000 --- a/.drive/projects/spi-inversion-and-deploy-results/slices/s2-enforce-wiring-contract/plan.md +++ /dev/null @@ -1,36 +0,0 @@ -# S2 — Dispatch plan - -## D1 — Failing tests - -**Outcome:** the four Slice-DoD cases exist in `lowering.test.ts`; cases -1 fails (no guard yet), 2–4 pass (pinning the exemptions before the guard -lands). -**Builds on:** S1 merged. -**Hands to:** D2 — an executable contract for the guard. -**Completed when:** test run shows exactly case 1 red. - -## D2 — Implement the guard - -**Outcome:** the spec's guard clause + proxy-fact comment in `buildConfig`; -all four cases green; dogfood/example lowering tests still green (any -newly-exposed under-delivery fixed as its own commit, named in the PR body). -**Builds on:** D1. -**Hands to:** D3 — a live guard whose user-visible consequence needs writing -down. -**Completed when:** full CI green. **DONE** (`ded15f4`, `49240bd`) — no -existing pair under-delivers; reach proven by mutating the real postgres -descriptor (3 e2e tests red, restored). - -## D3 — Document the new failure mode (F3) - -**Outcome:** `docs/guides/**` and `skills/prisma-composer/SKILL.md` both -explain the new deploy-time failure — added because -`.agents/rules/user-facing-surface-changes.mdc` (`alwaysApply: true`) -requires it and no slice owned the debt. -**Builds on:** D2 (documents behaviour that now exists). -**Hands to:** S3 — the branch's docs obligation for S2 is closed, so S3's -own surface changes are the only remaining debt. -**Completed when:** both surfaces updated; guides and skill do not disagree; -`website` content tests green; the guide explains the *consequence* (a -previously-green deploy now fails, and what to do) rather than the -mechanism. diff --git a/.drive/projects/spi-inversion-and-deploy-results/slices/s2-enforce-wiring-contract/spec.md b/.drive/projects/spi-inversion-and-deploy-results/slices/s2-enforce-wiring-contract/spec.md deleted file mode 100644 index 045eba60..00000000 --- a/.drive/projects/spi-inversion-and-deploy-results/slices/s2-enforce-wiring-contract/spec.md +++ /dev/null @@ -1,177 +0,0 @@ -# S2 — Enforce the wiring contract - -## At a glance - -A producer that fails to supply a consumer's declared connection param -today yields a silent `undefined` serialized into the consumer's -environment — the failure surfaces at the consumer's boot, far from the -mistake. Under the inverted seam (ADR-0033) the consumer's connection -declaration is the contract; this slice makes under-delivery a loud -`LowerError` at deploy time, naming the edge. Operator-confirmed behavior -change (2026-07-17). - -## Chosen design - -All changes in `packages/0-framework/1-core/core/src/deploy.ts`, -`buildConfig`'s inputs loop — the one place the contract is resolved. - -Current loop body (post-S1 shape): - -```ts -const producedOutputs = edge !== undefined ? (lowered.get(edge.from) ?? {}) : {}; -const values: Record = {}; -for (const [name, param] of Object.entries(inputNode.connection.params)) { - values[name] = - param.provision !== undefined - ? provisioned.get(`${id}.${inputName}`) - : producedOutputs[name]; -} -``` - -New behavior — inside the same `for` loop, for the non-provisioned branch -only, when **an edge exists**: - -```ts -const value = producedOutputs[name]; -if (value === undefined && param.optional !== true && edge !== undefined) { - throw new LowerError( - `Connection input "${id}.${inputName}" declares param "${name}", but its producer ` + - `"${edge.from}" did not supply it — the producer's wiring outputs carry ` + - `[${Object.keys(producedOutputs).join(', ') || 'nothing'}]. Add "${name}" to the ` + - `producer's returned wiring outputs, or declare the param optional on the connection.`, - ); -} -values[name] = value; -``` - -Pinned rulings: - -- **`throw`, not `Effect.fail`** — matches `resolveParam`'s existing idiom - in the same file; consistency wins over channel purity here. -- **`value === undefined` is the test** (not `name in producedOutputs`): a - producer explicitly setting a key to `undefined` counts as missing — - matches `resolveParam`'s bound-detection idiom. -- **Presence check only, no schema validation.** Wiring values at lowering - time are routinely alchemy `Output` proxies (e.g. `deployment.deployedUrl`) - — symbolic references that no Standard Schema can validate before apply - resolves them. Record this as a code comment on the check, so nobody - "completes" the enforcement later without noticing the proxy fact. -- **`edge === undefined` keeps today's behavior** (all params resolve - `undefined`, no error). An unwired input is a graph-construction concern, - out of this slice's scope; the check must not change that path. -- **Provisioned params are exempt** — the mint supplies them (ADR-0031); - the `param.provision !== undefined` branch is untouched. -- **`param.optional === true` is exempt** — the consumer said absent is - legal; boot-side `coerce()` already reads a missing var as `undefined`. - -## Coherence rationale - -One guard clause, one error message, a handful of tests — a reviewer holds -the entire semantic change (silent → loud) in one screen of diff. - -## Scope - -**In:** the guard, its comment, its tests. -**Deliberately out:** schema validation of wiring values (impossible -pre-resolution — see ruling); unwired-input handling; any descriptor -change (none should be needed — if a descriptor pair fails the new check -in tests or dogfood, that is a real latent bug, fixed as its own commit in -this slice with the failure named in the PR body). - -## Pre-investigated edge cases - -| Case | Ruling | -| --- | --- | -| s3-store's D4a↔D4b check | Its own serialize-time error stays — it guards `config` fields, not wiring presence; no overlap, no removal. | -| Optional connection params in existing modules | `coerce()`'s missing-var-as-absent contract (compute.ts serialize comment) is exactly the exempted path — unchanged. | - -## Two facts D1 established (2026-07-17) — carry into the PR narrative - -**1. The old behaviour was written down as a test, and this slice deletes -that assertion.** `lowering.test.ts` carried -*"a param the graph declares but the lowered outputs never produced -resolves to undefined"* — `db` wired, producer supplying nothing, `url` -declared required, asserting `{ url: undefined }`. That is precisely the -silent failure S2 retires, so DoD case 1 is that test **inverted**, and it -replaces rather than joins it. - -This is the right call and the operator has confirmed the behaviour change -— but the diff will show a deleted assertion, and a reviewer must see that -as *the point*, not an oversight. Note the honest reading: the old -behaviour was characterized, not designed. The test recorded what -`buildConfig` did; nothing argued it was correct that a missing producer -output should reach a booting service as `undefined`. - -**2. `buildConfig`'s `edge === undefined` branch is unreachable through -authoring.** `h.provision(auth, { id: 'auth' })` on a service declaring a -`db` input does not type-check — the authoring API requires declared inputs -be wired. So the branch is defensive only; D1's case 4 reaches it by -dropping the edge after `Load`, with a comment saying so. - -**Ruling: implement the pinned condition as written.** `edge !== undefined` -inside the per-param check is correct either way, and defensive coding in -core's loop is cheap. The observation is recorded, not acted on — see the -project plan's § Open items. - -## The blast radius is USER apps, not just our descriptors (established in review) - -`dependency` is exported from `@internal/core`'s index (`index.ts:53`), and -`packages/9-public/composer/src/index.ts` is `export * from '@internal/core'`. -**App authors declare their own connections.** So the population this change -affects is not "our five descriptors" — it is every user-authored connection -declaration whose producer doesn't supply a declared required param. - -This corrects how the null result must be framed. "No existing descriptor -pair under-delivers" is true and measured, but it covers **our** pairs and -says nothing about user apps, which is where the real exposure is. State it -that way in the PR; don't let the null result read as "nothing breaks." - -Two further precisions for the PR narrative, from review: - -- The postgres mutation proves the guard **can** fire through the real - pipeline. The green suite **with the guard live** is what proves nothing - under-delivers. The honest claim is therefore: *no descriptor pair the - suite exercises under-delivers.* -- The residual is inverted by the slice itself — an untested - under-delivering pair now fails **loudly at deploy** instead of silently - at boot. The uncovered case is handled by the mechanism under review, - which is why the qualified claim suffices. - -## Slice-DoD - -**Docs + skill (D3) — required, `.agents/rules/user-facing-surface-changes.mdc` -is `alwaysApply: true`:** - -- [ ] `docs/guides/**` and `skills/prisma-composer/SKILL.md` both updated in - this PR. - -**Amended 2026-07-17 (F3, review).** My original DoD listed only test cases -— a spec-authoring miss, not an implementation one. S2 is precisely the -rule's named most-missed case ("behaviour a user hits without changing a -line of their code — a new default, **failure mode**, or status code"), and -the rule's own example is the same shape (RPC endpoints began returning 401 -to unwired callers and neither surface said so). The one-PR decision is why -this is still catchable rather than already shipped: the "same PR" window is -open, but **no slice owned the debt** — S2's DoD listed tests, S3's spec is -primitives/rendering. It is now owned here, explicitly. - -Write the consequence, not the mechanism: the line worth writing is the one -that stops a bug report from a user whose previously-green deploy now fails. - -New `lowering.test.ts` cases, all green: - -1. Producer omits a declared required param → deploy fails with a - `LowerError` whose message contains the edge id (`consumer.input`), the - param name, the producer id, and the producer's actual key list. -2. Producer omits a declared `optional` param → lowering succeeds; the - consumer's config carries `undefined` for it. -3. A provisioned param with no producer-supplied value → untouched by the - guard (mint path). -4. Unwired input (no edge) → today's behavior, no error. - -## References - -- Project spec: [../../spec.md](../../spec.md) · builds on S1's - `WiringOutputs` seam. -- `packages/0-framework/1-core/core/src/deploy.ts:237-250` (the loop), - `:178-218` (`resolveParam`'s idioms this mirrors). diff --git a/.drive/projects/spi-inversion-and-deploy-results/slices/s3-deployment-results/plan.md b/.drive/projects/spi-inversion-and-deploy-results/slices/s3-deployment-results/plan.md deleted file mode 100644 index b22ec446..00000000 --- a/.drive/projects/spi-inversion-and-deploy-results/slices/s3-deployment-results/plan.md +++ /dev/null @@ -1,52 +0,0 @@ -# S3 — Dispatch plan - -## D1 — Action-mechanism probe - -**Outcome:** a throwaway stack (scratch, not committed) proves: an -`Action` whose input references a fresh resource's attribute plans and -runs on a first deploy; the nonce forces re-run on an unchanged redeploy; -the runner receives resolved values. It must also settle the two typing -questions the spec's edge-case table names: that `In`'s **mutable** arrays -map correctly through `Input<>` (a `readonly T[]` does not satisfy its -array branch), and that nested `Output` fields inside -`entries[].primitives[]` are accepted at the call site and arrive -**resolved** in the runner. Probe torn down after. -**Builds on:** S1 merged. -**Hands to:** D2 — the mechanism confirmed, or a STOP → discussion-mode -signal per the spec's edge-case table. -**Completed when:** both probe deploys observed; findings noted in the -dispatch return. - -## D2 — Core: LoweredResult + loop + action + joinDeployment - -**Outcome:** spec § Core types + § SPI change + § Loop change implemented; -`joinDeployment` exported and unit-tested (missing-address skip included); -existing lowering tests updated to `LoweredResult` returns; sync tests -still run without alchemy context. -**Builds on:** D1's confirmation. -**Hands to:** D3 — core compiles; descriptors don't (their returns are now -type errors), which is the migration worklist. -**Completed when:** core package tests green. - -## D3 — Descriptors + renderer + generated stack - -**Outcome:** the five descriptors return the pinned primitives table; -`render-deployment.ts` implements the pinned format (pure, unit-tested -against a fixture covering nested addresses, no-primitive nodes, url and -no-url primitives); `@prisma/composer` gains the `./report` export; -`generate-stack.ts` template emits the import + `report:` option; snapshot -tests updated. -**Builds on:** D2's types. -**Hands to:** D4 — a fully wired build. -**Completed when:** full repo CI green. - -## D4 — Live verification + #101 closure - -**Outcome:** deploy of the example/dogfood app shows the rendered tree on -a changed AND an unchanged redeploy, no stack-output blob; output-ordering -cosmetics assessed (upstream ask filed if ugly); PR #101 closed with the -supersession comment; slice PR opened. -**Builds on:** D3. -**Hands to:** project close-out. -**Completed when:** Slice-DoD checked off with deploy transcript excerpts -in the PR body. diff --git a/.drive/projects/spi-inversion-and-deploy-results/slices/s3-deployment-results/spec.md b/.drive/projects/spi-inversion-and-deploy-results/slices/s3-deployment-results/spec.md deleted file mode 100644 index 3f50f8a2..00000000 --- a/.drive/projects/spi-inversion-and-deploy-results/slices/s3-deployment-results/spec.md +++ /dev/null @@ -1,360 +0,0 @@ -# S3 — DeploymentResult and rendered deploys - -## At a glance - -A node's final lowering phase reports the platform primitives it became, -distinctly from its wiring outputs. The lowering loop assembles them; an -alchemy **Action** — declared last in the stack effect, forced to run every -deploy by a nonce — receives the *resolved* primitives during apply, joins -them to the graph it holds by closure, and invokes a CLI-supplied renderer. -The user sees their own topology with ids and public URLs; alchemy's raw -stack-output dump stays dead (the stack still returns `undefined`). -Supersedes PR #101. - -**Mechanism rationale (recorded so it isn't re-litigated):** the stack -effect runs before apply, so program code can't see resolved values after -the fact; the bin has no post-apply hook; parent-side state readback needs -a faked `Stack` service, re-takes the deploy lock, and must replicate -alchemy's `dev_${USER}` stage derivation. Actions are alchemy's designed -"run during apply with resolved inputs" primitive — verified in -`src/Action.ts` / `src/Plan.ts:1040-1100` / `src/Apply.ts:1103-1232` -(2.0.0-beta.59). Actions noop when their input hash is unchanged, hence -the nonce. - -## Chosen design - -### Core types — `packages/0-framework/1-core/core/src/deploy.ts` - -**Amended 2026-07-17 (F5, review) — `primitives` is required, not optional.** -The original pin had `primitives?`, with the loop reading -`result.primitives ?? []`. That lets a descriptor assert *"this node became -no platform primitives"* **by staying silent** — no error, no type -complaint, no test failure. - -That is the project's own thesis turned against its own code. ADR-0033 says a -claim must be **named, justified, and singular**, and that the bag's sin was -letting claims be made *anonymously, with nothing recording that a claim was -being made at all*. An omitted optional field is exactly that. The live -evidence: commit `0fe22f2`'s message records that rebuilding s3-store's -result by hand "would have silently dropped them" — the author's care -prevented a drop the **type** should have made impossible, which is the -substitution this project exists to reverse. `s3-credentials` already models -the honest form: `primitives: []`, deliberately, with a comment. - -Zero churn: all seven construction sites (five descriptors + two core test -fakes) already supply it explicitly. Making it required breaks nothing and -constrains only what comes next — D4's work and any third-party extension. - -**Amended 2026-07-17, before implementation** — S1-D2 established that -resource attributes are `Output`, not `T` (alchemy maps every attribute -through `Output`; `Resource.d.ts:95-100`). The originally pinned -`DeployedPrimitive.id: string` had the same defect S1's -`ComputeProvisioned.serviceId: string` did: a descriptor constructing a -primitive holds `svc.id`/`deployment.deployedUrl`, which are **unresolved -references**. So the primitive needs two shapes — the resolved one the -report consumer sees, and the construction-side one a descriptor returns. -This is not a workaround; it is the same lazy-Output truth the execution -model already records. - -```ts -/** - * One platform thing a node became, RESOLVED — what the report consumer - * sees. The descriptor names it; core never infers. `url` is present ONLY - * when the descriptor declares the address publicly reachable — a - * connection string is never a `url`. - */ -export interface DeployedPrimitive { - readonly kind: string; - readonly id: string; - readonly url?: string; - readonly details?: Readonly>; -} - -/** - * What a descriptor RETURNS: the same shape, but every field may still be - * an unresolved reference, because the stack effect runs before apply. - * Alchemy's `Input` mapping is deep and recursive (`Input.ts:11-29`: - * the object branch is `{ [K in keyof T]: Input }`), so this is - * exactly what the Action's input position accepts — and `Output.upstreamAny` - * walks it to give the action its upstream edges on every referenced - * resource, which is what makes apply run it last. - */ -export type ReportedPrimitive = Input; - -/** - * What a node's final lowering phase produces: wiring for dependents, - * primitives for reporting. `primitives` is REQUIRED — a node that became no - * platform primitives says so out loud with `[]`. - */ -export interface LoweredResult { - readonly wiring: WiringOutputs; - readonly primitives: readonly ReportedPrimitive[]; -} - -/** What one graph node became — the deploy subsystem's own result type. In-process only. */ -export interface DeploymentResult { - readonly address: string; - readonly node: ServiceNode | ResourceNode; - readonly primitives: readonly DeployedPrimitive[]; -} -``` - -`LowerOptions` gains: - -```ts -/** Invoked once per deploy, during apply, with every node's resolved results in topo order. Presentation belongs to the caller (the CLI wires its renderer here); core never formats. */ -readonly report?: (results: readonly DeploymentResult[]) => void; -``` - -### SPI change - -- `Lowering` (resources): returns `LoweredResult` (was `WiringOutputs`). -- `ServiceLowering.deploy`: returns `LoweredResult`. `provision`/`serialize`/ - `package` unchanged. - -### Loop change — `lowering()` - -- Per node: `const result = yield* …; lowered.set(id, result.wiring);` and - collect `entries.push({ address: id, primitives: result.primitives ?? [] })` - in topo order. -- After the loop, **only when `opts.report !== undefined`**, declare the - action (inline form, `Action('composer-deployment-report', runner)`, - imported from `alchemy`): - - **Input** (plain data + alchemy Inputs ONLY — never graph nodes: the - plan hashes the resolved input, and nodes carry functions/schemas). - Declare the action's `In` in **resolved** terms — that is what the - runner receives: - - ```ts - interface ReportEntry { - address: string; - primitives: DeployedPrimitive[]; // resolved — the runner's view - } - // In = { nonce: number; entries: ReportEntry[] } - ``` - - The call site passes `{ nonce: Date.now(), entries }` carrying - `ReportedPrimitive`s; the deep `Input<>` mapping on the input position - accepts their unresolved fields. The nonce defeats the input-hash noop - so the report runs on unchanged redeploys. - - **Declare `In`'s arrays `readonly`** (`readonly ReportEntry[]`, - `readonly DeployedPrimitive[]`), matching `LoweredResult` / - `DeploymentResult` and the codebase's `readonly`-throughout style. - - **Amended 2026-07-17 (D1 probe — the earlier pin was wrong).** The spec - previously required mutable arrays, reasoning that `Input`'s array - branch tests `T extends any[]`, which `readonly T[]` fails, so it would - fall to the object branch and map over array *members* (`length`, `map`) - rather than elements. **The premise is right; the conclusion is wrong.** - It does fall to the object branch — but that branch is - `{ [K in keyof T]: Input }`, a *homomorphic* mapped type over a - naked type parameter, and TypeScript special-cases those over arrays: it - maps over **elements**, preserves array-ness, and preserves the - `readonly` modifier. It never touches `length`/`map`/`filter`. - - Probed, and probed for *teeth* rather than mere compilation: both forms - accept a nested `Output`, both reject `id: 42`, and both reject - an excess key at the same nested position. `Input` stays - array-like and stays readonly (assigning it to `P[]` is correctly - rejected — which also rules out a collapse to `any`). - - Same shape as S1's `serviceId` defect: **a claim derived by reading - types instead of compiling them.** - - **Runner**: receives the resolved input; closes over `graph` and - `opts`; joins via the pure helper below; calls `opts.report(results)`; - returns `undefined`. - - `yield*` the action call so it lands in the stack's plan; its input - referencing every primitive gives it upstream edges on every reporting - resource, so apply runs it after them. -- The stack effect still returns `undefined` (no `setOutput`, no bin dump). -- Extract the join as an exported pure function so it is unit-testable - without alchemy: - -```ts -/** Joins resolved report entries back to their graph nodes. Skips addresses the graph no longer holds (defensive: entries are data, the graph is truth). */ -export function joinDeployment( - graph: Graph, - entries: readonly { address: string; primitives: readonly DeployedPrimitive[] }[], -): readonly DeploymentResult[] -``` - -- The gen-block's closing type assertion updates for the yield of the - action effect (whose requirements include alchemy's `Stack` context) — - keep the existing single-assertion idiom. -- Tests that run `lowering()` without `opts.report` never construct the - action and stay sync-runnable — this conditionality is REQUIRED, not an - optimization. - -### Descriptor primitives — pinned per descriptor - -| Descriptor | `primitives` | -| --- | --- | -| compute `deploy` | `[{ kind: 'compute-service', id: provisioned.serviceId, url: deployment.deployedUrl }]` | -| s3-store `deploy` | base compute's primitives, unchanged (delegation passes them through with the wiring spread) | -| postgres | `[{ kind: 'postgres-database', id: db.id }]` — **no `url`** (connection string is not public) | -| prisma-next | `[{ kind: 'postgres-database', id: db.id }]` | -| s3-credentials | `[]` — a minted keypair has nothing publishable; secret material must never appear in a primitive | - -Wiring returns wrap accordingly: e.g. compute deploy returns -`{ wiring: { url: deployment.deployedUrl, projectId: provisioned.projectId }, primitives: […] }`. - -### Renderer — `packages/0-framework/3-tooling/cli/src/render-deployment.ts` - -```ts -/** Renders a deploy's results as the app's own topology. Pure — returns the string; the caller prints. */ -export function renderDeployment(appName: string, results: readonly DeploymentResult[]): string -``` - -Pinned format — tree by dot-address segments, box-drawing guides, one line -per primitive `kind id`, URL indented on its own line when present, nodes -without primitives listed with `(no primitives reported)`: - -``` -storefront-auth -├─ auth -│ └─ api compute-service cps_abc123 -│ https://xyz.ewr.prisma.build -├─ db postgres-database pdb_def456 -└─ web compute-service cps_ghi789 - https://uvw.ewr.prisma.build -``` - -The default report callback (same file): - -```ts -/** The report hook the generated stack file wires into LowerOptions. */ -export function deploymentReport(appName: string): (results: readonly DeploymentResult[]) => void -``` - -— prints a leading blank line then `renderDeployment` output via -`console.log`. - -### Wiring it through — generated stack + public package - -- `packages/9-public/composer/package.json` gains an export path - `"./report"` mapping to a new build entry that re-exports - `deploymentReport` (and `renderDeployment`) from `@internal/cli`'s - module. Follow the existing per-entry build convention in that package - (mirror how `./deploy` is produced). -- `generate-stack.ts`'s template adds - `import { deploymentReport } from '@prisma/composer/report';` and, inside - the options literal, `report: deploymentReport(),` (the same - `name` already rendered into options). Snapshot tests in - `generate-stack.test.ts` update. -- `run-alchemy.ts` and `main.ts` are untouched — rendering happens in the - child, inside apply. - -### PR #101 - -Close with a comment linking this slice's PR and one sentence: superseded -by the consumer-declared-seams design (ADR-0033); `NodeReport` is -withdrawn. - -## A bound S2's review established — the line rendering must not cross - -**S2's guard enforces that a producer *declared* a key. It does NOT enforce -that the key resolves to anything real.** Presence-only against lazy proxies -catches a missing key but not a mis-named attribute read: `{ url: svc.typoAttr }` -returns a `PropExpr` — the resource proxy's `get` trap fabricates one for any -absent property (`Resource.ts:283`) — so it is non-`undefined`, passes the -guard, and fails at apply. That is inherent to the seam and consistent with -the guard's own comment; it is not a defect. - -**The consequence for S3: rendering must never present a wiring value as -verified.** S2's enforcement is not evidence a wiring value is trustworthy. -This is another reason the reported primitives come from the descriptor -naming them deliberately (`ReportedPrimitive`), resolved by apply, rather -than from anything scraped out of `WiringOutputs`. - -## ADR-0033's alchemy appendix gets S3's verified facts - -ADR-0033's appendix exists so S2/S3 build on recorded facts rather than -re-deriving them. S3 establishes four more, all probe-verified in D1 — -**append them** (don't restate the seam design; the ADR's decision is -unchanged): - -1. An `Action` whose input references a not-yet-created resource **plans** - and **runs during apply**, after the resources it references. -2. Its runner receives **resolved** values, arbitrarily deep — - `entries[].primitives[].id` arrives as a real `string`. -3. Alchemy persists the **resolved** input snapshot and hashes *that* - (`State/ActionState.ts`), noop-ing the action when the hash is unchanged. - A nonce evaluated at stack-effect time therefore defeats the noop. -4. `Input` maps `readonly T[]` correctly — the object branch is - homomorphic over a naked type parameter, so elements are mapped and both - array-ness and `readonly` survive. - -Cite against installed source at write time, not against these notes — -S1-D3 found most of `design-notes.md`'s line numbers had drifted. - -## Docs debt — S3 owns its own - -`.agents/rules/user-facing-surface-changes.mdc` is `alwaysApply: true`. -S3 changes what a user observes on every deploy (a rendered topology -replacing alchemy's dump) and adds a public export path -(`@prisma/composer/report`). **Both `docs/guides/**` and -`skills/prisma-composer/SKILL.md` must be updated in this PR** — a named -condition here because S2's review caught the project silently carrying this -debt with no slice owning it. - -## Specification warning inherited from S1 — read before trusting any type here - -**A cast in the code this spec is written against is evidence that someone -made a claim, not evidence that the claim was true.** S1 pinned three types -by reading existing casts as type facts and was wrong all three times (see -[../../learnings.md](../../learnings.md)). `DeployedPrimitive`'s split into -resolved + `ReportedPrimitive` already applies the lesson once. - -So: **derive every type here from the producing expression, with a probe, -before writing code against it.** Specifically distrust: `deployment.deployedUrl` -and `db.id` (both `Output`, not `string`); anything a declared prop -type appears to promise, since `Input` accepts `T | Output` and will -swallow both truth and lie. If a pinned type here is contradicted, **halt** — -same as S1-D2 did. That halt paid for itself twice. - -## Coherence rationale - -One reviewer can hold it: one SPI return-type change, five mechanical -descriptor edits, one new pure renderer, one action declaration, one -template line. The alchemy-facing novelty (the Action) is isolated to ~15 -lines in `lowering()` with its mechanism documented in the ADR appendix. - -## Scope - -**In:** everything above. -**Deliberately out:** per-node `ok`/diagnostics (fail-fast decision -pending — the `DeploymentResult` shape deliberately leaves room, adding a -field later is non-breaking); created/updated/noop change status -(CLI-event-only in alchemy — recorded non-goal); any `--json`/parent-process -consumer (future transport, own design); destroy-path reporting (the bin -zeroes actions on destroy — nothing renders, correct). - -## Pre-investigated edge cases - -| Case | Ruling | -| --- | --- | -| Action noops on unchanged input | Verified `Plan.ts:1069-1082` — hash-compared against prior state. The `Date.now()` nonce forces `run` every deploy. (`Date.now()` is fine here — it's a report trigger, not artifact input; determinism rules govern artifacts.) | -| Graph nodes in action input | FORBIDDEN — plan-time `hashInput` serializes the resolved input; nodes carry functions/Standard Schemas. Addresses + primitives only; the join uses the closure. | -| Output ordering vs alchemy's TUI | The action runs inside the apply session, so the summary may print before alchemy's final status flush. Verify visually in D4; if interleaving is ugly, accepted for this slice and recorded as an upstream ask — do NOT reach for stdout piping. | -| Plan-time `resolveInput` on first deploy | Action inputs referencing not-yet-created resources must still plan (alchemy's own feature contract for actions). D1 includes a probe verifying an action with resource-referencing input plans+runs on a fresh stack; if it fails, STOP → discussion mode (spec amendment), do not improvise a fallback. | -| `Input` vs `readonly` arrays | **Settled by D1's probe: `readonly` works — use it.** `readonly T[]` does fall to `Input`'s object branch, but that branch is homomorphic over a naked type parameter, which TypeScript special-cases over arrays: elements are mapped, array-ness and `readonly` survive. Verified to still reject `id: 42` and excess nested keys. The earlier mutable-array pin was wrong. | -| Deep `Input<>` resolution at runtime | **Settled by D1's probe.** A nested `Output` at `entries[].primitives[].id` — two levels deep — arrives in the runner as a real `string`. This is the claim the whole `ReportedPrimitive`/`DeployedPrimitive` split rests on, and it holds at runtime, not merely at the type level. | -| Nonce vs the action's input-hash noop | **Settled by D1's probe, with a control.** Fresh deploy → ran. Unchanged redeploy + new nonce → ran. Unchanged redeploy + **same** nonce → **did not run**. The third case is what proves the noop is real and the nonce is precisely what defeats it. Alchemy persists the **resolved** input snapshot and hashes that (`State/ActionState.ts`), which is why a `Date.now()` evaluated at stack-effect time is sufficient. | -| `lowering()` unit tests | Sync tests run without `report` and never touch alchemy context; the report path is covered by `joinDeployment` (pure), renderer (pure), generate-stack snapshots, and D4's live deploy. | - -## Slice-DoD - -- A real deploy of `examples/storefront-auth` (or the dogfood app) prints - the pinned tree with real ids/URLs after apply, on BOTH a changed and an - unchanged (all-noop) redeploy. -- No alchemy stack-output blob in the deploy output. -- PR #101 closed with the supersession comment. - -## References - -- Project spec: [../../spec.md](../../spec.md) · design notes § "The - corrected execution model". -- alchemy `2.0.0-beta.59`: `src/Action.ts:85-135`, `src/Plan.ts:1040-1100`, - `src/Apply.ts:1103-1232`, `src/Cli/commands/deploy.ts:171-175`. -- `packages/0-framework/3-tooling/cli/src/generate-stack.ts:50-70`. diff --git a/.drive/projects/spi-inversion-and-deploy-results/slices/s4-glossary-alignment/spec.md b/.drive/projects/spi-inversion-and-deploy-results/slices/s4-glossary-alignment/spec.md deleted file mode 100644 index d0f5a14d..00000000 --- a/.drive/projects/spi-inversion-and-deploy-results/slices/s4-glossary-alignment/spec.md +++ /dev/null @@ -1,111 +0,0 @@ -# S4 — Align the new names with the repo's ubiquitous language - -## At a glance - -Post-review rework on PR #117, operator-directed (2026-07-17). The branch -coined names for concepts the repo's glossary already covers. This slice -renames them to the established vocabulary and records the one genuinely -new noun ("Deployment entity") in the docs. **Pure rename + wording — zero -behaviour change.** Every gate that is green before this slice must be -green after, with identical counts. - -Grounding (operator-ratified): - -- The glossary (`docs/design/03-domain-model/glossary.md`) names what a - node provides to its dependents **Outputs** (:136, :365-370). "Wiring" was - a coinage. -- The check S2 added enforces the **connection** contract (glossary - :121-127; the domain doc is literally `connection-contracts.md`). - "Wiring contract" was a second name for it. -- The planes are authoring / provisioning / hosting (`layering.md:16-29`). - Naked "primitive" carries no meaning without a plane qualifier - (ADR-0014 "authoring primitive" vs layering.md "hosting primitives"), and - "Deployed" is not a plane — so `DeployedPrimitive` said nothing. The - operator's chosen noun for a thing on the deployment target: - **Deployment entity**. -- `DeploymentResult` is legitimate **only** as the result of the Deploy - operation. Today it names a per-node record; the actual operation result - is an unnamed array. Fixed by promoting it. - -## The rename set (complete — nothing else changes name) - -| Current | New | Notes | -| --- | --- | --- | -| `WiringOutputs` | `Outputs` | Verified collision-free in core + public. Doc comment: "The values a node provides to its dependents — what a consumer's declared connection params resolve against. Name-keyed and unknown-valued of necessity…" (keep the existing rationale text, minus the word "wiring"). | -| `LoweredResult.wiring` | `LoweredResult.outputs` | Type `Outputs`. | -| `LoweredResult.primitives` | `LoweredResult.entities` | Type `readonly Input[]`. | -| `DeployedPrimitive` | `DeployedEntity` | Same shape: `kind`, `id`, `url?`, `details?`. `kind` string values (`'compute-service'`, `'postgres-database'`) are hosting-plane nouns and DO NOT change. | -| `ReportedPrimitive` | **deleted** | Two use sites become `Input` written literally — Alchemy's own idiom for "this shape, fields possibly unresolved". No replacement alias. | -| `DeploymentResult` (per-node) | `DeployedNode` | `{ address, node, entities }` (field `primitives` → `entities`). | -| — (new) | `DeploymentResult` | **The result of the Deploy operation**: `{ readonly app: string; readonly nodes: readonly DeployedNode[] }`. Core's Action runner constructs it with `app = opts.name`. | -| `LowerOptions.report` | signature change | `(result: DeploymentResult) => void`. | -| `joinDeployment(graph, entries)` | returns `readonly DeployedNode[]` | Same join semantics (skip unknown addresses). The runner wraps it into `DeploymentResult`. Keep it pure and exported. | -| `renderDeployment(appName, results)` | `renderDeployment(result: DeploymentResult)` | App name now rides in the result. | -| `deploymentReport(appName)` factory | `deploymentReport(result: DeploymentResult): void` | No longer a factory — it IS the callback. Generated stack template passes `report: deploymentReport` (no call, no name argument). `generate-stack.ts` template + snapshot tests update; the name option the template previously threaded to the factory is no longer emitted there. | -| Action input `entries[].primitives` | `entries[].entities` | Nonce mechanics unchanged. Action name `composer-deployment-report` unchanged. | - -## Wording sweeps (exact) - -**S2 error text** (`deploy.ts`, `buildConfig`) — new pinned message: - -``` -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. -``` - -(Substitute the real interpolations; "[nothing]" fallback behaviour -unchanged.) The S2 tests asserting message fragments update to match. - -**Renderer empty case**: `(no primitives reported)` → `(no entities reported)`. -Renderer tests update; tree format otherwise byte-identical. - -**Guides + skill** (`docs/guides/deploying.md`, `skills/prisma-composer/SKILL.md`): -"wiring contract" → "connection contract"; "wiring outputs" → "outputs"; -the deploying.md section currently titled around a "wiring gap" retitles to -name the actual event, e.g. "When a deploy stops on a missing connection -value"; SKILL.md heading "The wiring contract is checked at deploy" → -"The connection contract is checked at deploy". Quoted error text in both -updates to the new message. "primitive(s)" referring to our report types → -"entities". Guide and skill must not disagree; skill rules -(`skills/README.md`) still bind — re-verify the quoted error text is a true -prefix of the live template after the reword. - -**ADR-0033** (`ADR-0033-lowering-types-are-defined-by-their-readers.md`): -sweep coined-noun "wiring" → Outputs/connection vocabulary; our-type -"primitive(s)" → "entities"/type names. **Keep** "hosting primitives" where -it cites layering.md's own vocabulary. Substance, taxonomy, citations -untouched. - -**Docs record the new noun** — one addition, pinned: in -`docs/design/03-domain-model/layering.md`, hosting-plane bullet (:26-29), -append: *"The framework's deploy report calls a thing on this plane a -**Deployment entity** (`DeployedEntity`): its kind, platform id, and — only -when the target says it is publicly reachable — its URL."* Mirror one line -in the glossary's Planes section if it reads naturally; skip if forced. - -**Code comments/tests** added by this branch that say "wiring" or use -"primitive" for our types: sweep on branch-added lines only. Pre-existing -files' vocabulary (e.g. `core-model.md`'s broader text, `testing.md`) stays -— except `core-model.md` lines this branch already edited, which follow the -rename (and note: the half-migrated `deploy` signature there — reads -`WiringOutputs`, code returns `LoweredResult` — gets fixed to the RENAMED -truth in the same pass; that closes the review's B5 defect). - -## Out of scope - -Behaviour of any kind. The review's F01 (report runner try/catch) — separate -decision, not bundled into a rename. The `.drive/` transient docs and the -`reviews/pr-117/` artifacts (snapshots of a round; they keep the old names). -The PR body (orchestrator owns it). - -## Completed when - -- `git grep -in "wiring" -- packages/ docs/guides/ skills/` shows no - coined-noun uses on branch-added lines (plain verb "wire up" in - pre-existing text is fine). -- `git grep -n "Primitive" -- packages/` returns nothing. -- Full suite green with **identical counts** (typecheck 58, test 48, lint 0, - lint:deps clean, lint:casts ≤ 30) — a rename that changes a count changed - behaviour. `pnpm lint` last, exit code read. diff --git a/.drive/projects/spi-inversion-and-deploy-results/spec.md b/.drive/projects/spi-inversion-and-deploy-results/spec.md deleted file mode 100644 index 71ead33a..00000000 --- a/.drive/projects/spi-inversion-and-deploy-results/spec.md +++ /dev/null @@ -1,159 +0,0 @@ -# Purpose - -Make the lowering SPI's seams point the right way — each consumer declares -the interface it relies on, producers implement it, and only the lowering -loop knows the routing — so that deployment results can be captured and -rendered as a first-class product of a deploy instead of being bolted onto -the wiring contract. The `NodeReport` attempt (composer PR #101) failed -because one shared producer-side bag (`LoweredNode`) serves three unrelated -contracts; this project replaces the bag with consumer-declared interfaces -and builds reporting on the clean seams. - -# At a glance - -Today `LoweredNode` (`{ outputs: Record }`) plays three -roles at once: - -1. **Intra-descriptor phase handoffs** — `provision` → `serialize`/`deploy`. - Private to one descriptor; core never reads them; the descriptor casts to - recover types it produced itself two phases earlier. -2. **Inter-node wiring** — `deploy`'s return, stored in the `lowered` map, - read by `buildConfig` by param name for downstream nodes. The only role - core genuinely consumes. -3. **Reporting** (the reverted `NodeReport`) — presentation data smuggled - onto the wiring type because the shared bag accepts anything. - -The project separates them: - -- **Phase handoffs** become descriptor-owned types carried generically by - the SPI — typed to the one party that writes and reads them, opaque to core. -- **Wiring** becomes its own named, name-keyed record type. The consumer-side - declaration already exists (a node input's connection params); enforcement - makes a producer that under-delivers a loud `LowerError` naming the edge - (operator-confirmed 2026-07-17). -- **Reporting** becomes `DeploymentResult`: per node, holding the graph node - itself plus typed platform primitives (kind, platform id, url only when - the descriptor declares it public). Assembled by the lowering loop at the - moment it holds full context; the whole-run value is a plain collection, - no aggregate noun. -- **Rendering** runs in the deploy child process, which holds both the Graph - and the results — no cross-process transport. The vehicle is an alchemy - **Action** declared at the end of the stack effect: actions run *during - apply* with resolved inputs, so the action's runner receives the resolved - primitives, joins them to the graph it holds by closure, and calls the - renderer the CLI wired in through the generated stack file. Core stays - presentation-free. - -Grounding facts that shape everything (verified against alchemy -2.0.0-beta.59 source): the stack effect runs entirely **before** apply; -resource yields return lazy Output proxies; resolved values reach program -code only through apply-time evaluation (the stack's return value, or an -Action's input). The stack effect returns `undefined` from S1 on, which -kills both alchemy's raw stack-output dump and the `setOutput` state write. -Parent-process readback via the state store was rejected on verified -grounds: it needs a faked `Stack` service, re-takes the deploy lock, and -must replicate alchemy's `dev_${USER}` stage derivation. - -# Non-goals - -- **Per-node `ok`/failure diagnostics.** `lower()` is `orDie`: one failure - kills the run, so `ok` would never vary. `DeploymentResult` may carry a - diagnostics slot, but populating it waits on a deliberate - collect-and-continue decision (deploy semantics, partly alchemy's) that - this project does not make. -- **Per-resource change status (created/updated/noop).** Verified not - exposed to the program — it flows only to alchemy's CLI event session. - Capturing it means wrapping alchemy's Cli service or an upstream change; - deferred. -- **Any cross-process transport for results.** The renderer runs where the - results are. A `--json`/CI consumer in the CLI parent process, if ever - wanted, is its own future design with its own serialized projection. -- **Collect-and-continue lowering.** Fail-fast semantics stay as they are. - -# Place in the larger world - -- Supersedes composer **PR #101** (`NodeReport` on `LoweredNode`) — that - approach is withdrawn; the PR is reworked or closed in favor of this - project's slices. -- Constrained by **ADR-0005** (users build, the framework assembles — - deterministic, no guessing) and the layering rules in - `architecture.config.json` (`framework.mayImportFrom: []`; the CLI must - not import prisma-cloud). The renderer-in-child design respects both. -- The wiring seam builds on the connection-params model (ADR-0031 for - provisioned params) — the consumer-side declaration this project starts - enforcing. -- Alchemy behavior grounded in `alchemy@2.0.0-beta.59` - (`Deploy.ts`/`Resource.ts`/`Apply.ts`): evalStack → Plan → Apply; Output - proxies; stack output evaluated and persisted via `setOutput`. - -# Cross-cutting requirements - -- **Dependency inversion at every seam.** Interfaces live with their - consumers: phase-handoff types with the descriptor, the wiring contract - with the connection declaration (core's graph model), the primitive/result - types with the deploy-result subsystem in core, formatting interfaces with - the CLI. The lowering loop is the only party that knows which producer - output feeds which consumer. -- **No shared mutable bag survives.** `LoweredNode` is retired; nothing - reintroduces a producer-side record that multiple subsystems read. A new - consumer requires a new declared interface and a visible routing edit in - the lowering loop. -- **The descriptor names what is publishable.** Core never infers meaning - from output keys; `url` on a primitive means publicly reachable because - the descriptor said so (the allowlist lesson from #101). -- **Whatever crosses the stack boundary is plain data** (address-keyed - primitives) because alchemy unconditionally persists the stack output. - Node-bearing types stay in-process, on our side of the boundary. -- **No new casts.** The refactor must reduce, not relocate, the - `blindCast`/`as` surface in descriptors (repo cast-ratchet rules apply). - -# Transitional-shape constraints - -- The DI refactor (S1) lands before the reporting slices; reporting builds - on the separated seams, never on `LoweredNode`. -- Between S1 and the reporting slice, deploys behave exactly as today - (hardcoded empty stack output); no intermediate state may change deploy - behavior except as its slice specifies. - -# Project-DoD - -- [ ] `LoweredNode` no longer exists; each of its three roles has its own - consumer-declared type, and every descriptor compiles against the new - SPI without casting to recover its own phase values. -- [ ] An ADR records the seam design (consumer-declared interfaces, results - assembled at full context, no transport before a cross-process - consumer exists) in `docs/design/90-decisions/`. -- [ ] A real deploy of an example app prints a rendered deployment summary: - the app's own topology with authored names, platform ids, and public - URLs — produced from `DeploymentResult`s, not parsed from alchemy - output — and alchemy's raw stack-output dump is gone or trivially - empty. -- [ ] Wiring-contract enforcement (if confirmed): a producer that fails to - supply a consumer's declared connection param fails the deploy with a - `LowerError` naming the edge and param, covered by a test. -- [ ] PR #101 is superseded: reworked to this design or closed with a - pointer. - -# Open questions - -- **Action plan-time input resolution on a fresh stack** — alchemy's own - feature contract says an action's resource-referencing input plans before - those resources exist; S3's D1 probe verifies it before anything builds - on it (STOP → discussion mode if it fails). - -Settled during shaping (see slice specs): wiring enforcement confirmed by -operator (S2); `ctx.application` becomes `unknown` narrowed by an -extension-owned type guard, and provisioner refs stay opaque `unknown` -(S1); the stack-output readback mechanism was rejected in favor of the -Action (S3). - -# References - -- Design notes: [design-notes.md](design-notes.md) — the argument, the - corrected alchemy execution model, alternatives rejected. -- `packages/0-framework/1-core/core/src/deploy.ts` — the SPI and lowering - loop this project rebuilds. -- `packages/1-prisma-cloud/1-extensions/target/src/descriptors/` — the - descriptors that migrate. -- Composer PR #101 — the superseded `NodeReport` attempt. -- Tracker: [Prisma Composer: SPI inversion & deployment results](https://linear.app/prisma-company/project/prisma-composer-spi-inversion-and-deployment-results-f87bb6d9de12)