refactor(service-rpc): the RPC kind is named service RPC#131
Merged
Conversation
Rename @prisma/composer/rpc -> @prisma/composer/service-rpc (@internal/service-rpc). Call-site names (rpc(), contract(), serve()) and the kind brand are unchanged. connection-contracts.md gains a Purpose and scope section: Connections are internal by definition; the kind exists for agent-generated, framework-connected service edges — not an application API layer, not general distributed-systems infrastructure. ADR-0033 records the decision. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
Components provided by different targets connect across networks; the scope keys on framework-provisioned, framework-authenticated edges in one application topology, and robustness calibrates per edge against the named failure modes of the targets carrying it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
wmadden
approved these changes
Jul 20, 2026
Main landed the src/exports entrypoint convention (ADR-0035), the lowering SPI inversion, and ADRs 0033/0034 while this branch was open. The rename is re-applied over the new layout (@internal/service-rpc entry at src/exports/index.ts, composer subpath entry at src/exports/service-rpc.ts) and the naming ADR moves to the next free number, 0036. Co-Authored-By: Claude Fable 5 <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 Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
commit: |
wmadden
pushed a commit
that referenced
this pull request
Jul 20, 2026
Paths follow #131 (@internal/service-rpc, src/exports entrypoint). The accepted residual is rejustified against connection-contracts scope — internal means inside the topology, cross-network edges are in scope, and robustness is calibrated against named failure modes — so the slice names PRO-217 as what it guards and names the cross-instance window it leaves open. The three fixes promised when #114 was declined are absorbed here because they touch the same files. ADR-0037 reserved for the protocol. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
wmadden-electric
added a commit
that referenced
this pull request
Jul 21, 2026
…#140) * chore(drive): spec + dispatch plan for the rpc-cold-start slice Contract-declared idempotence for rpc methods, the bounded client-side retry for marked methods only, and the RPC face of the PRO-217 canary, inheriting every rule from the cold-start canary's rebuild. Execution held pending operator sign-off. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> * chore(drive): the rpc slice becomes idempotency keys, not a boolean Will's design supersedes the per-method idempotent flag: the protocol requires a client-minted key on every call, serve() implements single- flight and replay within the retry envelope, handlers may use the key for durable control but need not. Every method becomes safely retryable with no declaration to trust. Spec and plan rewritten; scope calibration and the accepted cross-instance residual recorded in design-notes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> * chore(drive): rebase the rpc slice onto the service-rpc rename Paths follow #131 (@internal/service-rpc, src/exports entrypoint). The accepted residual is rejustified against connection-contracts scope — internal means inside the topology, cross-network edges are in scope, and robustness is calibrated against named failure modes — so the slice names PRO-217 as what it guards and names the cross-instance window it leaves open. The three fixes promised when #114 was declined are absorbed here because they touch the same files. ADR-0037 reserved for the protocol. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> * chore: drop a stray file left at the pre-rename rpc path An interrupted dispatch wrote protocol.ts under the old rpc/ directory; it was untracked, so the rebase reset did not clear it and the previous commit swept it in. The slice writes into service-rpc/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> * feat(service-rpc): client mints an idempotency key and retries safely makeClient() now mints one crypto.randomUUID() per logical call and sends it as the Idempotency-Key header on every attempt, byte-identical across retries of that call and never shared with another call. Every method gets a bounded, jittered retry (250 ms initial, x2, 5 s cap, up to 5 retries): thrown network errors, 429, and any 5xx retry; any other 4xx does not, since a malformed or unauthorized request stays wrong on a second try. This exists because PRO-217 (the Prisma Compute ingress closing a first-touch connection while a scale-to-zero target boots) hits every service-to-service RPC edge raw today; a blanket retry was unsafe until every call carried a key a server could dedupe on. Also drops the client's redundant re-validation of the server's response against the output schema: serve() already validates a handler's output before responding, and both ends of every edge are framework-provisioned, so the second pass only duplicated work. The MethodSchemas blindCast this required falls out too — the client now only needs method names off contract.__cmp. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> * feat(service-rpc)!: server requires the idempotency key and dedupes on it serve() now rejects a keyless request with a loud 400 naming the Idempotency-Key header. A duplicate key arriving while the first call for it is still executing single-flights onto that same execution, so the handler runs exactly once; a duplicate arriving after a completed 2xx/4xx answer replays it byte-identically for 60 seconds. A 5xx is never cached, since it's exactly the outcome a retry exists to re-execute. Storage is keyed by method first, then by idempotency key, so a lookup for one method can never find another method's cached answer, even if a key is reused across methods. The cache is LRU-bounded at 1000 entries so it stays a small, fixed amount of memory per provider process regardless of traffic. Handlers gain an optional third argument, ctx: RpcHandlerContext, carrying ctx.idempotencyKey after the existing deps argument — every existing two-argument handler still typechecks and runs unchanged. This is a breaking protocol change: a caller that doesn't go through makeClient (the client commit landing just before this one) must now supply its own key or its calls 400. This is permanent protocol semantics for this kind, not a compensation tied to any one platform incident — see PRO-217 for the motivating urgency. In-memory dedupe cannot cover a retry landing on a different instance than the one that applied the call; that residual is accepted, and the escalation path is a handler using ctx.idempotencyKey in its own transaction where its target's failure modes warrant it. Also lands the two remaining #114 fixes that touch this same dispatch path: - Request bodies are capped at 1 MiB, enforced against bytes actually read off the stream rather than the caller-supplied content-length (absent or lying, the cap still holds), and answered with 413. - A handler throw or an output-validation failure now masks its message behind a generic 500 and logs the real error server-side, so an internal exception string never reaches the caller. Input-validation 400s are unchanged — that detail is the caller's own malformed request and is how they fix it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> * fix(cron): give serve-schedule's test requests an idempotency key serve() now requires every request to carry an Idempotency-Key header; serveSchedule()'s production callers already get one for free since they go through makeClient (the trigger dep hydrates via rpc()), but this suite builds its trigger requests by hand and was failing 400 before reaching the behavior each test actually exercises. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> * docs(adr-0037): service RPC calls carry an idempotency key Records the decision the slice implements: one key per logical call, reused across retries; the server requires it, runs one call per key, and replays completed answers but never 5xx. No per-method opt-in, because a flag would be a claim the framework cannot verify. States what the in-process store does not cover and why that residue is deliberate, calibrated against connection-contracts scope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> * docs(connection-contracts): the network binding retries safely on a key Adds the idempotency-key protocol to the network-binding section as a property of the binding, not the contract, alongside the service key. Corrects the per-call check: the server validates both directions; the client no longer re-validates the response, since both ends are framework-generated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> * chore(drive): the rpc cold-start canary (PRO-217), plus a known limitation scripts/rpc-cold-start-canary.ts + rpc-cold-start-canary-classify.ts (+ its test) mirror cold-start-canary.ts wholesale: create -> upload -> start -> race the promote call, >=60s spacing including sample #0, 14-confirmed-cold-start-hold bug-gone budget, first-close early exit, MAX_RUN_MS self-stop, requirable exits. The probe is a bare single-attempt fetch straight at auth.service's POST /rpc/verify with a manually minted Idempotency-Key, never through makeClient — this slice just gave every framework RPC edge an automatic retry, and a probe through that client would mask the raw platform behavior being tested. New rpc-cold-start-canary job in e2e-deploy.yml, modeled on cold-start-canary, same deploy-verify-destroy action and needs: serialization, not added to any required-checks list. auth.service never logged anything on boot, so there was no listening line to read; examples/storefront-auth/modules/auth/src/server.ts now logs one self-timestamped line right after Bun.serve() returns (Compute's log relay passes plain app stdout through with no platform-added timestamp, verified live, so the app has to stamp its own, the same role streams-server's console.log patch plays for the streams face). auth.service is wired to exactly one RPC consumer (storefront), so per ADR-0030/0031 it enforces a per-edge bearer key this script cannot obtain: the Management API's own contract says an environment variable's value "is stored encrypted and is not returned by subsequent reads," confirmed live (an unauthenticated touch against a warm instance got back exactly serve()'s documented 401). Sending no Authorization header is the honest choice, and the classifier treats that specific, known 401 the same as a 2xx for classification purposes, since PRO-217's close happens at the ingress before serve()'s accepted-key check ever runs. Known limitation, evidenced by a 14-sample live run: auth.service's boot (spark starting bun -> the app's own listening line) is consistently well under 2s, so no touch's lead time can cross the 2000ms cross-clock-skew margin cold-start-canary-classify.ts's technique requires before calling a touch confirmed-cold. Every touch still raced a real boot (positive, consistent 376-1185ms gaps, 14/14 samples) and the close-decisive path is completely unaffected (a close needs no boot evidence), so the canary can still catch bug-present, but it cannot currently earn a bug-gone verdict against this target. Full numbers and a recommendation are in this dispatch's report to the orchestrator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> * Revert "the rpc cold-start canary (PRO-217)" — the canary is not warranted An RPC cold-start canary was dispatched but, on the evidence, dropped: 1. It cannot certify a cold start against examples/storefront-auth. The auth service restores no state, so it boots in under ~1.5s — narrower than the 2s clock-skew margin the coldness proof needs. The live run forced 14 real races (touch arrived 376-1185ms before listening, every time) yet certified none, correctly refusing to shrink the margin to fit. 2. More fundamentally, a cold-start canary exists to signal when a platform workaround can be deleted. The RPC idempotency retry is permanent protocol semantics (ADR-0037), not a workaround with an expiry — there is nothing for it to signal the removal of. And PRO-217 is a platform ingress bug, already watched by the streams cold-start canary, which detects it well precisely because streams has the long boot window RPC lacks. The slice ships the keyed protocol; the streams canary remains the platform-wide PRO-217 signal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> * chore(drive): record that the rpc canary was dropped on the evidence Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> * docs(gotchas): PRO-217 gains the service-RPC face — keyed retry, no canary The RPC client absorbs the cold-start close with a keyed, dedup-safe retry that is permanent protocol semantics, so there is no workaround to retire and no RPC canary; PRO-217 stays watched by the streams canary, whose long boot window RPC lacks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> * docs(guides): document the idempotency-key protocol on the RPC surface building-an-app gains a "Calls retry safely for you" section (the key, the safe retry, the keyless-400, the optional ctx third arg); getting-started and SKILL get terse pointers to it. Matches the new serve()/makeClient behavior from ADR-0037. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> * fix(service-rpc): close the review findings on the keyed protocol - A request body that errors mid-read now masks and logs like every other internal failure, instead of rejecting out of serve() (which broke its Response contract and skipped the operator log). New test, mutation-checked. - Add the missing test for output-validation masking (a handler returning schema-violating output → masked 500 + provider-bug log), mutation-checked. - Document why six retry attempts cover a boot up to ~22s (a held connection blocks until the server is listening; the budget only governs actively closed attempts) and that maxDelayMs is an unreached ceiling at maxRetries:5. - standard-schema.ts no longer claims the client re-validates responses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> * refactor(service-rpc): a keyless request opts out of dedup, not a 400 A request without an Idempotency-Key is now served once, without dedup or replay, instead of being rejected. The safe path is the generated client, which always pairs a key with the retry, so a keyless request never came from it — accepting it adds no unsafe retry path and lets curl and older callers work transparently, degrading gracefully instead of hard-breaking. ctx.idempotencyKey becomes string | undefined. Also: trim the oversized code comments down to what earns its place, and rewrite ADR-0037 to build the accepted mechanism directly (the rejected flag moves wholly to Alternatives) and to drop project-temporal framing. The keyless-400 wording is updated across connection-contracts, the guides, the skill, the ADR index, and the slice spec/plan. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> * docs(service-rpc): trim the comments flagged in review Remove the idempotency paragraph from serve()'s module header (the IdempotencyStore doc and the dispatch code already carry it) and tighten client.ts's header to its essentials. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> * fix(service-rpc): the LRU key separator was a literal NUL byte serve.ts embedded a raw NUL (0x00) as the method/key separator in the idempotency store's LRU key. A NUL makes git classify the whole file as binary, so GitHub rendered "Binary file not shown" and hid the entire diff. Written as the `\0` escape instead: identical runtime separator (still collision-proof — neither a method name nor a UUID contains NUL), text source. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> --------- Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Renames the RPC authoring surface to name what it connects, and records the scope that two recent proposals independently misread.
What changed
@prisma/composer/rpc→@prisma/composer/service-rpc(internal package@internal/service-rpc, directorypackages/0-framework/2-authoring/service-rpc). No compatibility alias — we are pre-adoption.import { rpc, contract, serve } from '@prisma/composer/service-rpc'. The kind brand stays'rpc'.connection-contracts.mdgains a Purpose and scope section: Connections are internal by definition; the service RPC kind exists for agent-generated, framework-connected service-to-service edges. It is not an application API layer and not general distributed-systems infrastructure — your app's own API is yours to build with anything, including full oRPC.Why
Bare "RPC" names the general concept, so it invites evaluation against general-purpose RPC frameworks and arbitrary-network standards. Two independent proposals made that category error within a month (#114 optimized the kind as an application API layer; a retry-semantics design optimized it as hostile-network infrastructure). Both were competent answers to a misread scope. "Service RPC" names what the kind connects — the same instinct as Cloudflare's service bindings.
Verification
Build, typecheck (58 tasks), tests (48 suites), and lint all pass after the rename.
🤖 Generated with Claude Code