Skip to content

feat!: typed RPC connection contracts (slice R6) - #13

Merged
wmadden-electric merged 17 commits into
mainfrom
claude/rpc-contracts
Jul 9, 2026
Merged

feat!: typed RPC connection contracts (slice R6)#13
wmadden-electric merged 17 commits into
mainfrom
claude/rpc-contracts

Conversation

@wmadden-electric

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

Copy link
Copy Markdown
Contributor

Types service-to-service Connections. A dependency can now declare a Contract (an RPC interface — methods with arktype-validated input/output); the provider is forced to satisfy it, the consumer gets a typed client, and an incompatible wiring is rejected at compile time, at Load, and per call. The untyped http() stays as the escape hatch.

Proven live on real Prisma Cloud (twice): the storefront's page calls auth.verify({ token }) as a typed client → a real POST /rpc/verify → auth's generated RPC server validates input, pings Postgres, validates output, returns { ok: true }, which the page renders.

What it adds

  • @makerkit/rpc (new): contract()/rpc() (Standard Schema / arktype), serve(service, handlers) (generates the RPC fetch server from service.expose; forces handlers to satisfy the exposed contracts), the typed client binding, and rpc(contract) — the typed sibling of http().
  • core: Contract<Kind, Cmp>; expose on services; ConnectionEnd carries its required contract; ProvisionedRef carries the provider's exposed ports; HexBuilder.provision is typed (an incompatible wiring is a compile error); loadHex runs a satisfies() backstop (LoadError on mismatch).
  • example: storefront-auth on the typed contract — the contract lives in the auth hex (hexes/auth/src/contract.ts, the service owns it), storefront depends on it, hex wiring is compat-checked via authRef.rpc.

The compat mechanism (the interesting part)

The core stays protocol-blind: plain assignability on an opaque Cmp with NoInfer on the kind brand. The RPC kind makes that correct by building Cmp as a concrete function map (so TS applies contravariant-input / covariant-output). This caught and avoided a real trap — deriving the comparison as a mapped-type-over-schema silently accepts incompatible providers. The design + the full accept/reject matrix are compiled proofs in the repo (contract-satisfaction.poc.ts, typed-hex-wiring.poc.ts), and the type-tests + the runtime LoadError test were mutation-checked (non-vacuous).

Design: docs/design/10-domains/connection-contracts.md · slice: .drive/projects/authoring-layer/slices/r6-typed-rpc-contracts/.

Status

Green and proven live; ready for review. typecheck 10/10, tests, biome clean. The Opus review's finding (compat not wired into the real hex) is closed — it's now enforced at HexBuilder.provision + loadHex. R5 is untouched: untyped http() and makerkit-hello work, and the existing hex/lowering tests passed unmodified. Review cleanups (client base-path, server error body) included. The early wip commit will be squashed at merge.

Follow-ups (tracked, out of scope)

Structural (vs nominal) satisfies; gRPC/WebSocket kinds; PDL authoring; distributed published-spec comparison; in-memory + mock bindings; contract() raw-function guard; POC-in-CI-tsconfig.

🤖 Generated with Claude Code

wmadden-electric and others added 5 commits July 8, 2026 17:36
…tion proof

The design for typing service-to-service Connections: a framework-owned,
protocol-parametric Contract; a neutral contract with a server adapter (serve())
and a client adapter (load()); the binding (network/in-memory/mock) chosen by hex
wiring; RPC-first; untyped http() retained as the escape hatch.

The compatibility check is proven, not asserted (contract-satisfaction.poc.ts,
compiled --strict): the core does plain assignability on an opaque Cmp with NoInfer
on the kind brand; the RPC kind makes it correct by building Cmp as a concrete
function map (rpc() returns a concrete (input)=>Promise<output>), so TS applies
contravariant-input / covariant-output. Records the trap it avoids — deriving
Client<M> as a mapped type relates the M's covariantly and silently accepts
incompatible providers.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n 12

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(unit 1)

The type-level foundation for typed RPC connections, faithful to the compiled
proof (contract-satisfaction.poc.ts):
- core: Contract<Kind, Cmp> (opaque Cmp, kind brand, runtime satisfies) + the
  standalone provision() wiring helper (plain assignability, NoInfer on the brand;
  runtime mirror via satisfies). Not yet wired into HexBuilder.
- new @makerkit/rpc: contract(fns) -> Contract<'rpc', Fns>; rpc({input,output})
  types a concrete (input)=>Promise<output> (the trick that makes assignability
  apply real function variance) over Standard Schema (arktype); Client<C>; nominal
  satisfies (value === value).
- type-tests port the proof's matrix to real arktype — accept exact/extra-output/
  extra-method, reject extra-input/missing/wrong-kind + client usage. Verified real
  (every @ts-expect-error fires for the intended reason).

No runtime transport (serve/client) yet, no example changes, http()/compute()/hex
untouched. typecheck 10/10, tests, biome green.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ection-end (unit 2)

- core: optional expose (Expose = record of named output-port Contracts) on
  ServiceNode/RunnableServiceNode, threaded through service(); compute() passes it.
- @makerkit/rpc runtime (web-standard fetch/Request/Response, runtime-agnostic):
  · makeClient(contract, url, {fetch?}) — POST <url>/rpc/<method>, arktype-validate
    the response; the transport is injectable (a serve() handler IS a valid transport).
  · serve(service, handlers) — fetch server generated from service.expose: flat
    /rpc/<method> dispatch, validate input -> handler(deps from load()) -> validate
    output; 404/405/400/500, never crashes; Handlers<S> forces an exhaustive, typed
    handler map (missing/mistyped = compile error; extra allowed).
  · rpc(contract) — the typed connection-end sibling of http(), same {url} param.
- tests: in-process round trip (client wired to the serve handler), bad-input/unknown-
  method/handler-throw paths, output-validation catching a lying server, serve-handler
  type-tests, expose threading, invariants.

No examples, no deploy. typecheck 10/10, tests, biome green.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Shared auth.contract.ts (verify: {token} -> {ok}). auth exposes { rpc: authContract }
and serves it via serve(service, {rpc:{verify}}) — the handler pings the DB and returns
{ok:false} on error (never throws; keeps FT-5219 resilience) — replacing the hand-rolled
Hono server. storefront depends via rpc(authContract) and its page calls
auth.verify({token}) typed, rendering 'Auth /verify says: true'. Build configs inline
@makerkit/rpc + arktype; e2e-verify.sh grep updated. The hex/deploy graph is unchanged
(rpc connection resolves the producer URL like http() did).

Local proof: POST /rpc/verify -> {ok:false} against an unreachable DB (RPC dispatch +
arktype validation + resilience); bad input 400; GET 405. typecheck/tests/biome green.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
wmadden-electric added a commit that referenced this pull request Jul 8, 2026
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is a poc in the docs?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleted. This matrix is now a CI-typechecked type-test: @makerkit/rpc's src/__tests__/contract-satisfaction.test-d.ts (283a8a8).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also doesn't belong here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleted — same as above, its accept/reject cases are covered by contract-satisfaction.test-d.ts (283a8a8).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a long-lived doc written as a transitory description.

I want you to read this doc with fresh eyes. Pretend you’re a member of my team who doesn’t have all our context. In particular, the doc should start with a clear grounding example and then have a strong narrative that clearly builds up the topic bit by bit, explaining clearly throughout. Lead with the decision we’re making, end with alternatives considered, to avoid overloading the reader with information we’re making irrelevant.

These are long-lived documentation. They should not contain references to Linear tickets, milestones in your project, or states the system passed through during or preceding your refactor which will never be seen again.

Rewrite the document to address the issues you identified.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewritten (283a8a8): leads with the decision, opens with the worked example, builds the mechanism up, ends with alternatives considered. Dropped the "being finalized" language and the POC-file reference.

import { compute, http } from '@makerkit/prisma-cloud';
import { compute } from '@makerkit/prisma-cloud';
import { rpc } from '@makerkit/rpc';
import { authContract } from '../../auth/src/contract.ts';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not ideal. Can we set up a proper monorepo structure so this imports from eg "@example/auth/contract"?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done (283a8a8). Renamed the hexes to @storefront-auth/auth and @storefront-auth/storefront with subpath exports; storefront now imports @storefront-auth/auth/contract, and hex.ts imports both services by package name. Proven with next build + tsdown.

Comment thread examples/storefront-auth/hex.ts Outdated
* contract; `storefront` consumes it (auth's `rpc` port → storefront's `auth`
* slot, compat-checked). Transparent wiring, executed at Load.
*/
export default hex('storefront-auth', (h: HexBuilder) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we drop the explicit HexBuilder type annotation?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped — hex(name, body) infers h as HexBuilder (283a8a8).

@wmadden wmadden left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few remaining comments, preemptively approved


### Growing the runtime check

Growing nominal into **structural** later is backward-compatible: runtime accepts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We never said that it was a nominal check, and in fact that's an implementation detail of the RPC contract, isn't it? We happen to do a nominal check right now.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded (e762620). The doc now says the identity comparison is the RPC contract's own satisfies() implementation — not a rule the framework or the Contract abstraction imposes — and that it can grow to structural inside the RPC contract without the framework knowing.


## Alternatives considered

- **RPC first, not HTTP.** RPC removes the HTTP semantic surface (methods, paths,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean "RPC first, not REST". We're still using HTTP under RPC

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed (e762620): "RPC first, not REST." HTTP stays the transport under RPC; the choice is the interface style — RPC drops REST's semantic surface (resources, verbs, status codes).

Comment thread packages/makerkit-core/src/node.ts Outdated
/** How the app's entry is built + assembled. */
readonly build: BuildAdapter;
/** Named output ports this service exposes — the Contracts a consumer's `rpc(contract)` can require. */
readonly expose?: E;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allow inputs to the factory methods to be optional, keep all data structures predictable: no optional keys, but potentially undefined values.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done (e762620). ServiceNode.expose is now an always-present key with a possibly-undefined value (E | undefined); the service() factory input stays optional but always sets the key.

Comment thread packages/makerkit-core/src/node.ts Outdated
export interface ConnectionEnd<C = unknown, Req = unknown> extends NodeBase {
readonly kind: 'connection';
readonly connection: Connection<Params, C>;
readonly required?: Req;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here. Check all other data structures

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done (e762620). Same for ConnectionEnd.required. Audited the rest: also fixed ConfigDeclaration.default (config.ts). The remaining ?: are all factory/options inputsservice()/connectionEnd()/compute() defs, and lower()/target()/client() options — which you said should stay optional, so I left them.

return loaded;
},
}) as RunnableServiceNode<D, typeof computeParams>;
}) as RunnableServiceNode<D, typeof computeParams, E>;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No as casts

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed (e762620): compute() now builds a typed RunnableServiceNode local and Object.freezes it — no return cast. (One as remains in load(), asserting the untyped deserialized config record to Loaded; that's a genuine runtime assertion — it becomes a blindCast once the no-bare-cast utils land.)

wmadden-electric and others added 10 commits July 9, 2026 08:28
… typed hex wiring + rpc client

R6 build checkpoint (previously "wip"): refines the Contract type and its runtime satisfies() check, the typed connection wiring and compatibility validation across node.ts/graph.ts, and the rpc client binding, with broadened compat type-tests.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Closes the review's finding: the typed compat is now enforced at the REAL hex, not
just a standalone helper. ConnectionEnd carries its required contract; ProvisionedRef
carries the provider's exposed ports (RefPort); HexBuilder.provision is typed (an
incompatible wiring is a compile error); loadHex runs a satisfies() backstop
(LoadError on mismatch). rpc(contract) carries its required contract; the example
wires authRef.rpc. Untyped http() + makerkit-hello unchanged (escape hatch intact;
R5 hex/lowering tests unmodified). Plus the two client cleanups (preserve base path;
surface the server error body). typed-hex-wiring.poc.ts is the compiled proof.

typecheck 10/10, tests, biome green.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…owns it

The contract is auth's public interface, so it lives with the service (hexes/auth/
src/contract.ts) rather than floating at the app root — how a user would lay it out.
auth imports it locally; storefront imports it cross-hex (the provider-owns-the-
contract pattern). Runtime unchanged (same contract, same wiring); both hexes still
build self-contained (arktype inlined). Top-level deps kept — alchemy.run.ts
transitively needs @makerkit/rpc + arktype through the services.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The app topology now lives in a clean hex.ts (hex + two provisions);
alchemy.run.ts is a thin interim deploy adapter that imports it and lowers
onto Prisma Cloud. `alchemy deploy` still finds alchemy.run.ts by convention,
so no tooling changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
- Rename example hexes to a real scope (@storefront-auth/auth,
  @storefront-auth/storefront) with subpath exports, so storefront imports
  the auth contract as @storefront-auth/auth/contract instead of a cross-hex
  relative path. hex.ts imports both services by package name.
- Drop the explicit HexBuilder annotation in hex.ts (inferred from hex()).
- Delete the two design POC files; their accept/reject matrix already lives
  as a CI-typechecked type-test (@makerkit/rpc contract-satisfaction.test-d.ts).
- Rewrite connection-contracts.md as long-lived docs: lead with the decision,
  open with a worked example, build the mechanism up, end with alternatives;
  drop transitory references (POC files, "being finalized").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Contract is the abstraction (parametric over kind); a bare "Contract" never
means a specific kind. Every kind is qualified — RPC Contract, Data Contract —
with the prose name tracking the `kind` brand. Drop "protocol" for the
discriminant (RPC is a kind, not a protocol); reserve "Connection" for the port.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…ndle

The rename to `@storefront-auth/auth` turned storefront/service.ts's contract import from a relative path into a package specifier. tsdown's noExternal only matched @makerkit/* and arktype, so it left `@storefront-auth/auth/contract` external — an unresolved import in main.mjs that crashed the storefront on boot, so Compute served "Service not found" and the E2E round trip failed. Inline the app's own hex packages (@storefront-auth/*) too.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…, drop cast

Data structures carry no optional keys, only possibly-undefined values (factory inputs stay optional): ServiceNode.expose, ConnectionEnd.required, and ConfigDeclaration.default are now always-present keys. Factories and the expected test declarations set them explicitly.

connection-contracts.md: the identity comparison is the RPC contract's own satisfies() implementation, not a rule the framework or Contract abstraction imposes; "RPC first, not REST" — HTTP is still the transport under RPC.

compute(): drop the `as RunnableServiceNode` return cast — build a typed local and freeze it.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…from prisma-next

The DCO workflow requires a Signed-off-by trailer on every commit; git-staging.mdc documents that (and explicit staging), and the multiline-commit-messages skill keeps Shell-tool commit messages from garbling.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
wmadden-electric and others added 2 commits July 9, 2026 08:36
Forbid bare `as` casts in production TS in favour of auditable helpers.

- blindCast<T, "Reason">/castAs<T> in @makerkit/core/casts, plus
  assertDefined/invariant in @makerkit/core/assertions as the safe
  narrowing alternatives.
- no-bare-cast GritQL Biome plugin (info severity; test files exempt,
  matching the existing biome test-file overrides).
- lint:casts CI ratchet + unit tests: counts plugin diagnostics at HEAD
  vs the PR base and fails on any per-PR increase. Scans the base in a
  worktree using HEAD's config+plugin, so it self-bootstraps — the 59
  existing casts are the baseline, delta=0.
- PR-only cast-ratchet CI job (credential-free: points origin/main at the
  base sha already in history) + .cursor rules docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Now that the no-bare-cast machinery is in the repo, R6 should not add casts. Removed 9 by narrowing (`in`/typeof guards in graph.ts, rpc.ts, client.ts) and named the genuine runtime assertions with blindCast<T, "reason"> (serve.ts/client.ts read schemas off the function value; compute.ts merges the untyped config record; a frozenShallowCopy helper in node.ts). Net delta vs main is -4 casts, so the ratchet passes. Also updates the core exports invariant test for the new ./casts + ./assertions entries.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@wmadden-electric
wmadden-electric merged commit 28f5d4c into main Jul 9, 2026
7 checks passed
wmadden pushed a commit that referenced this pull request Jul 9, 2026
…ssion brief

R6 (typed RPC contracts) merged via PR #13. plan.md: R6 slice -> [x] merged (the two .poc.ts files folded into contract-satisfaction.test-d.ts; no-bare-cast machinery landed alongside), a current-position update line, and two deferred items — the E2E bundling rule (noExternal must inline the app own hex packages) and the @makerkit/node naming nit. Adds next-session-brief.md as the fresh-session handoff.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants