The SDK is deliberately not an application framework. It answers questions
about identity, entitlement and storage; **your domain is yours**. This is one
worked slice showing where the seams go.

The example is a campaign. It has rules (you cannot send an empty one, or one
whose plan quota is spent), it is stored, and it is rendered.

## The layering

One direction, all the way down:

```
schema (Zod)  →  use-case  →  procedure (oRPC)  →  hook (TanStack Query)  →  component
                    ↓
              Connect SDK
```

A use-case may call Connect. A component never does — it calls a hook, which
calls a procedure. The two rules that make this hold:

- **Connect calls happen on the server.** `@cosmoops/connect-sdk/server` holds
  your API key and is fenced off from browser bundles by an export condition —
  importing it from a client component fails the build rather than shipping the
  key.
- **Entitlement is checked in the use-case, not the component.** A gate in the
  UI is a courtesy to the user; a check in the use-case is the one that
  actually holds.

## 1. The schema is the contract

```ts title="src/domain/campaign/campaign.schema.ts"
import { z } from "zod";

export const CampaignStatus = z.enum(["draft", "sending", "sent", "failed"]);

export const Campaign = z.object({
  id: z.string(),
  subject: z.string().min(1).max(200),
  body: z.string().min(1),
  status: CampaignStatus,
  recipientCount: z.number().int().nonnegative(),
});

export const CreateCampaignInput = Campaign.pick({ subject: true, body: true });

export type Campaign = z.infer<typeof Campaign>;
export type CreateCampaignInput = z.infer<typeof CreateCampaignInput>;
```

One schema, three jobs: it validates the input, it types the use-case, and it
becomes the API contract. A hand-written interface beside a validator is how
the two come to disagree.

## 2. The use-case holds the rules

```ts title="src/domain/campaign/create-campaign.use-case.ts"
import { ConnectClient } from "@cosmoops/connect-sdk/server";
import { CreateCampaignInput, type Campaign } from "./campaign.schema";

export class QuotaExceededError extends Error {}

export async function createCampaign(
  connect: ConnectClient,
  workspaceId: string,
  input: CreateCampaignInput,
): Promise<Campaign> {
  // Entitlement first, and on the server. A `ConnectFeatureGate` in the UI
  // hides the button; this is what stops a direct API call.
  const usage = await connect.billing.getAddonUsage(workspaceId);
  const credits = usage.addons.find((addon) => addon.addonId === "campaign-sends");
  if (credits && credits.remaining <= 0) {
    throw new QuotaExceededError("No campaign sends left on this plan's cycle.");
  }

  const created = await connect.data.create({
    workspaceId,
    collection: "campaigns",
    data: { ...input, status: "draft", recipientCount: 0 },
  });

  // Product analytics, not billing. Fire-and-forget by design — a metrics
  // failure must never fail the thing it was measuring.
  void connect.analytics.emit([
    { name: "campaign.created", workspaceId, props: { subject: input.subject } },
  ]);

  return { id: created.id, ...(created.data as Omit<Campaign, "id">) };
}
```

Three things worth noticing.

**The store is Connect's**, so there is no Firestore client, no security rules
and no second credential. `campaigns` is declared on your application's Store
tab; an undeclared name is refused rather than created.

**`_createdAt`/`_createdBy` come back for free** and cannot be forged — Connect
writes them and refuses any `_`-prefixed field from a caller.

**The quota check is where the money is.** `getAddonUsage` reads the balance;
`consumeAddon` debits it when the campaign actually sends, not when it is
drafted.

## 3. The procedure is thin

```ts title="src/server/router.ts"
import { os } from "@orpc/server";
import { getConnectSession } from "@cosmoops/connect-sdk/next";
import { CreateCampaignInput, Campaign } from "@/domain/campaign/campaign.schema";
import { createCampaign } from "@/domain/campaign/create-campaign.use-case";
import { connect } from "@/server/connect";

/** Resolves the caller once. The workspace comes from their session, never the input. */
const authed = os.use(async ({ next }) => {
  const session = await getConnectSession();
  if (!session) throw new Error("UNAUTHORIZED");
  return next({ context: { session } });
});

export const router = {
  createCampaign: authed
    .input(CreateCampaignInput)
    .output(Campaign)
    .handler(({ input, context }) =>
      createCampaign(connect, context.session.user.workspaceId, input),
    ),
};
```

<Callout type="warn" title="The workspace comes from the session">
  `context.session.user.workspaceId`, never an input field. A `workspaceId` a
  caller can supply is a cross-tenant write one forgotten check away — and
  since the session is what a workspace switch rotates, reading it here is also
  what makes switching work without touching this code.
</Callout>

The handler resolves a use-case and returns it. Authorization lives in the
middleware; business rules live in the use-case. Neither belongs here.

## 4. The hook owns cache behaviour

```ts title="src/hooks/use-create-campaign.ts"
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { client } from "@/lib/orpc";

export const campaignKeys = {
  all: ["campaigns"] as const,
  list: (filter: string) => [...campaignKeys.all, filter] as const,
};

export function useCreateCampaign() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: client.createCampaign,
    onSuccess: () => {
      // Prefix invalidation — every list, whatever its filter.
      queryClient.invalidateQueries({ queryKey: campaignKeys.all });
    },
  });
}
```

One hook per operation, keys in one place, invalidation by prefix. The keys
being a shared object rather than inline arrays is what stops a list somewhere
else in the app from quietly never refreshing.

### When not to be optimistic

Optimistic updates are right when you know the outcome at click time. Three
kinds of mutation here do not qualify, and patching the cache for them shows
the user something that is about to be untrue:

- **anything that settles on a webhook** — payments, verification, uploads
- **a workspace switch** — it rotates the session; reload instead
- **a store write that could hit a quota** — the server may refuse it

## 5. The component

```tsx title="app/campaigns/new/page.tsx"
"use client";

import { useConnectFeature } from "@cosmoops/connect-sdk/client";
import { ConnectPageShell, ConnectSection } from "@cosmoops/connect-sdk/ui";
import { useCreateCampaign } from "@/hooks/use-create-campaign";

export default function NewCampaign() {
  const canSend = useConnectFeature("campaigns");
  const create = useCreateCampaign();

  return (
    <ConnectPageShell title="New campaign">
      <ConnectSection title="Content">
        <CampaignForm
          isDisabled={!canSend}
          isPending={create.isPending}
          onSubmit={(values) => create.mutate(values)}
        />
      </ConnectSection>
    </ConnectPageShell>
  );
}
```

`useConnectFeature` disables the form; the use-case refuses the write. Both,
not either — the first is the explanation, the second is the enforcement.

## Where each piece lives

| Concern | Owner |
|---|---|
| Who is signed in, which workspace | Connect (`getConnectSession`) |
| What they may do on your application | Connect (`permissions`) |
| Whether their plan includes a feature | Connect (`useConnectFeature`) |
| Whether an action is *allowed* | Your use-case |
| What the data means | Your Zod schema |
| Where it is stored | Connect (`connect.data` / `connect.kv`) |
| Cache and invalidation | Your hooks |

The line worth holding: **Connect answers questions about the customer; your
code answers questions about your product.** Anything that starts "should this
user be able to…" is a question about the customer, and asking it yourself
means keeping a second copy of an answer Connect already has.