# @beignet/core

> Core framework primitives for Beignet

> [!CAUTION]
> Beignet is experimental alpha software. The `0.0.x` package line is for early
> evaluation, and APIs may change between releases while the framework settles.

This package provides Beignet's framework primitives: contracts, server runtime,
typed client, use cases, agent capabilities, ports, domain helpers, app errors, config, events,
idempotency, locks, outbox, mail, notifications, payments, search, webhooks,
feature flags, error reporting, schedules, uploads, entitlements, pagination
helpers, testing helpers, and OpenAPI generation.

## Installation

```bash
npm install @beignet/core

# Use with your preferred Standard Schema library
npm install zod
# or
npm install valibot
# or
npm install arktype
```


## TypeScript requirements

This package requires TypeScript 5.0 or higher for proper type inference.

## Agent skills

This package ships a TanStack Intent skill for coding agents:
`@beignet/core#app-architecture`. Load it when adding or fixing Beignet
schemas, contracts, use cases, app errors, ports, policies, app context,
providers, domain events, workflow primitives, seeds, tests, or core subpath
imports.

## Subpaths

Install `@beignet/core` once, then import the framework area you need. The
package intentionally has no root entrypoint; use explicit subpaths so imports
name the framework area they depend on.

| Import path | Responsibility |
| --- | --- |
| `@beignet/core/agent-capabilities` | Typed agent capability definitions, registries, validation, and execution |
| `@beignet/core/application` | Use case builder and test helpers |
| `@beignet/core/client` | Typed HTTP client |
| `@beignet/core/client-only` | Static lint marker for modules intended for client-side imports |
| `@beignet/core/config` | Environment config validation |
| `@beignet/core/contracts` | HTTP contract builders, types, path helpers, and contract metadata |
| `@beignet/core/domain` | Entities, value objects, and domain events |
| `@beignet/core/entitlements` | Product access decision types, helpers, and static entitlement adapter |
| `@beignet/core/error-reporting` | Error reporting port, memory adapter, no-op adapter, and helpers |
| `@beignet/core/errors` | Error catalogs and response helpers |
| `@beignet/core/errors/http` | Framework HTTP error constants and status helpers |
| `@beignet/core/events` | Events and listeners |
| `@beignet/core/flags` | Feature flag definitions, FlagsPort, memory/static adapters, and helpers |
| `@beignet/core/idempotency` | Retry-safe command, webhook, and job primitives |
| `@beignet/core/jobs` | Job definitions, retry policies, timeout guards, execution hooks, execution lease helpers, uniqueness guards, and inline job dispatch |
| `@beignet/core/locks` | Lease-backed LocksPort, memory adapter, memory provider, and helpers |
| `@beignet/core/mail` | Mail port, memory mailer, and memory mailer provider |
| `@beignet/core/memo` | Request-scoped memoization for port lookups |
| `@beignet/core/notifications` | Notification definitions, dispatchers, inline notifications provider, mail channels, and test adapters |
| `@beignet/core/openapi` | OpenAPI generation |
| `@beignet/core/outbox` | Durable event and job outbox |
| `@beignet/core/payments` | Payments port, memory payments adapter, and memory payments provider |
| `@beignet/core/pagination` | Offset/cursor page types, normalizers, and result helpers |
| `@beignet/core/ports` | App-facing ports, auth, audit, policies, cache, storage, logging, and redaction |
| `@beignet/core/providers` | Provider lifecycle and instrumentation primitives |
| `@beignet/core/search` | Search index definitions, SearchPort, memory adapter, memory provider, and helpers |
| `@beignet/core/schedules` | Schedule primitives |
| `@beignet/core/server` | Framework-agnostic server runtime, security headers, CSRF, and hook helpers |
| `@beignet/core/server-only` | Static lint marker for modules that must stay out of client bundles |
| `@beignet/core/tasks` | Operational task definitions and inline task execution |
| `@beignet/core/tenancy` | Branded tenant scope helpers for repository boundaries |
| `@beignet/core/testing` | Port and policy assertions, recording adapters, test context factories, memory port fixtures, provider install helper, factories, seeds, and database harnesses |
| `@beignet/core/tracing` | Dependency-free W3C trace context primitives |
| `@beignet/core/uploads` | Upload definitions (`createUploads<AppContext>()` app-bound builder), router, signer port, and test signer |
| `@beignet/core/uploads/client` | Browser upload client for server and direct uploads |
| `@beignet/core/webhooks` | Inbound webhook definitions, verifiers, memory test verifier, and HMAC verifier |

Use boundary markers as side-effect imports so local linting and formatting do
not treat them as unused symbols:

```typescript
import "@beignet/core/client-only";
import "@beignet/core/server-only";
```

## Agent capabilities

Agent capabilities are validated application entrypoints for authenticated AI
agent transports. Definitions remain transport-neutral and should delegate
business behavior to the same use cases called by HTTP routes, jobs, and
scripts.

```typescript
import {
  createAgentCapabilities,
  createAgentCapabilityExecutor,
} from "@beignet/core/agent-capabilities";
import { z } from "zod";
import type { AppContext } from "@/app-context";
import { createIssueUseCase } from "@/features/issues/use-cases";

type AgentPrincipal = { agentId: string; userId: string };

const { defineAgentCapability, defineAgentCapabilityRegistry } =
  createAgentCapabilities<AppContext, AgentPrincipal>();

const createIssue = defineAgentCapability("issues.create", {
  description: "Create an issue in one workspace.",
  input: z.object({ workspaceId: z.string(), title: z.string().min(1) }),
  output: z.object({ id: z.string(), title: z.string() }),
  async handle({ ctx, input }) {
    const { workspaceId: _workspaceId, ...useCaseInput } = input;
    return createIssueUseCase.run({ ctx, input: useCaseInput });
  },
});

const registry = defineAgentCapabilityRegistry([createIssue]);

export const executor = createAgentCapabilityExecutor({
  registry,
  async createContext({ principal, input }) {
    const server = await import("@/server").then(({ getServer }) => getServer());
    const membership = await server.ports.members.findMembership({
      workspaceId: input.workspaceId,
      userId: principal.userId,
    });
    if (!membership) throw new Error("Not a workspace member");

    return server.createServiceContext({
      asUser: { id: principal.userId, role: membership.role },
      tenantId: input.workspaceId,
    });
  },
});
```

Input is validated before `createContext(...)` runs. Output is validated before
it reaches the transport. Context construction remains app-owned: authenticate
the transport first, re-read tenant membership from an authoritative port, and
call `server.createServiceContext(...)` instead of assembling `AppContext` by
hand. Use `@beignet/agent-auth-better-auth` to expose a registry through Better
Auth Agent Auth.

Registry creation comes from the same app-bound factory as
`defineAgentCapability`, so definitions with another context or principal type
are rejected. Executor hooks observe the complete attempt and include a
`stage`. Completion events contain the capability, context, principal,
validated input, validated output, and duration. Failure events expose context
and validated input only when execution reached those stages; raw malformed
input and unvalidated output are not exposed. Dynamic transport adapters may
provide `authorize(...)` to inspect the exact parsed input before context
construction without causing a second validation pass. Pass an independent
`instrumentation` target and `tracing` port to the executor when lookup,
input-validation, and context failures must be visible before an app context
exists. Without those options, successful context construction lets the
executor derive observability ports from `ctx`.

## Durable failure language

Jobs, outbox delivery, and schedule runners use the same terms:

- `attempt` is the one-based execution or delivery attempt currently being
  handled.
- `attempts` in a retry policy is the maximum total attempts, including the
  first try.
- `backoff` is the delay before the next retry.
- `timeout` is the maximum execution window for one handler attempt.
- `hook` is app-owned behavior that wraps one handler attempt.
- `execution lease` is a TTL-backed lock around one handler attempt for a
  logical job key.
- `terminal failure` means the work should not be retried automatically.
- `dead letter` is a durable terminal delivery state, currently owned by the
  outbox.

Error reporting follows the same terminal boundary. Retry attempts stay in
logs and instrumentation; exhausted or non-retryable work becomes an incident.
Runtime owners can use `tryReportException(...)` when reporting must never
replace application behavior:

```typescript
import { tryReportException } from "@beignet/core/error-reporting";

await tryReportException({
  reporter: ctx.ports.errorReporter,
  error,
  reportOptions: {
    mechanism: "app.import",
    tags: { "beignet.kind": "task" },
  },
});
```

Best-effort capture and its failure observer are each bounded to one second by
default. Set `timeoutMs` to a different positive duration, or explicitly use
`false` only for a reporter that is intentionally allowed to block the owning
runtime boundary. Timeouts surface to `onReporterError` as
`ErrorReportingTimeoutError` and otherwise resolve to `undefined`.

`redactErrorReportOptions(...)` applies Beignet's shared sensitive-key rules to
structured user, tag, context, and extra metadata. It intentionally does not
rewrite the original exception message or stack.

Jobs may also declare dispatch-time uniqueness and execution leases:

```typescript
import {
  createJobExecutionLeaseHook,
  createInlineJobDispatcher,
  createJobs,
  createUniqueJobDispatcher,
  type JobDef,
  retry,
} from "@beignet/core/jobs";
import type { LocksPort } from "@beignet/core/locks";
import { z } from "zod";

type AppContext = {
  ports: {
    billing: {
      syncAccount(
        accountId: string,
        options?: { signal?: AbortSignal },
      ): Promise<void>;
    };
    locks: LocksPort;
  };
};

const { defineJob } = createJobs<AppContext>();

const syncAccountPayloadSchema = z.object({
  accountId: z.string().min(1),
});

const syncAccountExecutionLease = createJobExecutionLeaseHook<
  JobDef<"billing.sync-account", typeof syncAccountPayloadSchema, AppContext>,
  AppContext
>({
  locks: ({ ctx }) => ctx.ports.locks,
  key: ({ payload }) => payload.accountId,
  ttl: "5m",
});

export const SyncAccountJob = defineJob("billing.sync-account", {
  payload: syncAccountPayloadSchema,
  unique: ({ payload }) => ({
    key: payload.accountId,
    ttl: "10m",
  }),
  timeout: "30s",
  retry: retry.exponential({ attempts: 3 }),
  hooks: [syncAccountExecutionLease],
  async handle({ payload, ctx, signal }) {
    await ctx.ports.billing.syncAccount(payload.accountId, { signal });
  },
});

export function createJobsPort(ctx: AppContext) {
  return createUniqueJobDispatcher({
    jobs: createInlineJobDispatcher<AppContext>({ ctx }),
    locks: ctx.ports.locks,
  });
}
```

`unique` suppresses duplicate dispatches while the resolved lock key's TTL is
active. It does not replace handler idempotency: providers may still execute a
queued job more than once after a worker crash or retry.
Dispatcher and transport boundaries validate the payload but preserve the
original JSON-safe value for the next boundary. The runner parses immediately
before handler execution, so transforming schemas produce the handler value
exactly once.

`timeout` bounds each handler attempt. When the timeout expires, Beignet throws
`JobTimeoutError`, aborts the handler's `signal`, and lets the job retry policy
decide whether the timeout should retry. Cancellation is cooperative: a
handler that ignores the signal can keep running while a retry-capable runner
starts another attempt. Propagate the signal, keep the handler idempotent, and
treat the timeout as terminal when overlapping attempts would be unsafe.

`hooks` wrap each handler attempt when the job runs through a Beignet
dispatcher or worker helper. Runner-level hooks, such as
`createInlineJobDispatcher({ hooks })`, wrap job-local hooks. Hook failures are
classified by the same retry policy as handler failures. When a runner can
report attempt metadata, hooks receive Beignet's one-based `attempt` and
`maxAttempts` values. Direct `job.handle(...)` calls bypass hooks; use
`runJobHandler(...)` or a dispatcher when a test needs hook behavior.

`createJobExecutionLeaseHook(...)` is the first built-in hook helper. It
acquires a TTL-backed `LocksPort` lease for one handler attempt, then releases
best effort in `finally`. It does not start renewal loops, so serverless
entrypoints can use it with a shared locks provider; the TTL remains the safety
boundary if the runtime terminates early. Unavailable leases skip by default,
or can throw `JobExecutionLeaseUnavailableError` for retry classification.

Schedules do not own retry policies. They can carry provider attempt metadata
through `ScheduleRunContext.attempt`, then dispatch jobs or outbox messages when
the work needs Beignet-managed retry and dead-letter behavior.

Outbox drains emit first-class provider instrumentation for delivered, retried,
and dead-lettered messages when you pass a devtools or instrumentation port to
`drainOutbox(...)`. Pass `instrumentationContext` when the worker has request or
trace IDs that should connect the drain to devtools rows.

## Provider-contributed ports

Apps bind app-owned ports directly and defer the rest to providers with the
curried `definePorts<AppPorts>()({ bound, deferred })` form. Deferred keys boot
as throwing placeholders, and `createServer(...)` fails startup with the
unbound key list unless `onUnboundPorts` is set to `"warn"` or `"ignore"`.

```typescript
import { definePorts } from "@beignet/core/ports";
import type { AppPorts } from "@/ports";

export const initialPorts = definePorts<AppPorts>()({
  bound: { gate },
  deferred: ["db", "logger", "mailer", "storage"],
});
```

Use `InferProviderPorts` with an `as const` provider list to type the runtime
ports without casts:

```typescript
import type { InferProviderPorts } from "@beignet/core/providers";
import type { AppPorts } from "@/ports";
import type { providers } from "@/server/providers";

export type AppRuntimePorts = AppPorts & InferProviderPorts<typeof providers>;
```

Reusable provider packages should export a named `ServiceProvider` return type
and use `AnyProviderConfigSchema<Config>` for its config generic. That keeps a
private Zod or other Standard Schema implementation out of the package
declaration while preserving the validated config output and exact
contributed-port inference. App-local providers should keep their concrete
schema inference because they do not have a package compatibility boundary.

App-local providers can declare required ports, app context, and
service-context input through the curried `createProvider()` form. `setup`
then receives typed `ports` and a `createServiceContext` factory that returns
the app context:

```typescript
import { createProvider } from "@beignet/core/providers";

export const appDatabaseProvider = createProvider<
  { db: DbPort<typeof schema>; devtools?: DevtoolsPort },
  AppContext,
  AppServiceContextInput
>()({
  name: "app-database",
  async setup({ ports, createServiceContext }) {
    const repositories = createRepositories(ports.db.drizzle);
    return { ports: repositories };
  },
});
```

Lifecycle hooks returned from `setup` should close over setup locals; a
`start(ctx)` hook with an unannotated parameter keeps TypeScript from inferring
the provided ports from the returned `ports` object.

## Dev-default providers

Core ships provider factories for the mail and notifications ports so apps can
defer those ports before choosing production infrastructure.

`createMemoryMailerProvider(options?)` contributes `{ mailer: MailerPort }`
backed by `createMemoryMailer(...)`. Deliveries are captured in memory and
recorded as `mail.sent` devtools events through the `mail` watcher when an
instrumentation port is installed. Options extend
`CreateMemoryMailerOptions` (`defaultFrom`, `now`, `id`, `onSend`) plus a
provider `name` that defaults to `"memory-mailer"`.

The shared address formatter used by Beignet's Resend and SMTP providers
rejects carriage returns and line feeds in email addresses and display names,
and safely escapes quoted display names. This blocks address fields from
injecting additional mail header lines; providers still own full email-syntax
validation.

`createInlineNotificationsProvider(options?)` contributes
`{ notifications: NotificationPort }` backed by
`createInlineNotificationDispatcher(...)`. Channel handlers receive an app
service context built lazily through the server context blueprint on each
send, so registration order does not matter. One failed channel does not block
the remaining channels. Inline sends return ordered `sent`, `skipped`, and
`failed` results; queued dispatchers also return `queued`. Set
`failureMode: "throw"` to reject after every channel has run. Options also
accept an app-owned `preferences` evaluator, the dispatcher's `onError` result
mapper, and a provider `name` that defaults to `"inline-notifications"`.

```typescript
// server/providers.ts
import { createMemoryMailerProvider } from "@beignet/core/mail";
import { createInlineNotificationsProvider } from "@beignet/core/notifications";

export const providers = [
  createMemoryMailerProvider({
    defaultFrom: "App <noreply@example.local>",
  }),
  createInlineNotificationsProvider(),
] as const;
```

Replace `createMemoryMailerProvider(...)` with a real mail provider such as
`@beignet/provider-mail-resend` for production delivery. Production apps can
keep the inline provider or define a central notification registry and a
`defineNotificationDeliveryJob(...)`. Install
`createQueuedNotificationsProvider(...)` after the app's jobs provider to
enqueue one independently retryable job per channel. Register the delivery job
with every BullMQ/Inngest worker or outbox registry that can receive it.

```typescript
// server/notifications.ts
import {
  defineNotificationDeliveryJob,
  defineNotificationRegistry,
} from "@beignet/core/notifications";
import type { AppContext } from "@/app-context";
import { WelcomeNotification } from "@/features/users/notifications";

export const notificationRegistry = defineNotificationRegistry<AppContext>([
  WelcomeNotification,
]);

export const DeliverNotificationJob =
  defineNotificationDeliveryJob<AppContext>({
    registry: notificationRegistry,
  });
```

```typescript
// server/index.ts
import { createQueuedNotificationsProvider } from "@beignet/core/notifications";
import { createNextServer, createNextServerLoader } from "@beignet/next";

export const getServer = createNextServerLoader(async () => {
  const { providers } = await import("./providers");
  const { DeliverNotificationJob } = await import("./notifications");

  return createNextServer({
    // ...
    providers: [
      ...providers,
      createQueuedNotificationsProvider({
        deliveryJob: DeliverNotificationJob,
      }),
    ],
  });
});
```

The delivery job defaults to three attempts with exponential backoff. The
queued dispatcher validates notification payloads before enqueueing and uses
the app's existing `jobs` port, so the same setup works with direct job
providers or `createOutboxJobDispatcher(...)`.

## Entitlements

Use `@beignet/core/entitlements` for product access decisions derived from
app-owned billing or plan state. The resolver maps durable app state to
allow/deny decisions; `requireEntitlement(...)` enforces the decision from a
use case and throws a framework-owned 403 by default.

```typescript
import {
  createEntitlements,
  type EntitlementDecisionObserver,
  requireEntitlement,
} from "@beignet/core/entitlements";
import { createTenant } from "@beignet/core/ports";
import { createTenantScope } from "@beignet/core/tenancy";

function createBillingEntitlements(
  billing: BillingRepository,
  recordDecision?: EntitlementDecisionObserver,
) {
  return createEntitlements({
    async inspect(input) {
      if (input.subject.type !== "tenant") return false;
      const account = await billing.findByTenantScope(
        createTenantScope(createTenant(input.subject.id)),
      );
      return account?.status === "active";
    },
    onDecision: recordDecision,
  });
}

await requireEntitlement(ctx, {
  entitlement: "todos.create",
  subject: { type: "tenant", id: tenantId },
});
```

`onDecision` is diagnostic only. Observer errors are ignored and cannot change
the entitlement result.

## Feature flags

Use `@beignet/core/flags` for typed feature flag definitions and
provider-neutral evaluation. Flags always carry a default value, and provider
failures return that default instead of throwing into product workflows.

```typescript
import { defineFlag, defineFlags } from "@beignet/core/flags";

export const billingFlags = defineFlags({
  newCheckout: defineFlag.boolean("billing.new-checkout", {
    default: false,
  }),
});

const enabled = await ctx.ports.flags.evaluate(billingFlags.newCheckout, {
  context: {
    targetingKey: ctx.actor.id,
    tenant: ctx.tenant,
    requestId: ctx.requestId,
  },
});
```

Plain evaluation does not record exposure. Call `recordExposure(...)`
explicitly when a user actually sees or can be affected by the flagged
behavior. Use `createMemoryFlags(...)` or `createStaticFlags(...)` in tests, or
install `@beignet/provider-flags-openfeature` for production providers.
String and number flags widen to `string` and `number` by default; pass a
generic when an app wants a closed variant union.

## Error reporting

Use `@beignet/core/error-reporting` for provider-neutral exception and message
capture. The port accepts severity, tags, user, contexts, extra metadata, and
request/trace correlation IDs.

```typescript
import { createMemoryErrorReporter } from "@beignet/core/error-reporting";
import { createErrorReportingHooks } from "@beignet/core/server";
import type { AppContext } from "@/app-context";

await ctx.ports.errorReporter.captureException(error, {
  level: "error",
  requestId: ctx.requestId,
  traceId: ctx.traceId,
  tags: { feature: "billing" },
});

const errorReporter = createMemoryErrorReporter();

export const hooks = [createErrorReportingHooks<AppContext>()];
```

Use `createMemoryErrorReporter(...)` in tests, `createNoopErrorReporter()` when
an app needs a bound port without capture, and
`createErrorReportingHooks(...)` in `server/index.ts` to capture unexpected HTTP
failures without changing response mapping. Install
`@beignet/provider-error-reporting-sentry` for production providers.

## Locks and leases

Use `@beignet/core/locks` for provider-neutral lease-backed lock coordination.
Locks prevent overlapping schedules, singleton jobs, cache stampedes, and short
critical sections across multiple workers or servers.

```typescript
import { createMemoryLocks } from "@beignet/core/locks";

await ctx.ports.locks.withLease(
  "schedule:daily-report",
  { ttlMs: 60_000, waitMs: 0 },
  async ({ lease }) => {
    await runDailyReport(ctx, { fencingToken: lease.fencingToken });
  },
);

const locks = createMemoryLocks();
```

To resume ownership in a later invocation, call
`locks.restore(key, ownerToken, { ttlMs, expiresAt?, fencingToken? })` with
persisted state. The required `ttlMs` becomes the default for `renew()`;
omitted expiry and fencing metadata stay unknown. Stale handles cannot delete
or renew a newer owner's lease.

Use `createMemoryLocks(...)` in tests, `createMemoryLocksProvider()` for local
provider wiring, or install `@beignet/provider-locks-redis` for production
leases.

## Search

Use `@beignet/core/search` for provider-neutral search index definitions,
document indexing, and querying searchable read models.

```typescript
import { defineSearchIndex } from "@beignet/core/search";

const issueSearchIndex = defineSearchIndex("issues", {
  searchableAttributes: ["key", "title", "description"],
  filterableAttributes: ["tenantId", "status"],
  sortableAttributes: ["createdAt"],
});

await ctx.ports.search.indexDocuments(issueSearchIndex, issueDocument);

const results = await ctx.ports.search.search(issueSearchIndex, {
  query: "billing",
  filters: { tenantId },
  sort: ["createdAt:desc"],
  limit: 20,
});
```

Use `createMemorySearch(...)` in tests, `createMemorySearchProvider()` for local
provider wiring, or install `@beignet/provider-search-meilisearch` for
production search.
Provider adapters may require query fields to be declared in the index
metadata. For Meilisearch, `filters` and `facets` must use
`filterableAttributes`, and `sort` must use `sortableAttributes`.

## Storage

Use `StoragePort` for provider-neutral object storage and
`createMemoryStorage()` in tests. Storage keys are relative object paths with
one shared contract across memory, local disk, S3, and Vercel Blob adapters.

Custom storage adapters can reuse the same validation, prefix, and public-URL
behavior:

```typescript
import {
  assertValidStorageKey,
  createStoragePublicUrl,
  normalizeStorageKeyPrefix,
  prefixStorageKey,
} from "@beignet/core/ports";

assertValidStorageKey("projects/report.json");

const keyPrefix = normalizeStorageKeyPrefix("/production/");
const providerKey = prefixStorageKey({
  keyPrefix,
  key: "projects/report.json",
});
const publicUrl = createStoragePublicUrl({
  publicBaseUrl: "https://assets.example.com",
  key: providerKey,
});
```

The shared assertion rejects empty keys, control characters, leading or
trailing slashes, backslashes, empty segments, and `.` / `..` segments.
Provider adapters may add narrower restrictions for their own internal
namespaces.

## Uploads

Use `@beignet/core/uploads` for typed file workflows above `StoragePort`.
Upload definitions own metadata validation, authorization, storage keys, file
constraints, direct-upload signing, and completion hooks.

```typescript
import { createUploads } from "@beignet/core/uploads";
import { z } from "zod";

const { defineUpload } = createUploads<AppContext>();

export const issueAttachmentUpload = defineUpload("issues.attachment", {
  metadata: z.object({ issueKey: z.string() }),
  file: {
    contentTypes: ["application/pdf", "text/plain"],
    maxSizeBytes: 5 * 1024 * 1024,
    checksum: { algorithm: "sha256" },
  },
  authorize({ ctx }) {
    return ctx.actor.type === "user";
  },
  key({ ctx, metadata, uploadId }) {
    const actorId = ctx.actor.type === "user" ? ctx.actor.id : "anonymous";
    return `issues/${actorId}/${metadata.issueKey}/attachments/${uploadId}`;
  },
  async verifyFile({ ctx, file }) {
    const scan = await ctx.ports.fileScanner.scanObject(file.key);

    return scan.clean
      ? true
      : { valid: false, reason: "Upload did not pass scanning." };
  },
  async onComplete({ ctx, files }) {
    await ctx.ports.issueAttachments.upsertByUploadId({
      id: files[0]!.uploadId,
      key: files[0]!.key,
    });
  },
});
```

For supported media types, uploads verify the declared content type against the
file signature before completion. Set `contentTypeVerification: false` only for
workflows that intentionally accept mismatched supported file types. Direct
uploads can require a SHA-256 checksum with `checksum: { algorithm: "sha256" }`;
browser clients need a client-safe manifest so `@beignet/core/uploads/client`
can compute the digest before `prepare`. Use `verifyFile(...)` for app-owned
scanning, moderation, and quarantine decisions that run after the object exists
in storage and before `onComplete(...)`. Server uploads authorize each file
before reading its bytes for signature or checksum verification. If key
derivation, storage, or verification fails before `onComplete(...)` begins,
the router deletes every object already stored by that request before returning
the original error. Cleanup failures are instrumented as
`upload.server.cleanup.failed`. Once app-owned completion begins, Beignet
leaves the objects in place because the app may already have persisted durable
references and therefore owns transaction or compensation. Direct-upload
objects likewise remain app-owned because they existed before the completion
request.

Direct-upload completion is stateless. Beignet does not retain issuance or
single-use state between `prepare` and `complete`, so keys must include the
relevant actor, tenant, or resource owner and `onComplete(...)` must be
idempotent by upload ID or object key. Use an app-owned issuance table when a
workflow requires single-use completion or revocation.

`createUploadRouter(...)` bounds JSON request bodies and server-handled
multipart bodies. Multipart limits are enforced against both a declared
`Content-Length` and the bytes actually read, so chunked requests cannot bypass
`limits.multipartMaxBytes` before `formData()` parsing.

Upload route failures use Beignet's flat `{ code, message, details? }` error
body. The typed upload client maps that response to `UploadClientError` with
the same code, status, and details.

## Webhooks

Use `@beignet/core/webhooks` for provider-neutral inbound webhook definitions,
raw-body verification, typed event payload catalogs, and test verifiers.

```typescript
import {
  createHmacWebhookVerifier,
  defineWebhook,
} from "@beignet/core/webhooks";
import { z } from "zod";

export const issueWebhook = defineWebhook("issues.provider", {
  provider: "provider",
  events: {
    "issue.created": z.object({
      id: z.string(),
      type: z.literal("issue.created"),
      issueId: z.string(),
    }),
  },
  verifier: createHmacWebhookVerifier({
    secret: process.env.PROVIDER_WEBHOOK_SECRET ?? "",
    signatureHeader: "x-provider-signature",
    signaturePrefix: "sha256=",
    timestamp: {
      header: "x-provider-timestamp",
      toleranceSec: 300,
    },
  }),
});
```

Use `createMemoryWebhookVerifier(...)` in tests and `createWebhookRoute(...)`
from `@beignet/next` to expose raw-body webhook routes in Next.js apps. Use a
provider package such as `@beignet/webhooks-github` or
`@beignet/webhooks-stripe` when a vendor has signature semantics beyond
the generic HMAC verifier. For billing flows backed by `ctx.ports.payments`,
use `@beignet/core/payments` with `createPaymentWebhookRoute(...)` from
`@beignet/next` instead of a generic webhook catalog.

Feature webhook definitions stay provider-free: `defineWebhook(...)` catalogs
are contract-reachable code, and contract-reachable code cannot import
`@beignet/provider-*` packages — `beignet lint` enforces this dependency
direction. Attach provider verifiers at the route boundary through the
`verify` option of `createWebhookRoute(...)`; the inline `verifier:` option on
`defineWebhook(...)` is reserved for the core verifiers
(`createHmacWebhookVerifier(...)`, `createMemoryWebhookVerifier(...)`) and for
tests.

Generic webhook catalogs reject verified event types that are not declared in
`events` by default. Set `allowUnknownEvents: true` on
`createWebhookRoute(...)` or `verifyWebhook(...)` only for broad provider
endpoints that intentionally acknowledge valid events the app does not handle.
When a generic HMAC provider signs a timestamp header or payload field, pass
`timestamp` to reject replayed deliveries outside the configured tolerance.
Header mode authenticates the exact `<timestamp>.<rawBody>` bytes; payload mode
authenticates the raw body containing the timestamp.

## Provider metadata

Reusable provider packages should declare static metadata in `package.json`
under `beignet.provider`. That manifest metadata is package-owned and
side-effect-free, so CLI diagnostics can inspect installed provider packages
without importing provider implementation code.

```json
{
  "beignet": {
    "provider": {
      "displayName": "Cache provider",
      "ports": ["cache"],
      "appPorts": [{ "name": "cache", "type": "CachePort" }],
      "env": ["CACHE_URL", "CACHE_REGION"],
      "requiredEnv": ["CACHE_URL"],
      "requiredTables": ["cache_entries"],
      "registration": {
        "required": true,
        "tokens": ["createCacheProvider"]
      },
      "watchers": ["cache"]
    }
  }
}
```

`env` lists all variables the provider may read. `requiredEnv` is the subset
that `beignet doctor --strict` should require in app config. `requiredTables`
lists database tables the provider always needs when it is installed and used;
doctor checks app schema, migrations, and database setup files for those names.
When a provider supports mutually exclusive credential paths, use
`requiredEnvAlternatives` instead of `requiredEnv`. Each nested array is one
complete configuration; doctor, provider audit, and preflight accept the
provider when any one is complete:

```json
{
  "env": ["API_TOKEN", "OIDC_CLIENT_ID", "OIDC_TOKEN"],
  "requiredEnvAlternatives": [
    ["API_TOKEN"],
    ["OIDC_CLIENT_ID", "OIDC_TOKEN"]
  ]
}
```

`registration.required: true` marks providers that apps must register in
`server/providers.ts`; doctor reports a missing registration as a warning,
which fails `beignet doctor --strict`. Optional-by-design providers such as
`@beignet/devtools` can declare `registration.severity: "hint"` instead, so an
installed-but-unregistered package is reported as an informational hint that
never fails doctor, even in strict mode. Use
`parseProviderPackageMetadata(...)` to validate manifest metadata before
publishing a provider package.

Provider objects can also declare optional runtime-inert metadata for app-local
tooling and documentation. It does not change runtime setup; it describes the
package, contributed ports, required prior ports, env vars, and devtools
watchers owned by the provider.

```typescript
import { createProvider } from "@beignet/core/providers";

export const cacheProvider = createProvider({
  name: "cache",
  metadata: {
    packageName: "@acme/beignet-provider-cache",
    ports: ["cache"],
    env: ["CACHE_URL"],
    watchers: ["cache"],
  },
  setup() {
    return { ports: { cache: createCachePort() } };
  },
});
```

## Tasks

Use `@beignet/core/tasks` for app-owned operational entrypoints such as
backfills, maintenance work, and one-off repair scripts. Tasks are not HTTP
routes and are not background jobs; they are explicit functions a CLI or worker
can run with parsed input and an application context. Run them with
`runTask(...)` or `beignet task run`, and collect them with `defineTasks(...)`.

```typescript
import { createTasks } from "@beignet/core/tasks";
import { z } from "zod";
import type { AppContext } from "@/app-context";

const { defineTask } = createTasks<AppContext>();

export const backfillSearchTask = defineTask("posts.backfill-search", {
  input: z.object({
    dryRun: z.boolean().default(true),
  }),
  async handle({ input, ctx }) {
    ctx.ports.logger.info("Backfill started", {
      dryRun: input.dryRun,
    });
  },
});
```

Feature-owned task files should usually call use cases, repositories, or
ports rather than hiding business rules inside a script.

## Key concepts

### Contract

A **contract** is the single source of truth for an API endpoint. It describes:

- HTTP method and path (with path parameters)
- Path parameters, query parameters, request headers, and request body schemas
- Response schemas (per status code, including error responses)
- Metadata for auth, rate limiting, idempotency, etc.

### Contract group

A **contract group** allows you to share configuration across related endpoints, such as a common namespace, route metadata, headers, and shared response schemas.

## Usage

### Defining contracts

```ts
import { z } from "zod";
import { defineContractGroup } from "@beignet/core/contracts";

// Create a contract group for related endpoints
const todos = defineContractGroup()
  .namespace("todos")
  .prefix("/api/todos")
  .meta({ auth: "required" })
  .headers(z.object({
    authorization: z.string().startsWith("Bearer "),
  }));

// Define schemas
const TodoSchema = z.object({
  id: z.string(),
  title: z.string(),
  completed: z.boolean(),
});

const CreateTodoRequest = z.object({
  title: z.string().min(1),
  completed: z.boolean().optional(),
});

// Define contracts
export const getTodo = todos
  .get("/:id")
  .pathParams(z.object({ id: z.string() }))
  .responses({ 200: TodoSchema })
  .errors({
    TodoNotFound: {
      code: "TODO_NOT_FOUND",
      status: 404,
      message: "Todo not found",
      details: z.object({ id: z.string() }),
    },
  });

export const createTodo = todos
  .post("/")
  .body(CreateTodoRequest)
  .responses({ 201: TodoSchema });

export const listTodos = todos
  .get("/")
  .query(z.object({ 
    completed: z.boolean().optional(),
    limit: z.coerce.number().optional(),
  }))
  .responses({ 200: z.array(TodoSchema) });
```

Clients and OpenAPI generation infer required path argument keys from literal
path templates. Use `.pathParams(...)` when you want runtime validation,
coercion, richer OpenAPI schemas, or parameter descriptions.

`createServer(...)` enforces registration-time guarantees: each method + path
may only be registered once, contract names must be unique across the route
registry because typed clients, OpenAPI operations, and devtools key on them,
and an introspectable `.pathParams(...)` object schema must declare exactly the
`:param` keys from the path template. Mismatches fail server startup with the
contract name and path. Opaque Standard Schemas skip that registration-time key
comparison; OpenAPI falls back to required string parameters from the literal
path template unless a custom schema introspector is supplied. At dispatch
time, a request that matches a registered
path with an unregistered method receives a framework-owned `405
METHOD_NOT_ALLOWED` response with an `Allow` header listing the registered
methods. `GET` routes also serve `HEAD` when no explicit `HEAD` route exists;
explicit `HEAD` routes take precedence, and every `HEAD` response is bodyless.

### Runtime integrity

Workflow artifacts are explicit too. Use `createRuntimeIntegrity(...)` when an
app should fail startup if a listener, schedule, task, or outbox event/job is
listed in the app manifest but missing from the runtime registries:

```ts
import {
  createRuntimeIntegrity,
  defineRuntimeManifest,
  defineRuntimeRegistries,
} from "@beignet/core/server";
import { postEvents } from "@/features/posts/domain/events";
import { postJobs } from "@/features/posts/jobs";
import { postListeners } from "@/features/posts/listeners";
import { listeners } from "@/server/listeners";
import { outboxRegistry } from "@/server/outbox";

export const runtimeIntegrity = createRuntimeIntegrity({
  manifest: defineRuntimeManifest({
    listeners: [...postListeners],
    outbox: {
      events: [...postEvents],
      jobs: [...postJobs],
    },
  }),
  registries: defineRuntimeRegistries({
    listeners,
    outbox: outboxRegistry,
  }),
});
```

Pass `integrity: runtimeIntegrity` to `createServer(...)` or
`createNextServer(...)`. The check is pure and serverless-safe: it compares
imported definitions and registries in memory, without filesystem scanning,
provider calls, database access, worker startup, or background loops. Use
`mode: "warn"` to log findings without failing boot.

Contract path templates intentionally support concrete segments and
single-segment params such as `:id` and `[id]`. Framework or platform
catch-all route files can expose a central Beignet handler, but individual
contracts should stay on explicit paths; catch-all contract patterns such as
`/files/[...path]` are rejected.

For routes that cannot be contracts at all — third-party callback endpoints
with externally defined request shapes, signature-verified webhooks,
streaming endpoints that own body consumption —
`server.rawRoute({ name, method, path, metadata }).handle(fn)` builds a
handler that still runs the whole pipeline (hooks, context creation,
instrumentation, framework error mapping) without contract parsing or
validation. The request body stays unconsumed for the handler, `metadata`
feeds metadata-driven hooks such as rate limiting exactly like contract
metadata, and the route is not added to the registry — the adapter mounts
the returned handler at the route's own path.

Use `.headers(...)` for request headers that are part of the endpoint contract. Declare header keys in lowercase; server and client runtime matching is case-insensitive.

Request bodies are supported for `POST`, `PUT`, and `PATCH` contracts only.
JSON bodies require `Content-Type: application/json`; otherwise the runtime
passes the body to validation as text. When a missing content type accompanies
a valid JSON object or array that fails validation, the framework-owned error
includes a targeted `details.hint` without changing the response code.

If you do not pass `name`, Beignet generates one from the HTTP method and full path:

```ts
defineContract({ method: "GET", path: "/users/:id" }).name;
// "getUsersById"

defineContract({ method: "POST", path: "/api/todos" }).name;
// "createTodos"
```

Auto-generated names ignore a leading `/api` segment, include path parameters as `By...`, and are used as defaults in places like React Query keys and OpenAPI `operationId`s. Pass `name` explicitly when you need a custom stable identifier.

### Path prefixes

Use `.prefix(...)` on a contract group to compose shared URL path segments without repeating them on every route:

```ts
const api = defineContractGroup().prefix("/api/v1");

const todos = api
  .namespace("todos")
  .prefix("/todos");

export const listTodos = todos.get("/");
// GET /api/v1/todos

export const getTodo = todos.get("/:id");
// GET /api/v1/todos/:id
```

Prefixes compose immutably and normalize boundary slashes. `namespace()` controls
resource identity for contract names, OpenAPI tags, and client cache grouping;
`prefix()` only controls URL paths.

For public API versions, keep request and response shapes explicit with path
prefixes. Header negotiation remains app-owned. Mark an old contract or whole
version group with `.deprecated(...)` while it is still served:

```ts
const v1 = defineContractGroup()
  .namespace("legacyTodos")
  .prefix("/api/v1/todos")
  .deprecated({
    since: "2026-07-11T00:00:00Z",
    sunset: "2027-01-01T00:00:00Z",
    reason: "Use the current todos collection.",
    replacement: "/api/todos",
    documentation: "https://docs.example.com/migrations/todos-v1",
  });
```

The metadata sets OpenAPI `deprecated: true`, adds
`x-beignet-deprecation`, and sends standard `Deprecation`, `Sunset`, and
deprecation-documentation `Link` response headers. UTC ISO 8601 timestamps are
validated when contracts are built or registered.

### Test app fixtures

Use `@beignet/core/testing` to build app contexts and common memory ports
without hand-rolling audit, event, job, mail, notification, outbox, storage,
idempotency, logger, clock, and UOW setup in every test:

```ts
import { createUseCaseTester } from "@beignet/core/application";
import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
import {
  createTestTenant,
  createTestUserActor,
} from "@beignet/core/testing";

const fixture = createTestPorts<AppContext["ports"]>({
  base: initialPorts,
  overrides: {
    gate: initialPorts.gate,
    posts: { findById: async (id) => postRecord(id) },
  },
});
const createContext = createTestContextFactory<AppContext, AppContext["ports"]>({
  ports: fixture.ports,
  actor: createTestUserActor("user_test"),
  auth: { user: { id: "user_test" } },
  tenant: createTestTenant("tenant_example"),
});
const tester = createUseCaseTester<AppContext>(createContext);
```

The returned fixture exposes captured side effects such as `events`,
`dispatchedJobs`, `audit.entries`, `mailer.deliveries`,
`notifications.deliveries`, `outbox.messages`, and memory storage for
assertions.

`overrides` is typed as `TestPortsOverrides<Ports>`, which accepts typed
partial ports without casts. The partial rule is one level deep: an
object-valued port may supply only the members the test needs, and any missing
member becomes a named throwing function (`Test port "posts.update" was called
but not provided.`). Function-valued ports, class instances, and other exotic
objects are supplied whole — nested config objects are not partial.

The default `audit` port is wrapped with `createAmbientAuditLog(...)`, so
entries recorded inside an active request context inherit actor, tenant,
request ID, and trace ID exactly like production. `fixture.audit` still
exposes the underlying memory port for `entries` assertions.

#### One-call test contexts

Use `createTestContext(...)` when a job, listener, schedule, notification, or
task test needs a full app context instead of a repeated factory:

```ts
import { createTestContext } from "@beignet/core/testing";

const makeContext = createTestContext<AppContext>();

it("audits handled jobs", async () => {
  using fixture = makeContext({
    ports: { issues: { findById: async (id) => issueRecord(id) } },
  });

  await IndexIssueJob.handle({ job: IndexIssueJob, payload, ctx: fixture.ctx });

  expect(fixture.audit.entries).toMatchObject([
    { action: "jobs.issues.index", requestId: "test-request" },
  ]);
});
```

The fixture assembles `ctx` with actor (default
`createTestSystemActor("test-system")`), tenant, request ID, trace ID, `auth`,
ports, and a live bound `ctx.gate`. It also enters the ambient request context
so ambient enrichment (such as the default audit port) behaves like the
server; `using` (or an explicit `dispose()`) clears it:

```ts
let fixture: ReturnType<ReturnType<typeof createTestContext<AppContext>>>;

afterEach(() => {
  fixture.dispose();
});
```

Pass `ambient: false` to skip ambient entry. Reading an app port that is
neither a kit default nor supplied throws a named error
(`App port "tweets" is not bound in this test context.`), so partial port
wiring fails on use instead of failing silently.

#### Transactional domain events

When a use case records domain events through a buffered recorder on the
transaction ports, pass `transaction.outbox: true` to enqueue `tx.events` to
`ports.outbox` after commit and clear them after rollback:

```ts
import { createDomainEventRecorder } from "@beignet/core/ports";

const fixture = createTestPorts<AppContext["ports"], AppTransactionPorts>({
  transaction: {
    ports: (ports) => ({ ...ports, events: createDomainEventRecorder() }),
    outbox: true,
  },
});
```

`transaction.outbox` requires `transaction.ports` to include an `events`
recorder created by `createDomainEventRecorder()`; the kit throws a named error
otherwise. `createOutboxEventRecorder(...)` writes immediately through a
transaction-scoped outbox port and is intentionally not a buffered recorder.

#### Sharing the server context blueprint

Declare the `context` blueprint once with `defineServerContext(...)` from
`@beignet/core/server` and keep it in a canonical `server/context.ts` file.
The same value round-trips through `createServer(...)` adapters and
`createTestApp(...)` from `@beignet/web/testing` with full inference:

```ts
// server/context.ts
import { defineServerContext } from "@beignet/core/server";

export const appContext = defineServerContext<AppContext, AppPorts>()({
  gate: (ports) => ports.gate,
  request: async ({ req, ports, requestId, trace }) => ({
    actor: await resolveActor(req),
    auth: null,
    requestId,
    ...trace,
    ports,
  }),
  service: ({ ports, requestId, trace }) => ({
    actor: createServiceActor("app-service"),
    auth: null,
    requestId,
    ...trace,
    ports,
  }),
});
```

```ts
// server/index.ts
const server = await createNextServer({ ports, routes, context: appContext });

// features/<feature>/tests/routes.test.ts
import { createTestApp } from "@beignet/web/testing";

const app = await createTestApp({ ports, routes, context: appContext });
```

The `service` factory powers two server entrypoints:

- `server.createServiceContext(...)` returns the built context and enters the
  ambient correlation frame for the rest of the caller's async execution. Use
  it from long-lived runtimes only: servers, workers, and test runners.
- `server.runServiceContext(...)` builds the same context and runs a callback
  inside a scoped ambient frame, returning the callback's result. Use it from
  plain scripts such as seeds and one-off maintenance work — the
  `createServiceContext(...)` entrypoint relies on `AsyncLocalStorage.enterWith`,
  and resuming that frame across top-level await crashes Bun 1.3.x in plain
  scripts.

```ts
// scripts/seed.ts (plain script, top-level await)
const server = await createServer({ ports, context: appContext });

await server.runServiceContext({ tenantId: "tenant_demo" }, async (ctx) => {
  await seedDemoData(ctx);
});
```

Both entrypoints require `context.service` in the blueprint, generate fresh
`requestId` and `trace` values per call, and expose the service `actor` and
`tenant` on the ambient request context so audit and instrumentation wrappers
observe them at record time.

### Tenant scopes

Use `@beignet/core/tenancy` when a repository method should be scoped to the
current tenant without accepting arbitrary tenant IDs from callers:

```ts
import {
  requireTenantScope,
  tenantScopeId,
  type TenantScope,
} from "@beignet/core/tenancy";

export interface TodoRepository {
  create(input: CreateTodoInput, scope: TenantScope): Promise<Todo>;
}

const scope = requireTenantScope(ctx);
await ctx.ports.todos.create(input, scope);

const tenantId = tenantScopeId(scope); // adapter boundary
```

Apps still own tenant resolution and tenant data modeling. `TenantScope` only
brands the already-resolved `ctx.tenant` value for app-facing repository
boundaries. `beignet doctor --strict` checks generated tenant-scoped Drizzle
repositories, explicit raw `tenantId` repository boundaries, and scoped
`tenantId`/`workspaceId` predicates as a conservative drift detector.

### Testing providers

Use `installProviderForTest(...)` to run provider setup against test ports
without hand-rolling setup, port merge, and lifecycle plumbing:

```ts
import type { CachePort } from "@beignet/core/ports";
import { installProviderForTest } from "@beignet/core/testing";
import { createRedisCacheProvider } from "@beignet/provider-cache-redis";

const { ports, result, start, stop } = await installProviderForTest(
  createRedisCacheProvider(),
  {
    config: { URL: "redis://localhost:6379" },
  },
);

const cache = ports.cache as CachePort;
await cache.set("posts:list", "[]");

await stop();
```

`ports` contains the base ports merged with provider-contributed ports, and
`result` exposes the raw setup result for lifecycle-hook assertions. `config`
is passed to setup as-is, matching server startup where config is validated
before setup runs. Pass `createServiceContext` when the provider builds
service contexts from runtime entrypoints.

### Test factories and seeds

Use the same subpath to keep feature tests and demo seed data port-based.
Factories build app-owned records, and optional `persist` functions write
through the context you pass in:

```ts
import {
  createDatabaseTestHarness,
  createFactory,
  defineSeed,
  resetFactories,
  runSeeds,
} from "@beignet/core/testing";

const postFactory = createFactory("post", {
  defaults: ({ sequence }) => ({
    title: `Post ${sequence}`,
    content: "Created in a test.",
  }),
  persist: (ctx: AppContext, post) => ctx.ports.posts.create(post),
});

const demoPostsSeed = defineSeed("demo-posts", {
  run: async (ctx: AppContext) => {
    await postFactory.createList(ctx, 3);
  },
});

export async function seedDemoPosts(ctx: AppContext) {
  await runSeeds({ ctx, seeds: [demoPostsSeed] });
}

export function resetPostFactories() {
  resetFactories(postFactory);
}
```

For repository and persistence tests, compose the app-owned database fixture
with the same factories and seeds:

```ts
const databaseHarness = createDatabaseTestHarness({
  create: createTestDatabase,
  ctx: (database) => ({ ports: database.ports }),
  reset: (database) => database.reset(),
  close: (database) => database.close(),
  factories: [postFactory],
  seeds: [demoPostsSeed],
});

afterEach(async () => {
  await databaseHarness.cleanup();
});

const { ctx } = await databaseHarness.setup({ seed: true });
const post = await postFactory.create(ctx, { title: "Database conventions" });
```

Keep factories and seeds app-owned. They should not import database clients,
ORM table objects, or provider SDKs directly.

### Port testing helpers

Use `@beignet/core/testing` when tests need stable actor, tenant,
authorization, or audit assertions:

```ts
import {
  assertAuditEntry,
  createPolicyTester,
  createTestActivityContext,
  createTestTenant,
  createTestUserActor,
} from "@beignet/core/testing";

const activity = createTestActivityContext({
  actor: createTestUserActor("user_1", { role: "admin" }),
  tenant: createTestTenant("tenant_1"),
});

const tester = createPolicyTester({ policies: [postPolicy] });
await tester.assertMatrix([
  {
    name: "admin can publish",
    ctx: activity,
    ability: "posts.publish",
    subject: post,
    expected: "allow",
  },
]);

const permissions = await tester.gate.canMany(activity, {
  publish: ["posts.publish", post],
});
expect(permissions.publish).toBe(true);

assertAuditEntry(audit.entries, {
  action: "posts.publish",
  actorId: "user_1",
  tenantId: "tenant_1",
  resourceType: "post",
  resourceId: post.id,
});
```

`createTestImpersonatedUserActor(...)` is available for tests where an admin or
support actor is acting as another user and audit metadata should record the
impersonator ID.

The same subpath includes assertion helpers for common provider-backed test
adapters:

```ts
import {
  assertDispatchedJob,
  assertIdempotencyCompleted,
  assertMailDelivery,
  assertNotificationDelivery,
  assertOutboxDelivered,
  assertOutboxDrainResult,
  assertOutboxPending,
  assertProviderInstrumentationEvent,
  assertRecordedEvent,
  assertStorageObject,
  createRecordingEventBus,
  createRecordingJobDispatcher,
  createRecordingProviderInstrumentation,
} from "@beignet/core/testing";
import { drainOutbox } from "@beignet/core/outbox";
import { createProviderInstrumentation } from "@beignet/core/providers";

const { bus, events } = createRecordingEventBus();
const { jobs, dispatchedJobs } = createRecordingJobDispatcher();

await bus.publish(PostPublished, { postId: post.id });
await jobs.dispatch(LogPostPublishedJob, { postId: post.id });

assertRecordedEvent(events, {
  name: "posts.published",
  payload: { postId: post.id },
});

assertDispatchedJob(dispatchedJobs, {
  name: "posts.log-published",
  payload: { postId: post.id },
});

assertNotificationDelivery(notifications.deliveries, {
  notificationName: "posts.published",
  channels: ["email"],
});

assertMailDelivery(mailer.deliveries, {
  subject: "Post published",
});

await assertStorageObject(storage, {
  key: "posts/post_1/attachment.txt",
  text: "hello",
});

assertIdempotencyCompleted(fixture.idempotency, {
  namespace: "posts.create",
  key: "idem_1",
  result: { id: post.id },
});

assertOutboxPending(outbox, {
  kind: "event",
  name: "posts.published",
  payload: { postId: post.id },
});

const result = await drainOutbox({ outbox, registry, eventBus, jobs });

assertOutboxDrainResult(result, {
  claimed: 1,
  delivered: 1,
});

assertOutboxDelivered(outbox.messages, {
  kind: "event",
  name: "posts.published",
});

const { instrumentation, events: providerEvents } =
  createRecordingProviderInstrumentation();
const providerInstrumentation = createProviderInstrumentation(instrumentation, {
  providerName: "redis",
  watcher: "providers",
});

providerInstrumentation.custom({
  name: "cache.get",
  details: { key: "posts:list", hit: true },
});

assertProviderInstrumentationEvent(providerEvents, {
  type: "custom",
  name: "cache.get",
  providerName: "redis",
  details: { hit: true },
});
```

`createProviderInstrumentation(...)` adds `details.providerName` to custom and
typed provider events, so tests and devtools can group provider work
consistently. Watcher checks, synchronous sink errors, and rejected
asynchronous sink writes are isolated so observability cannot replace the
provider operation's result or error.

### Pagination

Use `@beignet/core/pagination` to keep list use cases and repository ports
consistent without coupling them to an ORM:

```ts
import { normalizeOffsetPage } from "@beignet/core/pagination";

const page = normalizeOffsetPage(input, {
  defaultLimit: 20,
  maxLimit: 100,
});

return ctx.ports.posts.findMany({
  page,
  filters: { status: input.status },
  sort: { field: "createdAt", direction: "desc" },
});
```

Beignet's convention is `items` for list contents and `page` for pagination
metadata. Keep filters and sort options app-owned plain objects.

### Request-scoped memoization

Use `createMemo(...)` from `@beignet/core/memo` to run a lookup once per
request no matter how many policies, use cases, and handlers ask for it. The
server enters a memo scope around every HTTP request and every
`server.runServiceContext(...)` execution; the scope's cache dies with it, so
there is no TTL, no invalidation policy, and no cross-request staleness.

```ts
import { createMemo } from "@beignet/core/memo";

const repository = createDrizzleIssuesRepository(db);

export const issues = {
  ...repository,
  findById: createMemo(repository.findById, { name: "issues.findById" }),
  update: async (id: string, patch: IssuePatch) => {
    const updated = await repository.update(id, patch);
    // A write makes the memoized read stale within this same request.
    issues.findById.invalidate(id);
    return updated;
  },
};
```

- Wrap reads in infra, next to the adapter — use cases and policies never
  know caching exists. Memoize reads, not mutations, and pair mutations with
  `invalidate(...)` as above.
- Concurrent calls share one in-flight promise; rejected promises are
  evicted so the next call retries instead of memoizing the failure.
- Default cache keys use a structural, type-tagged encoding of the arguments
  (`"1"` and `1` never collide, object key order is irrelevant). Arguments
  that cannot be encoded deterministically throw a `MemoKeyError` naming the
  memo; pass `key: (...args) => string` for those.
- Outside a scope — plain scripts, `createServiceContext(...)` callers —
  memoized functions call straight through uncached. `runMemoScope(fn)`
  creates a scope explicitly in scripts and unit tests.
- With devtools installed, each call records a `memo.hit` or `memo.miss`
  event (with fill duration) under the request, so duplicate lookups are
  visible in the waterfall.

For caching that must survive across requests, use the explicit tier —
`ports.cache.remember` with keys that change when the data changes — and see
the request lifecycle docs for context latency budgets.

### Trusted proxy request metadata

Beignet trusts no forwarding headers by default. Configure one server-level
policy when the app always runs behind a platform edge or reverse proxy that
strips or normalizes those headers:

```ts
const server = await createServer({
  ports,
  trustedProxy: {
    clientIp: "x-forwarded-for-last",
  },
  context: ({ ports, requestId, requestInfo, trace }) => ({
    ports,
    requestId,
    requestInfo,
    ...trace,
  }),
  hooks: [
    createCsrfHooks(),
    createRateLimitHooks(),
  ],
});
```

The request context factory and every server-hook phase receive the same
`requestInfo`, including the external URL, origin, protocol, host, and optional
client IP. Store it on `AppContext` when routes or Server Components need it.
`createRateLimitHooks(...)` and `createCsrfHooks(...)` consume the server policy
automatically. Their hook-local `trustedProxy` options remain available when
one hook deliberately needs a different policy.

`resolveTrustedRequest(...)` remains available for standalone adapters. Do not
copy `clientIp` into logs or audit metadata unless the application explicitly
needs that personal data and applies its retention policy.

### Rate limiting

Use `createRateLimitHooks(...)` from `@beignet/core/server` to enforce
`contract.metadata.rateLimit` at the HTTP boundary:

```ts
import { createRateLimitHooks } from "@beignet/core/server";

const server = await createServer<AppContext, AppPorts>({
  ports: initialPorts,
  hooks: [createRateLimitHooks<AppContext>()],
  // ...
});
```

- `global` and `ip` scopes run in `onRequest` before parsing and context
  creation; `user` scope runs in `beforeHandle` after route hooks have resolved
  identity and `ctx.actor` exists.
- `ip` scopes require an explicit server-level `trustedProxy.clientIp`,
  hook-local `trustedProxy.clientIp`, `ipSource`, or
  custom `earlyKey`. The hook's `validate` phase fails `createServer(...)`
  startup when a registered contract declares an `ip`-scoped rate limit without
  one, and enforcement throws the same configuration error for contracts added
  later through `server.route(...)`. Prefer
  `createServer({ trustedProxy: { clientIp: "x-forwarded-for-last" } })`
  behind a trusted proxy
  that appends the socket address, `"x-forwarded-for-first"` when a trusted
  edge normalizes the header, or
  `createServer({ trustedProxy: { clientIp: "cf-connecting-ip" } })` for
  platform headers.
- Pass `ipSource: "none"` to explicitly opt out of client-IP resolution:
  Beignet then trusts no forwarding headers and all `ip`-scoped traffic
  shares one `ip:unknown` bucket.
- Denials throw the framework `429 Too Many Requests` catalog error with
  `scope`, `retryAfterSeconds`, and `resetAt` details, and the response
  carries a standard `Retry-After` header when the limiter reports a reset
  time. The bucket key is never included in the client-visible response.
- Each denial emits a `rateLimit.denied` instrumentation event carrying the
  key, scope, limit, and window when the app ports include an
  `instrumentation` or `devtools` sink, so operators keep bucket visibility.

### Idempotency

Use `createIdempotencyHooks(...)` from `@beignet/core/server` to enforce
`contract.metadata.idempotency` at the HTTP boundary, mirroring
`createRateLimitHooks(...)`:

```ts
import { createIdempotencyHooks } from "@beignet/core/server";

const server = await createServer<AppContext, AppPorts>({
  ports: initialPorts,
  hooks: [createIdempotencyHooks<AppContext>()],
  // ...
});
```

The hook reads the key from the metadata header (default `idempotency-key`),
reserves it through `ctx.ports.idempotency` after request parsing and route hook
identity resolution, stores final route-owned 2xx responses after the
response-validation phase, replays completed matching responses with an
`idempotency-replayed: true` header, and maps in-progress and conflicting keys
to framework-owned `409` responses using the
`httpErrors.IdempotencyInProgress` and `httpErrors.IdempotencyConflict` catalog
entries.

Unfinished reservations expire after 300 seconds by default; override
`reservationTtlSec` when the protected operation has a different upper bound.
`ttlSec` controls the completed replay window. Omitted `meta.scope` binds the
key to the actor, includes the current tenant when one is present, and fails
closed when `ctx.actor.id` is missing. Use `"global"` explicitly only for a
public operation whose callers should share one key namespace. Explicit actor-
and tenant-scoped modes fail closed when their required identity is missing,
and stored HTTP responses are validated against the current contract before
replay. Disabling server response validation also disables response
persistence for HTTP idempotency.

Typed clients read the same metadata: `createClient(...)` endpoints attach a
generated UUID to the metadata header on every call (injected before request
header validation, so header schemas pass), and the header becomes optional in
call types. Pass `idempotencyKey` as a call option for retry-with-same-key
flows; an explicit `headers` value always wins over generation. Each direct
`call(...)` invocation otherwise receives a new key, so generate one outside an
application retry loop and pass it to every attempt of the same logical
command.

Use `runIdempotently(...)` from `@beignet/core/idempotency` when a non-HTTP
command, webhook, or job may be retried and must not perform duplicate work:

```ts
import {
  createIdempotencyFingerprint,
  runIdempotently,
} from "@beignet/core/idempotency";

const result = await runIdempotently(ctx.ports.idempotency, {
  namespace: "todos.import",
  key: input.importId,
  scope: {
    tenantId: ctx.tenant?.id,
    actorId: ctx.actor?.id,
  },
  fingerprint: await createIdempotencyFingerprint(input, {
    omit: ["importId"],
  }),
  ttlSec: 60 * 60 * 24,
  run: () => ctx.ports.uow.transaction((tx) => tx.todos.importBatch(input)),
});
```

The memory store is useful for tests and local examples:

```ts
import { createMemoryIdempotencyStore } from "@beignet/core/idempotency";

const idempotency = createMemoryIdempotencyStore();
```

Reservation tokens use Web Crypto `randomUUID()` or `getRandomValues()` when
available and securely fall back to `node:crypto` on supported Node runtimes.
The memory adapter accepts `createReservationToken` when a deterministic test
needs to supply its own token factory, and fails clearly if neither secure
runtime source is available.

Production apps should back `IdempotencyPort` with atomic SQL or Redis storage.
The Drizzle/libSQL path can use `createDrizzleSqliteIdempotencyPort(...)` from
`@beignet/provider-db-drizzle/sqlite`. For high-integrity workflows, prefer exposing
a transaction-scoped `tx.idempotency` port from the app Unit of Work so
reservation, business writes, audit records, domain-event records, and
idempotency completion commit together.

`runIdempotently(...)` releases a reservation only when the protected work
throws. If the work succeeds but `complete(...)` fails, the reservation stays
in progress and the completion error is rethrown so an immediate retry cannot
repeat the successful work.
If both the protected work and reservation release fail,
`runIdempotently(...)` throws an `AggregateError` whose `cause` is the original
operation error and whose `errors` retain both failures. HTTP idempotency keeps
the already-prepared application error response in this case and reports the
settlement failure through `ctx.ports.errorReporter` when that optional port is
available.
Completion and failure carry the opaque token returned by `reserve(...)`, so a
stale executor cannot mutate a successor reservation after its own TTL expires.
Implementations must reject `complete(...)` and `fail(...)` when that token,
fingerprint, or reservation state no longer matches; silently dropping a stale
mutation would let callers mistake a non-replayable result for a completed
operation. The memory adapter throws `IdempotencyMutationError`; each Drizzle
dialect exposes its corresponding `Drizzle*IdempotencyMutationError`.

### Outbox

Use `@beignet/core/outbox` when events or jobs must be recorded in the same
database transaction as the business write, then delivered later with retries:

```ts
import {
  createOutboxEventRecorder,
  defineOutboxRegistry,
  drainOutbox,
  type OutboxAdminPort,
} from "@beignet/core/outbox";
import {
  createDrizzleSqliteOutboxAdminPort,
  createDrizzleSqliteOutboxPort,
  createDrizzleSqliteUnitOfWork,
} from "@beignet/provider-db-drizzle/sqlite";

const outboxAdmin: OutboxAdminPort =
  createDrizzleSqliteOutboxAdminPort(db);

const uow = createDrizzleSqliteUnitOfWork({
  db,
  createTransactionPorts: (tx) => {
    const outbox = createDrizzleSqliteOutboxPort(tx);

    return {
      posts: createPostRepository(tx),
      events: createOutboxEventRecorder(outbox, {
        tracing: ports.tracing,
      }),
      outbox,
    };
  },
});

const registry = defineOutboxRegistry({
  events: [PostPublished],
  jobs: [SendPostPublishedEmailJob],
});

await drainOutbox({
  outbox: ctx.ports.outbox,
  registry,
  eventBus: ctx.ports.eventBus,
  jobs: ctx.ports.jobs,
  instrumentation: ctx.ports,
});
```

The outbox is at-least-once delivery. Use idempotent listeners or jobs when a
duplicate delivery would be harmful. Drizzle-backed outbox tables persist the
optional versioned trace carrier in `trace_context_json`; add that nullable
column to existing tables before upgrading the adapter.
Producer and drain validation preserve the original JSON-safe payload in
storage and across delivery. The receiving listener or job runner performs the
handler-facing parse, so schema transforms are applied exactly once at
execution.

An ordinary Unit of Work event flush has a different boundary: it runs after
commit, and a publishing failure rejects even though the database writes are
already durable. Do not treat that rejection as proof of rollback or blindly
retry non-idempotent work; use the outbox when delivery must survive that
window.

`createObservedUnitOfWork(...)` decorates any Unit of Work with an isolated
observer that runs only after the wrapped transaction resolves. Use it to
request best-effort follow-up scheduling without allowing observer failures to
reject a committed operation:

```ts
import { createObservedUnitOfWork } from "@beignet/core/ports";

const observedUow = createObservedUnitOfWork({
  unitOfWork: uow,
  afterCommit: scheduleOutboxDrain,
  onObserverError: (error) => logger.error("Drain scheduling failed", { error }),
});
```

The observer itself is not durable. The outbox row remains the durable intent,
and a recovery drain must handle missed scheduling callbacks.

When an inline dispatcher exposes Beignet's single-attempt delivery hook, an
`onError` observer is still notified but cannot swallow the failure. The outbox
retains retry and dead-letter ownership instead of marking the message
delivered.

Use `OutboxAdminPort` only from operational contexts. It lets `beignet outbox`
list, show, requeue, purge dead-lettered rows, and prune delivered rows without
exposing those destructive operations to transaction-scoped use cases.

### Schedules

Use `@beignet/core/schedules` to define typed schedules and run them
inline from cron routes, workers, scripts, and tests. Pass a
devtools-compatible sink as `instrumentation` and the inline runner records
`schedule` devtools events (`started`, `completed`, `failed`) for each run:

```ts
import { createInlineScheduleRunner } from "@beignet/core/schedules";

const runner = createInlineScheduleRunner<AppContext>({
  ctx,
  instrumentation: ctx.ports,
  instrumentationContext: {
    requestId: ctx.requestId,
    traceId: ctx.traceId,
  },
});

await runner.run(SendDailyDigestSchedule, { source: "vercel-cron" });
```

`instrumentationContext` attaches request correlation fields to recorded
events. The shared provider instrumentation helper applies watcher checks,
redaction, and sink-failure isolation. Default redaction covers secret-shaped
keys plus high-confidence credentials embedded in text, including error
messages and stacks. Lifecycle hook failures still reach `onHookError` when
provided. Handler failures reject `runner.run(...)` after
`onError` runs so trigger hosts can retry.

### Contract metadata and route hooks

Use metadata to describe cross-cutting concerns for OpenAPI, clients, docs, and
app conventions:

```ts
const sendMessage = messages
  .post("/api/messages")
  .body(SendMessageRequest)
  .responses({ 201: SendMessageResponse })
  .meta({
    auth: "required",
    idempotency: {
      required: true,
      header: "idempotency-key",
      scope: "actor-tenant",
      ttlSec: 300,
    },
    rateLimit: {
      max: 60,
      windowSec: 60,
      scope: "user",
    },
  });
```

The built-in server hooks enforce `rateLimit` and `idempotency` metadata:
install `createRateLimitHooks(...)` and `createIdempotencyHooks(...)` where the
server is composed. Use route hooks for runtime enforcement of route-specific
policy where the route is wired:

Bind route declarations to the app context once in `lib/routes.ts` with
`createRoutes<AppContext>()`, then import the resulting builders in feature
route files.

```ts
import { createAuthHooks } from "@beignet/core/server";
import { defineRouteGroup } from "@/lib/routes";
import type { AppContext } from "@/app-context";

const auth = createAuthHooks<AppContext>()({
  resolve: ({ ctx }) => {
    return ctx.auth ? { user: ctx.auth.user } : null;
  },
});

export const messageRoutes = defineRouteGroup({
  name: "messages",
  routes: [
    {
      contract: sendMessage,
      hooks: [auth.required()],
      useCase: sendMessageUseCase,
    },
  ],
});
```

Ordinary app routes bind `{ contract, useCase }`. The response status is
inferred when the contract declares exactly one `2xx` response (otherwise
`status` is required and typed to the declared keys), and the use case input
defaults to the merged request parts via `defaultBinderInput` — query lowest,
then body, then path; headers are never merged and need an explicit
`input: (parts) => ...` mapper. When the use case `.input(...)` schema is the
contract's sole request schema by reference, the server skips the use case's
input re-parse — one schema, one parse.

Use `{ contract, handle }` as the escape hatch for response headers,
streaming, and multi-status responses. `defineRoute` remains
available for full handlers that read hook-added `ctx` fields.

When credentials live in request headers, declare a `headers` schema on the
auth hooks. The hook validates the raw lowercase request header record before
`resolve` runs, so `resolve` receives typed header values; on `required()`
routes a schema failure returns a framework-owned `401`:

```ts
const writerAuth = createAuthHooks<AppContext>()({
  name: "writer",
  headers: writerHeadersSchema,
  resolve: ({ headers }) => ({
    actor: createUserActor(headers["x-user-id"]),
  }),
});
```

Use `createSecurityHeadersHooks(...)` for the default browser response-header
baseline. The hook adds common headers such as `X-Content-Type-Options`,
`X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`,
`Cross-Origin-Opener-Policy`, and `Cross-Origin-Resource-Policy`; it does not
guess your CSP or HSTS policy:

```ts
import { createSecurityHeadersHooks } from "@beignet/core/server";

const securityHeaders = createSecurityHeadersHooks({
  contentSecurityPolicy: "default-src 'self'; frame-ancestors 'none'",
  strictTransportSecurity: {
    maxAgeSec: 31_536_000,
    includeSubDomains: true,
  },
});
```

Existing response headers win, so routes that stream files, render HTML, or need
a different CSP can set their own policy.

Use `createCorsHooks(...)` for app-wide CORS headers. Wildcard origins are
accepted only for non-credentialed requests:

```ts
import { createCorsHooks } from "@beignet/core/server";

const publicCors = createCorsHooks({ origins: "*" });
const browserAppCors = createCorsHooks({
  origins: ["https://app.example.com"],
  credentials: true,
});
```

`createCorsHooks({ origins: "*", credentials: true })` throws during setup so
apps do not accidentally reflect arbitrary request origins for cookies or
authorization headers.

The hook short-circuits only real browser preflights: `OPTIONS` requests that
include both `Origin` and `Access-Control-Request-Method`. An explicit
`OPTIONS` contract without those headers continues through normal route
dispatch.

Use `createCsrfHooks(...)` when browser mutations depend on cookies. By default
the hook protects unsafe methods by rejecting cross-origin `Origin` or `Referer`
headers while still allowing requests that do not carry browser origin headers.
Set `allowMissingOrigin: false` and enable token checks for stricter
browser-only APIs:

```ts
import { createCsrfHooks } from "@beignet/core/server";

const csrf = createCsrfHooks({
  allowMissingOrigin: false,
  trustedOrigins: ["https://app.example.com"],
  token: {
    cookieName: "csrf",
    headerName: "x-csrf-token",
  },
  skip: ({ contract }) => contract.name.startsWith("webhooks."),
});
```

Set `trustedProxy: {}` on `createServer(...)` when Beignet should compare
`Origin` or `Referer` against the external `x-forwarded-host` and
`x-forwarded-proto` values written by your trusted edge. The CSRF hook uses
that central policy unless its own `trustedProxy` option deliberately
overrides it.

### Typed client errors

`@beignet/core/client` classifies failures by where they occur.
`ContractError.source` is `"client"` for local request preparation,
`"network"` for a rejected fetch, `"http"` for a non-2xx response, and
`"contract"` for malformed or contract-invalid responses. Unexpected failures
use `CLIENT_ERROR`, `NETWORK_ERROR`, or `RESPONSE_PROCESSING_ERROR`
respectively; expected input and response validation failures keep their more
specific codes. Response-processing errors preserve the native `Response` and
status, including when reading the response stream itself fails.

### HTTP adapter boundary

`@beignet/core/server` is framework-neutral. It owns route matching, hooks,
request validation, response validation, error mapping, and provider lifecycle.
Adapters own the platform edge only:

- Convert the native request into `HttpRequestLike`
- Call `server.api(...)` or a single route handler
- Convert `HttpResponse` back into the native response type

The public adapter contract is `HttpAdapter<NativeRequest, NativeResponse>`.
Use it when building a runtime package beyond the first-party `@beignet/web`
and `@beignet/next` adapters.

### Health and readiness

Use `createHealthHandler(...)` and `runHealthChecks(...)` from
`@beignet/core/server` for app-owned liveness and readiness endpoints.
Readiness checks should be cheap, bounded, and non-mutating:

```ts
import { createHealthHandler } from "@beignet/core/server";
import { getServer } from "@/server";

const server = await getServer();

const readiness = createHealthHandler(
  server.ports,
  {
    checks: {
      database: (ports) => ports.db.checkHealth(),
    },
    timeoutMs: 2000,
  },
  "production",
);
```

Provider checks such as `ctx.ports.db.checkHealth()` should be called from
routes, workers, or deployment probes. Do not run migrations, drains, workers,
or polling loops from health checks.

### Request instrumentation and tracing

`createServer(...)` owns request instrumentation. For every request it
resolves a request ID (from `x-request-id`, or generated) and a W3C trace
context (from `traceparent`, or generated) before user hooks and context
creation, passes them to context factories as `requestId` and `trace`, writes
both response headers, and records `request`/`error` events into the provider
instrumentation port resolved from final ports (`ports.instrumentation`, then
`ports.devtools`). Without an installed sink, headers are still written and
events are a no-op.

Recorded `request` events and `afterSend` hooks also carry a per-stage
timing breakdown (`stages`): `onRequestMs`, `parseMs`, `contextMs`,
`beforeHandleMs`, `handlerMs`, and `sendMs`. The devtools waterfall renders
these as sub-bars under each request span, so slow context creation or a
slow handler is visible per request instead of hiding inside one total
duration.

```ts
import { appContext } from "@/server/context";

const server = await createServer({
  ports,
  providers,
  // Defaults shown. Pass `instrumentation: false` to disable entirely.
  instrumentation: {
    requestIdHeader: "x-request-id",
    traceContextHeader: "traceparent",
    ignorePaths: ["/api/devtools"],
  },
  context: appContext,
});
```

Service contexts created with `server.createServiceContext(...)` receive fresh
`requestId` and `trace` values per call. Context values win: when a factory
sets its own `requestId`, headers and recorded events use it.

Trace primitives live in `@beignet/core/tracing` (`TraceContext`,
`TracingPort`, `TraceOperation`, `TraceSpan`, `createTraceContext`,
`createChildTraceContext`, `parseTraceparent`, `createTraceparent`,
`createTraceId`, `createSpanId`, `TraceCarrier`, `captureTraceCarrier`, and
`parseTraceCarrier`). The module is dependency-free so app context
types can be imported from client bundles.

When final ports include `ports.tracing`, requests execute inside an active
`beignet.request <contract>` span. Incoming `traceparent` and `tracestate`
continue the trace; if the host already established an active span, Beignet's
request span becomes its child. Use cases, listeners, job handlers, schedule
handlers, and task handlers create nested active spans through the same port.
Install `@beignet/provider-tracing-opentelemetry` to adapt this port to an
app-owned OpenTelemetry SDK and emit baseline duration, error, and provider
operation metrics.

The optional versioned `TraceCarrier` continues traces through outbox rows,
event bus envelopes, and provider-backed job payloads. Event publish and job
dispatch accept an optional third `{ trace }` argument for transport layers;
normal application calls remain two arguments. Unknown or malformed carriers
are ignored so trace metadata cannot block message delivery.

Listener, job, schedule, and task runners accept both a lazy `ctx` factory and
an explicit `tracing` port. Provider-backed runtimes should pass the installed
port so Beignet starts the workflow span before resolving
`server.createServiceContext(...)`; the resulting context then inherits the
real active span instead of treating local correlation IDs as a remote parent.

For custom `TraceOperation` values, `attributes` are span-only. Use
`metricAttributes` only for bounded operation dimensions such as a contract,
use-case, job, schedule, or task name. Never add request, actor, tenant, or
payload values to metric attributes.

Use cases created with `createUseCase(...)` are instrumented by default. Each
run resolves the instrumentation port from `ctx.ports` and records `usecase`
lifecycle events plus correlated `error` events for failures. When a tracing
port is installed, it also creates an active child span. Pass
`instrumentation: false` to opt out of instrumentation events; tracing is
controlled by whether the app installs `ports.tracing`.

App-owned `onRun` observers are best-effort. Synchronous throws and rejected
observer promises are ignored so instrumentation cannot fail a use case or
replace its original error.

`createInstrumentedAuditLog({ audit, instrumentation })` from
`@beignet/core/ports` writes durable audit entries first and mirrors sanitized
audit activity into the resolved instrumentation sink.

`createAmbientAuditLog(audit)` from `@beignet/core/server` fills missing
`actor`, `tenant`, `requestId`, and `traceId` fields from the ambient request
context at record time. The server keeps that context current for requests
(including identity elevated by route hooks) and for service contexts created
with `server.createServiceContext(...)`, so jobs, listeners, schedules, and
tasks are covered. Because enrichment happens at record time, the wrapper also
works for audit ports rebuilt per transaction inside a unit of work — wrap
both the top-level port and the per-transaction rebuild:

```ts
import { createAmbientAuditLog } from "@beignet/core/server";

const audit = createAmbientAuditLog(
  createInstrumentedAuditLog({ audit: durableAudit, instrumentation: ports }),
);

await audit.record({
  action: "posts.publish",
  resource: { type: "post", id: post.id },
});
```

Entry-provided fields always win; on runtimes without `AsyncLocalStorage`
the wrapper passes entries through unchanged, and entries without an actor
normalize to an anonymous actor.

Route-owned response validation can be disabled with
`validateResponses: false` on `createServer(...)`, mirroring the client option
of the same name. Binder routes whose use case `.output(...)` schema is the
declared success response schema by reference skip the redundant success-status
parse only when use-case output validation is enabled. If the use case sets
`validate: { output: false }`, route response validation still runs once. If
profiling justifies disabling validation entirely, drive `validateResponses`
from an environment flag so development and CI keep it on.

### OpenAPI metadata

Add OpenAPI-specific metadata for documentation using the `.openapi()` method:

```ts
export const getTodo = todos
  .get("/api/todos/:id")
  .pathParams(z.object({ id: z.string() }))
  .responses({ 200: TodoSchema })
  .openapi({
    summary: "Get a todo by ID",
    description: "Retrieves a single todo item by its unique identifier",
    tags: ["todos"],
    deprecated: false,
    operationId: "getTodoById",
    externalDocs: {
      url: "https://docs.example.com/todos",
      description: "Todo documentation",
    },
    security: [{ bearerAuth: [] }],
  });
```

`.openapi(...)` and `.meta({ openapi: ... })` share the same shallow merge
semantics. OpenAPI fields from contract groups and earlier calls are preserved,
while a later value replaces the same field. Structured fields such as
`responses` are replaced as a whole rather than deep-merged. Prefer
`.openapi(...)` for operation metadata; use `.meta(...)` when composing it with
other metadata conventions.

Use `requestBody`, `responses`, and `parameters` overrides when an operation
needs non-JSON media such as multipart uploads, binary downloads, event streams,
or cookie parameters. `contractsToOpenAPI(...)` accepts `schemaConverters` for
non-Zod Standard Schema libraries; custom converters run before Beignet's
default Zod converter.

Descriptions attached before `.optional()` are preserved on generated query
and header parameters, matching descriptions attached to the outer optional
schema.

OpenAPI `operationId` defaults to the stable contract name. Explicit operation
IDs are supported when an external SDK needs a different method name; server
registration and OpenAPI generation reject duplicates.

### External TypeScript clients

Applications outside the contract-owning codebase can generate types from the
served OpenAPI document and use them with an independent client:

```bash
bun add openapi-fetch
bun add --dev openapi-typescript typescript
bunx openapi-typescript https://api.example.com/api/openapi --output src/generated/api.ts
```

```ts
import createClient from "openapi-fetch";
import type { paths } from "./generated/api";

const api = createClient<paths>({
  baseUrl: "https://api.example.com",
});

const { data, error } = await api.GET("/api/todos/{id}", {
  params: { path: { id: "todo_123" } },
});
```

The generated client types cover paths, parameters, JSON bodies, responses,
and declared catalog errors. Map OpenAPI `string/binary` schemas to `Blob`
through the generator's Node API for typed uploads and downloads. See the
[OpenAPI guide](https://beignetjs.com/openapi) for the complete generation,
non-JSON media, authentication, and CI drift workflow.

### Schema introspection

Contracts expose their schemas for runtime introspection:

```ts
getTodo.schema.pathParams;  // Path parameter schema
getTodo.schema.query;      // Query parameter schema
getTodo.schema.body;       // Request body schema
getTodo.schema.responses;  // Response schemas by status code
getTodo.path;              // "/api/todos/:id"
getTodo.method;            // "GET"
getTodo.metadata;          // { auth: "required", ... }
```

## API reference

### `defineContractGroup()`

Creates a new contract group for defining related endpoints.

```ts
const group = defineContractGroup()
  .namespace("myNamespace")    // Optional resource namespace
  .prefix("/api/v1")           // Optional URL path prefix
  .meta({ auth: "required" })  // Shared metadata
  .headers(AuthHeaders)         // Shared request headers
  .errors({                     // Shared catalog errors
    TenantSuspended: errors.TenantSuspended,
  });
```

Shared catalog errors merge with route-level `.errors(...)` declarations, so
each contract carries the union of group and route errors. Later declarations
win when the same catalog key is declared twice.

Any non-empty response map is treated as a response contract. Include
successful statuses such as `200` or `201` alongside custom error statuses; use
`responses: {}` only when you want to skip response validation. Prefer
`.errors(...)` for expected business failures that should use Beignet's
standard error envelope.

### Contract builder methods

| Method | Description |
|--------|-------------|
| `.get(path)` | Define a GET endpoint |
| `.post(path)` | Define a POST endpoint |
| `.put(path)` | Define a PUT endpoint |
| `.patch(path)` | Define a PATCH endpoint |
| `.delete(path)` | Define a DELETE endpoint |
| `.pathParams(schema)` | Define path parameter schema |
| `.query(schema)` | Define query parameter schema |
| `.headers(schema)` | Define request header schema |
| `.body(schema)` | Define request body schema |
| `.responses({ ... })` | Define or merge response schemas by status code |
| `.errors({ ... })` | Declare route-owned catalog errors using Beignet's standard error envelope; merges with group and earlier declarations |
| `.meta(metadata)` | Merge custom metadata; nested `openapi` fields merge one level deep |
| `.deprecated(metadata)` | Mark the contract deprecated with validated lifecycle metadata and runtime headers |
| `.openapi(options)` | Merge OpenAPI metadata (summary, tags, etc.) |

## Standard Schema support

This package works with any [Standard Schema](https://github.com/standard-schema/standard-schema) compatible library:

- **Zod** - Most popular, excellent TypeScript inference
- **Valibot** - Lightweight alternative to Zod
- **ArkType** - High-performance runtime validation

OpenAPI generation includes a Zod converter and introspector by default. Other
Standard Schema libraries can supply `schemaConverters` and a
`schemaIntrospector`; opaque path parameter schemas degrade to required string
parameters derived from the contract path.

## Related packages

- [`@beignet/web`](https://beignetjs.com/server) - Web Fetch server adapter
- [`@beignet/next`](https://beignetjs.com/server) - Next.js server adapter
- [`@beignet/react-query`](https://beignetjs.com/react-query) - TanStack Query integration
- [`@beignet/react-hook-form`](https://beignetjs.com/react-hook-form) - React Hook Form integration
- [`@beignet/react-uploads`](https://beignetjs.com/react-uploads) - React upload state and progress hooks
- [`@beignet/nuqs`](https://beignetjs.com/nuqs) - URL query state integration with nuqs
- [`@beignet/devtools`](https://beignetjs.com/devtools) - Local request, provider, and audit timeline

## License

MIT
