Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
2a731a3
docs(drive): shape the SPI inversion + deployment results project
wmadden-electric Jul 17, 2026
b8672a5
refactor(core): invert lowering SPI seams to consumer-declared types
wmadden-electric Jul 17, 2026
ae5b0bd
test(core): update lowering.test.ts for the inverted SPI
wmadden-electric Jul 17, 2026
c019b80
test(core): type the fake compute descriptor with the pinned satisfie…
wmadden-electric Jul 17, 2026
4f63d09
refactor(prisma-cloud): guard ctx.application at the extension seam
wmadden-electric Jul 17, 2026
56d9fb1
refactor(prisma-cloud): type compute/s3-store phase handoffs, deletin…
wmadden-electric Jul 17, 2026
1670be3
refactor(prisma-cloud): return bare wiring records from the resource …
wmadden-electric Jul 17, 2026
53fb9e3
test(prisma-cloud): cover the inverted SPI and the projectIdOf seam g…
wmadden-electric Jul 17, 2026
bb69446
test(prisma-cloud): derive the mocked handoff mirrors from the real t…
wmadden-electric Jul 17, 2026
2c7cef3
docs(adr): ADR-0033 — lowering-SPI seams are consumer-declared
wmadden-electric Jul 17, 2026
45d7cba
docs(core-model): transcribe the lowering SPI to the inverted seams
wmadden-electric Jul 17, 2026
9ba85b1
docs(drive): record S1 spec amendments, review, and learnings
wmadden-electric Jul 17, 2026
60ec84f
docs(drive): all three slices land on one PR
wmadden-electric Jul 17, 2026
ded15f4
test(core): pin the wiring contract before enforcing it (S2 D1)
wmadden-electric Jul 17, 2026
49240bd
feat(core): enforce the wiring contract at deploy (S2 D2)
wmadden-electric Jul 17, 2026
3884d04
docs(drive): record S2 D1 findings and the defensive-branch open item
wmadden-electric Jul 17, 2026
357a755
docs(guides,skill): document the wiring contract failure (S2 D3)
wmadden-electric Jul 17, 2026
5cb768d
docs(drive): own the S2 docs debt (F3) and record the S3 rendering bound
wmadden-electric Jul 17, 2026
e291697
docs(drive): S3 probe settles the Action mechanism; readonly pin was …
wmadden-electric Jul 17, 2026
36b96f2
feat(core): report deployment results through an alchemy Action (S3 D2)
wmadden-electric Jul 17, 2026
a188f66
test(core): cover joinDeployment and the report path (S3 D2)
wmadden-electric Jul 17, 2026
0fe22f2
feat(prisma-cloud): descriptors report the primitives they became (S3…
wmadden-electric Jul 17, 2026
b78ed9b
feat(cli): render a deploy as the app's own topology (S3 D3)
wmadden-electric Jul 17, 2026
26a9353
feat(cli): wire the deploy report through the generated stack (S3 D3)
wmadden-electric Jul 17, 2026
1c5d46e
docs(drive): record the lint:deps blind spot and the control-your-gat…
wmadden-electric Jul 17, 2026
4aaceda
fix(lint): restore the JSON files and make the layering edits minimal…
wmadden-electric Jul 17, 2026
7a8f910
refactor(core): LoweredResult.primitives is required, not optional (F5)
wmadden-electric Jul 17, 2026
1eff398
docs(drive): record the false-green and its real mechanism
wmadden-electric Jul 17, 2026
23e7d31
docs(adr): append S3 Action facts to ADR-0033 alchemy appendix (S3 D4a)
wmadden-electric Jul 17, 2026
6b9ae71
docs(guides,skill): document what a deploy now prints (S3 D4a)
wmadden-electric Jul 17, 2026
aa9e851
docs(drive): close S3 non-deploy work; record the gate-adjudication l…
wmadden-electric Jul 17, 2026
25245d0
docs(guides,skill): correct the postgres id prefix from a live deploy…
wmadden-electric Jul 17, 2026
0ba6aa6
docs(adr): say what ADR-0033 means instead of calling it a seam
wmadden-electric Jul 17, 2026
1afb326
refactor: drop the "seam" jargon from the code this branch added
wmadden-electric Jul 17, 2026
8d1194c
docs(drive): spec S4 — align branch names with the glossary
wmadden-electric Jul 17, 2026
06ade0c
refactor(core): rename the deploy-report types to the glossary's voca…
wmadden-electric Jul 17, 2026
ecb67b4
refactor(prisma-cloud): descriptors follow the Outputs/entities renam…
wmadden-electric Jul 17, 2026
5800f07
refactor(cli): renderer takes the DeploymentResult; deploymentReport …
wmadden-electric Jul 17, 2026
6d13738
docs(guides,skill,adr): sweep the coined vocabulary; record "Deployme…
wmadden-electric Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions .drive/projects/spi-inversion-and-deploy-results/design-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# 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<string, unknown> }`, `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<Provisioned, Serialized>` 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<P, S>` 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`.
159 changes: 159 additions & 0 deletions .drive/projects/spi-inversion-and-deploy-results/learnings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# 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<string>`. 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.
Loading
Loading