diff --git a/packages/rpc/src/contract.ts b/packages/rpc/src/contract.ts
new file mode 100644
index 00000000..ad7c27f3
--- /dev/null
+++ b/packages/rpc/src/contract.ts
@@ -0,0 +1,66 @@
+import type { ObjectSchema } from "@wrnexus/validation";
+import type { AnyProcedures, InferInput, ProcedureDef, ServiceContract } from "./types.ts";
+
+/** A service name lands in a URL path, so keep it unescaped-safe. */
+const SAFE_SERVICE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
+/** A procedure name is also a property the caller writes as client.doThing(). */
+const SAFE_PROCEDURE = /^[a-z][a-zA-Z0-9]*$/;
+
+/**
+ * Fluent, IMMUTABLE builder: every method returns a new builder, so a shared
+ * base can be branched without one branch mutating another.
+ */
+export class ProcedureBuilder {
+ private constructor(private readonly def: ProcedureDef) {}
+
+ static create(): ProcedureBuilder {
+ return new ProcedureBuilder({});
+ }
+
+ input>(schema: S): ProcedureBuilder, Output> {
+ return new ProcedureBuilder, Output>({
+ ...this.def,
+ input: schema,
+ } as ProcedureDef, Output>);
+ }
+
+ output(): ProcedureBuilder {
+ return new ProcedureBuilder({ ...this.def } as ProcedureDef);
+ }
+
+ permission(id: string): ProcedureBuilder {
+ return new ProcedureBuilder({ ...this.def, permission: id });
+ }
+
+ /** Mark safe to retry. Anything not marked is never retried. */
+ idempotent(): ProcedureBuilder {
+ return new ProcedureBuilder({ ...this.def, idempotent: true });
+ }
+
+ build(): ProcedureDef {
+ return Object.freeze({ ...this.def });
+ }
+}
+
+export const procedure = ProcedureBuilder.create();
+
+export function defineService(def: {
+ name: string;
+ procedures: Procedures;
+}): ServiceContract {
+ if (!SAFE_SERVICE.test(def.name)) {
+ throw new Error(
+ `WRN-RPC-CONTRACT: service name ${JSON.stringify(def.name)} must be lowercase ` +
+ `alphanumeric with single hyphens, e.g. "billing" or "billing-v2".`,
+ );
+ }
+ for (const name of Object.keys(def.procedures)) {
+ if (!SAFE_PROCEDURE.test(name)) {
+ throw new Error(
+ `WRN-RPC-CONTRACT: procedure name ${JSON.stringify(name)} on service ` +
+ `'${def.name}' must be a lowercase-initial identifier, e.g. "createInvoice".`,
+ );
+ }
+ }
+ return Object.freeze({ name: def.name, procedures: Object.freeze({ ...def.procedures }) });
+}
diff --git a/packages/rpc/src/index.ts b/packages/rpc/src/index.ts
index 054b9d19..4ec5e5ae 100644
--- a/packages/rpc/src/index.ts
+++ b/packages/rpc/src/index.ts
@@ -20,3 +20,5 @@ export type {
export { RPC_ERROR_CODES, ServiceError, failure, isRetryableStatus, success } from "./errors.ts";
export type { RpcErrorCode, ToResultOptions } from "./errors.ts";
+
+export { defineService, procedure, ProcedureBuilder } from "./contract.ts";
diff --git a/packages/rpc/test/contract.test.ts b/packages/rpc/test/contract.test.ts
new file mode 100644
index 00000000..d3acc9fe
--- /dev/null
+++ b/packages/rpc/test/contract.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, test } from "bun:test";
+import { v } from "@wrnexus/validation";
+import { defineService, procedure } from "../src/contract.ts";
+
+describe("defineService", () => {
+ test("captures a procedure's input schema, permission and idempotency", () => {
+ const billing = defineService({
+ name: "billing",
+ procedures: {
+ createInvoice: procedure
+ .input(v.object({ userId: v.string(), amountCents: v.number() }))
+ .output<{ invoiceId: string }>()
+ .permission("invoice:create")
+ .build(),
+ getInvoice: procedure
+ .input(v.object({ invoiceId: v.string() }))
+ .output<{ amountCents: number }>()
+ .idempotent()
+ .build(),
+ },
+ });
+
+ expect(billing.name).toBe("billing");
+ expect(Object.keys(billing.procedures).sort()).toEqual(["createInvoice", "getInvoice"]);
+ expect(billing.procedures.createInvoice.permission).toBe("invoice:create");
+ expect(billing.procedures.createInvoice.idempotent).toBeUndefined();
+ expect(billing.procedures.getInvoice.idempotent).toBe(true);
+ expect(billing.procedures.getInvoice.permission).toBeUndefined();
+ });
+
+ test("the contract is frozen so it cannot drift after definition", () => {
+ const contract = defineService({ name: "demo", procedures: { ping: procedure.build() } });
+ expect(Object.isFrozen(contract)).toBe(true);
+ expect(Object.isFrozen(contract.procedures)).toBe(true);
+ });
+
+ test("rejects a service name that is not a safe path segment", () => {
+ // The name lands in a URL path, so it must not need escaping.
+ for (const name of ["", "has space", "has/slash", "has.dot", "UPPER"]) {
+ expect(() => defineService({ name, procedures: {} })).toThrow(/service name/i);
+ }
+ expect(() => defineService({ name: "billing-v2", procedures: {} })).not.toThrow();
+ });
+
+ test("rejects a procedure name that is not a safe path segment", () => {
+ expect(() =>
+ defineService({ name: "demo", procedures: { "bad name": procedure.build() } }),
+ ).toThrow(/procedure name/i);
+ });
+
+ test("the builder is immutable — reusing a base does not cross-contaminate", () => {
+ const base = procedure.permission("a:read");
+ const one = base.idempotent().build();
+ const two = base.build();
+ expect(one.idempotent).toBe(true);
+ expect(two.idempotent).toBeUndefined();
+ expect(two.permission).toBe("a:read");
+ });
+});